wowbagger 0.1.0-alpha.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +509 -0
- package/README.md +272 -136
- package/docs/adapter-contract.md +1 -1
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +354 -82
- package/docs/work-claim-contract.md +466 -88
- package/package.json +2 -2
- package/schemas/core-capabilities-response.json +1 -1
- package/schemas/core-envelope.json +4 -3
- package/schemas/index.json +18 -0
- package/schemas/ledger-repair-proposal.json +170 -0
- package/schemas/ledger-repair-request.json +61 -0
- package/schemas/ledger-repair-response.json +90 -0
- package/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +242 -59
- package/src/adapter/core-probe.js +3 -4
- package/src/adapter/process-outcome.js +8 -1
- package/src/claim-capabilities.js +3 -3
- package/src/claim-coordinator.js +61 -12
- package/src/claim-journal.js +212 -7
- package/src/claim-prospective.js +1 -28
- package/src/claim-publication.js +412 -78
- package/src/claim-request.js +9 -0
- package/src/claim-store.js +9 -4
- package/src/cli.js +302 -56
- package/src/extensions.js +1 -0
- package/src/git-autocommit.js +106 -43
- package/src/git-reconciliation.js +74 -19
- package/src/git-worktrees.js +73 -0
- package/src/instrumentation.js +1 -0
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +73 -15
- package/src/reconciliation-classifier.js +117 -0
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +109 -23
- package/src/version-drift.js +98 -0
- package/src/worktree-identity.js +165 -0
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
appendClaimEntry,
|
|
17
|
+
claimJournalPath,
|
|
18
|
+
claimReconcileLogPath,
|
|
19
|
+
replayClaimJournal,
|
|
20
|
+
writeReconcileLog,
|
|
21
|
+
} from './claim-journal.js';
|
|
22
|
+
import { loadLedger, parseLedgerItemSource } from './ledger.js';
|
|
23
|
+
import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
|
|
24
|
+
import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
|
|
25
|
+
import { readNamespace } from './namespace.js';
|
|
26
|
+
import { pointer, sortIssues } from './request.js';
|
|
27
|
+
|
|
28
|
+
import { isCalendarDate, validateLedger } from './validate.js';
|
|
29
|
+
const GIT_ENVIRONMENT = Object.fromEntries(
|
|
30
|
+
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
|
31
|
+
);
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
// The repair domain's own version. It is not the core contract version and not
|
|
35
|
+
// the work-claim contract version; a future change to the repair request or the
|
|
36
|
+
// repaired source shape requires a new version here rather than a silent
|
|
37
|
+
// reinterpretation of version 1.
|
|
38
|
+
export const LEDGER_REPAIR_CONTRACT_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
const REQUEST_MEMBERS = ['repair_id', 'ledger_snapshot_revision', 'date', 'changes'];
|
|
41
|
+
const CHANGE_MEMBERS = ['item_id', 'expected_revision', 'expected_number', 'replacement_number'];
|
|
42
|
+
const REQUEST_MEMBER_SET = new Set(REQUEST_MEMBERS);
|
|
43
|
+
const CHANGE_MEMBER_SET = new Set(CHANGE_MEMBERS);
|
|
44
|
+
// A repair ID is a bounded, sortable operator handle: `nr_`, the repair date,
|
|
45
|
+
// and a four-digit sequence within that date.
|
|
46
|
+
const REPAIR_ID = /^nr_\d{8}_\d{4}$/;
|
|
47
|
+
const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
48
|
+
const REVISION = /^sha256:[0-9a-f]{64}$/;
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const STAGING_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
52
|
+
const STAGING_RELATIVE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
|
|
53
|
+
|
|
54
|
+
export function repairStagingPath(gitCommonDir, namespace, repairId) {
|
|
55
|
+
assertStagingSegment(namespace, 'namespace');
|
|
56
|
+
assertStagingSegment(repairId, 'repair_id');
|
|
57
|
+
return path.join(
|
|
58
|
+
path.resolve(gitCommonDir),
|
|
59
|
+
'wowbagger',
|
|
60
|
+
namespace,
|
|
61
|
+
'repairs',
|
|
62
|
+
repairId,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function stageNumberRepairCandidates({
|
|
67
|
+
gitCommonDir,
|
|
68
|
+
namespace,
|
|
69
|
+
repairId,
|
|
70
|
+
ledgerSnapshotRevision,
|
|
71
|
+
candidates,
|
|
72
|
+
}) {
|
|
73
|
+
assertStagingSegment(namespace, 'namespace');
|
|
74
|
+
assertStagingSegment(repairId, 'repair_id');
|
|
75
|
+
if (!REVISION.test(ledgerSnapshotRevision) || !Array.isArray(candidates) || candidates.length === 0) {
|
|
76
|
+
throw stagingInvalid('manifest-shape');
|
|
77
|
+
}
|
|
78
|
+
const root = repairStagingPath(gitCommonDir, namespace, repairId);
|
|
79
|
+
const candidatesRoot = path.join(root, 'candidates');
|
|
80
|
+
await ensureStagingDirectory(candidatesRoot, path.resolve(gitCommonDir));
|
|
81
|
+
const manifestCandidates = [];
|
|
82
|
+
const seenPaths = new Set();
|
|
83
|
+
const seenItems = new Set();
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
if (!isPlainObject(candidate)
|
|
86
|
+
|| !ITEM_ID.test(candidate.item_id)
|
|
87
|
+
|| seenItems.has(candidate.item_id)
|
|
88
|
+
|| typeof candidate.path !== 'string'
|
|
89
|
+
|| !STAGING_RELATIVE_PATH.test(candidate.path)
|
|
90
|
+
|| seenPaths.has(candidate.path)
|
|
91
|
+
|| !REVISION.test(candidate.candidate_revision)
|
|
92
|
+
|| !Buffer.isBuffer(candidate.candidate_bytes)
|
|
93
|
+
|| candidate.candidate_bytes.byteLength > MAX_ITEM_SOURCE_BYTES) {
|
|
94
|
+
throw stagingInvalid('candidate-shape');
|
|
95
|
+
}
|
|
96
|
+
const candidatePath = path.join(candidatesRoot, candidate.path);
|
|
97
|
+
await ensureStagingDirectory(path.dirname(candidatePath), path.resolve(gitCommonDir));
|
|
98
|
+
await writeExclusive(candidatePath, candidate.candidate_bytes);
|
|
99
|
+
manifestCandidates.push({
|
|
100
|
+
item_id: candidate.item_id,
|
|
101
|
+
path: candidate.path,
|
|
102
|
+
candidate_revision: candidate.candidate_revision,
|
|
103
|
+
sha256: revisionOf(candidate.candidate_bytes),
|
|
104
|
+
size: candidate.candidate_bytes.byteLength,
|
|
105
|
+
});
|
|
106
|
+
seenPaths.add(candidate.path);
|
|
107
|
+
seenItems.add(candidate.item_id);
|
|
108
|
+
}
|
|
109
|
+
const manifest = {
|
|
110
|
+
schema_version: 1,
|
|
111
|
+
repair_id: repairId,
|
|
112
|
+
ledger_snapshot_revision: ledgerSnapshotRevision,
|
|
113
|
+
candidates: manifestCandidates,
|
|
114
|
+
};
|
|
115
|
+
await writeExclusive(
|
|
116
|
+
path.join(root, 'manifest.json'),
|
|
117
|
+
Buffer.from(`${JSON.stringify(manifest)}\n`),
|
|
118
|
+
);
|
|
119
|
+
await syncDirectory(root);
|
|
120
|
+
return manifest;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function readStagedNumberRepair({
|
|
124
|
+
gitCommonDir,
|
|
125
|
+
namespace,
|
|
126
|
+
repairId,
|
|
127
|
+
}) {
|
|
128
|
+
const root = repairStagingPath(gitCommonDir, namespace, repairId);
|
|
129
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
130
|
+
let manifest;
|
|
131
|
+
try {
|
|
132
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw stagingInvalid(error?.code === 'ENOENT' ? 'staging-absent' : 'manifest-unreadable');
|
|
135
|
+
}
|
|
136
|
+
if (!isPlainObject(manifest)
|
|
137
|
+
|| manifest.schema_version !== 1
|
|
138
|
+
|| manifest.repair_id !== repairId
|
|
139
|
+
|| !REVISION.test(manifest.ledger_snapshot_revision)
|
|
140
|
+
|| !Array.isArray(manifest.candidates)
|
|
141
|
+
|| manifest.candidates.length === 0) {
|
|
142
|
+
throw stagingInvalid('manifest-shape');
|
|
143
|
+
}
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const seenPaths = new Set();
|
|
146
|
+
for (const entry of manifest.candidates) {
|
|
147
|
+
if (!isPlainObject(entry)
|
|
148
|
+
|| !ITEM_ID.test(entry.item_id)
|
|
149
|
+
|| typeof entry.path !== 'string'
|
|
150
|
+
|| !STAGING_RELATIVE_PATH.test(entry.path)
|
|
151
|
+
|| seenPaths.has(entry.path)
|
|
152
|
+
|| !REVISION.test(entry.candidate_revision)
|
|
153
|
+
|| entry.sha256 !== entry.candidate_revision
|
|
154
|
+
|| !Number.isSafeInteger(entry.size)
|
|
155
|
+
|| entry.size < 0) {
|
|
156
|
+
throw stagingInvalid('manifest-candidate');
|
|
157
|
+
}
|
|
158
|
+
const candidatePath = path.join(root, 'candidates', entry.path);
|
|
159
|
+
let bytes;
|
|
160
|
+
try {
|
|
161
|
+
const info = await lstat(candidatePath);
|
|
162
|
+
if (!info.isFile() || info.isSymbolicLink()) throw stagingInvalid('candidate-not-file');
|
|
163
|
+
bytes = await readFile(candidatePath);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code === 'LEDGER_REPAIR_STAGING_INVALID') throw error;
|
|
166
|
+
throw stagingInvalid('candidate-absent');
|
|
167
|
+
}
|
|
168
|
+
if (bytes.byteLength !== entry.size || revisionOf(bytes) !== entry.candidate_revision) {
|
|
169
|
+
throw stagingInvalid('candidate-digest-mismatch');
|
|
170
|
+
}
|
|
171
|
+
candidates.push({ ...entry, candidate_bytes: bytes });
|
|
172
|
+
seenPaths.add(entry.path);
|
|
173
|
+
}
|
|
174
|
+
return { manifest, candidates };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function assertStagingSegment(value, field) {
|
|
178
|
+
if (typeof value !== 'string' || !STAGING_ID.test(value)) {
|
|
179
|
+
throw stagingInvalid(`invalid-${field}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function ensureStagingDirectory(directory, boundary) {
|
|
184
|
+
const relative = path.relative(boundary, directory);
|
|
185
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw stagingInvalid('path-traversal');
|
|
186
|
+
let current = boundary;
|
|
187
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
188
|
+
current = path.join(current, segment);
|
|
189
|
+
await mkdir(current, { recursive: true });
|
|
190
|
+
const info = await lstat(current);
|
|
191
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw stagingInvalid('symlink-directory');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function writeExclusive(file, bytes) {
|
|
196
|
+
let handle;
|
|
197
|
+
try {
|
|
198
|
+
handle = await openNoFollow(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
199
|
+
await handle.writeFile(bytes);
|
|
200
|
+
await handle.sync();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error?.code === 'EEXIST') throw stagingInvalid('staging-already-exists');
|
|
203
|
+
throw error;
|
|
204
|
+
} finally {
|
|
205
|
+
await handle?.close();
|
|
206
|
+
}
|
|
207
|
+
await syncDirectory(path.dirname(file));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function openNoFollow(file, flags) {
|
|
211
|
+
if (constants.O_NOFOLLOW !== undefined) return open(file, flags | constants.O_NOFOLLOW);
|
|
212
|
+
const info = await lstat(file).catch((error) => {
|
|
213
|
+
if (error?.code === 'ENOENT') return null;
|
|
214
|
+
throw error;
|
|
215
|
+
});
|
|
216
|
+
if (info?.isSymbolicLink()) throw stagingInvalid('symlink-file');
|
|
217
|
+
return open(file, flags);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function syncDirectory(directory) {
|
|
221
|
+
let handle;
|
|
222
|
+
try {
|
|
223
|
+
handle = await open(directory, 'r');
|
|
224
|
+
await handle.sync();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (['EISDIR', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(error?.code)) return;
|
|
227
|
+
throw error;
|
|
228
|
+
} finally {
|
|
229
|
+
await handle?.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stagingInvalid(reason) {
|
|
234
|
+
const error = new Error('ledger repair staging is invalid');
|
|
235
|
+
error.code = 'LEDGER_REPAIR_STAGING_INVALID';
|
|
236
|
+
error.reason = reason;
|
|
237
|
+
return error;
|
|
238
|
+
}
|
|
239
|
+
// Returns an array of {path, code, message} issues, sorted, and empty when the
|
|
240
|
+
// request is valid. The request must already be JSON-parsed and
|
|
241
|
+
// deep-normalized (plain objects/arrays, JsonNumber unwrapped) — this checks
|
|
242
|
+
// the request's own shape, never the ledger it names.
|
|
243
|
+
export function validateLedgerRepairRequest(request) {
|
|
244
|
+
if (!isPlainObject(request)) {
|
|
245
|
+
return [problem([], 'invalid-type', 'The number-repair request must be a JSON object.')];
|
|
246
|
+
}
|
|
247
|
+
const issues = [];
|
|
248
|
+
for (const member of REQUEST_MEMBERS) {
|
|
249
|
+
if (!Object.hasOwn(request, member)) {
|
|
250
|
+
issues.push(problem([member], 'missing-member', `Required member ${member} is missing.`));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const member of Object.keys(request)) {
|
|
254
|
+
if (!REQUEST_MEMBER_SET.has(member)) {
|
|
255
|
+
issues.push(problem([member], 'unknown-member', `Member ${member} is not allowed.`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (Object.hasOwn(request, 'repair_id') && !REPAIR_ID.test(request.repair_id)) {
|
|
259
|
+
issues.push(problem(['repair_id'], 'invalid-value', 'Member repair_id must match nr_YYYYMMDD_NNNN.'));
|
|
260
|
+
}
|
|
261
|
+
if (Object.hasOwn(request, 'ledger_snapshot_revision')
|
|
262
|
+
&& !REVISION.test(request.ledger_snapshot_revision)) {
|
|
263
|
+
issues.push(problem(['ledger_snapshot_revision'], 'invalid-value', 'Member ledger_snapshot_revision must match sha256:[0-9a-f]{64}.'));
|
|
264
|
+
}
|
|
265
|
+
if (Object.hasOwn(request, 'date') && !isCalendarDate(request.date)) {
|
|
266
|
+
issues.push(problem(['date'], 'invalid-value', 'Member date must be an ISO calendar date.'));
|
|
267
|
+
}
|
|
268
|
+
// A repair with no change is a confused request rather than a no-op, so an
|
|
269
|
+
// empty mapping is refused before any entry is read.
|
|
270
|
+
if (Object.hasOwn(request, 'changes')
|
|
271
|
+
&& (!Array.isArray(request.changes) || request.changes.length === 0)) {
|
|
272
|
+
issues.push(problem(['changes'], 'invalid-value', 'Member changes must be a non-empty array of number changes.'));
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(request.changes)) {
|
|
275
|
+
issues.push(...changeIssues(request.changes));
|
|
276
|
+
}
|
|
277
|
+
return sortIssues(issues);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The mutating apply entrypoint. The request contract is enforced here rather
|
|
281
|
+
// than in the CLI so the seam a host calls and the seam the command calls
|
|
282
|
+
// refuse identically.
|
|
283
|
+
export async function numberRepair(request, options = {}) {
|
|
284
|
+
const issues = validateLedgerRepairRequest(request);
|
|
285
|
+
if (issues.length > 0) {
|
|
286
|
+
return ledgerRepairInvalidRequest('number-repair', issues);
|
|
287
|
+
}
|
|
288
|
+
const pendingRecovery = await recoverPendingRepair(request, options);
|
|
289
|
+
if (pendingRecovery) return pendingRecovery;
|
|
290
|
+
const ledger = await loadLedger(options.ledgerDirectory);
|
|
291
|
+
const validation = validateLedger(ledger);
|
|
292
|
+
const duplicateErrors = validation.errors.filter((error) => error.code === 'duplicate-number');
|
|
293
|
+
if (duplicateErrors.length === 0
|
|
294
|
+
|| validation.errors.some((error) => error.code !== 'duplicate-number')) {
|
|
295
|
+
return refusal(
|
|
296
|
+
'number-repair',
|
|
297
|
+
4,
|
|
298
|
+
'ledger-repair-not-applicable',
|
|
299
|
+
'The ledger is not blocked only by duplicate numbers.',
|
|
300
|
+
{ validation_errors: validation.errors },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const actualSnapshot = ledgerSnapshotRevision(options.ledgerDirectory, ledger);
|
|
304
|
+
if (actualSnapshot !== request.ledger_snapshot_revision) {
|
|
305
|
+
return refusal(
|
|
306
|
+
'number-repair',
|
|
307
|
+
4,
|
|
308
|
+
'ledger-repair-revision-conflict',
|
|
309
|
+
'The ledger changed after the repair proposal was generated.',
|
|
310
|
+
{
|
|
311
|
+
expected_snapshot_revision: request.ledger_snapshot_revision,
|
|
312
|
+
actual_snapshot_revision: actualSnapshot,
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const itemsById = new Map(ledger.items.map((item) => [item.data.id, item]));
|
|
317
|
+
for (const change of request.changes) {
|
|
318
|
+
const item = itemsById.get(change.item_id);
|
|
319
|
+
if (!item
|
|
320
|
+
|| revisionOf(item.bytes) !== change.expected_revision
|
|
321
|
+
|| item.data.number !== change.expected_number) {
|
|
322
|
+
return refusal(
|
|
323
|
+
'number-repair',
|
|
324
|
+
4,
|
|
325
|
+
'ledger-repair-revision-conflict',
|
|
326
|
+
'A repair item witness no longer matches the ledger.',
|
|
327
|
+
{
|
|
328
|
+
item_id: change.item_id,
|
|
329
|
+
expected_revision: change.expected_revision,
|
|
330
|
+
actual_revision: item ? revisionOf(item.bytes) : null,
|
|
331
|
+
expected_number: change.expected_number,
|
|
332
|
+
actual_number: item?.data.number ?? null,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const expectedChangedIds = new Set();
|
|
338
|
+
const groupedItems = new Map();
|
|
339
|
+
for (const item of ledger.items) {
|
|
340
|
+
const number = item.data.number;
|
|
341
|
+
if (!Number.isSafeInteger(number) || number < 1) continue;
|
|
342
|
+
const members = groupedItems.get(number) ?? [];
|
|
343
|
+
members.push(item);
|
|
344
|
+
groupedItems.set(number, members);
|
|
345
|
+
}
|
|
346
|
+
for (const members of groupedItems.values()) {
|
|
347
|
+
if (members.length < 2) continue;
|
|
348
|
+
for (const item of members.sort((left, right) => compareText(left.data.id, right.data.id)).slice(1)) {
|
|
349
|
+
expectedChangedIds.add(item.data.id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const requestedChangedIds = new Set(request.changes.map((change) => change.item_id));
|
|
353
|
+
const missingItemIds = [...expectedChangedIds]
|
|
354
|
+
.filter((itemId) => !requestedChangedIds.has(itemId))
|
|
355
|
+
.sort(compareText);
|
|
356
|
+
const unexpectedItemIds = [...requestedChangedIds]
|
|
357
|
+
.filter((itemId) => !expectedChangedIds.has(itemId))
|
|
358
|
+
.sort(compareText);
|
|
359
|
+
if (missingItemIds.length > 0 || unexpectedItemIds.length > 0) {
|
|
360
|
+
return refusal(
|
|
361
|
+
'number-repair',
|
|
362
|
+
4,
|
|
363
|
+
'ledger-repair-mapping-incomplete',
|
|
364
|
+
'The repair mapping must include exactly the movable item from every duplicate group.',
|
|
365
|
+
{ missing_item_ids: missingItemIds, unexpected_item_ids: unexpectedItemIds },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const changedIds = new Set(request.changes.map((change) => change.item_id));
|
|
369
|
+
const occupiedNumbers = new Map(
|
|
370
|
+
ledger.items.map((item) => [item.data.number, item.data.id]),
|
|
371
|
+
);
|
|
372
|
+
const replacementOwners = new Map();
|
|
373
|
+
for (const change of request.changes) {
|
|
374
|
+
const occupiedBy = occupiedNumbers.get(change.replacement_number);
|
|
375
|
+
if (occupiedBy && (occupiedBy === change.item_id || !changedIds.has(occupiedBy))) {
|
|
376
|
+
return refusal(
|
|
377
|
+
'number-repair',
|
|
378
|
+
4,
|
|
379
|
+
'ledger-repair-number-collision',
|
|
380
|
+
'A replacement number belongs to an unchanged ledger item.',
|
|
381
|
+
{
|
|
382
|
+
item_id: change.item_id,
|
|
383
|
+
replacement_number: change.replacement_number,
|
|
384
|
+
occupied_by: occupiedBy,
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const previous = replacementOwners.get(change.replacement_number);
|
|
389
|
+
if (previous) {
|
|
390
|
+
return refusal(
|
|
391
|
+
'number-repair',
|
|
392
|
+
4,
|
|
393
|
+
'ledger-repair-number-collision',
|
|
394
|
+
'Multiple repair items request the same replacement number.',
|
|
395
|
+
{
|
|
396
|
+
replacement_number: change.replacement_number,
|
|
397
|
+
item_ids: [previous, change.item_id],
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
replacementOwners.set(change.replacement_number, change.item_id);
|
|
402
|
+
}
|
|
403
|
+
const gitCommonDir = await resolveVerifiedGitCommonDir(options.ledgerDirectory, { failClosed: true });
|
|
404
|
+
if (!gitCommonDir) return refusal(
|
|
405
|
+
'number-repair',
|
|
406
|
+
5,
|
|
407
|
+
'capability-unavailable',
|
|
408
|
+
'Duplicate repair requires a verified Git common directory.',
|
|
409
|
+
{ reason: 'git-directory-not-found' },
|
|
410
|
+
);
|
|
411
|
+
const namespace = await readNamespace(options.ledgerDirectory);
|
|
412
|
+
if (!namespace) return refusal(
|
|
413
|
+
'number-repair',
|
|
414
|
+
5,
|
|
415
|
+
'capability-unavailable',
|
|
416
|
+
'Duplicate repair requires a provisioned ledger namespace.',
|
|
417
|
+
{ reason: 'ledger-namespace-unbound' },
|
|
418
|
+
);
|
|
419
|
+
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
420
|
+
try {
|
|
421
|
+
return await withClaimLock(storePath, async () => {
|
|
422
|
+
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
423
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
424
|
+
const repairIntent = replayed.entries.find((entry) => (
|
|
425
|
+
entry.type === 'number-repair-intent' && entry.repair_id === request.repair_id
|
|
426
|
+
));
|
|
427
|
+
const repairFinal = replayed.entries.find((entry) => (
|
|
428
|
+
entry.type === 'number-repair-final' && entry.repair_id === request.repair_id
|
|
429
|
+
));
|
|
430
|
+
if (repairIntent && !repairFinal) {
|
|
431
|
+
return recoverNumberRepair({
|
|
432
|
+
gitCommonDir,
|
|
433
|
+
ledgerDirectory: options.ledgerDirectory,
|
|
434
|
+
namespace,
|
|
435
|
+
intent: repairIntent,
|
|
436
|
+
journalPath,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (repairFinal) return committedRepairResponse(request.repair_id, repairFinal.items);
|
|
440
|
+
const current = await loadLedger(options.ledgerDirectory);
|
|
441
|
+
const currentSnapshot = ledgerSnapshotRevision(options.ledgerDirectory, current);
|
|
442
|
+
if (currentSnapshot !== request.ledger_snapshot_revision) {
|
|
443
|
+
return refusal(
|
|
444
|
+
'number-repair',
|
|
445
|
+
4,
|
|
446
|
+
'ledger-repair-revision-conflict',
|
|
447
|
+
'The ledger changed while the repair was waiting for the namespace fence.',
|
|
448
|
+
{
|
|
449
|
+
expected_snapshot_revision: request.ledger_snapshot_revision,
|
|
450
|
+
actual_snapshot_revision: currentSnapshot,
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const candidates = buildRepairCandidates(current, request, options.ledgerDirectory);
|
|
455
|
+
if (candidates.error) return refusal(
|
|
456
|
+
'number-repair',
|
|
457
|
+
4,
|
|
458
|
+
candidates.error.code,
|
|
459
|
+
candidates.error.message,
|
|
460
|
+
candidates.error.details,
|
|
461
|
+
);
|
|
462
|
+
const staging = await stageNumberRepairCandidates({
|
|
463
|
+
gitCommonDir,
|
|
464
|
+
namespace,
|
|
465
|
+
repairId: request.repair_id,
|
|
466
|
+
ledgerSnapshotRevision: request.ledger_snapshot_revision,
|
|
467
|
+
candidates: candidates.items,
|
|
468
|
+
});
|
|
469
|
+
await appendClaimEntry(journalPath, {
|
|
470
|
+
type: 'number-repair-intent',
|
|
471
|
+
repair_id: request.repair_id,
|
|
472
|
+
ledger_namespace: namespace,
|
|
473
|
+
ledger_snapshot_revision: request.ledger_snapshot_revision,
|
|
474
|
+
date: request.date,
|
|
475
|
+
staging_id: request.repair_id,
|
|
476
|
+
items: candidates.items.map((candidate) => ({
|
|
477
|
+
item_id: candidate.item_id,
|
|
478
|
+
item_path: candidate.path,
|
|
479
|
+
expected_revision: candidate.expected_revision,
|
|
480
|
+
expected_number: candidate.expected_number,
|
|
481
|
+
replacement_number: candidate.replacement_number,
|
|
482
|
+
candidate_revision: candidate.candidate_revision,
|
|
483
|
+
})),
|
|
484
|
+
});
|
|
485
|
+
for (const candidate of candidates.items) {
|
|
486
|
+
await atomicReplace(
|
|
487
|
+
path.join(path.resolve(options.ledgerDirectory), candidate.path),
|
|
488
|
+
candidate.candidate_bytes,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const published = await loadLedger(options.ledgerDirectory);
|
|
492
|
+
const publishedValidation = validateLedger(published);
|
|
493
|
+
if (!publishedValidation.valid) {
|
|
494
|
+
return refusal(
|
|
495
|
+
'number-repair',
|
|
496
|
+
6,
|
|
497
|
+
'ledger-repair-successor-invalid',
|
|
498
|
+
'The repaired ledger failed validation after publication.',
|
|
499
|
+
{ validation_errors: publishedValidation.errors },
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const publishedById = new Map(published.items.map((item) => [item.data.id, item]));
|
|
503
|
+
const rereadMismatch = candidates.items.find((candidate) => (
|
|
504
|
+
revisionOf(publishedById.get(candidate.item_id)?.bytes ?? Buffer.alloc(0))
|
|
505
|
+
!== candidate.candidate_revision
|
|
506
|
+
));
|
|
507
|
+
if (rereadMismatch) {
|
|
508
|
+
return refusal(
|
|
509
|
+
'number-repair',
|
|
510
|
+
6,
|
|
511
|
+
'ledger-repair-successor-invalid',
|
|
512
|
+
'A published candidate did not match its staged bytes.',
|
|
513
|
+
{
|
|
514
|
+
item_id: rereadMismatch.item_id,
|
|
515
|
+
expected_revision: rereadMismatch.candidate_revision,
|
|
516
|
+
actual_revision: revisionOf(publishedById.get(rereadMismatch.item_id)?.bytes ?? Buffer.alloc(0)),
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const changedItems = candidates.items.map((candidate) => ({
|
|
521
|
+
item_id: candidate.item_id,
|
|
522
|
+
path: candidate.path,
|
|
523
|
+
revision: candidate.candidate_revision,
|
|
524
|
+
}));
|
|
525
|
+
await appendClaimEntry(journalPath, {
|
|
526
|
+
type: 'number-repair-final',
|
|
527
|
+
repair_id: request.repair_id,
|
|
528
|
+
ledger_namespace: namespace,
|
|
529
|
+
staging_id: staging.repair_id,
|
|
530
|
+
items: changedItems.map((item) => ({
|
|
531
|
+
item_id: item.item_id,
|
|
532
|
+
item_path: item.path,
|
|
533
|
+
candidate_revision: item.revision,
|
|
534
|
+
committed_revision: item.revision,
|
|
535
|
+
})),
|
|
536
|
+
observed_at: new Date().toISOString(),
|
|
537
|
+
});
|
|
538
|
+
let gitCommit = null;
|
|
539
|
+
if (options.autoCommit === true) {
|
|
540
|
+
const committed = await autoCommitRepair({
|
|
541
|
+
ledgerDirectory: options.ledgerDirectory,
|
|
542
|
+
gitCommonDir,
|
|
543
|
+
namespace,
|
|
544
|
+
journalPath,
|
|
545
|
+
repairId: request.repair_id,
|
|
546
|
+
changedItems,
|
|
547
|
+
});
|
|
548
|
+
if (!committed.ok) {
|
|
549
|
+
return refusal(
|
|
550
|
+
'number-repair',
|
|
551
|
+
6,
|
|
552
|
+
'ledger-repair-commit-failed',
|
|
553
|
+
'The repaired items were published, but Git commit failed.',
|
|
554
|
+
{ recovery_token: committed.recovery_token },
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
gitCommit = committed.git_commit;
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
exit: 0,
|
|
561
|
+
stdout: {
|
|
562
|
+
ok: true,
|
|
563
|
+
namespace: 'ledger-repair',
|
|
564
|
+
command: 'number-repair',
|
|
565
|
+
contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
|
|
566
|
+
state: 'committed',
|
|
567
|
+
result: {
|
|
568
|
+
repair_id: request.repair_id,
|
|
569
|
+
ledger_snapshot_revision: request.ledger_snapshot_revision,
|
|
570
|
+
changed_items: changedItems,
|
|
571
|
+
git_commit: gitCommit,
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (error?.code === 'CLAIM_LOCK_HELD') {
|
|
578
|
+
return refusal(
|
|
579
|
+
'number-repair',
|
|
580
|
+
4,
|
|
581
|
+
'lock-held',
|
|
582
|
+
'The ledger namespace is locked by another writer.',
|
|
583
|
+
{ reason: 'claim-store-locked' },
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// The read-only proposal takes no request: the ledger it reads is the whole
|
|
591
|
+
// input, and it changes nothing.
|
|
592
|
+
export async function numberRepairProposal(ledgerDirectory) {
|
|
593
|
+
const ledger = await loadLedger(ledgerDirectory);
|
|
594
|
+
const validation = validateLedger(ledger);
|
|
595
|
+
const duplicateErrors = validation.errors.filter((error) => error.code === 'duplicate-number');
|
|
596
|
+
if (duplicateErrors.length === 0
|
|
597
|
+
|| validation.errors.some((error) => error.code !== 'duplicate-number')) {
|
|
598
|
+
return refusal(
|
|
599
|
+
'number-repair-proposal',
|
|
600
|
+
4,
|
|
601
|
+
'ledger-repair-not-applicable',
|
|
602
|
+
'The ledger is not blocked only by duplicate numbers.',
|
|
603
|
+
{ validation_errors: validation.errors },
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const groups = new Map();
|
|
608
|
+
for (const item of ledger.items) {
|
|
609
|
+
const number = item.data.number;
|
|
610
|
+
if (!Number.isSafeInteger(number) || number < 1) continue;
|
|
611
|
+
const members = groups.get(number) ?? [];
|
|
612
|
+
members.push(item);
|
|
613
|
+
groups.set(number, members);
|
|
614
|
+
}
|
|
615
|
+
const duplicateGroups = [...groups.entries()]
|
|
616
|
+
.filter(([, members]) => members.length > 1)
|
|
617
|
+
.sort(([left], [right]) => left - right)
|
|
618
|
+
.map(([number, members]) => ({
|
|
619
|
+
number,
|
|
620
|
+
item_ids: members.map((item) => item.data.id).sort(compareText),
|
|
621
|
+
}));
|
|
622
|
+
const affectedIds = new Set(duplicateGroups.flatMap((group) => group.item_ids));
|
|
623
|
+
const affectedItems = ledger.items
|
|
624
|
+
.filter((item) => affectedIds.has(item.data.id))
|
|
625
|
+
.sort((left, right) => compareText(left.data.id, right.data.id));
|
|
626
|
+
const highest = ledger.items.reduce(
|
|
627
|
+
(current, item) => Number.isSafeInteger(item.data.number)
|
|
628
|
+
? Math.max(current, item.data.number)
|
|
629
|
+
: current,
|
|
630
|
+
0,
|
|
631
|
+
);
|
|
632
|
+
const preserved = new Set(
|
|
633
|
+
duplicateGroups.map((group) => group.item_ids.slice().sort(compareText)[0]),
|
|
634
|
+
);
|
|
635
|
+
const moved = affectedItems
|
|
636
|
+
.filter((item) => !preserved.has(item.data.id))
|
|
637
|
+
.sort((left, right) => compareText(left.data.id, right.data.id));
|
|
638
|
+
const suggestedChanges = moved.map((item, index) => ({
|
|
639
|
+
item_id: item.data.id,
|
|
640
|
+
expected_revision: revisionOf(item.bytes),
|
|
641
|
+
expected_number: item.data.number,
|
|
642
|
+
replacement_number: highest + index + 1,
|
|
643
|
+
}));
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
exit: 0,
|
|
647
|
+
stdout: {
|
|
648
|
+
ok: true,
|
|
649
|
+
namespace: 'ledger-repair',
|
|
650
|
+
command: 'number-repair-proposal',
|
|
651
|
+
contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
|
|
652
|
+
state: 'unchanged',
|
|
653
|
+
result: {
|
|
654
|
+
ledger_snapshot_revision: ledgerSnapshotRevision(ledgerDirectory, ledger),
|
|
655
|
+
duplicate_groups: duplicateGroups,
|
|
656
|
+
items: affectedItems.map((item) => ({
|
|
657
|
+
item_id: item.data.id,
|
|
658
|
+
path: path.relative(path.resolve(ledgerDirectory), item.file),
|
|
659
|
+
revision: revisionOf(item.bytes),
|
|
660
|
+
|
|
661
|
+
number: item.data.number,
|
|
662
|
+
})),
|
|
663
|
+
suggested_changes: suggestedChanges,
|
|
664
|
+
preserved_items: [...preserved].sort(compareText),
|
|
665
|
+
references: affectedItems.map((item) => ({
|
|
666
|
+
item_id: item.data.id,
|
|
667
|
+
depends_on: item.data.depends_on ?? [],
|
|
668
|
+
related: item.data.related ?? [],
|
|
669
|
+
parent: item.data.parent ?? null,
|
|
670
|
+
})),
|
|
671
|
+
validation_errors: validation.errors,
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
export function isNumberRepairRecoveryToken(token) {
|
|
678
|
+
const payload = decodeNumberRepairToken(token);
|
|
679
|
+
return payload?.command === 'number-repair';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function finalizeNumberRepairCommit({ ledgerDirectory, token }) {
|
|
683
|
+
const payload = decodeNumberRepairToken(token);
|
|
684
|
+
if (!payload || payload.command !== 'number-repair') {
|
|
685
|
+
return refusal(
|
|
686
|
+
'number-repair',
|
|
687
|
+
2,
|
|
688
|
+
'invalid-request',
|
|
689
|
+
'The number-repair recovery token is invalid.',
|
|
690
|
+
{ reason: 'recovery-token-invalid' },
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
|
|
694
|
+
const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
|
|
695
|
+
if (!gitCommonDir || namespace !== payload.ledger_namespace) {
|
|
696
|
+
return refusal(
|
|
697
|
+
'number-repair',
|
|
698
|
+
4,
|
|
699
|
+
'ledger-repair-commit-failed',
|
|
700
|
+
'The repair recovery token does not name this ledger namespace.',
|
|
701
|
+
{ reason: 'ledger-namespace-unbound' },
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const repoRoot = path.dirname(path.resolve(gitCommonDir));
|
|
705
|
+
const paths = payload.commit_set.map((entry) => entry.path).sort(compareText);
|
|
706
|
+
try {
|
|
707
|
+
await execFileAsync('git', ['add', '--', ...paths], { cwd: repoRoot, env: GIT_ENVIRONMENT });
|
|
708
|
+
const staged = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
|
|
709
|
+
cwd: repoRoot,
|
|
710
|
+
env: GIT_ENVIRONMENT,
|
|
711
|
+
});
|
|
712
|
+
const stagedPaths = staged.stdout.split('\n').filter(Boolean).sort(compareText);
|
|
713
|
+
if (stagedPaths.length !== paths.length
|
|
714
|
+
|| stagedPaths.some((entry, index) => entry !== paths[index])) {
|
|
715
|
+
return refusal(
|
|
716
|
+
'number-repair',
|
|
717
|
+
4,
|
|
718
|
+
'ledger-repair-commit-failed',
|
|
719
|
+
'The repair recovery commit set is not exact.',
|
|
720
|
+
{ reason: 'commit-set-mismatch', expected_paths: paths, actual_paths: stagedPaths },
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
await execFileAsync(
|
|
724
|
+
'git',
|
|
725
|
+
['commit', '-m', 'wowbagger: repair duplicate numbers'],
|
|
726
|
+
{ cwd: repoRoot, env: GIT_ENVIRONMENT },
|
|
727
|
+
);
|
|
728
|
+
const head = await execFileAsync(
|
|
729
|
+
'git',
|
|
730
|
+
['rev-parse', 'HEAD'],
|
|
731
|
+
{ cwd: repoRoot, env: GIT_ENVIRONMENT },
|
|
732
|
+
);
|
|
733
|
+
return committedRepairResponse(payload.repair_id, [], head.stdout.trim());
|
|
734
|
+
} catch {
|
|
735
|
+
return refusal(
|
|
736
|
+
'number-repair',
|
|
737
|
+
6,
|
|
738
|
+
'ledger-repair-commit-failed',
|
|
739
|
+
'The repair recovery commit failed.',
|
|
740
|
+
{ recovery_token: token },
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function decodeNumberRepairToken(token) {
|
|
746
|
+
if (typeof token !== 'string' || token.length === 0) return null;
|
|
747
|
+
try {
|
|
748
|
+
const payload = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
|
|
749
|
+
if (payload?.v !== 1
|
|
750
|
+
|| payload.command !== 'number-repair'
|
|
751
|
+
|| !REPAIR_ID.test(payload.repair_id)
|
|
752
|
+
|| !REPAIR_STAGING_ID.test(payload.ledger_namespace)
|
|
753
|
+
|| !Array.isArray(payload.commit_set)
|
|
754
|
+
|| payload.commit_set.length === 0
|
|
755
|
+
|| typeof payload.message !== 'string') return null;
|
|
756
|
+
if (payload.commit_set.some((entry) => (
|
|
757
|
+
!isPlainObject(entry)
|
|
758
|
+
|| typeof entry.path !== 'string'
|
|
759
|
+
|| !STAGING_RELATIVE_PATH.test(entry.path)
|
|
760
|
+
|| entry.sha256 !== null
|
|
761
|
+
))) return null;
|
|
762
|
+
return payload;
|
|
763
|
+
} catch {
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async function autoCommitRepair({
|
|
768
|
+
ledgerDirectory,
|
|
769
|
+
gitCommonDir,
|
|
770
|
+
namespace,
|
|
771
|
+
journalPath,
|
|
772
|
+
repairId,
|
|
773
|
+
changedItems,
|
|
774
|
+
}) {
|
|
775
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
776
|
+
const logPath = claimReconcileLogPath(ledgerDirectory, namespace);
|
|
777
|
+
await writeReconcileLog(logPath, namespace, replayed.entries);
|
|
778
|
+
const ledgerRoot = path.resolve(ledgerDirectory);
|
|
779
|
+
const repoRoot = path.dirname(path.resolve(gitCommonDir));
|
|
780
|
+
const paths = [
|
|
781
|
+
...changedItems.map((item) => path.relative(repoRoot, path.join(ledgerRoot, item.path)).split(path.sep).join('/')),
|
|
782
|
+
path.relative(repoRoot, logPath).split(path.sep).join('/'),
|
|
783
|
+
].sort(compareText);
|
|
784
|
+
const token = Buffer.from(JSON.stringify({
|
|
785
|
+
v: 1,
|
|
786
|
+
command: 'number-repair',
|
|
787
|
+
ledger_namespace: namespace,
|
|
788
|
+
repair_id: repairId,
|
|
789
|
+
commit_set: paths.map((entry) => ({ path: entry, sha256: null })),
|
|
790
|
+
message: 'wowbagger: repair duplicate numbers',
|
|
791
|
+
}), 'utf8').toString('base64url');
|
|
792
|
+
try {
|
|
793
|
+
await execFileAsync('git', ['add', '--', ...paths], { cwd: repoRoot, env: GIT_ENVIRONMENT });
|
|
794
|
+
const staged = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
|
|
795
|
+
cwd: repoRoot,
|
|
796
|
+
env: GIT_ENVIRONMENT,
|
|
797
|
+
});
|
|
798
|
+
const stagedPaths = staged.stdout.split('\n').filter(Boolean).sort(compareText);
|
|
799
|
+
if (stagedPaths.length !== paths.length
|
|
800
|
+
|| stagedPaths.some((entry, index) => entry !== paths[index])) {
|
|
801
|
+
return { ok: false, recovery_token: token };
|
|
802
|
+
}
|
|
803
|
+
const commit = await execFileAsync(
|
|
804
|
+
'git',
|
|
805
|
+
['commit', '-m', 'wowbagger: repair duplicate numbers'],
|
|
806
|
+
{ cwd: repoRoot, env: GIT_ENVIRONMENT },
|
|
807
|
+
);
|
|
808
|
+
const head = await execFileAsync(
|
|
809
|
+
'git',
|
|
810
|
+
['rev-parse', 'HEAD'],
|
|
811
|
+
{ cwd: repoRoot, env: GIT_ENVIRONMENT },
|
|
812
|
+
);
|
|
813
|
+
return { ok: true, git_commit: head.stdout.trim(), output: commit.stdout };
|
|
814
|
+
} catch {
|
|
815
|
+
return { ok: false, recovery_token: token };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function recoverPendingRepair(request, options) {
|
|
820
|
+
const gitCommonDir = await resolveVerifiedGitCommonDir(options.ledgerDirectory, { failClosed: true });
|
|
821
|
+
if (!gitCommonDir) return null;
|
|
822
|
+
const namespace = await readNamespace(options.ledgerDirectory);
|
|
823
|
+
if (!namespace) return null;
|
|
824
|
+
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
825
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
826
|
+
const intent = replayed.entries.find((entry) => (
|
|
827
|
+
entry.type === 'number-repair-intent' && entry.repair_id === request.repair_id
|
|
828
|
+
));
|
|
829
|
+
if (!intent) return null;
|
|
830
|
+
const final = replayed.entries.find((entry) => (
|
|
831
|
+
entry.type === 'number-repair-final' && entry.repair_id === request.repair_id
|
|
832
|
+
));
|
|
833
|
+
if (final) return committedRepairResponse(request.repair_id, final.items);
|
|
834
|
+
try {
|
|
835
|
+
return await withClaimLock(
|
|
836
|
+
claimStorePath(gitCommonDir, namespace),
|
|
837
|
+
() => recoverNumberRepair({
|
|
838
|
+
gitCommonDir,
|
|
839
|
+
ledgerDirectory: options.ledgerDirectory,
|
|
840
|
+
namespace,
|
|
841
|
+
intent,
|
|
842
|
+
journalPath,
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (error?.code === 'CLAIM_LOCK_HELD') {
|
|
847
|
+
return refusal(
|
|
848
|
+
'number-repair',
|
|
849
|
+
4,
|
|
850
|
+
'lock-held',
|
|
851
|
+
'The ledger namespace is locked by another writer.',
|
|
852
|
+
{ reason: 'claim-store-locked' },
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
throw error;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function recoverNumberRepair({
|
|
860
|
+
gitCommonDir,
|
|
861
|
+
ledgerDirectory,
|
|
862
|
+
namespace,
|
|
863
|
+
intent,
|
|
864
|
+
journalPath,
|
|
865
|
+
}) {
|
|
866
|
+
let staged;
|
|
867
|
+
try {
|
|
868
|
+
staged = await readStagedNumberRepair({
|
|
869
|
+
gitCommonDir,
|
|
870
|
+
namespace,
|
|
871
|
+
repairId: intent.repair_id,
|
|
872
|
+
});
|
|
873
|
+
} catch (error) {
|
|
874
|
+
return outcomeUnknown(
|
|
875
|
+
intent.repair_id,
|
|
876
|
+
'Repair intent exists but its staged candidates cannot be read.',
|
|
877
|
+
{ reason: error.reason ?? 'staging-unavailable' },
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (staged.manifest.ledger_snapshot_revision !== intent.ledger_snapshot_revision) {
|
|
881
|
+
return outcomeUnknown(
|
|
882
|
+
intent.repair_id,
|
|
883
|
+
'Repair intent and staged manifest disagree about the ledger snapshot.',
|
|
884
|
+
{ reason: 'staging-snapshot-mismatch' },
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const current = await loadLedger(ledgerDirectory);
|
|
888
|
+
const currentById = new Map(current.items.map((item) => [item.data.id, item]));
|
|
889
|
+
const stagedById = new Map(staged.candidates.map((candidate) => [candidate.item_id, candidate]));
|
|
890
|
+
for (const entry of intent.items) {
|
|
891
|
+
const candidate = stagedById.get(entry.item_id);
|
|
892
|
+
const item = currentById.get(entry.item_id);
|
|
893
|
+
const currentRevision = item ? revisionOf(item.bytes) : null;
|
|
894
|
+
if (!candidate || currentRevision === null) {
|
|
895
|
+
return outcomeUnknown(intent.repair_id, 'A staged repair item is not present in the ledger.', {
|
|
896
|
+
item_id: entry.item_id,
|
|
897
|
+
reason: 'candidate-item-missing',
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
if (currentRevision === candidate.candidate_revision) continue;
|
|
901
|
+
if (currentRevision !== entry.expected_revision) {
|
|
902
|
+
return outcomeUnknown(intent.repair_id, 'A repair target contains a third revision.', {
|
|
903
|
+
item_id: entry.item_id,
|
|
904
|
+
reason: 'third-revision',
|
|
905
|
+
expected_revision: entry.expected_revision,
|
|
906
|
+
actual_revision: currentRevision,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
await atomicReplace(item.file, candidate.candidate_bytes);
|
|
910
|
+
}
|
|
911
|
+
const repaired = await loadLedger(ledgerDirectory);
|
|
912
|
+
const validation = validateLedger(repaired);
|
|
913
|
+
if (!validation.valid) {
|
|
914
|
+
return outcomeUnknown(intent.repair_id, 'Recovered repair candidates do not form a valid ledger.', {
|
|
915
|
+
reason: 'successor-invalid',
|
|
916
|
+
validation_errors: validation.errors,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const items = intent.items.map((entry) => ({
|
|
920
|
+
item_id: entry.item_id,
|
|
921
|
+
item_path: entry.item_path,
|
|
922
|
+
candidate_revision: entry.candidate_revision,
|
|
923
|
+
committed_revision: entry.candidate_revision,
|
|
924
|
+
}));
|
|
925
|
+
await appendClaimEntry(journalPath, {
|
|
926
|
+
type: 'number-repair-final',
|
|
927
|
+
repair_id: intent.repair_id,
|
|
928
|
+
ledger_namespace: namespace,
|
|
929
|
+
staging_id: intent.staging_id,
|
|
930
|
+
items,
|
|
931
|
+
observed_at: new Date().toISOString(),
|
|
932
|
+
});
|
|
933
|
+
return committedRepairResponse(intent.repair_id, items);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function committedRepairResponse(repairId, items, gitCommit = null) {
|
|
937
|
+
return {
|
|
938
|
+
exit: 0,
|
|
939
|
+
stdout: {
|
|
940
|
+
ok: true,
|
|
941
|
+
namespace: 'ledger-repair',
|
|
942
|
+
command: 'number-repair',
|
|
943
|
+
contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
|
|
944
|
+
state: 'committed',
|
|
945
|
+
result: {
|
|
946
|
+
repair_id: repairId,
|
|
947
|
+
changed_items: items.map((item) => ({
|
|
948
|
+
item_id: item.item_id,
|
|
949
|
+
path: item.item_path,
|
|
950
|
+
revision: item.committed_revision,
|
|
951
|
+
})),
|
|
952
|
+
git_commit: gitCommit,
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function outcomeUnknown(repairId, message, details) {
|
|
959
|
+
return {
|
|
960
|
+
exit: 6,
|
|
961
|
+
stdout: {
|
|
962
|
+
ok: false,
|
|
963
|
+
namespace: 'ledger-repair',
|
|
964
|
+
command: 'number-repair',
|
|
965
|
+
contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
|
|
966
|
+
state: 'unknown',
|
|
967
|
+
error: {
|
|
968
|
+
code: 'ledger-repair-outcome-unknown',
|
|
969
|
+
message,
|
|
970
|
+
details: { repair_id: repairId, ...details },
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function buildRepairCandidates(ledger, request, ledgerDirectory) {
|
|
977
|
+
const itemsById = new Map(ledger.items.map((item) => [item.data.id, item]));
|
|
978
|
+
const candidates = [];
|
|
979
|
+
for (const change of request.changes) {
|
|
980
|
+
const item = itemsById.get(change.item_id);
|
|
981
|
+
const source = item?.bytes?.toString('utf8');
|
|
982
|
+
const numberLines = source?.match(/^number:[ \t]*[0-9]+[ \t]*$/gm) ?? [];
|
|
983
|
+
if (!item || numberLines.length !== 1) {
|
|
984
|
+
return {
|
|
985
|
+
error: {
|
|
986
|
+
code: 'ledger-repair-successor-invalid',
|
|
987
|
+
message: 'An affected item does not contain one canonical number scalar.',
|
|
988
|
+
details: { item_id: change.item_id },
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const candidateSource = source.replace(
|
|
993
|
+
numberLines[0],
|
|
994
|
+
`number: ${change.replacement_number}`,
|
|
995
|
+
);
|
|
996
|
+
const parsed = parseLedgerItemSource(candidateSource);
|
|
997
|
+
if (parsed.error) {
|
|
998
|
+
return {
|
|
999
|
+
error: {
|
|
1000
|
+
code: 'ledger-repair-successor-invalid',
|
|
1001
|
+
message: 'A candidate item could not be parsed.',
|
|
1002
|
+
details: { item_id: change.item_id, validation_errors: [parsed.error] },
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
candidates.push({
|
|
1007
|
+
item_id: change.item_id,
|
|
1008
|
+
path: path.relative(path.resolve(ledgerDirectory), item.file)
|
|
1009
|
+
.split(path.sep).join('/'),
|
|
1010
|
+
expected_revision: change.expected_revision,
|
|
1011
|
+
expected_number: change.expected_number,
|
|
1012
|
+
replacement_number: change.replacement_number,
|
|
1013
|
+
candidate_revision: revisionOf(Buffer.from(candidateSource)),
|
|
1014
|
+
candidate_bytes: Buffer.from(candidateSource),
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
const candidateById = new Map(candidates.map((candidate) => [candidate.item_id, candidate]));
|
|
1018
|
+
const candidateItems = ledger.items.map((item) => {
|
|
1019
|
+
const candidate = candidateById.get(item.data.id);
|
|
1020
|
+
if (!candidate) return item;
|
|
1021
|
+
const parsed = parseLedgerItemSource(candidate.candidate_bytes.toString('utf8'));
|
|
1022
|
+
return { ...item, bytes: candidate.candidate_bytes, data: parsed.data, body: parsed.body };
|
|
1023
|
+
});
|
|
1024
|
+
const successor = validateLedger({ ...ledger, items: candidateItems });
|
|
1025
|
+
if (!successor.valid) {
|
|
1026
|
+
return {
|
|
1027
|
+
error: {
|
|
1028
|
+
code: 'ledger-repair-successor-invalid',
|
|
1029
|
+
message: 'The proposed number changes do not produce a valid ledger.',
|
|
1030
|
+
details: { validation_errors: successor.errors },
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
return { items: candidates };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function atomicReplace(target, bytes) {
|
|
1038
|
+
const info = await lstat(target);
|
|
1039
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
1040
|
+
const error = new Error('repair target is not a regular file');
|
|
1041
|
+
error.code = 'LEDGER_REPAIR_TARGET_INVALID';
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.candidate`;
|
|
1045
|
+
await writeExclusive(temporary, bytes);
|
|
1046
|
+
try {
|
|
1047
|
+
await rename(temporary, target);
|
|
1048
|
+
await syncDirectory(path.dirname(target));
|
|
1049
|
+
} finally {
|
|
1050
|
+
await rmIfExists(temporary);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function rmIfExists(file) {
|
|
1054
|
+
try {
|
|
1055
|
+
await rm(file, { force: true });
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function compareText(left, right) {
|
|
1062
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function revisionOf(bytes) {
|
|
1066
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function ledgerSnapshotRevision(ledgerDirectory, ledger) {
|
|
1070
|
+
const root = path.resolve(ledgerDirectory);
|
|
1071
|
+
const hash = createHash('sha256');
|
|
1072
|
+
for (const item of ledger.items.slice().sort((left, right) => (
|
|
1073
|
+
compareText(left.file, right.file)
|
|
1074
|
+
))) {
|
|
1075
|
+
const relative = path.relative(root, item.file).split(path.sep).join('/');
|
|
1076
|
+
hash.update(relative);
|
|
1077
|
+
hash.update('\0');
|
|
1078
|
+
hash.update(item.bytes);
|
|
1079
|
+
hash.update('\0');
|
|
1080
|
+
}
|
|
1081
|
+
return `sha256:${hash.digest('hex')}`;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// The repair domain's own refusal envelope. A consumer dispatches on
|
|
1085
|
+
// `namespace` before it reads any version member, so a repair refusal is never
|
|
1086
|
+
// read as a core version 5 or work-claim version 1 response.
|
|
1087
|
+
export function ledgerRepairInvalidRequest(command, issues) {
|
|
1088
|
+
return refusal(command, 2, 'invalid-request', `The ${command} request is invalid.`, { issues });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
function refusal(command, exit, code, message, details) {
|
|
1093
|
+
return {
|
|
1094
|
+
exit,
|
|
1095
|
+
stdout: {
|
|
1096
|
+
ok: false,
|
|
1097
|
+
namespace: 'ledger-repair',
|
|
1098
|
+
command,
|
|
1099
|
+
contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
|
|
1100
|
+
state: 'unchanged',
|
|
1101
|
+
error: { code, message, details },
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Every change is one item's number move. The entry carries its own witnesses,
|
|
1107
|
+
// so a malformed entry is named by its index rather than collapsing the whole
|
|
1108
|
+
// mapping into one issue. The repeat checks are request-internal: an item that
|
|
1109
|
+
// moves twice, or two items that land on one number, make the applied result
|
|
1110
|
+
// depend on entry order.
|
|
1111
|
+
function changeIssues(changes) {
|
|
1112
|
+
const issues = [];
|
|
1113
|
+
const seenItemIds = new Set();
|
|
1114
|
+
const seenReplacements = new Set();
|
|
1115
|
+
for (let index = 0; index < changes.length; index += 1) {
|
|
1116
|
+
const change = changes[index];
|
|
1117
|
+
const location = ['changes', String(index)];
|
|
1118
|
+
if (!isPlainObject(change)) {
|
|
1119
|
+
issues.push(problem(location, 'invalid-type', 'Member changes entries must be JSON objects.'));
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
for (const member of CHANGE_MEMBERS) {
|
|
1123
|
+
if (!Object.hasOwn(change, member)) {
|
|
1124
|
+
issues.push(problem([...location, member], 'missing-member', `Required member ${member} is missing.`));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
for (const member of Object.keys(change)) {
|
|
1128
|
+
if (!CHANGE_MEMBER_SET.has(member)) {
|
|
1129
|
+
issues.push(problem([...location, member], 'unknown-member', `Member ${member} is not allowed.`));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (Object.hasOwn(change, 'item_id')) {
|
|
1133
|
+
if (!ITEM_ID.test(change.item_id)) {
|
|
1134
|
+
issues.push(problem([...location, 'item_id'], 'invalid-value', 'Member item_id must be a canonical Wowbagger item ID.'));
|
|
1135
|
+
} else if (seenItemIds.has(change.item_id)) {
|
|
1136
|
+
issues.push(problem([...location, 'item_id'], 'invalid-value', 'Member item_id must not repeat within changes.'));
|
|
1137
|
+
}
|
|
1138
|
+
seenItemIds.add(change.item_id);
|
|
1139
|
+
}
|
|
1140
|
+
if (Object.hasOwn(change, 'expected_revision') && !REVISION.test(change.expected_revision)) {
|
|
1141
|
+
issues.push(problem([...location, 'expected_revision'], 'invalid-value', 'Member expected_revision must match sha256:[0-9a-f]{64}.'));
|
|
1142
|
+
}
|
|
1143
|
+
// The old number is a witness the apply path compares against the item's
|
|
1144
|
+
// own number, so a string is a mismatch rather than a spelling.
|
|
1145
|
+
if (Object.hasOwn(change, 'expected_number') && !isPositiveInteger(change.expected_number)) {
|
|
1146
|
+
issues.push(problem([...location, 'expected_number'], 'invalid-value', 'Member expected_number must be a positive integer.'));
|
|
1147
|
+
}
|
|
1148
|
+
if (Object.hasOwn(change, 'replacement_number')) {
|
|
1149
|
+
if (!isPositiveInteger(change.replacement_number)) {
|
|
1150
|
+
issues.push(problem([...location, 'replacement_number'], 'invalid-value', 'Member replacement_number must be a positive integer.'));
|
|
1151
|
+
} else if (seenReplacements.has(change.replacement_number)) {
|
|
1152
|
+
issues.push(problem([...location, 'replacement_number'], 'invalid-value', 'Member replacement_number must not repeat within changes.'));
|
|
1153
|
+
}
|
|
1154
|
+
seenReplacements.add(change.replacement_number);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return issues;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function isPositiveInteger(value) {
|
|
1161
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function isPlainObject(value) {
|
|
1165
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function problem(location, code, message) {
|
|
1169
|
+
return { path: pointer(location), code, message };
|
|
1170
|
+
}
|