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