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
package/src/claim-coordinator.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
appendClaimEntry,
|
|
6
6
|
assertClaimJournalCapacity,
|
|
7
7
|
claimJournalPath,
|
|
8
|
+
isClaimJournalCapacityError,
|
|
8
9
|
claimReconcileLogPath,
|
|
9
10
|
replayClaimJournal,
|
|
10
11
|
writeReconcileLog,
|
|
@@ -14,34 +15,69 @@ import { readBack } from './claim-operations.js';
|
|
|
14
15
|
import { reconcileClaimJournal } from './claim-publication.js';
|
|
15
16
|
import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
|
|
16
17
|
import { readNamespace } from './namespace.js';
|
|
18
|
+
import {
|
|
19
|
+
assertUniqueWorktreeIdentity,
|
|
20
|
+
ensureWorktreeIdentity,
|
|
21
|
+
identityDiagnosticDetails,
|
|
22
|
+
} from './worktree-identity.js';
|
|
17
23
|
|
|
18
|
-
export async function withLegacyMutationFence(
|
|
24
|
+
export async function withLegacyMutationFence(
|
|
25
|
+
ledgerDirectory,
|
|
26
|
+
itemId,
|
|
27
|
+
command,
|
|
28
|
+
write,
|
|
29
|
+
{ responseCommand = command } = {},
|
|
30
|
+
) {
|
|
19
31
|
let gitCommonDir;
|
|
20
32
|
try {
|
|
21
33
|
gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
|
|
22
34
|
} catch {
|
|
23
|
-
return claimStoreUnavailable(
|
|
35
|
+
return claimStoreUnavailable(responseCommand, 'git-verification-failed');
|
|
24
36
|
}
|
|
25
37
|
const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
|
|
26
38
|
const capability = resolveWorkClaimCapability({ gitCommonDir, namespace });
|
|
27
39
|
if (!capability.claim_protected_publication) return write();
|
|
28
40
|
|
|
41
|
+
// A create is the one mutation that reads an identity it was not given: it
|
|
42
|
+
// allocates the ledger's next number from the items this checkout holds. Its
|
|
43
|
+
// reconciliation therefore stays target-scoped like every other mutation, and
|
|
44
|
+
// gains one extra barrier below, for the coordinated items this working
|
|
45
|
+
// ledger cannot see at all.
|
|
46
|
+
const create = command === 'create-v1';
|
|
29
47
|
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
30
48
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
31
49
|
let intent = null;
|
|
32
50
|
try {
|
|
33
51
|
return await withClaimLock(storePath, async () => {
|
|
52
|
+
// The writer's own identity must exist before anything it does can be
|
|
53
|
+
// attributed, so it is established under the same lock that serializes
|
|
54
|
+
// the journal, ahead of reconciliation and of any intent append. Its own
|
|
55
|
+
// file is judged first, so bytes this worktree owns keep reporting as its
|
|
56
|
+
// own invalid identity rather than as an unreadable roster.
|
|
57
|
+
const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
|
|
58
|
+
// Then the domain: a UUID two live worktrees answer to attributes
|
|
59
|
+
// nothing, so nothing may be written from it.
|
|
60
|
+
await assertUniqueWorktreeIdentity({ ledgerDirectory });
|
|
34
61
|
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
35
62
|
const reconciled = await reconcileClaimJournal({
|
|
36
63
|
ledgerDirectory,
|
|
37
64
|
gitCommonDir,
|
|
38
65
|
namespace,
|
|
39
66
|
replayed,
|
|
67
|
+
currentWorktreeId,
|
|
40
68
|
physicalNow: new Date().toISOString(),
|
|
41
69
|
targetItemId: itemId,
|
|
70
|
+
writeLogOnUnsafe: false,
|
|
71
|
+
writeLogWhenEmpty: !create,
|
|
42
72
|
});
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
// The extra create barrier promised above rides on the same refusal: a
|
|
74
|
+
// coordinated item this checkout does not hold carries a number nobody
|
|
75
|
+
// here can read, so the next number this create would allocate may be one
|
|
76
|
+
// a sibling worktree already published. A stale revision of an item that
|
|
77
|
+
// is present hides no number: an item's number is immutable, so target
|
|
78
|
+
// scoping above still lets that create through.
|
|
79
|
+
if (reconciled.unsafe || (create && reconciled.missingCoordinatedItems.length > 0)) {
|
|
80
|
+
return claimStoreUnavailable(responseCommand, 'publication-reconciliation-required', {
|
|
45
81
|
findings: reconciled.findings,
|
|
46
82
|
});
|
|
47
83
|
}
|
|
@@ -52,10 +88,10 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
52
88
|
const projected = [...reconciled.entries];
|
|
53
89
|
const record = reconciled.state.claims.find((entry) => entry.item_id === itemId)
|
|
54
90
|
?? { item_id: itemId, last_epoch: '0', active: null };
|
|
55
|
-
const mustRefuse =
|
|
91
|
+
const mustRefuse = create
|
|
56
92
|
? record.last_epoch !== '0'
|
|
57
93
|
: record.active !== null && observedAt < record.active.expires_at;
|
|
58
|
-
if (mustRefuse) return legacyRefusal(
|
|
94
|
+
if (mustRefuse) return legacyRefusal(responseCommand, namespace, itemId, observedAt, record);
|
|
59
95
|
|
|
60
96
|
const authorize = async (expectedRevision, candidateRevision, itemPath) => {
|
|
61
97
|
const attemptId = randomUUID();
|
|
@@ -68,6 +104,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
68
104
|
expected_revision: expectedRevision,
|
|
69
105
|
candidate_revision: candidateRevision,
|
|
70
106
|
item_path: itemPath,
|
|
107
|
+
writer_worktree_id: currentWorktreeId,
|
|
71
108
|
observed_at: observedAt,
|
|
72
109
|
};
|
|
73
110
|
const terminalEntry = {
|
|
@@ -78,6 +115,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
78
115
|
command,
|
|
79
116
|
committed_revision: candidateRevision,
|
|
80
117
|
item_path: itemPath,
|
|
118
|
+
writer_worktree_id: currentWorktreeId,
|
|
81
119
|
observed_at: observedAt,
|
|
82
120
|
};
|
|
83
121
|
const abortEntry = {
|
|
@@ -85,6 +123,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
85
123
|
attempt_id: attemptId,
|
|
86
124
|
ledger_namespace: namespace,
|
|
87
125
|
item_id: itemId,
|
|
126
|
+
...(create ? { command } : {}),
|
|
88
127
|
observed_revision: expectedRevision,
|
|
89
128
|
observed_at: observedAt,
|
|
90
129
|
};
|
|
@@ -108,7 +147,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
108
147
|
outcome = await write(authorize, reconciled.ledger);
|
|
109
148
|
} catch (error) {
|
|
110
149
|
if (intent) {
|
|
111
|
-
return claimStoreUnavailable(
|
|
150
|
+
return claimStoreUnavailable(responseCommand, 'legacy-mutation-outcome-unknown', {
|
|
112
151
|
attempt_id: intent.attempt_id,
|
|
113
152
|
candidate_revision: intent.candidate_revision,
|
|
114
153
|
}, 'unknown');
|
|
@@ -125,7 +164,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
125
164
|
? outcome.item?.revision
|
|
126
165
|
: outcome.error?.details?.revision;
|
|
127
166
|
if (committedRevision !== intent.candidate_revision) {
|
|
128
|
-
return claimStoreUnavailable(
|
|
167
|
+
return claimStoreUnavailable(responseCommand, 'legacy-mutation-outcome-unknown', {
|
|
129
168
|
attempt_id: intent.attempt_id,
|
|
130
169
|
candidate_revision: intent.candidate_revision,
|
|
131
170
|
}, 'unknown');
|
|
@@ -140,9 +179,10 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
140
179
|
committed_revision: committedRevision,
|
|
141
180
|
observed_at: observedAt,
|
|
142
181
|
item_path: intent.item_path,
|
|
182
|
+
writer_worktree_id: currentWorktreeId,
|
|
143
183
|
}));
|
|
144
184
|
} catch {
|
|
145
|
-
return claimStoreUnavailable(
|
|
185
|
+
return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed', {
|
|
146
186
|
attempt_id: intent.attempt_id,
|
|
147
187
|
committed_revision: committedRevision,
|
|
148
188
|
}, 'unknown');
|
|
@@ -154,11 +194,12 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
154
194
|
attempt_id: intent.attempt_id,
|
|
155
195
|
ledger_namespace: namespace,
|
|
156
196
|
item_id: itemId,
|
|
197
|
+
...(create ? { command } : {}),
|
|
157
198
|
observed_revision: intent.expected_revision,
|
|
158
199
|
observed_at: observedAt,
|
|
159
200
|
}));
|
|
160
201
|
} catch {
|
|
161
|
-
return claimStoreUnavailable(
|
|
202
|
+
return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed');
|
|
162
203
|
}
|
|
163
204
|
}
|
|
164
205
|
try {
|
|
@@ -182,9 +223,17 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
|
|
|
182
223
|
: outcome;
|
|
183
224
|
});
|
|
184
225
|
} catch (error) {
|
|
185
|
-
|
|
226
|
+
const reason = error?.code === 'CLAIM_LOCK_HELD'
|
|
186
227
|
? 'claim-store-locked'
|
|
187
|
-
:
|
|
228
|
+
: !intent && isClaimJournalCapacityError(error)
|
|
229
|
+
? 'journal-capacity-exceeded'
|
|
230
|
+
: 'claim-store-unreadable';
|
|
231
|
+
return claimStoreUnavailable(
|
|
232
|
+
responseCommand,
|
|
233
|
+
reason,
|
|
234
|
+
identityDiagnosticDetails(error),
|
|
235
|
+
intent ? 'unknown' : 'unchanged',
|
|
236
|
+
);
|
|
188
237
|
}
|
|
189
238
|
}
|
|
190
239
|
|
package/src/claim-journal.js
CHANGED
|
@@ -2,6 +2,7 @@ import { constants } from 'node:fs';
|
|
|
2
2
|
import { lstat, mkdir, open, readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { validateClaimRequest } from './claim-request.js';
|
|
5
|
+
import { isCalendarDate } from './validate.js';
|
|
5
6
|
|
|
6
7
|
import { advanceClockFloor, claimAcquire, claimRelease, claimRenew } from './claim-operations.js';
|
|
7
8
|
import { emptyClaimState } from './claim-store.js';
|
|
@@ -19,12 +20,33 @@ const JOURNAL_ENTRY_TYPES = new Set([
|
|
|
19
20
|
'publish-finalization',
|
|
20
21
|
'publish-intent',
|
|
21
22
|
'revision-adoption',
|
|
23
|
+
'number-repair-intent',
|
|
24
|
+
'number-repair-final',
|
|
22
25
|
]);
|
|
26
|
+
// Every command the legacy mutation fence journals.
|
|
27
|
+
const LEGACY_MUTATION_COMMANDS = new Set(['patch-v1', 'transition-v1', 'create-v1']);
|
|
23
28
|
// The reconciliation log is a tracked derived artifact. Entry types that every
|
|
24
29
|
// invocation appends, including refusals and read-only verification, stay out
|
|
25
30
|
// of it so a mutation that changes nothing leaves the working tree unchanged.
|
|
26
31
|
const UNPROJECTED_ENTRY_TYPES = new Set(['clock', 'publish-finalization']);
|
|
27
32
|
|
|
33
|
+
// The one place that answers whether the reconciliation log carries an entry.
|
|
34
|
+
export function isProjectedJournalEntry(entry) {
|
|
35
|
+
return !UNPROJECTED_ENTRY_TYPES.has(entry.type);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isClaimJournalCapacityError(error) {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (let current = error; current && typeof current === 'object'; current = current.cause) {
|
|
41
|
+
if (seen.has(current)) return false;
|
|
42
|
+
seen.add(current);
|
|
43
|
+
if (current.code === 'CLAIM_JOURNAL_CAPACITY'
|
|
44
|
+
|| current.reason === 'journal-capacity-exceeded') {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
28
50
|
export function claimJournalPath(commonDir, namespace) {
|
|
29
51
|
return path.join(commonDir, 'wowbagger', namespace, 'journal.ndjson');
|
|
30
52
|
}
|
|
@@ -66,6 +88,10 @@ export async function assertClaimJournalCapacity(journalPath, plannedEntries) {
|
|
|
66
88
|
|
|
67
89
|
export async function replayClaimJournal(journalPath, namespace) {
|
|
68
90
|
const entries = await readJournalEntries(journalPath, namespace);
|
|
91
|
+
return { state: replayClaimEntries(entries, namespace), entries };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function replayClaimEntries(entries, namespace) {
|
|
69
95
|
let state = emptyClaimState(namespace);
|
|
70
96
|
const operations = { acquire: claimAcquire, release: claimRelease, renew: claimRenew };
|
|
71
97
|
for (const entry of entries) {
|
|
@@ -75,11 +101,11 @@ export async function replayClaimJournal(journalPath, namespace) {
|
|
|
75
101
|
state = operations[entry.command](state, entry.request, entry.physical_now).state;
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
|
-
return
|
|
104
|
+
return state;
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
export async function writeReconcileLog(logPath, namespace, entries) {
|
|
82
|
-
const mergeableEntries = entries.filter(
|
|
108
|
+
const mergeableEntries = entries.filter(isProjectedJournalEntry);
|
|
83
109
|
const content = [
|
|
84
110
|
`# Wowbagger reconciliation log \`${namespace}\``,
|
|
85
111
|
'',
|
|
@@ -91,7 +117,8 @@ export async function writeReconcileLog(logPath, namespace, entries) {
|
|
|
91
117
|
'',
|
|
92
118
|
].join('\n');
|
|
93
119
|
if (Buffer.byteLength(content) > MAX_RECONCILE_LOG_BYTES) throw journalCapacityExceeded();
|
|
94
|
-
|
|
120
|
+
const directory = path.dirname(logPath);
|
|
121
|
+
await ensureDirectoryNoFollow(directory);
|
|
95
122
|
const handle = await openNoFollow(logPath,
|
|
96
123
|
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC);
|
|
97
124
|
try {
|
|
@@ -102,6 +129,53 @@ export async function writeReconcileLog(logPath, namespace, entries) {
|
|
|
102
129
|
}
|
|
103
130
|
}
|
|
104
131
|
|
|
132
|
+
export function parseReconcileLog(source, namespace) {
|
|
133
|
+
const lines = source.toString('utf8').split('\n');
|
|
134
|
+
const start = lines.indexOf('```jsonl');
|
|
135
|
+
if (start < 0) return [];
|
|
136
|
+
const end = lines.indexOf('```', start + 1);
|
|
137
|
+
if (end < 0) return { error: { code: 'ambiguous-journal', reason: 'unterminated-jsonl-block' } };
|
|
138
|
+
const entries = [];
|
|
139
|
+
for (const line of lines.slice(start + 1, end)) {
|
|
140
|
+
if (line.trim() === '') continue;
|
|
141
|
+
try {
|
|
142
|
+
const entry = JSON.parse(line);
|
|
143
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
144
|
+
return { error: { code: 'ambiguous-journal', reason: 'entry-not-object' } };
|
|
145
|
+
}
|
|
146
|
+
if (Object.hasOwn(entry, 'seq') && !validSequence(entry.seq)) {
|
|
147
|
+
return { error: { code: 'ambiguous-journal', reason: 'sequence-out-of-range' } };
|
|
148
|
+
}
|
|
149
|
+
const entryNamespace = entry.ledger_namespace
|
|
150
|
+
?? entry.request?.ledger_namespace
|
|
151
|
+
?? entry.fence?.ledger_namespace;
|
|
152
|
+
if (entryNamespace !== undefined && entryNamespace !== namespace) {
|
|
153
|
+
return { error: { code: 'ambiguous-journal', reason: 'namespace-mismatch' } };
|
|
154
|
+
}
|
|
155
|
+
entries.push(entry);
|
|
156
|
+
} catch {
|
|
157
|
+
return { error: { code: 'ambiguous-journal', reason: 'entry-not-json' } };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return entries;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function ensureDirectoryNoFollow(directory) {
|
|
164
|
+
let info;
|
|
165
|
+
try {
|
|
166
|
+
info = await lstat(directory);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
169
|
+
await mkdir(directory);
|
|
170
|
+
info = await lstat(directory);
|
|
171
|
+
}
|
|
172
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
173
|
+
const error = new Error(`refusing non-directory reconciliation path ${directory}`);
|
|
174
|
+
error.code = 'ELOOP';
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
105
179
|
// O_NOFOLLOW is undefined on win32, so the no-follow guarantee falls back to
|
|
106
180
|
// an lstat immediately before the open. The TOCTOU window that leaves is the
|
|
107
181
|
// platform's best available answer; the thrown code stays ELOOP so every
|
|
@@ -137,6 +211,9 @@ async function readJournalEntries(journalPath, namespace = null) {
|
|
|
137
211
|
const lines = source.split('\n').filter(Boolean);
|
|
138
212
|
if (lines.length > MAX_JOURNAL_ENTRIES) throw journalCapacityExceeded();
|
|
139
213
|
const entries = lines.map((line) => JSON.parse(line));
|
|
214
|
+
const createAttempts = new Map();
|
|
215
|
+
const repairIntents = new Map();
|
|
216
|
+
const repairFinals = new Set();
|
|
140
217
|
for (let index = 0; index < entries.length; index += 1) {
|
|
141
218
|
if (!Number.isSafeInteger(entries[index]?.seq) || entries[index].seq !== index + 1) {
|
|
142
219
|
throw journalInvalid('non-contiguous-sequence');
|
|
@@ -147,6 +224,12 @@ async function readJournalEntries(journalPath, namespace = null) {
|
|
|
147
224
|
if (!validJournalEntry(entries[index], namespace)) {
|
|
148
225
|
throw journalInvalid('invalid-entry');
|
|
149
226
|
}
|
|
227
|
+
if (!validCreateResolution(entries[index], createAttempts)) {
|
|
228
|
+
throw journalInvalid('invalid-entry');
|
|
229
|
+
}
|
|
230
|
+
if (!validRepairResolution(entries[index], repairIntents, repairFinals)) {
|
|
231
|
+
throw journalInvalid('invalid-entry');
|
|
232
|
+
}
|
|
150
233
|
}
|
|
151
234
|
return entries;
|
|
152
235
|
}
|
|
@@ -220,6 +303,13 @@ function journalInvalid(reason) {
|
|
|
220
303
|
return error;
|
|
221
304
|
}
|
|
222
305
|
|
|
306
|
+
// A committed sequence outside the journal's own capacity names no reachable
|
|
307
|
+
// journal position, so it fails closed here rather than reaching hydration,
|
|
308
|
+
// which would otherwise fill every absent position below it.
|
|
309
|
+
function validSequence(seq) {
|
|
310
|
+
return Number.isSafeInteger(seq) && seq >= 1 && seq <= MAX_JOURNAL_ENTRIES;
|
|
311
|
+
}
|
|
312
|
+
|
|
223
313
|
function validJournalEntry(entry, namespace) {
|
|
224
314
|
if (entry.type === 'clock') {
|
|
225
315
|
return typeof entry.now === 'string' && typeof entry.floor === 'string';
|
|
@@ -235,10 +325,14 @@ function validJournalEntry(entry, namespace) {
|
|
|
235
325
|
&& typeof entry.ledger_namespace === 'string'
|
|
236
326
|
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
237
327
|
&& typeof entry.item_id === 'string'
|
|
238
|
-
&&
|
|
239
|
-
&&
|
|
328
|
+
&& LEGACY_MUTATION_COMMANDS.has(entry.command)
|
|
329
|
+
&& (entry.command === 'create-v1'
|
|
330
|
+
? entry.expected_revision === null
|
|
331
|
+
: typeof entry.expected_revision === 'string')
|
|
240
332
|
&& typeof entry.candidate_revision === 'string'
|
|
241
333
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
334
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
335
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
242
336
|
&& typeof entry.observed_at === 'string';
|
|
243
337
|
}
|
|
244
338
|
if (entry.type === 'legacy-mutation') {
|
|
@@ -246,17 +340,27 @@ function validJournalEntry(entry, namespace) {
|
|
|
246
340
|
&& typeof entry.ledger_namespace === 'string'
|
|
247
341
|
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
248
342
|
&& typeof entry.item_id === 'string'
|
|
249
|
-
&&
|
|
343
|
+
&& LEGACY_MUTATION_COMMANDS.has(entry.command)
|
|
250
344
|
&& typeof entry.committed_revision === 'string'
|
|
251
345
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
346
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
347
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
252
348
|
&& typeof entry.observed_at === 'string';
|
|
253
349
|
}
|
|
254
350
|
if (entry.type === 'legacy-mutation-abort') {
|
|
351
|
+
// A create abort names no predecessor revision, so the absence of one is
|
|
352
|
+
// its evidence. Patch and transition aborts keep their exact legacy shape
|
|
353
|
+
// and never carry a command.
|
|
354
|
+
const validAbortRevision = (
|
|
355
|
+
!Object.hasOwn(entry, 'command') && typeof entry.observed_revision === 'string'
|
|
356
|
+
) || (
|
|
357
|
+
entry.command === 'create-v1' && entry.observed_revision === null
|
|
358
|
+
);
|
|
255
359
|
return typeof entry.attempt_id === 'string'
|
|
256
360
|
&& typeof entry.ledger_namespace === 'string'
|
|
257
361
|
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
258
362
|
&& typeof entry.item_id === 'string'
|
|
259
|
-
&&
|
|
363
|
+
&& validAbortRevision
|
|
260
364
|
&& typeof entry.observed_at === 'string';
|
|
261
365
|
}
|
|
262
366
|
if (entry.type === 'revision-adoption') {
|
|
@@ -271,6 +375,12 @@ function validJournalEntry(entry, namespace) {
|
|
|
271
375
|
&& typeof entry.git_commit === 'string'
|
|
272
376
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string');
|
|
273
377
|
}
|
|
378
|
+
if (entry.type === 'number-repair-intent') {
|
|
379
|
+
return validRepairIntent(entry, namespace);
|
|
380
|
+
}
|
|
381
|
+
if (entry.type === 'number-repair-final') {
|
|
382
|
+
return validRepairFinal(entry, namespace);
|
|
383
|
+
}
|
|
274
384
|
if (entry.type === 'publish-intent') {
|
|
275
385
|
return typeof entry.operation_id === 'string'
|
|
276
386
|
&& typeof entry.operation_digest === 'string'
|
|
@@ -278,6 +388,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
278
388
|
&& typeof entry.expected_revision === 'string'
|
|
279
389
|
&& typeof entry.candidate_sha256 === 'string'
|
|
280
390
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
391
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
392
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
281
393
|
&& isRecord(entry.fence)
|
|
282
394
|
&& (namespace === null || entry.fence.ledger_namespace === namespace)
|
|
283
395
|
&& entry.fence.item_id === entry.item_id;
|
|
@@ -288,6 +400,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
288
400
|
&& typeof entry.ledger_namespace === 'string'
|
|
289
401
|
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
290
402
|
&& typeof entry.item_id === 'string'
|
|
403
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
404
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
291
405
|
&& validPublicationOutcome(entry);
|
|
292
406
|
}
|
|
293
407
|
return typeof entry.operation_id === 'string'
|
|
@@ -296,6 +410,97 @@ function validJournalEntry(entry, namespace) {
|
|
|
296
410
|
&& typeof entry.git_commit === 'string';
|
|
297
411
|
}
|
|
298
412
|
|
|
413
|
+
const REPAIR_ID = /^nr_\d{8}_\d{4}$/;
|
|
414
|
+
const REPAIR_ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
415
|
+
const REPAIR_REVISION = /^sha256:[0-9a-f]{64}$/;
|
|
416
|
+
const REPAIR_STAGING_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
417
|
+
|
|
418
|
+
function validRepairIntent(entry, namespace) {
|
|
419
|
+
return REPAIR_ID.test(entry.repair_id)
|
|
420
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
421
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
422
|
+
&& REPAIR_REVISION.test(entry.ledger_snapshot_revision)
|
|
423
|
+
&& isCalendarDate(entry.date)
|
|
424
|
+
&& REPAIR_STAGING_ID.test(entry.staging_id)
|
|
425
|
+
&& validRepairItems(entry.items, { includeExpected: true });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function validRepairFinal(entry, namespace) {
|
|
429
|
+
return REPAIR_ID.test(entry.repair_id)
|
|
430
|
+
&& typeof entry.ledger_namespace === 'string'
|
|
431
|
+
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
432
|
+
&& REPAIR_STAGING_ID.test(entry.staging_id)
|
|
433
|
+
&& typeof entry.observed_at === 'string'
|
|
434
|
+
&& validRepairItems(entry.items, { includeCommitted: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function validRepairItems(items, options) {
|
|
438
|
+
if (!Array.isArray(items) || items.length === 0) return false;
|
|
439
|
+
const seen = new Set();
|
|
440
|
+
for (const item of items) {
|
|
441
|
+
if (!isRecord(item)
|
|
442
|
+
|| !REPAIR_ITEM_ID.test(item.item_id)
|
|
443
|
+
|| seen.has(item.item_id)
|
|
444
|
+
|| typeof item.item_path !== 'string'
|
|
445
|
+
|| !REPAIR_REVISION.test(item.candidate_revision)) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (options.includeExpected
|
|
449
|
+
&& (!REPAIR_REVISION.test(item.expected_revision)
|
|
450
|
+
|| !Number.isSafeInteger(item.expected_number)
|
|
451
|
+
|| item.expected_number < 1
|
|
452
|
+
|| !Number.isSafeInteger(item.replacement_number)
|
|
453
|
+
|| item.replacement_number < 1)) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
if (options.includeCommitted && !REPAIR_REVISION.test(item.committed_revision)) return false;
|
|
457
|
+
seen.add(item.item_id);
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validRepairResolution(entry, intents, finals) {
|
|
463
|
+
if (entry.type === 'number-repair-intent') {
|
|
464
|
+
if (intents.has(entry.repair_id)) return false;
|
|
465
|
+
intents.set(entry.repair_id, entry);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
if (entry.type !== 'number-repair-final') return true;
|
|
469
|
+
const intent = intents.get(entry.repair_id);
|
|
470
|
+
if (!intent || finals.has(entry.repair_id) || intent.staging_id !== entry.staging_id) return false;
|
|
471
|
+
const expected = new Map(intent.items.map((item) => [item.item_id, item.candidate_revision]));
|
|
472
|
+
if (entry.items.length !== expected.size) return false;
|
|
473
|
+
for (const item of entry.items) {
|
|
474
|
+
if (expected.get(item.item_id) !== item.candidate_revision) return false;
|
|
475
|
+
}
|
|
476
|
+
finals.add(entry.repair_id);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A journal-fenced create is only meaningful as a pair: the terminal that ends
|
|
481
|
+
// an attempt must name the intent that opened it, so a standalone create
|
|
482
|
+
// terminal names no authorized attempt and fails closed. Patch and transition
|
|
483
|
+
// entries remain valid standing alone, so only create-v1 attempts are tracked.
|
|
484
|
+
function validCreateResolution(entry, attempts) {
|
|
485
|
+
if (entry.command !== 'create-v1') return true;
|
|
486
|
+
if (entry.type === 'legacy-mutation-intent') {
|
|
487
|
+
attempts.set(entry.attempt_id, entry);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
if (entry.type !== 'legacy-mutation' && entry.type !== 'legacy-mutation-abort') return true;
|
|
491
|
+
const intent = attempts.get(entry.attempt_id);
|
|
492
|
+
if (intent === undefined || intent.item_id !== entry.item_id) return false;
|
|
493
|
+
// A committed create publishes exactly the revision its intent proposed; an
|
|
494
|
+
// aborted one publishes none.
|
|
495
|
+
if (entry.type === 'legacy-mutation' && intent.candidate_revision !== entry.committed_revision) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
// One attempt resolves once, so the intent is consumed here and a repeated
|
|
499
|
+
// resolution finds no open attempt to end.
|
|
500
|
+
attempts.delete(entry.attempt_id);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
299
504
|
function isRecord(value) {
|
|
300
505
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
301
506
|
}
|
package/src/claim-prospective.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
|
|
4
4
|
import { parseLedgerItemSource } from './ledger.js';
|
|
5
|
+
import { parseReconcileLog } from './claim-journal.js';
|
|
5
6
|
import { readGitTreeFile, readGitTreeLedger } from './git-reconciliation.js';
|
|
6
7
|
import { revisionFor } from './mutation.js';
|
|
7
8
|
|
|
@@ -69,34 +70,6 @@ export function checkProspectiveLedger({ namespace, items, entries, parents, can
|
|
|
69
70
|
return { ok: true };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
export function parseReconcileLog(bytes, namespace) {
|
|
73
|
-
const lines = bytes.toString('utf8').split('\n');
|
|
74
|
-
const start = lines.indexOf('```jsonl');
|
|
75
|
-
if (start < 0) return [];
|
|
76
|
-
const end = lines.indexOf('```', start + 1);
|
|
77
|
-
if (end < 0) return { error: { code: 'ambiguous-journal', reason: 'unterminated-jsonl-block' } };
|
|
78
|
-
const entries = [];
|
|
79
|
-
for (const line of lines.slice(start + 1, end)) {
|
|
80
|
-
if (line.trim() === '') continue;
|
|
81
|
-
try {
|
|
82
|
-
const entry = JSON.parse(line);
|
|
83
|
-
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
84
|
-
return { error: { code: 'ambiguous-journal', reason: 'entry-not-object' } };
|
|
85
|
-
}
|
|
86
|
-
entries.push(entry);
|
|
87
|
-
} catch {
|
|
88
|
-
return { error: { code: 'ambiguous-journal', reason: 'entry-not-json' } };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const namespaceIssue = entries.find((entry) => {
|
|
92
|
-
const entryNamespace = entry.ledger_namespace
|
|
93
|
-
?? entry.request?.ledger_namespace
|
|
94
|
-
?? entry.fence?.ledger_namespace;
|
|
95
|
-
return entryNamespace !== undefined && entryNamespace !== namespace;
|
|
96
|
-
});
|
|
97
|
-
return entries;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
73
|
export async function checkProspectiveMerge({ ledgerDirectory, namespace, baseRef, headRef }) {
|
|
101
74
|
const root = (await gitText(ledgerDirectory, ['rev-parse', '--show-toplevel'])).trim();
|
|
102
75
|
const base = (await gitText(root, ['rev-parse', '--verify', baseRef])).trim();
|