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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
import { relative } from 'node:path';
|
|
15
15
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
16
|
+
import { redactSensitivePaths } from '../core/redact.mjs';
|
|
16
17
|
import { canonicalArtifactPath } from './path-key.mjs';
|
|
17
18
|
import { acquireProjectLock } from './project-lock.mjs';
|
|
18
19
|
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
recordAppliedEntry,
|
|
33
34
|
createBackup,
|
|
34
35
|
writeRecoveryRequiredFile,
|
|
36
|
+
convergeTerminalRecord,
|
|
35
37
|
} from './transaction-journal.mjs';
|
|
36
38
|
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
@@ -499,20 +501,20 @@ async function readCurrentEntry(handle, path) {
|
|
|
499
501
|
// ---------------------------------------------------------------------------
|
|
500
502
|
|
|
501
503
|
/**
|
|
502
|
-
*
|
|
504
|
+
* Closed artifact-plan v1 schema validation (zero filesystem side effects).
|
|
503
505
|
*
|
|
504
506
|
* P0-6: Validates apiVersion/bindings, plan schema, safeToWrite, artifact
|
|
505
|
-
* schema,
|
|
506
|
-
*
|
|
507
|
+
* schema, entry schema/decoding, Buffer bytes range (0..255), and unknown
|
|
508
|
+
* fields/kinds. Path safety, path uniqueness, and full CAS are handled by
|
|
509
|
+
* the generic write-set preflight (performWriteSetPreflightAndCas).
|
|
507
510
|
*
|
|
508
|
-
* @param {object} handle — root DirectoryHandle.
|
|
509
511
|
* @param {object} plan — decoded plan (bytes decoded in-place).
|
|
510
512
|
* @param {string} planPath — for error context.
|
|
511
513
|
* @throws {ReleaseError} On any validation failure.
|
|
512
514
|
*/
|
|
513
|
-
|
|
515
|
+
function assertArtifactPlanClosedSchema(plan, planPath) {
|
|
514
516
|
if (!plan || typeof plan !== 'object') {
|
|
515
|
-
throw new ReleaseError(PLAN_STALE, 'plan is not a valid object', { path: planPath });
|
|
517
|
+
throw new ReleaseError(PLAN_STALE, 'plan is not a valid object', { path: redactSensitivePaths(planPath) });
|
|
516
518
|
}
|
|
517
519
|
|
|
518
520
|
if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1') {
|
|
@@ -570,7 +572,7 @@ async function performPreflightAndCas(handle, plan, planPath) {
|
|
|
570
572
|
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'plan nextAction must be the apply command');
|
|
571
573
|
}
|
|
572
574
|
if (!Array.isArray(plan.artifacts)) {
|
|
573
|
-
throw new ReleaseError(PLAN_STALE, 'plan missing artifacts array', { path: planPath });
|
|
575
|
+
throw new ReleaseError(PLAN_STALE, 'plan missing artifacts array', { path: redactSensitivePaths(planPath) });
|
|
574
576
|
}
|
|
575
577
|
|
|
576
578
|
// P0-6: Validate no unknown plan fields (closed schema)
|
|
@@ -634,19 +636,51 @@ async function performPreflightAndCas(handle, plan, planPath) {
|
|
|
634
636
|
validateAndDecodeEntry(artifact.newEntry, 'newEntry');
|
|
635
637
|
validateAndDecodeEntry(artifact.oldEntry, 'oldEntry');
|
|
636
638
|
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Generic write-set preflight with zero filesystem side effects.
|
|
643
|
+
*
|
|
644
|
+
* Validates every write-set item (id, closed entry schema/decoding for
|
|
645
|
+
* oldEntry/newEntry, Buffer bytes range 0..255), path safety, path
|
|
646
|
+
* uniqueness, and runs the full CAS for every old entry BEFORE any target
|
|
647
|
+
* mutation may happen. Shared by the artifact-plan v1 path and the generic
|
|
648
|
+
* applyWriteSetUnderLock entry (docs-refresh and future write sets).
|
|
649
|
+
*
|
|
650
|
+
* @param {object} handle — root DirectoryHandle.
|
|
651
|
+
* @param {object[]} writeSet — items shaped { id, path, oldEntry, newEntry }.
|
|
652
|
+
* @throws {ReleaseError} On any validation failure or CAS mismatch.
|
|
653
|
+
*/
|
|
654
|
+
async function performWriteSetPreflightAndCas(handle, writeSet) {
|
|
655
|
+
if (!Array.isArray(writeSet) || writeSet.length === 0) {
|
|
656
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'writeSet must be a non-empty array');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Validate and decode all entries (in-place bytes modification)
|
|
660
|
+
for (const item of writeSet) {
|
|
661
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)
|
|
662
|
+
|| typeof item.id !== 'string' || item.id.length === 0) {
|
|
663
|
+
throw new ReleaseError(
|
|
664
|
+
TRANSACTION_INCOMPLETE,
|
|
665
|
+
'writeSet item missing id or id is not a string',
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
validateAndDecodeEntry(item.newEntry, 'newEntry');
|
|
669
|
+
validateAndDecodeEntry(item.oldEntry, 'oldEntry');
|
|
670
|
+
}
|
|
637
671
|
|
|
638
672
|
// Path validation
|
|
639
|
-
for (const
|
|
640
|
-
validatePath(
|
|
673
|
+
for (const item of writeSet) {
|
|
674
|
+
validatePath(item.path);
|
|
641
675
|
}
|
|
642
|
-
validatePathUniqueness(
|
|
676
|
+
validatePathUniqueness(writeSet);
|
|
643
677
|
|
|
644
678
|
// Full CAS for all old entries (zero side effects)
|
|
645
|
-
for (const
|
|
646
|
-
const canonicalPath =
|
|
647
|
-
?
|
|
648
|
-
:
|
|
649
|
-
await assertFullCas(handle,
|
|
679
|
+
for (const item of writeSet) {
|
|
680
|
+
const canonicalPath = item.path.endsWith('/')
|
|
681
|
+
? item.path.slice(0, -1)
|
|
682
|
+
: item.path;
|
|
683
|
+
await assertFullCas(handle, item.oldEntry, canonicalPath);
|
|
650
684
|
}
|
|
651
685
|
}
|
|
652
686
|
|
|
@@ -859,6 +893,9 @@ async function checkTargetUnchanged(handle, artifacts) {
|
|
|
859
893
|
* @param {Error} options.originalError — the error that triggered recovery.
|
|
860
894
|
* @param {object[]} options.artifacts — plan artifacts.
|
|
861
895
|
* @param {boolean} options.journalCreated — whether journal exists.
|
|
896
|
+
* @param {string} [options.recoverCommand] — optional caller-supplied unique
|
|
897
|
+
* recover command; defaults to the authoritative artifacts recover command
|
|
898
|
+
* bound to the transaction id.
|
|
862
899
|
* @returns {Promise<{ recoveryError: Error|null, targetUnchanged: boolean }>}
|
|
863
900
|
*/
|
|
864
901
|
async function tryRecoveryProtocol({
|
|
@@ -868,6 +905,7 @@ async function tryRecoveryProtocol({
|
|
|
868
905
|
originalError,
|
|
869
906
|
artifacts,
|
|
870
907
|
journalCreated,
|
|
908
|
+
recoverCommand,
|
|
871
909
|
}) {
|
|
872
910
|
// P0-8: Journal建立前失败不得谎称recovery
|
|
873
911
|
if (!journalCreated) {
|
|
@@ -890,8 +928,11 @@ async function tryRecoveryProtocol({
|
|
|
890
928
|
journalState = 'unreadable';
|
|
891
929
|
}
|
|
892
930
|
|
|
893
|
-
// P0-8: Unique recover command
|
|
894
|
-
|
|
931
|
+
// P0-8: Unique recover command (callers may bind their own recover command
|
|
932
|
+
// family; the default stays the authoritative artifacts recover command).
|
|
933
|
+
const recover = typeof recoverCommand === 'string' && recoverCommand.length > 0
|
|
934
|
+
? recoverCommand
|
|
935
|
+
: `release-skill artifacts recover --transaction ${transactionId}`;
|
|
895
936
|
|
|
896
937
|
let recoveryStatePersisted = journalState === 'RECOVERY_REQUIRED';
|
|
897
938
|
let transitionErrorCode = null;
|
|
@@ -964,53 +1005,81 @@ function generateTransactionId(clock) {
|
|
|
964
1005
|
// ---------------------------------------------------------------------------
|
|
965
1006
|
|
|
966
1007
|
/**
|
|
967
|
-
*
|
|
1008
|
+
* Generic durable write-set application under a caller-held project lock.
|
|
1009
|
+
*
|
|
1010
|
+
* Implements the shared transaction core: multi-file preflight with full CAS
|
|
1011
|
+
* BEFORE the first target mutation, safe-fs probe, durable journal
|
|
1012
|
+
* (PREPARED → APPLYING → APPLIED → VERIFYING → COMMITTED), per-entry
|
|
1013
|
+
* write-ahead recording, per-entry re-CAS with the backup taken from the
|
|
1014
|
+
* SAME stable read, identity-bound createTemp+rename writes, manifest
|
|
1015
|
+
* verification, and the RECOVERY_REQUIRED recovery protocol with the unique
|
|
1016
|
+
* recover command on mid-flight failure.
|
|
968
1017
|
*
|
|
969
|
-
*
|
|
1018
|
+
* The canonicalPlan is persisted as the journal authority; its schema is
|
|
1019
|
+
* validated by the transaction journal dispatch (artifact-plan v1 or
|
|
1020
|
+
* docs-refresh v1). The plan carries NO target bytes in the docs-refresh
|
|
1021
|
+
* case — new bytes live only in the write set and the journal manifests.
|
|
1022
|
+
*
|
|
1023
|
+
* ALL transactional filesystem mutations (journal, backup, target files) go
|
|
1024
|
+
* through the safe-fs backend DirectoryHandle; no Node path-based writes are
|
|
1025
|
+
* used for them. The only exception is the best-effort retention prune that
|
|
1026
|
+
* runs inside createTransactionJournal — see pruneTerminalTransactionRecords
|
|
1027
|
+
* in transaction-journal.mjs.
|
|
970
1028
|
*
|
|
971
1029
|
* @param {object} options
|
|
972
1030
|
* @param {string} options.root — Repository root (absolute).
|
|
973
|
-
* @param {
|
|
974
|
-
* @param {
|
|
975
|
-
* @param {
|
|
1031
|
+
* @param {object[]} options.writeSet — items shaped { id, path, oldEntry, newEntry }.
|
|
1032
|
+
* @param {object} options.canonicalPlan — journal authority plan (closed schema).
|
|
1033
|
+
* @param {string} options.planDigest — sha256:<64hex> digest binding the journal.
|
|
1034
|
+
* @param {object} [options.safeFs] — Safe filesystem backend (required).
|
|
976
1035
|
* @param {Function} [options.faultInjector] — Fault injection for testing.
|
|
977
|
-
* @param {Function} [options.clock] — Clock function for
|
|
1036
|
+
* @param {Function} [options.clock] — Clock function for transaction ids.
|
|
1037
|
+
* @param {Function} [options.assertLockOwner] — Caller-held lock assertion.
|
|
1038
|
+
* @param {string} [options.recoverCommand] — Optional unique recover command.
|
|
1039
|
+
* @param {object} [options.rootHandle] — Internal reuse: an already-open root
|
|
1040
|
+
* handle owned by the caller (not closed here).
|
|
1041
|
+
* @param {number} [options.transactionRetentionMax] — Optional cap on retained
|
|
1042
|
+
* terminal transaction records (defaults to DEFAULT_TRANSACTION_RETENTION_MAX).
|
|
978
1043
|
* @returns {Promise<TransactionResult>}
|
|
979
|
-
* @throws {ReleaseError} On validation failure
|
|
1044
|
+
* @throws {ReleaseError} On validation failure, CAS mismatch, or mid-flight
|
|
1045
|
+
* failure (RECOVERY_REQUIRED protocol).
|
|
980
1046
|
*/
|
|
981
|
-
async function
|
|
1047
|
+
export async function applyWriteSetUnderLock({
|
|
982
1048
|
root,
|
|
983
|
-
|
|
1049
|
+
writeSet,
|
|
1050
|
+
canonicalPlan,
|
|
984
1051
|
planDigest,
|
|
985
1052
|
safeFs,
|
|
986
1053
|
faultInjector,
|
|
987
1054
|
clock,
|
|
988
1055
|
assertLockOwner = async () => {},
|
|
1056
|
+
recoverCommand,
|
|
1057
|
+
rootHandle = null,
|
|
1058
|
+
transactionRetentionMax,
|
|
989
1059
|
} = {}) {
|
|
990
1060
|
// === PHASE 0: validate inputs and safe-fs availability ===
|
|
991
1061
|
|
|
992
1062
|
if (!root || typeof root !== 'string') {
|
|
993
1063
|
throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
|
|
994
1064
|
}
|
|
995
|
-
if (!planPath || typeof planPath !== 'string') {
|
|
996
|
-
throw new ReleaseError(MISSING_PARAMETERS, 'planPath is required');
|
|
997
|
-
}
|
|
998
|
-
if (!planDigest || typeof planDigest !== 'string') {
|
|
999
|
-
throw new ReleaseError(MISSING_PARAMETERS, 'planDigest is required');
|
|
1000
|
-
}
|
|
1001
|
-
if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
|
|
1002
|
-
throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
|
|
1003
|
-
}
|
|
1004
1065
|
if (!safeFs) {
|
|
1005
1066
|
throw new ReleaseError(
|
|
1006
1067
|
SAFE_WRITE_UNAVAILABLE,
|
|
1007
1068
|
'safe filesystem backend is required',
|
|
1008
1069
|
);
|
|
1009
1070
|
}
|
|
1071
|
+
if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
|
|
1072
|
+
throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
|
|
1073
|
+
}
|
|
1074
|
+
if (!Array.isArray(writeSet) || writeSet.length === 0) {
|
|
1075
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'writeSet must be a non-empty array');
|
|
1076
|
+
}
|
|
1077
|
+
if (!canonicalPlan || typeof canonicalPlan !== 'object' || Array.isArray(canonicalPlan)) {
|
|
1078
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'canonicalPlan must be an object');
|
|
1079
|
+
}
|
|
1010
1080
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const handle = await safeFs.openRoot(root);
|
|
1081
|
+
const handle = rootHandle ?? await safeFs.openRoot(root);
|
|
1082
|
+
const ownsRootHandle = rootHandle === null;
|
|
1014
1083
|
let txnResult;
|
|
1015
1084
|
const assertLockAuthority = async () => {
|
|
1016
1085
|
try {
|
|
@@ -1021,59 +1090,9 @@ async function applyArtifactPlanUnderLock({
|
|
|
1021
1090
|
}
|
|
1022
1091
|
};
|
|
1023
1092
|
try {
|
|
1024
|
-
|
|
1025
|
-
// Convert the absolute plan path to a canonical root-relative path before
|
|
1026
|
-
// any fd-relative access. This rejects root itself and all escape spellings.
|
|
1027
|
-
const relPlanPath = canonicalArtifactPath(relative(root, planPath)).path;
|
|
1028
|
-
const planFileData = await withParentHandle(handle, relPlanPath, async (parent, leaf) => {
|
|
1029
|
-
const planEntry = await parent.readEntry(leaf);
|
|
1030
|
-
if (!planEntry || planEntry.kind === 'absent') {
|
|
1031
|
-
throw new ReleaseError(PLAN_STALE, 'plan file does not exist', { path: planPath });
|
|
1032
|
-
}
|
|
1033
|
-
const planIsRegular = planEntry.kind === 'regular'
|
|
1034
|
-
|| planEntry.type === 'file'
|
|
1035
|
-
|| planEntry.type === 'blob';
|
|
1036
|
-
if (!planIsRegular) {
|
|
1037
|
-
throw new ReleaseError(PATH_UNSAFE, 'plan path is not a regular file', { path: planPath });
|
|
1038
|
-
}
|
|
1039
|
-
if (typeof planEntry.nlink === 'number' && planEntry.nlink !== 1) {
|
|
1040
|
-
throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
|
|
1041
|
-
}
|
|
1042
|
-
const data = await parent.readFile(leaf);
|
|
1043
|
-
if (!data) {
|
|
1044
|
-
throw new ReleaseError(PLAN_STALE, 'plan file is unreadable', { path: planPath });
|
|
1045
|
-
}
|
|
1046
|
-
if (Number(data.nlink) !== 1) {
|
|
1047
|
-
throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
|
|
1048
|
-
}
|
|
1049
|
-
return data;
|
|
1050
|
-
});
|
|
1051
|
-
|
|
1052
|
-
let plan;
|
|
1053
|
-
try {
|
|
1054
|
-
plan = JSON.parse(planFileData.bytes.toString('utf8'));
|
|
1055
|
-
} catch (err) {
|
|
1056
|
-
throw new ReleaseError(
|
|
1057
|
-
PLAN_STALE,
|
|
1058
|
-
'plan file is not valid JSON',
|
|
1059
|
-
{ path: planPath, error: err.message },
|
|
1060
|
-
);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// === PHASE 2: recompute canonical plan digest ===
|
|
1064
|
-
|
|
1065
|
-
const recomputedDigest = computeCanonicalPlanDigest(plan);
|
|
1066
|
-
if (plan.planDigest !== planDigest || recomputedDigest !== planDigest) {
|
|
1067
|
-
throw new ReleaseError(
|
|
1068
|
-
PLAN_STALE,
|
|
1069
|
-
'plan digest does not match expected',
|
|
1070
|
-
{ expected: planDigest, embedded: plan.planDigest, actual: recomputedDigest },
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
1093
|
// === PHASE 3: full preflight + CAS (zero side effects) ===
|
|
1075
1094
|
|
|
1076
|
-
await
|
|
1095
|
+
await performWriteSetPreflightAndCas(handle, writeSet);
|
|
1077
1096
|
|
|
1078
1097
|
if (faultInjector) {
|
|
1079
1098
|
await faultInjector('preflight-complete');
|
|
@@ -1097,24 +1116,21 @@ async function applyArtifactPlanUnderLock({
|
|
|
1097
1116
|
const transactionId = generateTransactionId(clock);
|
|
1098
1117
|
let journalCreated = false;
|
|
1099
1118
|
|
|
1100
|
-
const oldManifest = await buildOldManifest(handle,
|
|
1101
|
-
const newManifest = buildNewManifest(
|
|
1119
|
+
const oldManifest = await buildOldManifest(handle, writeSet);
|
|
1120
|
+
const newManifest = buildNewManifest(writeSet);
|
|
1102
1121
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
// Journal creation failed — no side effects, clean failure
|
|
1116
|
-
throw journalErr;
|
|
1117
|
-
}
|
|
1122
|
+
await assertLockAuthority();
|
|
1123
|
+
txnResult = await createTransactionJournal({
|
|
1124
|
+
rootHandle: handle,
|
|
1125
|
+
root,
|
|
1126
|
+
transactionId,
|
|
1127
|
+
planDigest,
|
|
1128
|
+
canonicalPlan,
|
|
1129
|
+
oldManifest,
|
|
1130
|
+
newManifest,
|
|
1131
|
+
retentionMax: transactionRetentionMax,
|
|
1132
|
+
});
|
|
1133
|
+
journalCreated = true;
|
|
1118
1134
|
|
|
1119
1135
|
const { txnHandle } = txnResult;
|
|
1120
1136
|
|
|
@@ -1131,11 +1147,11 @@ async function applyArtifactPlanUnderLock({
|
|
|
1131
1147
|
});
|
|
1132
1148
|
if (faultInjector) await faultInjector('after-applying-transition');
|
|
1133
1149
|
|
|
1134
|
-
// === PHASE 6: apply each
|
|
1150
|
+
// === PHASE 6: apply each entry with write-ahead journaling ===
|
|
1135
1151
|
|
|
1136
1152
|
const results = [];
|
|
1137
|
-
for (let i = 0; i <
|
|
1138
|
-
const
|
|
1153
|
+
for (let i = 0; i < writeSet.length; i++) {
|
|
1154
|
+
const item = writeSet[i];
|
|
1139
1155
|
|
|
1140
1156
|
// Write-ahead: record entry index BEFORE mutation
|
|
1141
1157
|
await assertLockAuthority();
|
|
@@ -1143,24 +1159,24 @@ async function applyArtifactPlanUnderLock({
|
|
|
1143
1159
|
txnHandle,
|
|
1144
1160
|
transactionId,
|
|
1145
1161
|
entryIndex: i,
|
|
1146
|
-
entry: { id:
|
|
1162
|
+
entry: { id: item.id, path: item.path, status: 'pending' },
|
|
1147
1163
|
});
|
|
1148
1164
|
if (faultInjector) await faultInjector(`after-entry-pending:${i}`);
|
|
1149
1165
|
|
|
1150
1166
|
// P0-4: Re-exact CAS before each target mutation
|
|
1151
|
-
const canonicalPath =
|
|
1152
|
-
?
|
|
1153
|
-
:
|
|
1154
|
-
const current = await assertFullCas(handle,
|
|
1167
|
+
const canonicalPath = item.path.endsWith('/')
|
|
1168
|
+
? item.path.slice(0, -1)
|
|
1169
|
+
: item.path;
|
|
1170
|
+
const current = await assertFullCas(handle, item.oldEntry, canonicalPath);
|
|
1155
1171
|
|
|
1156
1172
|
// P0-4: Create backup from the SAME stable read as CAS verification
|
|
1157
1173
|
// backup bytes must come from this CAS read, not a separate one
|
|
1158
|
-
if (
|
|
1174
|
+
if (item.oldEntry && item.oldEntry.kind === 'regular') {
|
|
1159
1175
|
// The exact bytes and unforgeable identity token come from the same
|
|
1160
1176
|
// stable read used for this per-entry CAS.
|
|
1161
|
-
const expectedOldSha =
|
|
1162
|
-
?
|
|
1163
|
-
: `sha256:${
|
|
1177
|
+
const expectedOldSha = item.oldEntry.sha256.startsWith('sha256:')
|
|
1178
|
+
? item.oldEntry.sha256
|
|
1179
|
+
: `sha256:${item.oldEntry.sha256}`;
|
|
1164
1180
|
if (current.sha256 !== expectedOldSha) {
|
|
1165
1181
|
throw new ReleaseError(
|
|
1166
1182
|
PLAN_STALE,
|
|
@@ -1173,7 +1189,7 @@ async function applyArtifactPlanUnderLock({
|
|
|
1173
1189
|
txnHandle,
|
|
1174
1190
|
transactionId,
|
|
1175
1191
|
entryIndex: i,
|
|
1176
|
-
oldEntry: { ...
|
|
1192
|
+
oldEntry: { ...item.oldEntry, bytes: current.bytes },
|
|
1177
1193
|
});
|
|
1178
1194
|
} else {
|
|
1179
1195
|
await assertLockAuthority();
|
|
@@ -1186,12 +1202,12 @@ async function applyArtifactPlanUnderLock({
|
|
|
1186
1202
|
}
|
|
1187
1203
|
if (faultInjector) await faultInjector(`after-entry-backup:${i}`);
|
|
1188
1204
|
|
|
1189
|
-
// Apply
|
|
1205
|
+
// Apply the mutation through the safe-fs handle
|
|
1190
1206
|
if (faultInjector) await faultInjector(`before-entry-mutation:${i}`);
|
|
1191
1207
|
await assertLockAuthority();
|
|
1192
1208
|
await applySingleArtifact(
|
|
1193
1209
|
handle,
|
|
1194
|
-
|
|
1210
|
+
item,
|
|
1195
1211
|
current?.kind === 'regular' ? current.identityToken : null,
|
|
1196
1212
|
);
|
|
1197
1213
|
if (faultInjector) await faultInjector(`after-entry-mutation:${i}`);
|
|
@@ -1202,11 +1218,11 @@ async function applyArtifactPlanUnderLock({
|
|
|
1202
1218
|
txnHandle,
|
|
1203
1219
|
transactionId,
|
|
1204
1220
|
entryIndex: i,
|
|
1205
|
-
entry: { id:
|
|
1221
|
+
entry: { id: item.id, path: item.path, status: 'applied' },
|
|
1206
1222
|
});
|
|
1207
1223
|
if (faultInjector) await faultInjector(`after-entry-applied:${i}`);
|
|
1208
1224
|
|
|
1209
|
-
results.push({ id:
|
|
1225
|
+
results.push({ id: item.id, path: item.path, applied: true });
|
|
1210
1226
|
|
|
1211
1227
|
}
|
|
1212
1228
|
|
|
@@ -1230,19 +1246,36 @@ async function applyArtifactPlanUnderLock({
|
|
|
1230
1246
|
});
|
|
1231
1247
|
if (faultInjector) await faultInjector('after-verifying-transition');
|
|
1232
1248
|
|
|
1233
|
-
await verifyManifest(handle,
|
|
1249
|
+
await verifyManifest(handle, writeSet);
|
|
1234
1250
|
if (faultInjector) await faultInjector('after-verify');
|
|
1235
1251
|
|
|
1236
1252
|
await assertLockAuthority();
|
|
1253
|
+
// The full COMMITTED journal becomes durable first (the 'after-committed'
|
|
1254
|
+
// durable point); terminal convergence runs as a POST-COMMITTED phase so
|
|
1255
|
+
// a crash at 'after-committed' still leaves the complete full record.
|
|
1237
1256
|
await writeJournalTransition({
|
|
1238
1257
|
txnHandle,
|
|
1239
1258
|
transactionId,
|
|
1240
1259
|
from: 'VERIFYING',
|
|
1241
1260
|
to: 'COMMITTED',
|
|
1261
|
+
convergeTerminal: false,
|
|
1242
1262
|
});
|
|
1243
1263
|
if (faultInjector) await faultInjector('after-committed');
|
|
1244
1264
|
|
|
1245
|
-
|
|
1265
|
+
// === PHASE 8: terminal convergence to the versioned receipt (AC-1) ===
|
|
1266
|
+
// Atomically rewrites journal.json as the small terminal receipt
|
|
1267
|
+
// ('before-terminal-receipt-write' / 'after-terminal-receipt-write' fault
|
|
1268
|
+
// points), then removes the now-unneeded backups/ and RECOVERY_REQUIRED
|
|
1269
|
+
// marker. A convergence failure rejects honestly (TRANSACTION_INCOMPLETE
|
|
1270
|
+
// with terminalReceiptPersisted/targetApplied) and keeps the complete
|
|
1271
|
+
// verifiable COMMITTED record on disk — see convergeTerminalRecord.
|
|
1272
|
+
await assertLockAuthority();
|
|
1273
|
+
const finalJournal = await convergeTerminalRecord({
|
|
1274
|
+
txnHandle,
|
|
1275
|
+
transactionId,
|
|
1276
|
+
faultInjector,
|
|
1277
|
+
recoverCommand,
|
|
1278
|
+
});
|
|
1246
1279
|
|
|
1247
1280
|
return Object.freeze({
|
|
1248
1281
|
transactionId,
|
|
@@ -1261,6 +1294,16 @@ async function applyArtifactPlanUnderLock({
|
|
|
1261
1294
|
if (applyErr?.lockOwnershipLost === true) {
|
|
1262
1295
|
throw applyErr;
|
|
1263
1296
|
}
|
|
1297
|
+
// Terminal receipt convergence failure AFTER the target was applied,
|
|
1298
|
+
// verified, and durably COMMITTED: the complete verifiable record is
|
|
1299
|
+
// already on disk at its latest durable state (the full COMMITTED
|
|
1300
|
+
// journal, or the durable receipt if cleanup failed). The RECOVERY_REQUIRED
|
|
1301
|
+
// protocol must NOT run — COMMITTED has no outgoing transitions, the
|
|
1302
|
+
// target must never be reported as needing rollback, and re-running
|
|
1303
|
+
// convergence completes the record. Propagate the honest error verbatim.
|
|
1304
|
+
if (applyErr?.terminalReceiptConvergenceFailed === true) {
|
|
1305
|
+
throw applyErr;
|
|
1306
|
+
}
|
|
1264
1307
|
|
|
1265
1308
|
const { recoveryError } = await tryRecoveryProtocol({
|
|
1266
1309
|
rootHandle: handle,
|
|
@@ -1270,8 +1313,9 @@ async function applyArtifactPlanUnderLock({
|
|
|
1270
1313
|
typeof applyErr?.code === 'string' ? applyErr.code : TRANSACTION_INCOMPLETE,
|
|
1271
1314
|
applyErr?.message || 'safe filesystem operation failed',
|
|
1272
1315
|
),
|
|
1273
|
-
artifacts:
|
|
1316
|
+
artifacts: writeSet,
|
|
1274
1317
|
journalCreated,
|
|
1318
|
+
recoverCommand,
|
|
1275
1319
|
});
|
|
1276
1320
|
|
|
1277
1321
|
if (recoveryError) {
|
|
@@ -1289,15 +1333,154 @@ async function applyArtifactPlanUnderLock({
|
|
|
1289
1333
|
closeError = error;
|
|
1290
1334
|
}
|
|
1291
1335
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1336
|
+
if (ownsRootHandle) {
|
|
1337
|
+
try {
|
|
1338
|
+
await handle.close();
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
closeError ??= error;
|
|
1341
|
+
}
|
|
1296
1342
|
}
|
|
1297
1343
|
if (closeError) throw closeError;
|
|
1298
1344
|
}
|
|
1299
1345
|
}
|
|
1300
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* Apply an artifact plan with durable transaction journaling.
|
|
1349
|
+
*
|
|
1350
|
+
* Reads and digest-verifies the artifact-plan v1 plan file through safe-fs
|
|
1351
|
+
* handles, validates the closed v1 schema, then delegates the write set to
|
|
1352
|
+
* the generic applyWriteSetUnderLock transaction core. All filesystem
|
|
1353
|
+
* mutations use the safe-fs backend. No Node path writes.
|
|
1354
|
+
*
|
|
1355
|
+
* @param {object} options
|
|
1356
|
+
* @param {string} options.root — Repository root (absolute).
|
|
1357
|
+
* @param {string} options.planPath — Path to the artifact plan file.
|
|
1358
|
+
* @param {string} options.planDigest — Expected plan digest.
|
|
1359
|
+
* @param {object} [options.safeFs] — Safe filesystem backend.
|
|
1360
|
+
* @param {Function} [options.faultInjector] — Fault injection for testing.
|
|
1361
|
+
* @param {Function} [options.clock] — Clock function for timestamps.
|
|
1362
|
+
* @returns {Promise<TransactionResult>}
|
|
1363
|
+
* @throws {ReleaseError} On validation failure or CAS mismatch.
|
|
1364
|
+
*/
|
|
1365
|
+
async function applyArtifactPlanUnderLock({
|
|
1366
|
+
root,
|
|
1367
|
+
planPath,
|
|
1368
|
+
planDigest,
|
|
1369
|
+
safeFs,
|
|
1370
|
+
faultInjector,
|
|
1371
|
+
clock,
|
|
1372
|
+
assertLockOwner = async () => {},
|
|
1373
|
+
} = {}) {
|
|
1374
|
+
// === PHASE 0: validate inputs and safe-fs availability ===
|
|
1375
|
+
|
|
1376
|
+
if (!root || typeof root !== 'string') {
|
|
1377
|
+
throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
|
|
1378
|
+
}
|
|
1379
|
+
if (!planPath || typeof planPath !== 'string') {
|
|
1380
|
+
throw new ReleaseError(MISSING_PARAMETERS, 'planPath is required');
|
|
1381
|
+
}
|
|
1382
|
+
if (!planDigest || typeof planDigest !== 'string') {
|
|
1383
|
+
throw new ReleaseError(MISSING_PARAMETERS, 'planDigest is required');
|
|
1384
|
+
}
|
|
1385
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
|
|
1386
|
+
throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
|
|
1387
|
+
}
|
|
1388
|
+
if (!safeFs) {
|
|
1389
|
+
throw new ReleaseError(
|
|
1390
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
1391
|
+
'safe filesystem backend is required',
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// === PHASE 1: validate plan file through safe-fs ===
|
|
1396
|
+
|
|
1397
|
+
const handle = await safeFs.openRoot(root);
|
|
1398
|
+
try {
|
|
1399
|
+
|
|
1400
|
+
// Convert the absolute plan path to a canonical root-relative path before
|
|
1401
|
+
// any fd-relative access. This rejects root itself and all escape spellings.
|
|
1402
|
+
const relPlanPath = canonicalArtifactPath(relative(root, planPath)).path;
|
|
1403
|
+
const planFileData = await withParentHandle(handle, relPlanPath, async (parent, leaf) => {
|
|
1404
|
+
const planEntry = await parent.readEntry(leaf);
|
|
1405
|
+
if (!planEntry || planEntry.kind === 'absent') {
|
|
1406
|
+
throw new ReleaseError(PLAN_STALE, 'plan file does not exist', { path: redactSensitivePaths(planPath) });
|
|
1407
|
+
}
|
|
1408
|
+
const planIsRegular = planEntry.kind === 'regular'
|
|
1409
|
+
|| planEntry.type === 'file'
|
|
1410
|
+
|| planEntry.type === 'blob';
|
|
1411
|
+
if (!planIsRegular) {
|
|
1412
|
+
throw new ReleaseError(PATH_UNSAFE, 'plan path is not a regular file', { path: redactSensitivePaths(planPath) });
|
|
1413
|
+
}
|
|
1414
|
+
if (typeof planEntry.nlink === 'number' && planEntry.nlink !== 1) {
|
|
1415
|
+
throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
|
|
1416
|
+
}
|
|
1417
|
+
const data = await parent.readFile(leaf);
|
|
1418
|
+
if (!data) {
|
|
1419
|
+
throw new ReleaseError(PLAN_STALE, 'plan file is unreadable', { path: redactSensitivePaths(planPath) });
|
|
1420
|
+
}
|
|
1421
|
+
if (Number(data.nlink) !== 1) {
|
|
1422
|
+
throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
|
|
1423
|
+
}
|
|
1424
|
+
return data;
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
let plan;
|
|
1428
|
+
try {
|
|
1429
|
+
plan = JSON.parse(planFileData.bytes.toString('utf8'));
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
throw new ReleaseError(
|
|
1432
|
+
PLAN_STALE,
|
|
1433
|
+
'plan file is not valid JSON',
|
|
1434
|
+
{ path: redactSensitivePaths(planPath), error: err.message },
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// === PHASE 2: recompute canonical plan digest ===
|
|
1439
|
+
|
|
1440
|
+
const recomputedDigest = computeCanonicalPlanDigest(plan);
|
|
1441
|
+
if (plan.planDigest !== planDigest || recomputedDigest !== planDigest) {
|
|
1442
|
+
throw new ReleaseError(
|
|
1443
|
+
PLAN_STALE,
|
|
1444
|
+
'plan digest does not match expected',
|
|
1445
|
+
{ expected: planDigest, embedded: plan.planDigest, actual: recomputedDigest },
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// === PHASE 3: closed artifact-plan v1 schema (zero side effects) ===
|
|
1450
|
+
|
|
1451
|
+
assertArtifactPlanClosedSchema(plan, planPath);
|
|
1452
|
+
|
|
1453
|
+
// === Delegate to the generic write-set transaction core ===
|
|
1454
|
+
// The closed v1 schema guarantees every artifact carries id/path/oldEntry/
|
|
1455
|
+
// newEntry (bytes decoded in place). Path safety, path uniqueness, full
|
|
1456
|
+
// CAS, probing, durable journaling, per-entry CAS/backup, manifest
|
|
1457
|
+
// verification, recovery, and the fault-injector point names all come from
|
|
1458
|
+
// the shared applyWriteSetUnderLock core.
|
|
1459
|
+
const writeSet = plan.artifacts.map((artifact) => ({
|
|
1460
|
+
id: artifact.id,
|
|
1461
|
+
path: artifact.path,
|
|
1462
|
+
oldEntry: artifact.oldEntry,
|
|
1463
|
+
newEntry: artifact.newEntry,
|
|
1464
|
+
}));
|
|
1465
|
+
return await applyWriteSetUnderLock({
|
|
1466
|
+
root,
|
|
1467
|
+
writeSet,
|
|
1468
|
+
canonicalPlan: plan,
|
|
1469
|
+
planDigest,
|
|
1470
|
+
safeFs,
|
|
1471
|
+
faultInjector,
|
|
1472
|
+
clock,
|
|
1473
|
+
assertLockOwner,
|
|
1474
|
+
rootHandle: handle,
|
|
1475
|
+
});
|
|
1476
|
+
} finally {
|
|
1477
|
+
// The root handle is owned by this wrapper; the generic core reuses it
|
|
1478
|
+
// without closing it (mirrors the pre-refactor single-handle lifecycle).
|
|
1479
|
+
// A close failure after any outcome fails closed.
|
|
1480
|
+
await handle.close();
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1301
1484
|
/**
|
|
1302
1485
|
* Public apply entry. The shared project lock is held from plan read and
|
|
1303
1486
|
* preflight through COMMITTED or durable RECOVERY_REQUIRED. Direct API users
|