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
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-document refresh service: the two-phase local closed loop
|
|
3
|
+
* (2026-07-21-release-docs-command-and-prepare-gate §2/§3,
|
|
4
|
+
* 2026-07-21-release-docs-refresh-protocol).
|
|
5
|
+
*
|
|
6
|
+
* `planReleaseDocsRefreshForUnit({ root, config, unit, version,
|
|
7
|
+
* backendFactory })` is the shared read-only planning primitive (used by the
|
|
8
|
+
* prepare freshness gate and by this service): it selects the unit's
|
|
9
|
+
* releaseDocuments block, reads the notes source through
|
|
10
|
+
* `loadReleaseNotesSource`, reads EVERY configured target through the
|
|
11
|
+
* exported `readSafeFileThroughHandles` safe-read primitive (root handle,
|
|
12
|
+
* per-segment openat/O_NOFOLLOW, regular file, nlink === 1, size cap, stable
|
|
13
|
+
* identity — no absolute-path fs fallback; fail closed when the backend is
|
|
14
|
+
* unavailable), captures the permission mode of each target from the same
|
|
15
|
+
* stable readFile, and returns the deeply frozen `{ plan, display, modes }`
|
|
16
|
+
* triple. It performs ZERO workspace/control-plane writes: no `.release-skill`
|
|
17
|
+
* directory, no lock, no plan file, no journal, no probe.
|
|
18
|
+
*
|
|
19
|
+
* `runReleaseDocsRefresh({ root, unitId, write, confirmRefresh,
|
|
20
|
+
* ackLocalDocumentWrite, explicitVersion, backendFactory, faultInjector,
|
|
21
|
+
* clock })`:
|
|
22
|
+
* - dry-run (write falsy): selects the unique unit from `loadProjectConfig`
|
|
23
|
+
* (UNIT_NOT_FOUND / UNIT_DUPLICATE fail closed), resolves the authoritative
|
|
24
|
+
* version from `unit.version.source` (an explicitVersion is only a
|
|
25
|
+
* consistency assertion — mismatch fails closed with GATE_FAILED), and
|
|
26
|
+
* returns the safe display projection. Zero writes.
|
|
27
|
+
* - write (write === true): requires ALL THREE authorizations — `write`,
|
|
28
|
+
* an exact `confirmRefresh` digest match, and
|
|
29
|
+
* `ackLocalDocumentWrite === true` — validated before ANY I/O. Under the
|
|
30
|
+
* exclusive project lock it reloads config/version/notes/targets and
|
|
31
|
+
* re-plans; a diverging refreshDigest converges to RELEASE_DOCS_REFRESH_STALE
|
|
32
|
+
* with zero target writes; a clean plan is a zero-write no-op. Otherwise it
|
|
33
|
+
* commits every changed target through the generic
|
|
34
|
+
* `applyWriteSetUnderLock` transaction core (multi-file preflight CAS
|
|
35
|
+
* before the first write, per-entry re-CAS, durable journal/backup,
|
|
36
|
+
* RECOVERY_REQUIRED recovery with the unique recover command), then
|
|
37
|
+
* re-plans read-only under the lock and requires `clean`. A mid-flight
|
|
38
|
+
* failure restores the exact old bytes under the still-held lock (the
|
|
39
|
+
* journal stays RECOVERY_REQUIRED and the unique recover command remains
|
|
40
|
+
* authoritative).
|
|
41
|
+
*
|
|
42
|
+
* All user-visible output carries canonical relative paths only: never
|
|
43
|
+
* `/Users/...`, never note/old body text, never credentials, never full
|
|
44
|
+
* diffs, never serialized buffers.
|
|
45
|
+
*
|
|
46
|
+
* @module src/docs/refresh-service
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
50
|
+
|
|
51
|
+
import { loadSafeFs } from '../artifacts/safe-fs.mjs';
|
|
52
|
+
import { acquireProjectLock } from '../artifacts/project-lock.mjs';
|
|
53
|
+
import { applyWriteSetUnderLock } from '../artifacts/transaction.mjs';
|
|
54
|
+
import { loadProjectConfig } from '../core/config.mjs';
|
|
55
|
+
import {
|
|
56
|
+
ReleaseError,
|
|
57
|
+
MISSING_PARAMETERS,
|
|
58
|
+
PATH_UNSAFE,
|
|
59
|
+
RELEASE_DOCS_INVALID,
|
|
60
|
+
RELEASE_DOCS_REFRESH_STALE,
|
|
61
|
+
TRANSACTION_INCOMPLETE,
|
|
62
|
+
} from '../core/errors.mjs';
|
|
63
|
+
import { resolveUnitVersion } from '../commands/prepare.mjs';
|
|
64
|
+
|
|
65
|
+
import { normalizeReleaseDocumentsConfig } from './config.mjs';
|
|
66
|
+
import { loadReleaseNotesSource, readSafeFileThroughHandles } from './notes-loader.mjs';
|
|
67
|
+
import { DEFAULT_MAX_NOTES_BYTES } from './notes.mjs';
|
|
68
|
+
import {
|
|
69
|
+
createReleaseDocsRefreshPlan,
|
|
70
|
+
projectReleaseDocsRefreshDisplay,
|
|
71
|
+
} from './refresh-planner.mjs';
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Small utilities
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deeply freeze a plain object/array structure. Typed arrays (Buffer) are
|
|
79
|
+
* skipped: the JS specification forbids freezing non-empty ArrayBuffer
|
|
80
|
+
* views; byte buffers stay immutable by always being fresh copies.
|
|
81
|
+
*/
|
|
82
|
+
function deepFreeze(value) {
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
for (const item of value) deepFreeze(item);
|
|
85
|
+
return Object.freeze(value);
|
|
86
|
+
}
|
|
87
|
+
if (value !== null && typeof value === 'object') {
|
|
88
|
+
if (ArrayBuffer.isView(value)) return value;
|
|
89
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
90
|
+
return Object.freeze(value);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render a permission mode captured by a stable readFile (a number such as
|
|
97
|
+
* 0o644 === 420) as the six-digit octal string the transaction entry schema
|
|
98
|
+
* expects ('000644'); the last three digits drive CAS and journal checks.
|
|
99
|
+
*/
|
|
100
|
+
function modeToString(mode) {
|
|
101
|
+
if (typeof mode === 'string' && mode.length > 0) return mode;
|
|
102
|
+
if (Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o777) {
|
|
103
|
+
return mode.toString(8).padStart(6, '0');
|
|
104
|
+
}
|
|
105
|
+
return '000644';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Select the unique release unit by id from the loaded project config.
|
|
110
|
+
* Unknown and duplicated ids fail closed with precise stable errors.
|
|
111
|
+
*/
|
|
112
|
+
function selectUnit(config, unitId) {
|
|
113
|
+
const units = Array.isArray(config?.releaseUnits) ? config.releaseUnits : [];
|
|
114
|
+
const matches = units.filter((unit) => unit?.id === unitId);
|
|
115
|
+
if (matches.length === 0) {
|
|
116
|
+
throw new ReleaseError(
|
|
117
|
+
RELEASE_DOCS_INVALID,
|
|
118
|
+
`release unit "${unitId}" was not found in the project configuration`,
|
|
119
|
+
{ reason: 'UNIT_NOT_FOUND', unitId, available: units.map((unit) => unit?.id) },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (matches.length > 1) {
|
|
123
|
+
throw new ReleaseError(
|
|
124
|
+
RELEASE_DOCS_INVALID,
|
|
125
|
+
`release unit "${unitId}" is declared more than once in the project configuration`,
|
|
126
|
+
{ reason: 'UNIT_DUPLICATE', unitId },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return matches[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Read-only planning primitive (shared by the service and the prepare gate)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Plan the release-document refresh for one release unit, read-only.
|
|
138
|
+
*
|
|
139
|
+
* Reads the notes source and every configured target exclusively through
|
|
140
|
+
* the safe-fs backend handle API; performs ZERO workspace or control-plane
|
|
141
|
+
* writes (no `.release-skill`, no lock, no plan file, no journal, no probe).
|
|
142
|
+
*
|
|
143
|
+
* @param {object} options
|
|
144
|
+
* @param {string} options.root — Absolute project root.
|
|
145
|
+
* @param {object} [options.config] — Loaded project config (reserved for
|
|
146
|
+
* gate/service context; the unit object below is authoritative).
|
|
147
|
+
* @param {object} options.unit — Release unit object from config.releaseUnits.
|
|
148
|
+
* @param {string} options.version — Already-resolved authoritative version.
|
|
149
|
+
* @param {() => Promise<object>} [options.backendFactory] — Safe-fs backend
|
|
150
|
+
* factory (default loadSafeFs); fail closed when unavailable.
|
|
151
|
+
* @returns {Promise<Readonly<{ plan: object, display: object, modes: Map<string, number> }>>}
|
|
152
|
+
* Deeply frozen triple; `modes` maps canonical target paths to the
|
|
153
|
+
* permission mode captured by the same stable readFile (write-phase
|
|
154
|
+
* internal only — never part of the display projection).
|
|
155
|
+
* @throws {ReleaseError} RELEASE_DOCS_INVALID (unit/config/semantic),
|
|
156
|
+
* PATH_UNSAFE (path/race), SAFE_WRITE_UNAVAILABLE (backend), plus
|
|
157
|
+
* STRUCTURE_INVALID / RELEASE_DOCS_CONFLICT / RELEASE_DOCS_TRANSLATION_MISSING
|
|
158
|
+
* propagated from the renderers/parser.
|
|
159
|
+
*/
|
|
160
|
+
export async function planReleaseDocsRefreshForUnit({
|
|
161
|
+
root,
|
|
162
|
+
config,
|
|
163
|
+
unit,
|
|
164
|
+
version,
|
|
165
|
+
backendFactory,
|
|
166
|
+
} = {}) {
|
|
167
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
168
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, 'root must be a non-empty string', {
|
|
169
|
+
reason: 'INVALID_OPTIONS',
|
|
170
|
+
field: 'root',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (!unit || typeof unit !== 'object' || Array.isArray(unit)) {
|
|
174
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit must be a release unit object', {
|
|
175
|
+
reason: 'INVALID_OPTIONS',
|
|
176
|
+
field: 'unit',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (typeof unit.id !== 'string' || unit.id.length === 0) {
|
|
180
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit.id must be a non-empty string', {
|
|
181
|
+
reason: 'INVALID_OPTIONS',
|
|
182
|
+
field: 'unit.id',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (typeof version !== 'string' || version.length === 0) {
|
|
186
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, 'version must be a non-empty string', {
|
|
187
|
+
reason: 'INVALID_OPTIONS',
|
|
188
|
+
field: 'version',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (unit.releaseDocuments === undefined || unit.releaseDocuments === null) {
|
|
192
|
+
throw new ReleaseError(
|
|
193
|
+
RELEASE_DOCS_INVALID,
|
|
194
|
+
`release unit "${unit.id}" does not configure releaseDocuments`,
|
|
195
|
+
{ reason: 'RELEASE_DOCUMENTS_NOT_CONFIGURED', unitId: unit.id },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (typeof unit.source !== 'string' || unit.source.length === 0) {
|
|
199
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit.source must be a non-empty string', {
|
|
200
|
+
reason: 'INVALID_OPTIONS',
|
|
201
|
+
field: 'unit.source',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unitRoot = resolve(root, unit.source);
|
|
206
|
+
|
|
207
|
+
// One backend instance per planning pass; fail closed when unavailable —
|
|
208
|
+
// there is no absolute-path fallback.
|
|
209
|
+
const backend = await (backendFactory ?? loadSafeFs)();
|
|
210
|
+
const sharedFactory = async () => backend;
|
|
211
|
+
|
|
212
|
+
// 1. Structured notes source through the existing safe loader.
|
|
213
|
+
const notesSource = await loadReleaseNotesSource({
|
|
214
|
+
unitRoot,
|
|
215
|
+
config: unit.releaseDocuments,
|
|
216
|
+
version,
|
|
217
|
+
backendFactory: sharedFactory,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 2. Closed semantic normalization, then every target through the same
|
|
221
|
+
// safe-read primitive (regular file, nlink === 1, size cap, identity).
|
|
222
|
+
const normalized = normalizeReleaseDocumentsConfig(unit.releaseDocuments);
|
|
223
|
+
const targets = [
|
|
224
|
+
...normalized.changelogs.map((entry) => ({ path: entry.path, kind: 'changelog', locale: entry.locale })),
|
|
225
|
+
...normalized.readmes.map((entry) => ({ path: entry.path, kind: 'readme', locale: entry.locale })),
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const oldFiles = [];
|
|
229
|
+
const modes = new Map();
|
|
230
|
+
for (const target of targets) {
|
|
231
|
+
const read = await readSafeFileThroughHandles(
|
|
232
|
+
backend,
|
|
233
|
+
unitRoot,
|
|
234
|
+
target.path,
|
|
235
|
+
DEFAULT_MAX_NOTES_BYTES,
|
|
236
|
+
undefined,
|
|
237
|
+
'release document target',
|
|
238
|
+
);
|
|
239
|
+
oldFiles.push({
|
|
240
|
+
path: target.path,
|
|
241
|
+
kind: target.kind,
|
|
242
|
+
locale: target.locale,
|
|
243
|
+
bytes: read.bytes,
|
|
244
|
+
});
|
|
245
|
+
modes.set(target.path, read.mode);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. Pure deterministic planning + safe display projection.
|
|
249
|
+
const plan = createReleaseDocsRefreshPlan({
|
|
250
|
+
unitId: unit.id,
|
|
251
|
+
version,
|
|
252
|
+
config: normalized,
|
|
253
|
+
notes: notesSource.notes,
|
|
254
|
+
notesSourceDigest: notesSource.bytesDigest,
|
|
255
|
+
oldFiles,
|
|
256
|
+
});
|
|
257
|
+
const display = projectReleaseDocsRefreshDisplay(plan);
|
|
258
|
+
|
|
259
|
+
return deepFreeze({ plan, display, modes });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Mid-flight failure: restore the exact old bytes under the held lock
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Restore one target to its exact old bytes through the safe-fs backend
|
|
268
|
+
* handle API (stable read → identity-bound createTemp+rename → fsync).
|
|
269
|
+
* Fails closed; handles are closed in reverse order.
|
|
270
|
+
*/
|
|
271
|
+
async function restoreOneTarget(backend, unitRoot, file, mode) {
|
|
272
|
+
const segments = file.path.split('/');
|
|
273
|
+
const handleStack = [];
|
|
274
|
+
let primaryError = null;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
handleStack.push(await backend.openRoot(unitRoot));
|
|
278
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
279
|
+
handleStack.push(await handleStack[handleStack.length - 1].openDir(segments[i]));
|
|
280
|
+
}
|
|
281
|
+
const parent = handleStack[handleStack.length - 1];
|
|
282
|
+
const leaf = segments[segments.length - 1];
|
|
283
|
+
|
|
284
|
+
// Stable read of the current state: its identity authorizes the
|
|
285
|
+
// identity-bound rename (and tells us whether a write is needed at all).
|
|
286
|
+
const current = await parent.readFile(leaf);
|
|
287
|
+
const oldBytes = Buffer.from(file.oldBytes);
|
|
288
|
+
const alreadyOld = current !== null
|
|
289
|
+
&& current !== undefined
|
|
290
|
+
&& Buffer.isBuffer(current.bytes)
|
|
291
|
+
&& current.bytes.equals(oldBytes);
|
|
292
|
+
|
|
293
|
+
if (!alreadyOld) {
|
|
294
|
+
const writeMode = Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o777 ? mode : 0o644;
|
|
295
|
+
const token = await parent.createTemp(leaf, writeMode, oldBytes);
|
|
296
|
+
try {
|
|
297
|
+
await parent.rename(token, leaf, current ?? undefined);
|
|
298
|
+
} catch (renameError) {
|
|
299
|
+
try {
|
|
300
|
+
await parent.abortTemp(token);
|
|
301
|
+
} catch {
|
|
302
|
+
// Best effort: the primary rename error is authoritative.
|
|
303
|
+
}
|
|
304
|
+
throw renameError;
|
|
305
|
+
}
|
|
306
|
+
await parent.fsync();
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
primaryError = err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const closeFailures = [];
|
|
313
|
+
for (let i = handleStack.length - 1; i >= 0; i -= 1) {
|
|
314
|
+
try {
|
|
315
|
+
await handleStack[i].close();
|
|
316
|
+
} catch (closeErr) {
|
|
317
|
+
closeFailures.push(closeErr?.code ?? 'CLOSE_FAILED');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (primaryError) throw primaryError;
|
|
321
|
+
if (closeFailures.length > 0) {
|
|
322
|
+
throw new ReleaseError(
|
|
323
|
+
TRANSACTION_INCOMPLETE,
|
|
324
|
+
'release document restore handle close failed',
|
|
325
|
+
{ closeFailures },
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Restore every changed target to its exact old bytes under the still-held
|
|
332
|
+
* project lock after a mid-flight transaction failure. Returns true only
|
|
333
|
+
* when every target is back at its old bytes; any failure resolves false so
|
|
334
|
+
* the caller surfaces the original RECOVERY_REQUIRED error unchanged.
|
|
335
|
+
*/
|
|
336
|
+
async function tryRestoreOldBytes(backend, unitRoot, changedFiles, modes) {
|
|
337
|
+
try {
|
|
338
|
+
for (const file of changedFiles) {
|
|
339
|
+
await restoreOneTarget(backend, unitRoot, file, modes.get(file.path));
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Public command entry: dry-run projection and authorized atomic write
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Run the two-phase release-document refresh for one release unit.
|
|
353
|
+
*
|
|
354
|
+
* Dry-run (write falsy): read-only, zero workspace/control-plane writes,
|
|
355
|
+
* returns the safe display projection plus the authoritative version.
|
|
356
|
+
*
|
|
357
|
+
* Write (write === true): requires the exact `confirmRefresh` digest AND
|
|
358
|
+
* `ackLocalDocumentWrite === true` (validated before any I/O); re-plans
|
|
359
|
+
* everything under the exclusive project lock; commits changed targets
|
|
360
|
+
* through the durable transaction core; re-plans read-only afterwards and
|
|
361
|
+
* requires a clean result.
|
|
362
|
+
*
|
|
363
|
+
* @param {object} options
|
|
364
|
+
* @param {string} options.root — Absolute project root.
|
|
365
|
+
* @param {string} options.unitId — Release unit identifier.
|
|
366
|
+
* @param {boolean} [options.write] — Explicit write authorization.
|
|
367
|
+
* @param {string} [options.confirmRefresh] — Exact dry-run refreshDigest.
|
|
368
|
+
* @param {boolean} [options.ackLocalDocumentWrite] — Explicit local document
|
|
369
|
+
* write acknowledgement.
|
|
370
|
+
* @param {string} [options.explicitVersion] — Consistency assertion only;
|
|
371
|
+
* never overrides `unit.version.source`.
|
|
372
|
+
* @param {() => Promise<object>} [options.backendFactory] — Safe-fs backend
|
|
373
|
+
* factory (default loadSafeFs).
|
|
374
|
+
* @param {Function} [options.faultInjector] — Test-only fault injection for
|
|
375
|
+
* the transaction core.
|
|
376
|
+
* @param {Function} [options.clock] — Test-only clock.
|
|
377
|
+
* @returns {Promise<object>} Frozen dry-run/write/clean result.
|
|
378
|
+
* @throws {ReleaseError} MISSING_PARAMETERS (24), RELEASE_DOCS_INVALID (42),
|
|
379
|
+
* RELEASE_DOCS_REFRESH_STALE (45), GATE_FAILED (13), PATH_UNSAFE (28),
|
|
380
|
+
* SAFE_WRITE_UNAVAILABLE (39), PLAN_STALE (36), TRANSACTION_INCOMPLETE (38).
|
|
381
|
+
*/
|
|
382
|
+
export async function runReleaseDocsRefresh({
|
|
383
|
+
root,
|
|
384
|
+
unitId,
|
|
385
|
+
write = false,
|
|
386
|
+
confirmRefresh,
|
|
387
|
+
ackLocalDocumentWrite = false,
|
|
388
|
+
explicitVersion,
|
|
389
|
+
backendFactory,
|
|
390
|
+
faultInjector,
|
|
391
|
+
clock,
|
|
392
|
+
} = {}) {
|
|
393
|
+
// 1. Parameters precede everything.
|
|
394
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
395
|
+
throw new ReleaseError(MISSING_PARAMETERS, 'root is required', { field: 'root' });
|
|
396
|
+
}
|
|
397
|
+
if (typeof unitId !== 'string' || unitId.length === 0) {
|
|
398
|
+
throw new ReleaseError(MISSING_PARAMETERS, 'unitId is required', { field: 'unitId' });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 2. Write authorization precedes ALL I/O (defense in depth: the CLI
|
|
402
|
+
// validates the same three-way binding before invoking the service).
|
|
403
|
+
if (write) {
|
|
404
|
+
const missing = [];
|
|
405
|
+
if (typeof confirmRefresh !== 'string' || confirmRefresh.length === 0) {
|
|
406
|
+
missing.push('confirmRefresh');
|
|
407
|
+
}
|
|
408
|
+
if (ackLocalDocumentWrite !== true) {
|
|
409
|
+
missing.push('ackLocalDocumentWrite');
|
|
410
|
+
}
|
|
411
|
+
if (missing.length > 0) {
|
|
412
|
+
throw new ReleaseError(
|
|
413
|
+
MISSING_PARAMETERS,
|
|
414
|
+
'docs refresh --write requires the exact confirmRefresh digest and an explicit local document write acknowledgement',
|
|
415
|
+
{ reason: 'MISSING_WRITE_PARAMETERS', missing },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 3. One backend instance per invocation; fail closed when unavailable.
|
|
421
|
+
const backend = await (backendFactory ?? loadSafeFs)();
|
|
422
|
+
const sharedFactory = async () => backend;
|
|
423
|
+
|
|
424
|
+
// -----------------------------------------------------------------
|
|
425
|
+
// DRY-RUN — read-only, zero writes
|
|
426
|
+
// -----------------------------------------------------------------
|
|
427
|
+
if (!write) {
|
|
428
|
+
const { config } = await loadProjectConfig({ root });
|
|
429
|
+
const unit = selectUnit(config, unitId);
|
|
430
|
+
const version = await resolveUnitVersion(unit, root, explicitVersion);
|
|
431
|
+
const { display } = await planReleaseDocsRefreshForUnit({
|
|
432
|
+
root,
|
|
433
|
+
config,
|
|
434
|
+
unit,
|
|
435
|
+
version,
|
|
436
|
+
backendFactory: sharedFactory,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const result = {
|
|
440
|
+
command: 'docs-refresh',
|
|
441
|
+
mode: 'dry-run',
|
|
442
|
+
status: display.status,
|
|
443
|
+
unitId,
|
|
444
|
+
locales: [...display.locales],
|
|
445
|
+
inputDigest: display.inputDigest,
|
|
446
|
+
refreshDigest: display.refreshDigest,
|
|
447
|
+
files: display.files,
|
|
448
|
+
nextCommand: display.nextCommand,
|
|
449
|
+
};
|
|
450
|
+
// The authoritative version is exposed to callers but kept off the
|
|
451
|
+
// enumerable projection surface; the CLI re-adds it to its JSON shape.
|
|
452
|
+
Object.defineProperty(result, 'version', {
|
|
453
|
+
value: version,
|
|
454
|
+
enumerable: false,
|
|
455
|
+
writable: false,
|
|
456
|
+
configurable: false,
|
|
457
|
+
});
|
|
458
|
+
return Object.freeze(result);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// -----------------------------------------------------------------
|
|
462
|
+
// WRITE — exclusive lock, under-lock re-plan, transactional commit
|
|
463
|
+
// -----------------------------------------------------------------
|
|
464
|
+
// The project lock keeps its own wall-clock owner timestamps (its clock
|
|
465
|
+
// contract requires ISO-8601); the injected test clock is reserved for the
|
|
466
|
+
// deterministic transaction-id generation inside the transaction core.
|
|
467
|
+
const lock = await acquireProjectLock({
|
|
468
|
+
root,
|
|
469
|
+
command: 'docs refresh',
|
|
470
|
+
mode: 'exclusive',
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
let result;
|
|
474
|
+
let primaryError;
|
|
475
|
+
try {
|
|
476
|
+
result = await lock.capture(async () => {
|
|
477
|
+
// Reload everything under the exclusive lock and re-plan from
|
|
478
|
+
// scratch: config, authoritative version, notes source, all targets.
|
|
479
|
+
const { config } = await loadProjectConfig({ root });
|
|
480
|
+
const unit = selectUnit(config, unitId);
|
|
481
|
+
const version = await resolveUnitVersion(unit, root, explicitVersion);
|
|
482
|
+
const { plan, modes } = await planReleaseDocsRefreshForUnit({
|
|
483
|
+
root,
|
|
484
|
+
config,
|
|
485
|
+
unit,
|
|
486
|
+
version,
|
|
487
|
+
backendFactory: sharedFactory,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Exact refreshDigest confirmation. ANY concurrent change (config,
|
|
491
|
+
// notes source, version, or any target) changes the digest and
|
|
492
|
+
// converges here with ZERO target writes.
|
|
493
|
+
if (plan.refreshDigest !== confirmRefresh) {
|
|
494
|
+
throw new ReleaseError(
|
|
495
|
+
RELEASE_DOCS_REFRESH_STALE,
|
|
496
|
+
`release documents for unit "${unitId}" changed since the confirmed dry-run`,
|
|
497
|
+
{
|
|
498
|
+
unitId,
|
|
499
|
+
version,
|
|
500
|
+
expected: confirmRefresh,
|
|
501
|
+
actual: plan.refreshDigest,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Clean plans are a zero-write no-op even in write mode (no
|
|
507
|
+
// transaction, no journal).
|
|
508
|
+
if (plan.status === 'clean') {
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
command: 'docs-refresh',
|
|
511
|
+
mode: 'write',
|
|
512
|
+
status: 'clean',
|
|
513
|
+
refreshed: false,
|
|
514
|
+
unitId,
|
|
515
|
+
version,
|
|
516
|
+
refreshDigest: plan.refreshDigest,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Build the write set (changed targets only; canonical path order)
|
|
521
|
+
// and the closed docs-refresh v1 canonical plan (no bytes, no
|
|
522
|
+
// absolute paths) persisted as the journal authority.
|
|
523
|
+
//
|
|
524
|
+
// The transaction core resolves write-set paths against the project
|
|
525
|
+
// root (where the durable journal lives), while planning, target
|
|
526
|
+
// reads/restores, and every user-facing projection stay unit-relative.
|
|
527
|
+
// Prefix each unit-relative target path with the unit's canonical
|
|
528
|
+
// project-relative location ('' when the unit sits at the root).
|
|
529
|
+
const changedFiles = plan.files.filter((file) => file.changed);
|
|
530
|
+
const unitRoot = resolve(root, unit.source);
|
|
531
|
+
const unitLocation = relative(root, unitRoot);
|
|
532
|
+
if (unitLocation === '..'
|
|
533
|
+
|| unitLocation.startsWith(`..${sep}`)
|
|
534
|
+
|| isAbsolute(unitLocation)) {
|
|
535
|
+
throw new ReleaseError(
|
|
536
|
+
PATH_UNSAFE,
|
|
537
|
+
'release unit source escapes the project root',
|
|
538
|
+
{ reason: 'UNIT_SOURCE_ESCAPE', unitId },
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const unitPrefix = unitLocation === ''
|
|
542
|
+
? ''
|
|
543
|
+
: `${unitLocation.split(sep).join('/')}/`;
|
|
544
|
+
const projectPath = (targetPath) => `${unitPrefix}${targetPath}`;
|
|
545
|
+
const writeSet = changedFiles.map((file) => ({
|
|
546
|
+
id: `${file.kind}:${projectPath(file.path)}`,
|
|
547
|
+
path: projectPath(file.path),
|
|
548
|
+
oldEntry: {
|
|
549
|
+
kind: 'regular',
|
|
550
|
+
sha256: file.oldDigest,
|
|
551
|
+
size: file.summary.oldSize,
|
|
552
|
+
mode: modeToString(modes.get(file.path)),
|
|
553
|
+
},
|
|
554
|
+
newEntry: {
|
|
555
|
+
kind: 'regular',
|
|
556
|
+
bytes: Buffer.from(file.newBytes),
|
|
557
|
+
sha256: file.newDigest,
|
|
558
|
+
size: file.summary.newSize,
|
|
559
|
+
mode: modeToString(modes.get(file.path)),
|
|
560
|
+
},
|
|
561
|
+
}));
|
|
562
|
+
const canonicalPlan = {
|
|
563
|
+
apiVersion: 'release-skill.dev/docs-refresh/v1',
|
|
564
|
+
operation: 'refresh',
|
|
565
|
+
unitId,
|
|
566
|
+
version,
|
|
567
|
+
refreshDigest: plan.refreshDigest,
|
|
568
|
+
files: changedFiles.map((file) => ({
|
|
569
|
+
id: `${file.kind}:${projectPath(file.path)}`,
|
|
570
|
+
path: projectPath(file.path),
|
|
571
|
+
kind: file.kind,
|
|
572
|
+
locale: file.locale,
|
|
573
|
+
oldDigest: file.oldDigest,
|
|
574
|
+
newDigest: file.newDigest,
|
|
575
|
+
change: file.change,
|
|
576
|
+
})),
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
let applyResult;
|
|
580
|
+
try {
|
|
581
|
+
applyResult = await applyWriteSetUnderLock({
|
|
582
|
+
root,
|
|
583
|
+
writeSet,
|
|
584
|
+
canonicalPlan,
|
|
585
|
+
planDigest: plan.refreshDigest,
|
|
586
|
+
safeFs: backend,
|
|
587
|
+
faultInjector,
|
|
588
|
+
clock,
|
|
589
|
+
assertLockOwner: () => lock.assertOwner(),
|
|
590
|
+
});
|
|
591
|
+
} catch (err) {
|
|
592
|
+
// Mid-flight failure: the transaction core has already entered the
|
|
593
|
+
// durable RECOVERY_REQUIRED protocol (journal + marker + unique
|
|
594
|
+
// recover command). Restore the exact old bytes under the
|
|
595
|
+
// still-held lock so the workspace never keeps an undeclared
|
|
596
|
+
// partial refresh; the journal state and the recover command
|
|
597
|
+
// remain authoritative for reconciliation.
|
|
598
|
+
if (err instanceof ReleaseError
|
|
599
|
+
&& err.code === TRANSACTION_INCOMPLETE
|
|
600
|
+
&& typeof err.details?.recover === 'string') {
|
|
601
|
+
const restored = await tryRestoreOldBytes(
|
|
602
|
+
backend,
|
|
603
|
+
resolve(root, unit.source),
|
|
604
|
+
changedFiles,
|
|
605
|
+
modes,
|
|
606
|
+
);
|
|
607
|
+
if (restored) {
|
|
608
|
+
const adjusted = new ReleaseError(TRANSACTION_INCOMPLETE, err.message, {
|
|
609
|
+
...err.details,
|
|
610
|
+
targetUnchanged: true,
|
|
611
|
+
});
|
|
612
|
+
adjusted.transactionId = err.transactionId;
|
|
613
|
+
throw adjusted;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Post-write read-only re-plan under the lock: success requires a
|
|
620
|
+
// clean plan (the freshly written bytes render to themselves).
|
|
621
|
+
const { plan: recheckPlan } = await planReleaseDocsRefreshForUnit({
|
|
622
|
+
root,
|
|
623
|
+
config,
|
|
624
|
+
unit,
|
|
625
|
+
version,
|
|
626
|
+
backendFactory: sharedFactory,
|
|
627
|
+
});
|
|
628
|
+
if (recheckPlan.status !== 'clean') {
|
|
629
|
+
throw new ReleaseError(
|
|
630
|
+
TRANSACTION_INCOMPLETE,
|
|
631
|
+
'release documents are not clean after the transactional write',
|
|
632
|
+
{ reason: 'POST_WRITE_NOT_CLEAN', unitId, version },
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return Object.freeze({
|
|
637
|
+
command: 'docs-refresh',
|
|
638
|
+
mode: 'write',
|
|
639
|
+
status: 'refreshed',
|
|
640
|
+
refreshed: true,
|
|
641
|
+
unitId,
|
|
642
|
+
version,
|
|
643
|
+
refreshDigest: plan.refreshDigest,
|
|
644
|
+
transactionId: applyResult.transactionId,
|
|
645
|
+
refreshedPaths: changedFiles.map((file) => file.path),
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
primaryError = error;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Release the lock; mirror the artifact-apply combined-error pattern on
|
|
653
|
+
// release failure — never swallow the business error.
|
|
654
|
+
try {
|
|
655
|
+
await lock.release();
|
|
656
|
+
} catch (releaseError) {
|
|
657
|
+
if (primaryError) {
|
|
658
|
+
const combined = new ReleaseError(
|
|
659
|
+
TRANSACTION_INCOMPLETE,
|
|
660
|
+
'docs refresh failed and project lock release also failed',
|
|
661
|
+
{
|
|
662
|
+
businessErrorCode: primaryError?.code || null,
|
|
663
|
+
releaseErrorCode: releaseError?.code || null,
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
combined.cause = primaryError;
|
|
667
|
+
combined.releaseCause = releaseError;
|
|
668
|
+
throw combined;
|
|
669
|
+
}
|
|
670
|
+
throw releaseError;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (primaryError) throw primaryError;
|
|
674
|
+
return result;
|
|
675
|
+
}
|