wendkeep 0.78.0 → 0.80.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 +67 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +116 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/sessions-and-import.md +6 -0
- package/docs/en/commands/verify.md +54 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +115 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/sessions-and-import.md +7 -0
- package/docs/pt-BR/commands/verify.md +53 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +220 -123
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/session-stop.mjs +40 -1
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +7 -0
- package/packages/vault/src/memory-handoff.mjs +15 -0
- package/schema/artifact-manifest-v1.schema.json +35 -0
- package/schema/handoff-contract-v1.schema.json +37 -0
- package/schema/task-contract-v1.schema.json +57 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/task-contracts.mjs +510 -0
- package/src/task-leases.mjs +105 -0
- package/src/task.mjs +115 -0
- package/src/verify.mjs +32 -0
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
package/src/worktree-cleanup.mjs
CHANGED
|
@@ -1,32 +1,239 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import {
|
|
4
|
-
appendFileSync,
|
|
5
4
|
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
5
|
readFileSync,
|
|
8
6
|
readdirSync,
|
|
9
7
|
} from 'node:fs';
|
|
10
|
-
import {
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
11
9
|
|
|
12
10
|
import {
|
|
13
11
|
discoverWorktreeRepository,
|
|
14
12
|
mutateWorktreeRegistry,
|
|
15
13
|
readWorktreeRegistry,
|
|
16
|
-
withWorktreeRegistryLock,
|
|
17
14
|
} from '../packages/vault/src/worktree-metadata.mjs';
|
|
18
|
-
import {
|
|
19
|
-
|
|
15
|
+
import {
|
|
16
|
+
cleanupReservationForWorktree,
|
|
17
|
+
comparableCleanupPath,
|
|
18
|
+
readSessionRegistry,
|
|
19
|
+
} from '../hooks/obsidian-common.mjs';
|
|
20
|
+
import {
|
|
21
|
+
markActiveContextCleanupTerminal,
|
|
22
|
+
mutateActiveContext,
|
|
23
|
+
releaseActiveContextCleanup,
|
|
24
|
+
reserveActiveContextCleanup,
|
|
25
|
+
updateActiveContextCleanupPhase,
|
|
26
|
+
} from '../hooks/active-context-store.mjs';
|
|
27
|
+
import {
|
|
28
|
+
appendReceipt as appendLedgerReceipt,
|
|
29
|
+
createFileReceiptStore,
|
|
30
|
+
readReceiptLedger,
|
|
31
|
+
} from './receipt-ledger.mjs';
|
|
32
|
+
import {
|
|
33
|
+
classifyReceipt,
|
|
34
|
+
evaluateProvenanceGate,
|
|
35
|
+
} from './provenance-gate.mjs';
|
|
36
|
+
|
|
37
|
+
const RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v2.jsonl';
|
|
38
|
+
const LEGACY_RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v1.jsonl';
|
|
39
|
+
const activeCleanupOperations = new Set();
|
|
20
40
|
|
|
21
|
-
|
|
41
|
+
function cleanupOperationKey(repository, slug) {
|
|
42
|
+
return `${String(repository?.commonDir || '')}\u0000${String(slug || '')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function processIsAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return error?.code === 'EPERM';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanupReservationIsActive(repository, slug, entry) {
|
|
55
|
+
const key = cleanupOperationKey(repository, slug);
|
|
56
|
+
if (activeCleanupOperations.has(key)) return true;
|
|
57
|
+
// A failed entry is explicitly retryable. The shared SESSION_REGISTRY
|
|
58
|
+
// reservation performs the owner-token CAS; do not re-open the worktree
|
|
59
|
+
// registry lock here (reserve() already holds it).
|
|
60
|
+
if (String(entry?.cleanup?.state || '') === 'failed') return false;
|
|
61
|
+
const pathExists = Boolean(entry?.path && existsSync(entry.path));
|
|
62
|
+
const ownerPid = Number(entry?.cleanup?.ownerPid);
|
|
63
|
+
if (!Number.isSafeInteger(ownerPid) || ownerPid < 1) return pathExists;
|
|
64
|
+
return processIsAlive(ownerPid);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function orphanedReservationOperationId(report, identity, {
|
|
68
|
+
mode, authority, head, slug, resumed,
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (resumed) return '';
|
|
71
|
+
const marker = cleanupReservationForWorktree(
|
|
72
|
+
readSessionRegistry(report.registry.vaultPath),
|
|
73
|
+
identity.worktree_id,
|
|
74
|
+
identity.repository_id,
|
|
75
|
+
);
|
|
76
|
+
if (!marker) return '';
|
|
77
|
+
const ownerPid = Number(marker.owner_pid);
|
|
78
|
+
if (Number.isSafeInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) return '';
|
|
79
|
+
const sortedIds = (value) => [...new Set(value || [])].map(String).sort();
|
|
80
|
+
const snapshotsEqual = JSON.stringify(
|
|
81
|
+
(Array.isArray(marker.target_context_snapshot) ? marker.target_context_snapshot : [])
|
|
82
|
+
.slice().sort((left, right) => String(left?.key || '').localeCompare(String(right?.key || ''))),
|
|
83
|
+
) === JSON.stringify(
|
|
84
|
+
(Array.isArray(identity.target_context_snapshot) ? identity.target_context_snapshot : [])
|
|
85
|
+
.slice().sort((left, right) => String(left?.key || '').localeCompare(String(right?.key || ''))),
|
|
86
|
+
);
|
|
87
|
+
const sameSubject = marker.mode === mode
|
|
88
|
+
&& marker.authority === authority
|
|
89
|
+
&& marker.head === head
|
|
90
|
+
&& marker.slug === slug
|
|
91
|
+
&& marker.pull_request_number === identity.pull_request_number
|
|
92
|
+
&& marker.pull_request_repository === identity.pull_request_repository
|
|
93
|
+
&& marker.head_ref_oid === identity.head_ref_oid
|
|
94
|
+
&& marker.merge_commit_oid === identity.merge_commit_oid
|
|
95
|
+
&& marker.project_id === identity.project_id
|
|
96
|
+
&& marker.repository_id === identity.repository_id
|
|
97
|
+
&& marker.worktree_id === identity.worktree_id
|
|
98
|
+
&& marker.work_session_id === identity.work_session_id
|
|
99
|
+
&& marker.change_slug === identity.change_slug
|
|
100
|
+
&& marker.actor_context_id === identity.actor_context_id
|
|
101
|
+
&& JSON.stringify(sortedIds(marker.target_context_ids))
|
|
102
|
+
=== JSON.stringify(sortedIds(identity.target_context_ids))
|
|
103
|
+
&& JSON.stringify(sortedIds(marker.target_change_slugs))
|
|
104
|
+
=== JSON.stringify(sortedIds(identity.target_change_slugs))
|
|
105
|
+
&& snapshotsEqual
|
|
106
|
+
&& comparableCleanupPath(marker.worktree_path) === comparableCleanupPath(identity.worktree_path);
|
|
107
|
+
return sameSubject ? String(marker.operation_id || '') : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function releaseCleanupOperation(repository, slug) {
|
|
111
|
+
activeCleanupOperations.delete(cleanupOperationKey(repository, slug));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sanitizeDiagnosticText(value) {
|
|
115
|
+
let text = String(value || '');
|
|
116
|
+
text = text
|
|
117
|
+
.replace(/\b(?:ghp|github_pat|npm_|sk-|xox[baprs]-)[A-Za-z0-9_\-]+/gi, '[redacted-token]')
|
|
118
|
+
.replace(/\b(?:token|authorization|bearer|password|secret|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '[redacted-token]')
|
|
119
|
+
.replace(/\bBearer\s+[^\s,;]+/gi, '[redacted-token]')
|
|
120
|
+
.replace(/[A-Za-z]:\\[^\r\n"'`;|&]+/g, '[redacted-path]')
|
|
121
|
+
.replace(/(?:^|\s)\/[^\s"'`;|&]+/g, ' [redacted-path]')
|
|
122
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
123
|
+
.replace(/[;&|`$<>(){}[\]]/g, ' ')
|
|
124
|
+
.replace(/\s{2,}/g, ' ')
|
|
125
|
+
.trim();
|
|
126
|
+
return text.slice(0, 240);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sanitizeDiagnosticValue(value) {
|
|
130
|
+
if (typeof value === 'string') return sanitizeDiagnosticText(value);
|
|
131
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeDiagnosticValue(item));
|
|
132
|
+
if (value && typeof value === 'object') {
|
|
133
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
134
|
+
key, sanitizeDiagnosticValue(item),
|
|
135
|
+
]));
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function recoverySegment(value, fallback) {
|
|
141
|
+
const segment = sanitizeDiagnosticText(value)
|
|
142
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
|
|
143
|
+
.replace(/-{2,}/g, '-')
|
|
144
|
+
.replace(/^-+|-+$/g, '')
|
|
145
|
+
.slice(0, 80);
|
|
146
|
+
return segment || fallback;
|
|
147
|
+
}
|
|
22
148
|
|
|
23
149
|
function cleanupError(code, message, details = {}) {
|
|
24
|
-
const error = new Error(message);
|
|
150
|
+
const error = new Error(sanitizeDiagnosticText(message));
|
|
151
|
+
Object.assign(error, sanitizeDiagnosticValue(details));
|
|
25
152
|
error.code = code;
|
|
26
|
-
Object.assign(error, details);
|
|
27
153
|
return error;
|
|
28
154
|
}
|
|
29
155
|
|
|
156
|
+
function sha256(value) {
|
|
157
|
+
return createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function canonicalPrAuthority(proof) {
|
|
161
|
+
const repository = githubRepository(proof?.url || proof?.repository || '');
|
|
162
|
+
const number = Number(proof?.number);
|
|
163
|
+
if (!repository || !Number.isSafeInteger(number) || number < 1) {
|
|
164
|
+
throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR não possui autoridade canônica comprovada.');
|
|
165
|
+
}
|
|
166
|
+
return `${repository}#${number}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function canonicalPrSnapshot(proof) {
|
|
170
|
+
if (!proof) return null;
|
|
171
|
+
return {
|
|
172
|
+
repository: githubRepository(proof.url || proof.repository || ''),
|
|
173
|
+
number: Number(proof.number),
|
|
174
|
+
head_ref_name: String(proof.headRefName || ''),
|
|
175
|
+
head_ref_oid: String(proof.headRefOid || ''),
|
|
176
|
+
merge_commit_oid: String(proof.mergeCommitOid || ''),
|
|
177
|
+
base_ref_name: String(proof.baseRefName || ''),
|
|
178
|
+
merge_mode: String(proof.mergeMode || 'github'),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizedReason(value) {
|
|
183
|
+
const label = recoverySegment(value, 'motivo-nao-informado');
|
|
184
|
+
const digest = sha256(label);
|
|
185
|
+
return { label, digest, authority: `reason:${digest}` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function invokeFault(faultInjection, phase, context = {}) {
|
|
189
|
+
const snake = phase.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
190
|
+
const callback = typeof faultInjection === 'function'
|
|
191
|
+
? faultInjection
|
|
192
|
+
: faultInjection?.[phase] || faultInjection?.[snake];
|
|
193
|
+
if (typeof callback !== 'function') return;
|
|
194
|
+
const result = callback({ phase, ...context });
|
|
195
|
+
if (result instanceof Error) throw result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function storedPullRequestProof(entry) {
|
|
199
|
+
const proof = entry?.pullRequest;
|
|
200
|
+
if (!proof || typeof proof !== 'object') return null;
|
|
201
|
+
const complete = Boolean(
|
|
202
|
+
proof.number
|
|
203
|
+
&& proof.url
|
|
204
|
+
&& proof.mergedAt
|
|
205
|
+
&& proof.headRefName
|
|
206
|
+
&& proof.headRefOid
|
|
207
|
+
&& proof.mergeCommitOid
|
|
208
|
+
&& proof.baseRefName,
|
|
209
|
+
);
|
|
210
|
+
if (!complete) return null;
|
|
211
|
+
if (String(proof.headRefName) !== String(entry.branch)) {
|
|
212
|
+
throw cleanupError(
|
|
213
|
+
'WENDKEEP_WORKTREE_PR_MISMATCH',
|
|
214
|
+
'A prova reservada do PR não corresponde à branch da worktree.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...proof,
|
|
219
|
+
repository: githubRepository(proof.url || proof.repository || ''),
|
|
220
|
+
authority: canonicalPrAuthority(proof),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isResumableCleanup(entry, mode) {
|
|
225
|
+
return ['cleaning', 'failed'].includes(String(entry?.cleanup?.state || ''))
|
|
226
|
+
&& entry?.cleanup?.mode === mode
|
|
227
|
+
&& Boolean(String(entry?.cleanup?.operationId || '').trim());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function actorContextId(value) {
|
|
231
|
+
if (value && typeof value === 'object') {
|
|
232
|
+
return String(value.id || value.context_id || value.contextId || value.key || '').trim();
|
|
233
|
+
}
|
|
234
|
+
return String(value || '').trim();
|
|
235
|
+
}
|
|
236
|
+
|
|
30
237
|
function git(cwd, args, { ok = true, spawn = spawnSync } = {}) {
|
|
31
238
|
const result = spawn('git', args, {
|
|
32
239
|
cwd,
|
|
@@ -36,7 +243,7 @@ function git(cwd, args, { ok = true, spawn = spawnSync } = {}) {
|
|
|
36
243
|
if (ok && result.status !== 0) {
|
|
37
244
|
throw cleanupError(
|
|
38
245
|
'WENDKEEP_WORKTREE_GIT_FAILED',
|
|
39
|
-
|
|
246
|
+
sanitizeDiagnosticText(result.stderr || result.error?.message || `git ${args[0]} falhou`),
|
|
40
247
|
{ gitArgs: [...args], status: result.status },
|
|
41
248
|
);
|
|
42
249
|
}
|
|
@@ -90,7 +297,7 @@ async function defaultGithub({ cwd, pullRequest }) {
|
|
|
90
297
|
if (result.status !== 0) {
|
|
91
298
|
throw cleanupError(
|
|
92
299
|
'WENDKEEP_WORKTREE_PR_UNAVAILABLE',
|
|
93
|
-
|
|
300
|
+
sanitizeDiagnosticText(result.stderr || result.error?.message || 'GitHub indisponível.'),
|
|
94
301
|
);
|
|
95
302
|
}
|
|
96
303
|
const value = JSON.parse(result.stdout || '{}');
|
|
@@ -138,6 +345,22 @@ export async function verifyMergedPullRequest({
|
|
|
138
345
|
if (value?.isCrossRepository === true || value?.headRefName !== entry.branch) {
|
|
139
346
|
throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR não corresponde à branch da worktree.');
|
|
140
347
|
}
|
|
348
|
+
const managedHeadResult = git(startDir, [
|
|
349
|
+
'rev-parse', '--verify', `refs/heads/${entry.branch}`,
|
|
350
|
+
], { ok: false, spawn });
|
|
351
|
+
const managedHead = managedHeadResult.status === 0
|
|
352
|
+
? String(managedHeadResult.stdout || '').trim() : '';
|
|
353
|
+
if (!mergeCommitOid || !managedHead || String(value?.headRefOid || '').trim() !== managedHead) {
|
|
354
|
+
throw cleanupError(
|
|
355
|
+
'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
|
|
356
|
+
'PR não corresponde ao commit head comprovado da worktree.',
|
|
357
|
+
{
|
|
358
|
+
managedHead,
|
|
359
|
+
receivedHead: String(value?.headRefOid || '').trim(),
|
|
360
|
+
branch: String(entry.branch || ''),
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
}
|
|
141
364
|
const baseRefName = String(value?.baseRefName || entry.base || '').trim();
|
|
142
365
|
if (!baseRefName || git(startDir, [
|
|
143
366
|
'merge-base', '--is-ancestor', mergeCommitOid, baseRefName,
|
|
@@ -150,6 +373,8 @@ export async function verifyMergedPullRequest({
|
|
|
150
373
|
return {
|
|
151
374
|
number: Number(value.number || normalized.number),
|
|
152
375
|
url: String(value.url || normalized.reference),
|
|
376
|
+
repository: returnedRepository,
|
|
377
|
+
authority: `${returnedRepository}#${Number(value.number || normalized.number)}`,
|
|
153
378
|
state: 'MERGED',
|
|
154
379
|
mergedAt: String(value.mergedAt),
|
|
155
380
|
headRefName: String(value.headRefName),
|
|
@@ -179,6 +404,22 @@ function contextsForWorktree(registry, entry) {
|
|
|
179
404
|
.filter(([, context]) => context?.state === 'active' && context?.worktree_id === entry.worktreeId);
|
|
180
405
|
}
|
|
181
406
|
|
|
407
|
+
function contextCausalSnapshot(entries = []) {
|
|
408
|
+
return entries.map(([key, context]) => ({
|
|
409
|
+
key: String(key),
|
|
410
|
+
project_id: String(context?.project_id || ''),
|
|
411
|
+
repository_id: String(context?.repository_id || ''),
|
|
412
|
+
worktree_id: String(context?.worktree_id || ''),
|
|
413
|
+
work_session_id: String(context?.work_session_id || ''),
|
|
414
|
+
change_slug: String(context?.change_slug || ''),
|
|
415
|
+
branch: String(context?.branch || ''),
|
|
416
|
+
head_sha: String(context?.head_sha || ''),
|
|
417
|
+
delivery_id: String(context?.delivery_id || ''),
|
|
418
|
+
state: String(context?.state || ''),
|
|
419
|
+
revision: Number(context?.revision || 0),
|
|
420
|
+
})).sort((left, right) => left.key.localeCompare(right.key));
|
|
421
|
+
}
|
|
422
|
+
|
|
182
423
|
export function inspectWorktreeCleanup({
|
|
183
424
|
startDir = process.cwd(), slug, spawn = spawnSync,
|
|
184
425
|
} = {}) {
|
|
@@ -197,14 +438,26 @@ export function inspectWorktreeCleanup({
|
|
|
197
438
|
if (status.status !== 0 || String(status.stdout || '').trim()) {
|
|
198
439
|
blockers.push({
|
|
199
440
|
code: 'WENDKEEP_WORKTREE_DIRTY',
|
|
200
|
-
recovery: `limpe o checkout e rode wendkeep worktree finish ${slug} novamente`,
|
|
441
|
+
recovery: `limpe o checkout e rode wendkeep worktree finish ${recoverySegment(slug, 'worktree')} novamente`,
|
|
201
442
|
});
|
|
202
443
|
}
|
|
203
444
|
}
|
|
204
445
|
const sessionRegistry = readSessionRegistry(registry.vaultPath);
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
|
|
446
|
+
const matchingContexts = contextsForWorktree(sessionRegistry, entry);
|
|
447
|
+
const foreignContexts = matchingContexts.filter(([, context]) => (
|
|
448
|
+
context?.project_id !== registry.projectId
|
|
449
|
+
|| context?.repository_id !== registry.repositoryId
|
|
450
|
+
));
|
|
451
|
+
const foreignKeys = new Set(foreignContexts.map(([key]) => key));
|
|
452
|
+
const contexts = matchingContexts.filter(([key]) => !foreignKeys.has(key));
|
|
453
|
+
if (foreignContexts.length) {
|
|
454
|
+
blockers.push({
|
|
455
|
+
code: 'WENDKEEP_WORKTREE_CONTEXT_MISMATCH',
|
|
456
|
+
recovery: 'feche o contexto ativo estrangeiro antes do cleanup',
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const workSessions = new Set(matchingContexts.map(([, context]) => String(context.work_session_id || '')));
|
|
460
|
+
const activeSessions = Object.entries(sessionRegistry.sessions || {}).filter(([, session]) => (
|
|
208
461
|
session?.status === 'active'
|
|
209
462
|
&& (workSessions.has(String(session.work_session_id || ''))
|
|
210
463
|
|| (String(session.project_scope?.repoRoot || '').trim()
|
|
@@ -247,6 +500,7 @@ export function inspectWorktreeCleanup({
|
|
|
247
500
|
registry,
|
|
248
501
|
entry: structuredClone(entry),
|
|
249
502
|
contexts: contexts.map(([key, context]) => ({ key, context: structuredClone(context) })),
|
|
503
|
+
contextSnapshot: contextCausalSnapshot(matchingContexts),
|
|
250
504
|
};
|
|
251
505
|
}
|
|
252
506
|
|
|
@@ -254,27 +508,407 @@ export function cleanupReceiptPath(repository) {
|
|
|
254
508
|
return join(repository.commonDir, ...RECEIPT_REL.split('/'));
|
|
255
509
|
}
|
|
256
510
|
|
|
511
|
+
export function cleanupReceiptLegacyPath(repository) {
|
|
512
|
+
return join(repository.commonDir, ...LEGACY_RECEIPT_REL.split('/'));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function cleanupReceiptCheckpointPath(repository) {
|
|
516
|
+
return `${cleanupReceiptPath(repository)}.checkpoint.json`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function receiptStore(repository) {
|
|
520
|
+
return createFileReceiptStore({
|
|
521
|
+
ledgerPath: cleanupReceiptPath(repository),
|
|
522
|
+
checkpointPath: cleanupReceiptCheckpointPath(repository),
|
|
523
|
+
legacyPath: cleanupReceiptLegacyPath(repository),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function cleanupReceiptFromRecord(record) {
|
|
528
|
+
const claims = record?.claims || {};
|
|
529
|
+
const observations = record?.observations || {};
|
|
530
|
+
return {
|
|
531
|
+
schema_version: record.schema_version,
|
|
532
|
+
schemaVersion: record.schema_version,
|
|
533
|
+
id: record.receipt_id,
|
|
534
|
+
receipt_id: record.receipt_id,
|
|
535
|
+
receipt_hash: record.receipt_hash,
|
|
536
|
+
sequence: record.sequence,
|
|
537
|
+
previous_hash: record.previous_hash,
|
|
538
|
+
kind: record.kind,
|
|
539
|
+
repository_id: record.subject?.repository_id,
|
|
540
|
+
project_id: record.subject?.project_id,
|
|
541
|
+
worktree_id: record.subject?.worktree_id,
|
|
542
|
+
work_session_id: record.subject?.work_session_id,
|
|
543
|
+
change_slug: record.subject?.change_slug,
|
|
544
|
+
target_context_ids: record.subject?.target_context_ids || [],
|
|
545
|
+
target_change_slugs: record.subject?.target_change_slugs || [],
|
|
546
|
+
target_context_snapshot: record.subject?.target_context_snapshot || [],
|
|
547
|
+
actor_context_id: record.subject?.actor_context_id || '',
|
|
548
|
+
slug: record.subject?.slug,
|
|
549
|
+
mode: record.subject?.mode,
|
|
550
|
+
authority: record.subject?.authority,
|
|
551
|
+
pull_request_number: record.subject?.pull_request_number || '',
|
|
552
|
+
pull_request_repository: record.subject?.pull_request_repository || '',
|
|
553
|
+
head_ref_oid: record.subject?.head_ref_oid || '',
|
|
554
|
+
merge_commit_oid: record.subject?.merge_commit_oid || '',
|
|
555
|
+
worktree_path: record.subject?.worktree_path || '',
|
|
556
|
+
phase: record.subject?.phase || 'finalized',
|
|
557
|
+
operationId: observations.operation_id,
|
|
558
|
+
reservationId: observations.reservation_id || observations.operation_id,
|
|
559
|
+
observations,
|
|
560
|
+
finished_at: record.recorded_at,
|
|
561
|
+
...claims,
|
|
562
|
+
head_sha: claims.head || record.subject?.head || '',
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
257
566
|
function readReceipts(repository) {
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
|
|
567
|
+
const ledger = readReceiptLedger({ store: receiptStore(repository) });
|
|
568
|
+
if (existsSync(cleanupReceiptPath(repository))
|
|
569
|
+
&& !existsSync(cleanupReceiptCheckpointPath(repository))) {
|
|
570
|
+
throw cleanupError(
|
|
571
|
+
'WENDKEEP_RECEIPT_LEDGER_TRUNCATED',
|
|
572
|
+
'O checkpoint do ledger de cleanup está ausente; a cauda não é comprovável.',
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return ledger.records
|
|
576
|
+
.filter((record) => record.kind === 'worktree-cleanup')
|
|
577
|
+
.map(cleanupReceiptFromRecord);
|
|
261
578
|
}
|
|
262
579
|
|
|
263
|
-
function
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
|
|
580
|
+
function cleanupReceiptDraft(receipt) {
|
|
581
|
+
return {
|
|
582
|
+
kind: 'worktree-cleanup',
|
|
583
|
+
subject: {
|
|
584
|
+
project_id: String(receipt.project_id || ''),
|
|
585
|
+
repository_id: String(receipt.repository_id || ''),
|
|
586
|
+
worktree_id: String(receipt.worktree_id || ''),
|
|
587
|
+
work_session_id: String(receipt.work_session_id || ''),
|
|
588
|
+
change_slug: String(receipt.change_slug || ''),
|
|
589
|
+
target_context_ids: [...new Set(receipt.target_context_ids || [])].map(String).sort(),
|
|
590
|
+
target_change_slugs: [...new Set(receipt.target_change_slugs || [])].map(String).sort(),
|
|
591
|
+
target_context_snapshot: structuredClone(receipt.target_context_snapshot || []),
|
|
592
|
+
actor_context_id: String(receipt.actor_context_id || ''),
|
|
593
|
+
slug: String(receipt.slug || ''),
|
|
594
|
+
mode: String(receipt.mode || ''),
|
|
595
|
+
authority: String(receipt.authority || ''),
|
|
596
|
+
head: String(receipt.head || ''),
|
|
597
|
+
pull_request_number: String(receipt.pull_request_number || ''),
|
|
598
|
+
pull_request_repository: String(receipt.pull_request_repository || ''),
|
|
599
|
+
head_ref_oid: String(receipt.head_ref_oid || ''),
|
|
600
|
+
merge_commit_oid: String(receipt.merge_commit_oid || ''),
|
|
601
|
+
worktree_path: String(receipt.worktree_path || ''),
|
|
602
|
+
phase: String(receipt.phase || 'finalized'),
|
|
603
|
+
},
|
|
604
|
+
claims: {
|
|
605
|
+
outcome: String(receipt.outcome || ''),
|
|
606
|
+
branch: String(receipt.branch || ''),
|
|
607
|
+
head: String(receipt.head || ''),
|
|
608
|
+
...(receipt.pull_request ? { pull_request: canonicalPrSnapshot(receipt.pull_request) } : {}),
|
|
609
|
+
...(receipt.reason ? { reason: recoverySegment(receipt.reason, 'motivo-nao-informado') } : {}),
|
|
610
|
+
...(receipt.reason_digest ? { reason_digest: String(receipt.reason_digest) } : {}),
|
|
611
|
+
local_branch_deleted: Boolean(receipt.local_branch_deleted),
|
|
612
|
+
remote_branch_deleted: Boolean(receipt.remote_branch_deleted),
|
|
613
|
+
},
|
|
614
|
+
observations: {
|
|
615
|
+
status: 'verified',
|
|
616
|
+
project_id: String(receipt.project_id || ''),
|
|
617
|
+
repository_id: String(receipt.repository_id || ''),
|
|
618
|
+
worktree_id: String(receipt.worktree_id || ''),
|
|
619
|
+
work_session_id: String(receipt.work_session_id || ''),
|
|
620
|
+
change_slug: String(receipt.change_slug || ''),
|
|
621
|
+
target_context_ids: [...new Set(receipt.target_context_ids || [])].map(String).sort(),
|
|
622
|
+
target_change_slugs: [...new Set(receipt.target_change_slugs || [])].map(String).sort(),
|
|
623
|
+
target_context_snapshot: structuredClone(receipt.target_context_snapshot || []),
|
|
624
|
+
actor_context_id: String(receipt.actor_context_id || ''),
|
|
625
|
+
branch: String(receipt.branch || ''),
|
|
626
|
+
head_sha: String(receipt.head || ''),
|
|
627
|
+
authority: String(receipt.authority || ''),
|
|
628
|
+
pull_request_number: String(receipt.pull_request_number || ''),
|
|
629
|
+
pull_request_repository: String(receipt.pull_request_repository || ''),
|
|
630
|
+
head_ref_oid: String(receipt.head_ref_oid || ''),
|
|
631
|
+
merge_commit_oid: String(receipt.merge_commit_oid || ''),
|
|
632
|
+
worktree_path: String(receipt.worktree_path || ''),
|
|
633
|
+
phase: String(receipt.phase || 'finalized'),
|
|
634
|
+
operation_id: String(receipt.operationId || ''),
|
|
635
|
+
reservation_id: String(receipt.reservationId || receipt.operationId || ''),
|
|
636
|
+
},
|
|
637
|
+
recorded_at: String(receipt.finished_at || new Date().toISOString()),
|
|
638
|
+
};
|
|
267
639
|
}
|
|
268
640
|
|
|
269
641
|
function appendReceipt(repository, receipt) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
642
|
+
const result = appendLedgerReceipt({
|
|
643
|
+
store: receiptStore(repository),
|
|
644
|
+
draft: cleanupReceiptDraft(receipt),
|
|
645
|
+
});
|
|
646
|
+
return cleanupReceiptFromRecord(result.record);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function receiptForOperation(repository, operationId, { slug, mode, authority } = {}) {
|
|
650
|
+
if (!operationId) return null;
|
|
651
|
+
const receipt = readReceipts(repository).find((item) => (
|
|
652
|
+
item.operationId === operationId
|
|
653
|
+
&& item.slug === slug
|
|
654
|
+
&& item.mode === mode
|
|
655
|
+
&& item.authority === authority
|
|
656
|
+
));
|
|
657
|
+
return receipt || null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function cleanupIdentity(report, {
|
|
661
|
+
authority = '', proof = null, actorContext = '', prior = null,
|
|
662
|
+
} = {}) {
|
|
663
|
+
const contexts = [...(report.contexts || [])]
|
|
664
|
+
.sort((left, right) => String(left.key).localeCompare(String(right.key)));
|
|
665
|
+
const context = contexts[0]?.context;
|
|
666
|
+
const targetContextIds = contexts.map(({ key, context: item }) => (
|
|
667
|
+
String(item?.context_id || item?.contextId || key)
|
|
668
|
+
)).filter(Boolean).sort();
|
|
669
|
+
const targetChangeSlugs = [...new Set(contexts
|
|
670
|
+
.map(({ context: item }) => String(item?.change_slug || '').trim())
|
|
671
|
+
.filter(Boolean))].sort();
|
|
672
|
+
const targetContextSnapshot = reportContextSnapshot(report);
|
|
673
|
+
const snapshot = canonicalPrSnapshot(proof);
|
|
674
|
+
const hasPrior = prior && typeof prior === 'object';
|
|
675
|
+
const priorHas = (field) => hasPrior && Object.hasOwn(prior, field);
|
|
676
|
+
return {
|
|
677
|
+
project_id: report.registry.projectId,
|
|
678
|
+
repository_id: report.registry.repositoryId,
|
|
679
|
+
worktree_id: report.entry.worktreeId,
|
|
680
|
+
work_session_id: priorHas('workSessionId')
|
|
681
|
+
? String(prior.workSessionId || '') : String(context?.work_session_id || ''),
|
|
682
|
+
change_slug: priorHas('changeSlug')
|
|
683
|
+
? String(prior.changeSlug || '') : String(context?.change_slug || ''),
|
|
684
|
+
target_context_ids: priorHas('targetContextIds')
|
|
685
|
+
? [...(prior.targetContextIds || [])].map(String).sort() : targetContextIds,
|
|
686
|
+
target_change_slugs: priorHas('targetChangeSlugs')
|
|
687
|
+
? [...(prior.targetChangeSlugs || [])].map(String).sort() : targetChangeSlugs,
|
|
688
|
+
target_context_snapshot: priorHas('targetContextSnapshot')
|
|
689
|
+
? structuredClone(prior.targetContextSnapshot || []) : targetContextSnapshot,
|
|
690
|
+
// The first attempt is authoritative. A retry may run under a different
|
|
691
|
+
// caller, but must not rewrite the causal actor bound to the receipt.
|
|
692
|
+
actor_context_id: priorHas('actorContextId')
|
|
693
|
+
? String(prior.actorContextId || '') : actorContextId(actorContext),
|
|
694
|
+
worktree_path: priorHas('worktreePath')
|
|
695
|
+
? String(prior.worktreePath || '') : String(report.entry.path || ''),
|
|
696
|
+
authority,
|
|
697
|
+
pull_request_number: snapshot?.number ? String(snapshot.number) : '',
|
|
698
|
+
pull_request_repository: snapshot?.repository || '',
|
|
699
|
+
head_ref_oid: snapshot?.head_ref_oid || '',
|
|
700
|
+
merge_commit_oid: snapshot?.merge_commit_oid || '',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function identityCausalSnapshot(identity = {}) {
|
|
705
|
+
return {
|
|
706
|
+
project_id: String(identity.project_id || ''),
|
|
707
|
+
repository_id: String(identity.repository_id || ''),
|
|
708
|
+
worktree_id: String(identity.worktree_id || ''),
|
|
709
|
+
work_session_id: String(identity.work_session_id || ''),
|
|
710
|
+
change_slug: String(identity.change_slug || ''),
|
|
711
|
+
target_context_ids: [...new Set(identity.target_context_ids || [])].map(String).sort(),
|
|
712
|
+
target_change_slugs: [...new Set(identity.target_change_slugs || [])].map(String).sort(),
|
|
713
|
+
target_context_snapshot: structuredClone(identity.target_context_snapshot || []),
|
|
714
|
+
actor_context_id: String(identity.actor_context_id || ''),
|
|
715
|
+
worktree_path: String(identity.worktree_path || ''),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function reportContextSnapshot(report) {
|
|
720
|
+
if (Array.isArray(report?.contextSnapshot)) return report.contextSnapshot;
|
|
721
|
+
return contextCausalSnapshot((report?.contexts || []).map(({ key, context }) => [key, context]));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function cleanupCausalConflict(expected, observed) {
|
|
725
|
+
return cleanupProvenanceError({
|
|
726
|
+
state: 'conflict',
|
|
727
|
+
reasonCodes: ['WENDKEEP_PROVENANCE_CONTEXT_MISMATCH'],
|
|
728
|
+
diagnostics: [{
|
|
729
|
+
blocker: 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH', expected, observed,
|
|
730
|
+
}],
|
|
731
|
+
repair: { command: 'reconcile active contexts, then retry cleanup' },
|
|
732
|
+
}, expected, observed);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function assertCleanupCausalSnapshot({
|
|
736
|
+
expectedReport, observedReport, expectedIdentity, observedIdentity,
|
|
737
|
+
} = {}) {
|
|
738
|
+
const expected = {
|
|
739
|
+
contexts: reportContextSnapshot(expectedReport),
|
|
740
|
+
identity: identityCausalSnapshot(expectedIdentity),
|
|
741
|
+
worktree_path: String(expectedReport?.entry?.path || ''),
|
|
742
|
+
};
|
|
743
|
+
const observed = {
|
|
744
|
+
contexts: reportContextSnapshot(observedReport),
|
|
745
|
+
identity: identityCausalSnapshot(observedIdentity),
|
|
746
|
+
worktree_path: String(observedReport?.entry?.path || ''),
|
|
747
|
+
};
|
|
748
|
+
if (JSON.stringify(expected) !== JSON.stringify(observed)) {
|
|
749
|
+
throw cleanupCausalConflict(expected, observed);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function revalidateCleanupBeforeMutation({
|
|
754
|
+
repository,
|
|
755
|
+
slug,
|
|
756
|
+
baselineReport,
|
|
757
|
+
baselineIdentity,
|
|
758
|
+
priorCleanup,
|
|
759
|
+
proof,
|
|
760
|
+
authority,
|
|
761
|
+
actorContext,
|
|
762
|
+
expectedHead,
|
|
763
|
+
spawn,
|
|
764
|
+
provenanceGate,
|
|
765
|
+
} = {}) {
|
|
766
|
+
const observedReport = inspectWorktreeCleanup({
|
|
767
|
+
startDir: repository.mainWorktree, slug, spawn,
|
|
768
|
+
});
|
|
769
|
+
if (!observedReport.ok) throw blockerError(observedReport);
|
|
770
|
+
const observedIdentity = cleanupIdentity(observedReport, {
|
|
771
|
+
authority, proof, actorContext, prior: priorCleanup,
|
|
772
|
+
});
|
|
773
|
+
assertCleanupCausalSnapshot({
|
|
774
|
+
expectedReport: baselineReport,
|
|
775
|
+
observedReport,
|
|
776
|
+
expectedIdentity: baselineIdentity,
|
|
777
|
+
observedIdentity,
|
|
778
|
+
});
|
|
779
|
+
const derivedHead = branchHead(repository, observedReport.entry.branch, spawn);
|
|
780
|
+
const effectiveHead = derivedHead || String(expectedHead || '');
|
|
781
|
+
if (!effectiveHead) {
|
|
782
|
+
throw cleanupError(
|
|
783
|
+
'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
|
|
784
|
+
'Não foi possível provar o head atual da branch antes do cleanup.',
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
if (derivedHead && expectedHead && derivedHead !== expectedHead) {
|
|
788
|
+
throw cleanupError(
|
|
789
|
+
'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
|
|
790
|
+
'O head atual da branch mudou antes da mutação.',
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
requireCleanupOperationGate({
|
|
794
|
+
mode: proof ? 'finish' : 'remove',
|
|
795
|
+
report: observedReport,
|
|
796
|
+
proof,
|
|
797
|
+
authority,
|
|
798
|
+
head: effectiveHead,
|
|
799
|
+
identity: observedIdentity,
|
|
800
|
+
provenanceGate,
|
|
277
801
|
});
|
|
802
|
+
const postGateReport = inspectWorktreeCleanup({
|
|
803
|
+
startDir: repository.mainWorktree, slug, spawn,
|
|
804
|
+
});
|
|
805
|
+
if (!postGateReport.ok) throw blockerError(postGateReport);
|
|
806
|
+
const postGateIdentity = cleanupIdentity(postGateReport, {
|
|
807
|
+
authority, proof, actorContext, prior: priorCleanup,
|
|
808
|
+
});
|
|
809
|
+
assertCleanupCausalSnapshot({
|
|
810
|
+
expectedReport: observedReport,
|
|
811
|
+
observedReport: postGateReport,
|
|
812
|
+
expectedIdentity: observedIdentity,
|
|
813
|
+
observedIdentity: postGateIdentity,
|
|
814
|
+
});
|
|
815
|
+
return { report: postGateReport, identity: postGateIdentity, head: effectiveHead };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function assertPostRemovalCausalSnapshot({
|
|
819
|
+
beforeReport, beforeIdentity, repository, slug, proof, authority, actorContext, spawn, priorCleanup,
|
|
820
|
+
} = {}) {
|
|
821
|
+
const afterReport = inspectWorktreeCleanup({
|
|
822
|
+
startDir: repository.mainWorktree, slug, spawn,
|
|
823
|
+
});
|
|
824
|
+
if (!afterReport.ok) throw blockerError(afterReport);
|
|
825
|
+
const afterIdentity = cleanupIdentity(afterReport, {
|
|
826
|
+
authority, proof, actorContext, prior: priorCleanup,
|
|
827
|
+
});
|
|
828
|
+
assertCleanupCausalSnapshot({
|
|
829
|
+
expectedReport: beforeReport,
|
|
830
|
+
observedReport: afterReport,
|
|
831
|
+
expectedIdentity: beforeIdentity,
|
|
832
|
+
observedIdentity: afterIdentity,
|
|
833
|
+
});
|
|
834
|
+
return { report: afterReport, identity: afterIdentity };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function assertNoActiveCleanupContexts(vaultPath, entry) {
|
|
838
|
+
const registry = readSessionRegistry(vaultPath);
|
|
839
|
+
const observed = contextCausalSnapshot(contextsForWorktree(registry, entry));
|
|
840
|
+
if (observed.length) {
|
|
841
|
+
throw cleanupCausalConflict({ contexts: [] }, { contexts: observed });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function reserveContextCausally({
|
|
846
|
+
vaultPath,
|
|
847
|
+
reserveOptions,
|
|
848
|
+
repository,
|
|
849
|
+
slug,
|
|
850
|
+
baselineReport,
|
|
851
|
+
baselineIdentity,
|
|
852
|
+
priorCleanup,
|
|
853
|
+
proof,
|
|
854
|
+
authority,
|
|
855
|
+
actorContext,
|
|
856
|
+
spawn,
|
|
857
|
+
} = {}) {
|
|
858
|
+
try {
|
|
859
|
+
return reserveActiveContextCleanup(vaultPath, reserveOptions);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (error?.code !== 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH') throw error;
|
|
862
|
+
const observedReport = inspectWorktreeCleanup({
|
|
863
|
+
startDir: repository.mainWorktree, slug, spawn,
|
|
864
|
+
});
|
|
865
|
+
const observedIdentity = cleanupIdentity(observedReport, {
|
|
866
|
+
authority, proof, actorContext, prior: priorCleanup,
|
|
867
|
+
});
|
|
868
|
+
throw cleanupCausalConflict({
|
|
869
|
+
contexts: reportContextSnapshot(baselineReport),
|
|
870
|
+
identity: identityCausalSnapshot(baselineIdentity),
|
|
871
|
+
}, {
|
|
872
|
+
contexts: reportContextSnapshot(observedReport),
|
|
873
|
+
identity: identityCausalSnapshot(observedIdentity),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function recordPreReservationFailure({
|
|
879
|
+
repository,
|
|
880
|
+
slug,
|
|
881
|
+
mode,
|
|
882
|
+
authority,
|
|
883
|
+
proof,
|
|
884
|
+
reason,
|
|
885
|
+
reasonDigest,
|
|
886
|
+
identity,
|
|
887
|
+
head,
|
|
888
|
+
now,
|
|
889
|
+
operationId,
|
|
890
|
+
error,
|
|
891
|
+
} = {}) {
|
|
892
|
+
try {
|
|
893
|
+
const reservation = reserve(repository, slug, {
|
|
894
|
+
mode,
|
|
895
|
+
authority,
|
|
896
|
+
proof,
|
|
897
|
+
reason,
|
|
898
|
+
reasonDigest,
|
|
899
|
+
identity,
|
|
900
|
+
head,
|
|
901
|
+
now,
|
|
902
|
+
operationId,
|
|
903
|
+
});
|
|
904
|
+
const failedRegistry = failReservation(repository, slug, error, now);
|
|
905
|
+
return {
|
|
906
|
+
operationId: reservation.operationId,
|
|
907
|
+
entry: failedRegistry?.entries?.[slug],
|
|
908
|
+
};
|
|
909
|
+
} finally {
|
|
910
|
+
releaseCleanupOperation(repository, slug);
|
|
911
|
+
}
|
|
278
912
|
}
|
|
279
913
|
|
|
280
914
|
function blockerError(report) {
|
|
@@ -282,8 +916,32 @@ function blockerError(report) {
|
|
|
282
916
|
return cleanupError(first.code, first.recovery, { blockers: report.blockers });
|
|
283
917
|
}
|
|
284
918
|
|
|
285
|
-
function reserve(repository, slug, {
|
|
286
|
-
|
|
919
|
+
function reserve(repository, slug, {
|
|
920
|
+
mode, authority, proof, reason, reasonDigest, identity = {}, head = '', now,
|
|
921
|
+
operationId = randomUUID(), attemptToken = '',
|
|
922
|
+
}) {
|
|
923
|
+
const has = (field) => Object.hasOwn(identity, field);
|
|
924
|
+
const subjectMetadata = {
|
|
925
|
+
...(has('project_id') ? { projectId: String(identity.project_id || '') } : {}),
|
|
926
|
+
...(has('repository_id') ? { repositoryId: String(identity.repository_id || '') } : {}),
|
|
927
|
+
...(has('worktree_id') ? { worktreeId: String(identity.worktree_id || '') } : {}),
|
|
928
|
+
...(has('work_session_id') ? { workSessionId: String(identity.work_session_id || '') } : {}),
|
|
929
|
+
...(has('change_slug') ? { changeSlug: String(identity.change_slug || '') } : {}),
|
|
930
|
+
...(has('target_context_ids')
|
|
931
|
+
? { targetContextIds: [...new Set(identity.target_context_ids || [])].map(String).sort() } : {}),
|
|
932
|
+
...(has('target_change_slugs')
|
|
933
|
+
? { targetChangeSlugs: [...new Set(identity.target_change_slugs || [])].map(String).sort() } : {}),
|
|
934
|
+
...(has('target_context_snapshot')
|
|
935
|
+
? { targetContextSnapshot: structuredClone(identity.target_context_snapshot || []) } : {}),
|
|
936
|
+
...(has('actor_context_id') ? { actorContextId: String(identity.actor_context_id || '') } : {}),
|
|
937
|
+
...(has('pull_request_number')
|
|
938
|
+
? { pullRequestNumber: String(identity.pull_request_number || '') } : {}),
|
|
939
|
+
...(has('pull_request_repository')
|
|
940
|
+
? { pullRequestRepository: String(identity.pull_request_repository || '') } : {}),
|
|
941
|
+
...(has('head_ref_oid') ? { headRefOid: String(identity.head_ref_oid || '') } : {}),
|
|
942
|
+
...(has('merge_commit_oid') ? { mergeCommitOid: String(identity.merge_commit_oid || '') } : {}),
|
|
943
|
+
...(has('worktree_path') ? { worktreePath: String(identity.worktree_path || '') } : {}),
|
|
944
|
+
};
|
|
287
945
|
let previous = null;
|
|
288
946
|
let resumed = false;
|
|
289
947
|
mutateWorktreeRegistry(repository, (registry) => {
|
|
@@ -293,14 +951,38 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
|
|
|
293
951
|
previous = entry;
|
|
294
952
|
return registry;
|
|
295
953
|
}
|
|
296
|
-
if (entry.cleanup?.state
|
|
297
|
-
|
|
954
|
+
if (['cleaning', 'failed'].includes(entry.cleanup?.state)) {
|
|
955
|
+
const storedAuthority = String(entry.cleanup.authority || '');
|
|
956
|
+
const compatibleLegacyAuthority = mode === 'finish'
|
|
957
|
+
&& proof
|
|
958
|
+
&& canonicalPrAuthority(proof) === authority
|
|
959
|
+
&& storedAuthority === String(proof.url || '');
|
|
960
|
+
if ((entry.cleanup.state === 'cleaning'
|
|
961
|
+
&& cleanupReservationIsActive(repository, slug, entry))
|
|
298
962
|
|| entry.cleanup.mode !== mode
|
|
299
|
-
||
|
|
963
|
+
|| (storedAuthority !== authority && !compatibleLegacyAuthority)) {
|
|
300
964
|
throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
|
|
301
965
|
}
|
|
302
966
|
previous = entry;
|
|
303
967
|
resumed = true;
|
|
968
|
+
registry.entries[slug] = {
|
|
969
|
+
...entry,
|
|
970
|
+
state: 'cleaning',
|
|
971
|
+
...(proof ? { pullRequest: proof } : {}),
|
|
972
|
+
cleanup: {
|
|
973
|
+
...entry.cleanup,
|
|
974
|
+
state: 'cleaning',
|
|
975
|
+
...(reason ? { reason } : {}),
|
|
976
|
+
...(reasonDigest ? { reason_digest: reasonDigest } : {}),
|
|
977
|
+
...(mode === 'finish' ? { authority } : {}),
|
|
978
|
+
...subjectMetadata,
|
|
979
|
+
...(head ? { head } : {}),
|
|
980
|
+
...(attemptToken ? { attemptToken } : {}),
|
|
981
|
+
ownerPid: process.pid,
|
|
982
|
+
updatedAt: now,
|
|
983
|
+
},
|
|
984
|
+
updatedAt: now,
|
|
985
|
+
};
|
|
304
986
|
return registry;
|
|
305
987
|
}
|
|
306
988
|
registry.entries[slug] = {
|
|
@@ -314,12 +996,20 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
|
|
|
314
996
|
mode,
|
|
315
997
|
authority,
|
|
316
998
|
...(reason ? { reason } : {}),
|
|
999
|
+
...(reasonDigest ? { reason_digest: reasonDigest } : {}),
|
|
1000
|
+
...subjectMetadata,
|
|
1001
|
+
...(head ? { head } : {}),
|
|
1002
|
+
...(attemptToken ? { attemptToken } : {}),
|
|
1003
|
+
ownerPid: process.pid,
|
|
317
1004
|
startedAt: now,
|
|
318
1005
|
},
|
|
319
1006
|
updatedAt: now,
|
|
320
1007
|
};
|
|
321
1008
|
return registry;
|
|
322
1009
|
});
|
|
1010
|
+
if (previous?.state !== 'cleaned') {
|
|
1011
|
+
activeCleanupOperations.add(cleanupOperationKey(repository, slug));
|
|
1012
|
+
}
|
|
323
1013
|
return {
|
|
324
1014
|
operationId: resumed ? previous.cleanup.operationId : operationId,
|
|
325
1015
|
previous,
|
|
@@ -327,24 +1017,8 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
|
|
|
327
1017
|
};
|
|
328
1018
|
}
|
|
329
1019
|
|
|
330
|
-
function associatePullRequest(repository, slug, proof, now) {
|
|
331
|
-
mutateWorktreeRegistry(repository, (registry) => {
|
|
332
|
-
const entry = registry.entries?.[slug];
|
|
333
|
-
if (!entry) throw cleanupError('WENDKEEP_WORKTREE_NOT_FOUND', `Worktree "${slug}" ausente.`);
|
|
334
|
-
if (entry.cleanup?.state === 'cleaning') {
|
|
335
|
-
const authority = proof.url || `pr:${proof.number}`;
|
|
336
|
-
if (entry.cleanup.authority !== authority) {
|
|
337
|
-
throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
|
|
338
|
-
}
|
|
339
|
-
return registry;
|
|
340
|
-
}
|
|
341
|
-
registry.entries[slug] = { ...entry, pullRequest: proof, updatedAt: now };
|
|
342
|
-
return registry;
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
1020
|
function failReservation(repository, slug, error, now) {
|
|
347
|
-
mutateWorktreeRegistry(repository, (registry) => {
|
|
1021
|
+
return mutateWorktreeRegistry(repository, (registry) => {
|
|
348
1022
|
const entry = registry.entries?.[slug];
|
|
349
1023
|
if (!entry || entry.cleanup?.state !== 'cleaning') return registry;
|
|
350
1024
|
registry.entries[slug] = {
|
|
@@ -356,7 +1030,7 @@ function failReservation(repository, slug, error, now) {
|
|
|
356
1030
|
failedAt: now,
|
|
357
1031
|
error: {
|
|
358
1032
|
code: String(error?.code || 'WENDKEEP_WORKTREE_CLEANUP_FAILED'),
|
|
359
|
-
message:
|
|
1033
|
+
message: sanitizeDiagnosticText(error?.message || 'Cleanup falhou.'),
|
|
360
1034
|
},
|
|
361
1035
|
},
|
|
362
1036
|
updatedAt: now,
|
|
@@ -365,7 +1039,7 @@ function failReservation(repository, slug, error, now) {
|
|
|
365
1039
|
});
|
|
366
1040
|
}
|
|
367
1041
|
|
|
368
|
-
function closeContexts(vaultBase, contexts, now) {
|
|
1042
|
+
function closeContexts(vaultBase, contexts, now, cleanupOperationId = '') {
|
|
369
1043
|
for (const { context } of contexts) {
|
|
370
1044
|
mutateActiveContext(vaultBase, {
|
|
371
1045
|
projectId: context.project_id,
|
|
@@ -378,7 +1052,7 @@ function closeContexts(vaultBase, contexts, now) {
|
|
|
378
1052
|
...current,
|
|
379
1053
|
state: 'closed',
|
|
380
1054
|
delivery_id: '',
|
|
381
|
-
}), { expectedRevision: context.revision, now });
|
|
1055
|
+
}), { expectedRevision: context.revision, now, cleanupOperationId });
|
|
382
1056
|
}
|
|
383
1057
|
}
|
|
384
1058
|
|
|
@@ -426,9 +1100,26 @@ function deleteRemoteBranch(repository, branch, expectedHead, spawn) {
|
|
|
426
1100
|
return true;
|
|
427
1101
|
}
|
|
428
1102
|
|
|
429
|
-
function finalize(repository, slug, {
|
|
1103
|
+
function finalize(repository, slug, {
|
|
1104
|
+
receipt, now, expectedOperationId = '', expectedAttemptToken = '', expectedSubjectHash = '',
|
|
1105
|
+
}) {
|
|
430
1106
|
mutateWorktreeRegistry(repository, (registry) => {
|
|
431
1107
|
const entry = registry.entries[slug];
|
|
1108
|
+
const subject = cleanupReceiptSubject(registry, entry);
|
|
1109
|
+
const subjectMismatches = cleanupReceiptCausalMismatches(receipt, subject);
|
|
1110
|
+
if (!entry
|
|
1111
|
+
|| entry.state !== 'cleaning'
|
|
1112
|
+
|| entry.cleanup?.state !== 'cleaning'
|
|
1113
|
+
|| String(entry.cleanup?.operationId || '') !== String(expectedOperationId || '')
|
|
1114
|
+
|| String(entry.cleanup?.attemptToken || '') !== String(expectedAttemptToken || '')
|
|
1115
|
+
|| (expectedSubjectHash && String(receipt?.id || '') !== String(expectedSubjectHash))
|
|
1116
|
+
|| subjectMismatches.length
|
|
1117
|
+
|| !cleanupReceiptMatches(receipt, registry, entry)) {
|
|
1118
|
+
throw cleanupError(
|
|
1119
|
+
'WENDKEEP_WORKTREE_CLEANUP_CAS_CONFLICT',
|
|
1120
|
+
'O subject ou owner do cleanup mudou antes da finalização; nenhuma conclusão foi publicada.',
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
432
1123
|
registry.entries[slug] = {
|
|
433
1124
|
...entry,
|
|
434
1125
|
state: 'cleaned',
|
|
@@ -436,6 +1127,22 @@ function finalize(repository, slug, { receipt, now }) {
|
|
|
436
1127
|
...entry.cleanup,
|
|
437
1128
|
state: 'completed',
|
|
438
1129
|
receiptId: receipt.id,
|
|
1130
|
+
projectId: receipt.project_id,
|
|
1131
|
+
worktreeId: receipt.worktree_id,
|
|
1132
|
+
workSessionId: receipt.work_session_id,
|
|
1133
|
+
changeSlug: receipt.change_slug,
|
|
1134
|
+
targetContextIds: receipt.target_context_ids,
|
|
1135
|
+
targetChangeSlugs: receipt.target_change_slugs,
|
|
1136
|
+
targetContextSnapshot: structuredClone(receipt.target_context_snapshot || []),
|
|
1137
|
+
actorContextId: receipt.actor_context_id,
|
|
1138
|
+
authority: receipt.authority,
|
|
1139
|
+
pullRequestNumber: receipt.pull_request_number,
|
|
1140
|
+
pullRequestRepository: receipt.pull_request_repository,
|
|
1141
|
+
headRefOid: receipt.head_ref_oid,
|
|
1142
|
+
mergeCommitOid: receipt.merge_commit_oid,
|
|
1143
|
+
head: receipt.head,
|
|
1144
|
+
subjectHash: receipt.id,
|
|
1145
|
+
phase: 'finalized',
|
|
439
1146
|
finishedAt: now,
|
|
440
1147
|
},
|
|
441
1148
|
updatedAt: now,
|
|
@@ -444,10 +1151,322 @@ function finalize(repository, slug, { receipt, now }) {
|
|
|
444
1151
|
});
|
|
445
1152
|
}
|
|
446
1153
|
|
|
447
|
-
function
|
|
1154
|
+
function cleanupReceiptSubject(registry, entry) {
|
|
1155
|
+
const cleanup = entry?.cleanup || {};
|
|
1156
|
+
return {
|
|
1157
|
+
project_id: String(registry?.projectId || ''),
|
|
1158
|
+
repository_id: String(registry?.repositoryId || ''),
|
|
1159
|
+
worktree_id: String(entry?.worktreeId || ''),
|
|
1160
|
+
work_session_id: String(cleanup.workSessionId || entry?.workSessionId || ''),
|
|
1161
|
+
change_slug: String(cleanup.changeSlug || entry?.changeSlug || ''),
|
|
1162
|
+
target_context_ids: [...new Set(cleanup.targetContextIds || [])].map(String).sort(),
|
|
1163
|
+
target_change_slugs: [...new Set(cleanup.targetChangeSlugs || [])].map(String).sort(),
|
|
1164
|
+
target_context_snapshot: structuredClone(cleanup.targetContextSnapshot || []),
|
|
1165
|
+
actor_context_id: String(cleanup.actorContextId || ''),
|
|
1166
|
+
branch: String(entry?.branch || ''),
|
|
1167
|
+
head_sha: String(cleanup.head || entry?.head || ''),
|
|
1168
|
+
authority: String(cleanup.authority || ''),
|
|
1169
|
+
pull_request_number: String(cleanup.pullRequestNumber || ''),
|
|
1170
|
+
pull_request_repository: String(cleanup.pullRequestRepository || ''),
|
|
1171
|
+
head_ref_oid: String(cleanup.headRefOid || ''),
|
|
1172
|
+
merge_commit_oid: String(cleanup.mergeCommitOid || ''),
|
|
1173
|
+
worktree_path: String(entry?.path || ''),
|
|
1174
|
+
phase: String(cleanup.phase || 'finalized'),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function comparableContextSnapshot(value = []) {
|
|
1179
|
+
return (Array.isArray(value) ? value : [])
|
|
1180
|
+
.map((item) => Object.fromEntries(
|
|
1181
|
+
Object.entries(item || {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
1182
|
+
))
|
|
1183
|
+
.sort((left, right) => String(left.key || '').localeCompare(String(right.key || '')));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function cleanupReceiptCausalMismatches(item, subject) {
|
|
1187
|
+
const mismatches = [];
|
|
1188
|
+
for (const key of [
|
|
1189
|
+
'target_context_ids', 'target_change_slugs',
|
|
1190
|
+
]) {
|
|
1191
|
+
const expected = [...new Set(subject?.[key] || [])].map(String).sort();
|
|
1192
|
+
const observed = [...new Set(item?.[key] || [])].map(String).sort();
|
|
1193
|
+
if (JSON.stringify(expected) !== JSON.stringify(observed)) mismatches.push(`${key} mismatch`);
|
|
1194
|
+
}
|
|
1195
|
+
if (JSON.stringify(comparableContextSnapshot(item?.target_context_snapshot))
|
|
1196
|
+
!== JSON.stringify(comparableContextSnapshot(subject?.target_context_snapshot))) {
|
|
1197
|
+
mismatches.push('target_context_snapshot mismatch');
|
|
1198
|
+
}
|
|
1199
|
+
for (const key of [
|
|
1200
|
+
'actor_context_id', 'authority', 'pull_request_number', 'pull_request_repository',
|
|
1201
|
+
'head_ref_oid', 'merge_commit_oid', 'worktree_path', 'phase',
|
|
1202
|
+
]) {
|
|
1203
|
+
if (String(item?.[key] || '') !== String(subject?.[key] || '')) mismatches.push(`${key} mismatch`);
|
|
1204
|
+
}
|
|
1205
|
+
return mismatches;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function cleanupReceiptMatches(item, registry, entry) {
|
|
1209
|
+
const cleanup = entry?.cleanup || {};
|
|
1210
|
+
return item.repository_id === registry?.repositoryId
|
|
1211
|
+
&& item.project_id === registry?.projectId
|
|
1212
|
+
&& item.worktree_id === entry.worktreeId
|
|
1213
|
+
&& item.work_session_id === (cleanup.workSessionId || '')
|
|
1214
|
+
&& item.change_slug === (cleanup.changeSlug || '')
|
|
1215
|
+
&& JSON.stringify([...new Set(item.target_context_ids || [])].map(String).sort())
|
|
1216
|
+
=== JSON.stringify([...new Set(cleanup.targetContextIds || [])].map(String).sort())
|
|
1217
|
+
&& JSON.stringify([...new Set(item.target_change_slugs || [])].map(String).sort())
|
|
1218
|
+
=== JSON.stringify([...new Set(cleanup.targetChangeSlugs || [])].map(String).sort())
|
|
1219
|
+
&& JSON.stringify(comparableContextSnapshot(item.target_context_snapshot))
|
|
1220
|
+
=== JSON.stringify(comparableContextSnapshot(cleanup.targetContextSnapshot))
|
|
1221
|
+
&& item.actor_context_id === (cleanup.actorContextId || '')
|
|
1222
|
+
&& item.slug === entry.slug
|
|
1223
|
+
&& item.branch === entry.branch
|
|
1224
|
+
&& item.mode === cleanup.mode
|
|
1225
|
+
&& item.authority === cleanup.authority
|
|
1226
|
+
&& item.pull_request_number === (cleanup.pullRequestNumber || '')
|
|
1227
|
+
&& item.pull_request_repository === (cleanup.pullRequestRepository || '')
|
|
1228
|
+
&& item.head_ref_oid === (cleanup.headRefOid || '')
|
|
1229
|
+
&& item.merge_commit_oid === (cleanup.mergeCommitOid || '')
|
|
1230
|
+
&& item.worktree_path === (entry.path || '')
|
|
1231
|
+
&& item.phase === (cleanup.phase || 'finalized')
|
|
1232
|
+
&& item.head === cleanup.head;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function cleanupReceiptAssessment(
|
|
1236
|
+
item,
|
|
1237
|
+
registry,
|
|
1238
|
+
entry,
|
|
1239
|
+
{ receiptClassifier = classifyReceipt, provenanceGate = evaluateProvenanceGate } = {},
|
|
1240
|
+
) {
|
|
1241
|
+
const subject = cleanupReceiptSubject(registry, entry);
|
|
1242
|
+
const observation = {
|
|
1243
|
+
...(item.observations || {}),
|
|
1244
|
+
status: item.observations?.status || 'reported',
|
|
1245
|
+
receipt_id: item.id,
|
|
1246
|
+
};
|
|
1247
|
+
const assessment = receiptClassifier({ receipt: item, observation, subject });
|
|
1248
|
+
const gate = provenanceGate({
|
|
1249
|
+
purpose: 'worktree-cleanup',
|
|
1250
|
+
assessments: [assessment],
|
|
1251
|
+
requiredKinds: ['worktree-cleanup'],
|
|
1252
|
+
});
|
|
1253
|
+
const causalMismatches = cleanupReceiptCausalMismatches(item, subject);
|
|
1254
|
+
if (!causalMismatches.length) return { gate, subject, observation };
|
|
1255
|
+
return {
|
|
1256
|
+
gate: {
|
|
1257
|
+
...gate,
|
|
1258
|
+
ok: false,
|
|
1259
|
+
state: 'conflict',
|
|
1260
|
+
reasonCodes: [...new Set([...(gate.reasonCodes || []), 'PROV_RECEIPT_CONFLICT'])],
|
|
1261
|
+
diagnostics: [{
|
|
1262
|
+
kind: 'worktree-cleanup',
|
|
1263
|
+
state: 'conflict',
|
|
1264
|
+
blocker: 'PROV_RECEIPT_CONFLICT',
|
|
1265
|
+
expected: subject,
|
|
1266
|
+
observed: item,
|
|
1267
|
+
}],
|
|
1268
|
+
repair: gate.repair || { command: 'wendkeep verify --deep' },
|
|
1269
|
+
},
|
|
1270
|
+
subject,
|
|
1271
|
+
observation,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function cleanupOperationAssessment({ mode, report, proof, authority, head, identity = {} }) {
|
|
1276
|
+
const snapshot = canonicalPrSnapshot(proof);
|
|
1277
|
+
const subject = {
|
|
1278
|
+
project_id: String(report?.registry?.projectId || ''),
|
|
1279
|
+
repository_id: String(report?.registry?.repositoryId || ''),
|
|
1280
|
+
worktree_id: String(report?.entry?.worktreeId || ''),
|
|
1281
|
+
work_session_id: String(identity.work_session_id || ''),
|
|
1282
|
+
change_slug: String(identity.change_slug || ''),
|
|
1283
|
+
target_context_ids: [...new Set(identity.target_context_ids || [])].map(String).sort(),
|
|
1284
|
+
target_change_slugs: [...new Set(identity.target_change_slugs || [])].map(String).sort(),
|
|
1285
|
+
target_context_snapshot: structuredClone(identity.target_context_snapshot || []),
|
|
1286
|
+
actor_context_id: String(identity.actor_context_id || ''),
|
|
1287
|
+
worktree_path: String(identity.worktree_path || report?.entry?.path || ''),
|
|
1288
|
+
branch: String(report?.entry?.branch || ''),
|
|
1289
|
+
head_sha: String(head || ''),
|
|
1290
|
+
authority: String(authority || ''),
|
|
1291
|
+
pull_request_number: snapshot?.number ? String(snapshot.number) : '',
|
|
1292
|
+
pull_request_repository: snapshot?.repository || '',
|
|
1293
|
+
head_ref_oid: snapshot?.head_ref_oid || '',
|
|
1294
|
+
merge_commit_oid: snapshot?.merge_commit_oid || '',
|
|
1295
|
+
};
|
|
1296
|
+
const identityValid = [
|
|
1297
|
+
subject.project_id,
|
|
1298
|
+
subject.repository_id,
|
|
1299
|
+
subject.worktree_id,
|
|
1300
|
+
subject.branch,
|
|
1301
|
+
subject.head_sha,
|
|
1302
|
+
subject.authority,
|
|
1303
|
+
].every(Boolean);
|
|
1304
|
+
const proofValid = mode === 'remove'
|
|
1305
|
+
? /^reason:[a-f0-9]{64}$/.test(subject.authority)
|
|
1306
|
+
: Boolean(
|
|
1307
|
+
proof?.state === 'MERGED'
|
|
1308
|
+
&& proof?.headRefName === subject.branch
|
|
1309
|
+
&& proof?.headRefOid === subject.head_sha
|
|
1310
|
+
&& proof?.mergeCommitOid
|
|
1311
|
+
&& canonicalPrAuthority(proof) === subject.authority,
|
|
1312
|
+
);
|
|
1313
|
+
const state = identityValid && proofValid ? 'verified' : 'unproven';
|
|
1314
|
+
const reasonCodes = state === 'verified' ? [] : ['PROV_RECEIPT_INVALID'];
|
|
1315
|
+
return {
|
|
1316
|
+
kind: 'worktree-cleanup',
|
|
1317
|
+
state,
|
|
1318
|
+
reasonCodes,
|
|
1319
|
+
diagnostics: [{
|
|
1320
|
+
kind: 'worktree-cleanup',
|
|
1321
|
+
state,
|
|
1322
|
+
blocker: reasonCodes[0] || null,
|
|
1323
|
+
expected: subject,
|
|
1324
|
+
observed: {
|
|
1325
|
+
proof: canonicalPrSnapshot(proof),
|
|
1326
|
+
mode,
|
|
1327
|
+
authority: subject.authority,
|
|
1328
|
+
head_sha: subject.head_sha,
|
|
1329
|
+
work_session_id: subject.work_session_id,
|
|
1330
|
+
change_slug: subject.change_slug,
|
|
1331
|
+
target_context_ids: subject.target_context_ids,
|
|
1332
|
+
target_change_slugs: subject.target_change_slugs,
|
|
1333
|
+
actor_context_id: subject.actor_context_id,
|
|
1334
|
+
},
|
|
1335
|
+
}],
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function requireCleanupOperationGate({
|
|
1340
|
+
mode, report, proof, authority, head, identity = {},
|
|
1341
|
+
provenanceGate = evaluateProvenanceGate,
|
|
1342
|
+
} = {}) {
|
|
1343
|
+
const assessment = cleanupOperationAssessment({
|
|
1344
|
+
mode, report, proof, authority, head, identity,
|
|
1345
|
+
});
|
|
1346
|
+
const gate = provenanceGate({
|
|
1347
|
+
purpose: 'worktree-cleanup',
|
|
1348
|
+
assessments: [assessment],
|
|
1349
|
+
requiredKinds: ['worktree-cleanup'],
|
|
1350
|
+
});
|
|
1351
|
+
if (gate?.ok !== true || gate?.state !== 'verified') {
|
|
1352
|
+
throw cleanupProvenanceError(
|
|
1353
|
+
gate || { state: 'unproven', reasonCodes: ['PROV_RECEIPT_INVALID'], diagnostics: [] },
|
|
1354
|
+
assessment.diagnostics[0].expected,
|
|
1355
|
+
assessment.diagnostics[0].observed,
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
return gate;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function requireCleanupReceiptGate(
|
|
1362
|
+
receipt,
|
|
1363
|
+
repository,
|
|
1364
|
+
slug,
|
|
1365
|
+
{ receiptClassifier = classifyReceipt, provenanceGate = evaluateProvenanceGate } = {},
|
|
1366
|
+
) {
|
|
1367
|
+
const { registry } = readWorktreeRegistry(repository);
|
|
1368
|
+
const entry = registry?.entries?.[slug];
|
|
1369
|
+
const result = cleanupReceiptAssessment(receipt, registry, entry, {
|
|
1370
|
+
receiptClassifier,
|
|
1371
|
+
provenanceGate,
|
|
1372
|
+
});
|
|
1373
|
+
if (result.gate?.ok !== true || result.gate?.state !== 'verified') {
|
|
1374
|
+
throw cleanupProvenanceError(result.gate, result.subject, receipt);
|
|
1375
|
+
}
|
|
1376
|
+
return result.gate;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function cleanupProvenanceError(gate, subject, observed) {
|
|
1380
|
+
const diagnostic = gate?.diagnostics?.[0] || {};
|
|
1381
|
+
return cleanupError(
|
|
1382
|
+
'WENDKEEP_PROVENANCE_GATE_BLOCKED',
|
|
1383
|
+
'O receipt de cleanup não satisfaz o gate de proveniência comum.',
|
|
1384
|
+
{
|
|
1385
|
+
operation: 'worktree-cleanup',
|
|
1386
|
+
state: gate?.state || 'unproven',
|
|
1387
|
+
blocker: diagnostic.blocker || gate?.reasonCodes?.[0] || 'PROV_RECEIPT_INVALID',
|
|
1388
|
+
reasonCodes: gate?.reasonCodes || [],
|
|
1389
|
+
expected: diagnostic.expected || subject,
|
|
1390
|
+
observed: diagnostic.observed || observed,
|
|
1391
|
+
recovery: gate?.repair?.command || 'wendkeep verify --deep',
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function existingCompletion(repository, entry, options = {}) {
|
|
448
1397
|
if (entry?.state !== 'cleaned') return null;
|
|
449
|
-
const
|
|
450
|
-
|
|
1398
|
+
const { registry } = readWorktreeRegistry(repository);
|
|
1399
|
+
const receipts = readReceipts(repository);
|
|
1400
|
+
const receipt = receipts.find((item) => item.id === entry.cleanup?.receiptId);
|
|
1401
|
+
if (!receipt) {
|
|
1402
|
+
const legacy = hasValidLegacyReceiptAnchor(repository);
|
|
1403
|
+
const subject = cleanupReceiptSubject(registry, entry);
|
|
1404
|
+
throw cleanupProvenanceError({
|
|
1405
|
+
state: legacy ? 'legacy-unbound' : 'unproven',
|
|
1406
|
+
reasonCodes: [legacy ? 'PROV_RECEIPT_LEGACY' : 'PROV_RECEIPT_OBSERVATION_MISSING'],
|
|
1407
|
+
diagnostics: [{
|
|
1408
|
+
blocker: legacy ? 'PROV_RECEIPT_LEGACY' : 'PROV_RECEIPT_OBSERVATION_MISSING',
|
|
1409
|
+
expected: subject,
|
|
1410
|
+
observed: null,
|
|
1411
|
+
}],
|
|
1412
|
+
repair: { command: legacy ? 'wendkeep verify --deep' : 'revise o registry e o receipt store' },
|
|
1413
|
+
}, subject, null);
|
|
1414
|
+
}
|
|
1415
|
+
const { gate, subject } = cleanupReceiptAssessment(receipt, registry, entry, options);
|
|
1416
|
+
if (!gate.ok) throw cleanupProvenanceError(gate, subject, receipt);
|
|
1417
|
+
if (!cleanupReceiptMatches(receipt, registry, entry)) {
|
|
1418
|
+
throw cleanupProvenanceError({
|
|
1419
|
+
state: 'conflict',
|
|
1420
|
+
reasonCodes: ['PROV_RECEIPT_CONFLICT'],
|
|
1421
|
+
diagnostics: [{
|
|
1422
|
+
blocker: 'PROV_RECEIPT_CONFLICT', expected: subject, observed: receipt,
|
|
1423
|
+
}],
|
|
1424
|
+
repair: { command: 'wendkeep verify --deep' },
|
|
1425
|
+
}, subject, receipt);
|
|
1426
|
+
}
|
|
1427
|
+
const sessionRegistry = readSessionRegistry(registry.vaultPath);
|
|
1428
|
+
const cleanupReservation = cleanupReservationForWorktree(
|
|
1429
|
+
sessionRegistry,
|
|
1430
|
+
entry?.worktreeId,
|
|
1431
|
+
registry?.repositoryId,
|
|
1432
|
+
);
|
|
1433
|
+
if (receipt.operationId) {
|
|
1434
|
+
// Reconcile a crash between finalize and release. The tombstone is
|
|
1435
|
+
// durable, so a later writer cannot reopen the cleaned worktree while
|
|
1436
|
+
// this idempotent completion is being repaired.
|
|
1437
|
+
markActiveContextCleanupTerminal(registry.vaultPath, {
|
|
1438
|
+
operationId: receipt.operationId,
|
|
1439
|
+
projectId: receipt.project_id,
|
|
1440
|
+
repositoryId: receipt.repository_id,
|
|
1441
|
+
worktreeId: receipt.worktree_id,
|
|
1442
|
+
workSessionId: receipt.work_session_id,
|
|
1443
|
+
changeSlug: receipt.change_slug,
|
|
1444
|
+
targetContextIds: receipt.target_context_ids,
|
|
1445
|
+
targetChangeSlugs: receipt.target_change_slugs,
|
|
1446
|
+
targetContextSnapshot: receipt.target_context_snapshot,
|
|
1447
|
+
worktreePath: entry.path,
|
|
1448
|
+
subjectHash: receipt.id,
|
|
1449
|
+
actorContextId: receipt.actor_context_id,
|
|
1450
|
+
mode: receipt.mode,
|
|
1451
|
+
authority: receipt.authority,
|
|
1452
|
+
head: receipt.head,
|
|
1453
|
+
slug: receipt.slug,
|
|
1454
|
+
pullRequestNumber: receipt.pull_request_number,
|
|
1455
|
+
pullRequestRepository: receipt.pull_request_repository,
|
|
1456
|
+
headRefOid: receipt.head_ref_oid,
|
|
1457
|
+
mergeCommitOid: receipt.merge_commit_oid,
|
|
1458
|
+
attemptToken: entry.cleanup?.attemptToken || cleanupReservation?.attempt_token || '',
|
|
1459
|
+
allowMissingReservation: true,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
if (cleanupReservation
|
|
1463
|
+
&& String(cleanupReservation.operation_id || '') === String(receipt.operationId || '')) {
|
|
1464
|
+
releaseActiveContextCleanup(registry.vaultPath, receipt.operationId, {
|
|
1465
|
+
repositoryId: registry.repositoryId,
|
|
1466
|
+
worktreeId: entry.worktreeId,
|
|
1467
|
+
attemptToken: cleanupReservation.attempt_token || entry.cleanup?.attemptToken || '',
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
451
1470
|
return receipt ? { state: 'completed', idempotent: true, receipt } : null;
|
|
452
1471
|
}
|
|
453
1472
|
|
|
@@ -459,66 +1478,327 @@ export async function finishManagedWorktree({
|
|
|
459
1478
|
github = defaultGithub,
|
|
460
1479
|
spawn = spawnSync,
|
|
461
1480
|
now = () => new Date().toISOString(),
|
|
1481
|
+
actorContext = '',
|
|
1482
|
+
faultInjection = {},
|
|
1483
|
+
provenanceGate = evaluateProvenanceGate,
|
|
1484
|
+
receiptClassifier = classifyReceipt,
|
|
462
1485
|
} = {}) {
|
|
463
1486
|
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
464
1487
|
const initial = requiredEntry(repository, slug);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (git(repository.mainWorktree, ['remote', 'get-url', 'origin'], {
|
|
471
|
-
ok: false, spawn,
|
|
472
|
-
}).status === 0) {
|
|
473
|
-
git(repository.mainWorktree, ['fetch', '--prune', 'origin'], { spawn });
|
|
474
|
-
}
|
|
475
|
-
const proof = await verifyMergedPullRequest({
|
|
476
|
-
startDir: repository.mainWorktree,
|
|
477
|
-
entry: initial.entry,
|
|
478
|
-
pullRequest: pullRequestReference,
|
|
479
|
-
github,
|
|
480
|
-
spawn,
|
|
1488
|
+
// Validate the append-only receipt chain before any fetch, reservation, or
|
|
1489
|
+
// irreversible worktree mutation. A corrupt/truncated ledger is a hard stop.
|
|
1490
|
+
readReceipts(repository);
|
|
1491
|
+
const completed = existingCompletion(repository, initial.entry, {
|
|
1492
|
+
provenanceGate, receiptClassifier,
|
|
481
1493
|
});
|
|
1494
|
+
if (completed) return completed;
|
|
1495
|
+
if (initial.entry.cleanup?.state === 'cleaning'
|
|
1496
|
+
&& cleanupReservationIsActive(repository, slug, initial.entry)) {
|
|
1497
|
+
throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
|
|
1498
|
+
}
|
|
1499
|
+
const resumed = isResumableCleanup(initial.entry, 'finish');
|
|
1500
|
+
let proof = resumed ? storedPullRequestProof(initial.entry) : null;
|
|
1501
|
+
if (resumed && !proof) {
|
|
1502
|
+
throw cleanupError(
|
|
1503
|
+
'WENDKEEP_WORKTREE_PR_UNAVAILABLE',
|
|
1504
|
+
'A prova reservada do PR está incompleta; não é seguro refazer a prova após a mutação.',
|
|
1505
|
+
{ operationId: initial.entry.cleanup.operationId },
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
if (!proof) {
|
|
1509
|
+
const pullRequestReference = pullRequest
|
|
1510
|
+
|| initial.entry.pullRequest?.number
|
|
1511
|
+
|| initial.entry.pullRequest?.url;
|
|
1512
|
+
if (git(repository.mainWorktree, ['remote', 'get-url', 'origin'], {
|
|
1513
|
+
ok: false, spawn,
|
|
1514
|
+
}).status === 0) {
|
|
1515
|
+
git(repository.mainWorktree, ['fetch', '--prune', 'origin'], { spawn });
|
|
1516
|
+
}
|
|
1517
|
+
proof = await verifyMergedPullRequest({
|
|
1518
|
+
startDir: repository.mainWorktree,
|
|
1519
|
+
entry: initial.entry,
|
|
1520
|
+
pullRequest: pullRequestReference,
|
|
1521
|
+
github,
|
|
1522
|
+
spawn,
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
482
1525
|
const at = String(now());
|
|
483
|
-
associatePullRequest(repository, slug, proof, at);
|
|
484
1526
|
const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
|
|
485
1527
|
if (!report.ok) throw blockerError(report);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1528
|
+
// The PR proof is awaited above. A concurrent caller may therefore have
|
|
1529
|
+
// completed the cleanup while this invocation was waiting. Reconcile the
|
|
1530
|
+
// current registry/receipt before deriving the branch head again; otherwise
|
|
1531
|
+
// the already-deleted branch is misreported as an unprovable fresh cleanup.
|
|
1532
|
+
const racedCompletion = existingCompletion(repository, requiredEntry(repository, slug).entry, {
|
|
1533
|
+
provenanceGate, receiptClassifier,
|
|
1534
|
+
});
|
|
1535
|
+
if (racedCompletion) return racedCompletion;
|
|
1536
|
+
const authority = canonicalPrAuthority(proof);
|
|
1537
|
+
const initialDerivedHead = branchHead(repository, report.entry.branch, spawn);
|
|
1538
|
+
const initialHead = initialDerivedHead || (resumed
|
|
1539
|
+
? String(initial.entry.cleanup?.head || proof.headRefOid || '') : '');
|
|
1540
|
+
if (!initialHead) {
|
|
1541
|
+
throw cleanupError(
|
|
1542
|
+
'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
|
|
1543
|
+
'Não foi possível provar o head atual da branch antes do cleanup.',
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
if (proof.headRefOid && initialDerivedHead && proof.headRefOid !== initialDerivedHead) {
|
|
1547
|
+
throw cleanupError(
|
|
1548
|
+
'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
|
|
1549
|
+
'O head atual da branch mudou desde a prova do PR.',
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
const identity = cleanupIdentity(report, {
|
|
1553
|
+
authority, proof, actorContext,
|
|
1554
|
+
prior: initial.entry.cleanup,
|
|
1555
|
+
});
|
|
1556
|
+
requireCleanupOperationGate({
|
|
1557
|
+
mode: 'finish', report, proof, authority, head: initialHead, identity, provenanceGate,
|
|
1558
|
+
});
|
|
1559
|
+
const orphanedOperationId = orphanedReservationOperationId(report, identity, {
|
|
1560
|
+
mode: 'finish', authority, head: initialHead, slug, resumed,
|
|
489
1561
|
});
|
|
490
|
-
|
|
491
|
-
|
|
1562
|
+
const reservationOperationId = resumed
|
|
1563
|
+
? String(initial.entry.cleanup.operationId)
|
|
1564
|
+
: (orphanedOperationId || randomUUID());
|
|
1565
|
+
const attemptToken = randomUUID();
|
|
1566
|
+
let reservation;
|
|
1567
|
+
let contextReservationAcquired = false;
|
|
1568
|
+
try {
|
|
1569
|
+
reserveContextCausally({
|
|
1570
|
+
vaultPath: report.registry.vaultPath,
|
|
1571
|
+
repository,
|
|
1572
|
+
slug,
|
|
1573
|
+
baselineReport: report,
|
|
1574
|
+
baselineIdentity: identity,
|
|
1575
|
+
priorCleanup: initial.entry.cleanup,
|
|
1576
|
+
proof,
|
|
1577
|
+
authority,
|
|
1578
|
+
actorContext,
|
|
1579
|
+
spawn,
|
|
1580
|
+
reserveOptions: {
|
|
1581
|
+
operationId: reservationOperationId,
|
|
1582
|
+
projectId: identity.project_id,
|
|
1583
|
+
repositoryId: identity.repository_id,
|
|
1584
|
+
worktreeId: identity.worktree_id,
|
|
1585
|
+
workSessionId: identity.work_session_id,
|
|
1586
|
+
changeSlug: identity.change_slug,
|
|
1587
|
+
targetContextIds: identity.target_context_ids,
|
|
1588
|
+
targetChangeSlugs: identity.target_change_slugs,
|
|
1589
|
+
targetContextSnapshot: identity.target_context_snapshot,
|
|
1590
|
+
allowClosedContexts: resumed,
|
|
1591
|
+
allowActiveSessions: resumed && !existsSync(report.entry.path),
|
|
1592
|
+
actorContextId: identity.actor_context_id,
|
|
1593
|
+
worktreePath: report.entry.path,
|
|
1594
|
+
mode: 'finish',
|
|
1595
|
+
authority,
|
|
1596
|
+
head: initialHead,
|
|
1597
|
+
slug,
|
|
1598
|
+
pullRequestNumber: identity.pull_request_number,
|
|
1599
|
+
pullRequestRepository: identity.pull_request_repository,
|
|
1600
|
+
headRefOid: identity.head_ref_oid,
|
|
1601
|
+
mergeCommitOid: identity.merge_commit_oid,
|
|
1602
|
+
ownerPid: process.pid,
|
|
1603
|
+
attemptToken,
|
|
1604
|
+
phase: 'reserved-before-worktree',
|
|
1605
|
+
now: at,
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
contextReservationAcquired = true;
|
|
1609
|
+
reservation = reserve(repository, slug, {
|
|
1610
|
+
mode: 'finish', authority, proof, identity, head: initialHead, now: at,
|
|
1611
|
+
operationId: reservationOperationId, attemptToken,
|
|
1612
|
+
});
|
|
1613
|
+
if (reservation.previous?.state === 'cleaned') {
|
|
1614
|
+
releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
|
|
1615
|
+
repositoryId: identity.repository_id,
|
|
1616
|
+
worktreeId: identity.worktree_id,
|
|
1617
|
+
attemptToken,
|
|
1618
|
+
});
|
|
1619
|
+
contextReservationAcquired = false;
|
|
1620
|
+
return existingCompletion(repository, requiredEntry(repository, slug).entry, {
|
|
1621
|
+
provenanceGate, receiptClassifier,
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
if (contextReservationAcquired) {
|
|
1626
|
+
try {
|
|
1627
|
+
releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
|
|
1628
|
+
repositoryId: identity.repository_id,
|
|
1629
|
+
worktreeId: identity.worktree_id,
|
|
1630
|
+
attemptToken,
|
|
1631
|
+
});
|
|
1632
|
+
} catch { /* preserve root */ }
|
|
1633
|
+
}
|
|
1634
|
+
if (!contextReservationAcquired
|
|
1635
|
+
&& error?.code === 'WENDKEEP_PROVENANCE_GATE_BLOCKED'
|
|
1636
|
+
&& error?.blocker === 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH') {
|
|
1637
|
+
try {
|
|
1638
|
+
const failed = recordPreReservationFailure({
|
|
1639
|
+
repository,
|
|
1640
|
+
slug,
|
|
1641
|
+
mode: 'finish',
|
|
1642
|
+
authority,
|
|
1643
|
+
proof,
|
|
1644
|
+
identity,
|
|
1645
|
+
head: initialHead,
|
|
1646
|
+
now: at,
|
|
1647
|
+
operationId: reservationOperationId,
|
|
1648
|
+
error,
|
|
1649
|
+
});
|
|
1650
|
+
error.operationId = failed.operationId;
|
|
1651
|
+
error.state = failed.entry?.cleanup?.state || 'failed';
|
|
1652
|
+
error.recovery = cleanupRecovery(failed.entry || initial.entry);
|
|
1653
|
+
} catch { /* preserve the causal conflict */ }
|
|
1654
|
+
}
|
|
1655
|
+
throw error;
|
|
492
1656
|
}
|
|
493
1657
|
const { operationId } = reservation;
|
|
1658
|
+
let irreversibleStarted = false;
|
|
494
1659
|
try {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1660
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
1661
|
+
operationId, repositoryId: identity.repository_id, worktreeId: identity.worktree_id,
|
|
1662
|
+
attemptToken, phase: 'ready', now: at,
|
|
1663
|
+
});
|
|
1664
|
+
invokeFault(faultInjection, 'beforePathRemoval', { operationId, slug });
|
|
1665
|
+
const mutation = revalidateCleanupBeforeMutation({
|
|
1666
|
+
repository,
|
|
1667
|
+
slug,
|
|
1668
|
+
baselineReport: report,
|
|
1669
|
+
baselineIdentity: identity,
|
|
1670
|
+
priorCleanup: initial.entry.cleanup,
|
|
1671
|
+
proof,
|
|
1672
|
+
authority,
|
|
1673
|
+
actorContext,
|
|
1674
|
+
expectedHead: initialHead,
|
|
1675
|
+
spawn,
|
|
1676
|
+
provenanceGate,
|
|
1677
|
+
});
|
|
1678
|
+
const expectedHead = mutation.head;
|
|
1679
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
1680
|
+
operationId, repositoryId: identity.repository_id, worktreeId: identity.worktree_id,
|
|
1681
|
+
attemptToken, phase: 'removing', now: at,
|
|
1682
|
+
});
|
|
1683
|
+
irreversibleStarted = true;
|
|
1684
|
+
removePath(repository, mutation.report.entry, spawn);
|
|
1685
|
+
invokeFault(faultInjection, 'afterPathRemoval', { operationId, slug });
|
|
1686
|
+
const postRemoval = assertPostRemovalCausalSnapshot({
|
|
1687
|
+
beforeReport: mutation.report,
|
|
1688
|
+
beforeIdentity: mutation.identity,
|
|
1689
|
+
repository,
|
|
1690
|
+
slug,
|
|
1691
|
+
proof,
|
|
1692
|
+
authority,
|
|
1693
|
+
actorContext,
|
|
1694
|
+
spawn,
|
|
1695
|
+
priorCleanup: initial.entry.cleanup,
|
|
1696
|
+
});
|
|
1697
|
+
closeContexts(postRemoval.report.registry.vaultPath, postRemoval.report.contexts, at, operationId);
|
|
1698
|
+
assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
|
|
498
1699
|
const remoteBranchDeleted = deleteRemote
|
|
499
|
-
? deleteRemoteBranch(repository, report.entry.branch, expectedHead, spawn)
|
|
1700
|
+
? deleteRemoteBranch(repository, mutation.report.entry.branch, expectedHead, spawn)
|
|
500
1701
|
: false;
|
|
501
1702
|
const localBranchDeleted = deleteLocalBranch(
|
|
502
|
-
repository, report.entry.branch, expectedHead, spawn,
|
|
1703
|
+
repository, mutation.report.entry.branch, expectedHead, spawn,
|
|
503
1704
|
);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1705
|
+
invokeFault(faultInjection, 'afterBranchDeletion', { operationId, slug });
|
|
1706
|
+
invokeFault(faultInjection, 'beforeAppend', { operationId, slug });
|
|
1707
|
+
const receipt = receiptForOperation(repository, operationId, {
|
|
1708
|
+
slug, mode: 'finish', authority,
|
|
1709
|
+
}) || appendReceipt(repository, {
|
|
1710
|
+
...identity,
|
|
507
1711
|
operationId,
|
|
1712
|
+
reservationId: operationId,
|
|
508
1713
|
slug,
|
|
509
1714
|
mode: 'finish',
|
|
1715
|
+
authority,
|
|
510
1716
|
outcome: 'completed',
|
|
511
|
-
branch: report.entry.branch,
|
|
512
|
-
head: expectedHead || report.entry.head,
|
|
1717
|
+
branch: mutation.report.entry.branch,
|
|
1718
|
+
head: expectedHead || mutation.report.entry.head,
|
|
513
1719
|
pull_request: proof,
|
|
514
1720
|
local_branch_deleted: localBranchDeleted,
|
|
515
1721
|
remote_branch_deleted: remoteBranchDeleted,
|
|
1722
|
+
worktree_path: mutation.report.entry.path,
|
|
1723
|
+
phase: 'finalized',
|
|
516
1724
|
finished_at: at,
|
|
517
1725
|
});
|
|
518
|
-
|
|
1726
|
+
requireCleanupReceiptGate(receipt, repository, slug, {
|
|
1727
|
+
receiptClassifier, provenanceGate,
|
|
1728
|
+
});
|
|
1729
|
+
invokeFault(faultInjection, 'afterAppend', { operationId, receipt, slug });
|
|
1730
|
+
invokeFault(faultInjection, 'beforeFinalize', { operationId, receipt, slug });
|
|
1731
|
+
assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
|
|
1732
|
+
markActiveContextCleanupTerminal(mutation.report.registry.vaultPath, {
|
|
1733
|
+
operationId,
|
|
1734
|
+
projectId: receipt.project_id,
|
|
1735
|
+
repositoryId: receipt.repository_id,
|
|
1736
|
+
worktreeId: receipt.worktree_id,
|
|
1737
|
+
workSessionId: receipt.work_session_id,
|
|
1738
|
+
changeSlug: receipt.change_slug,
|
|
1739
|
+
targetContextIds: receipt.target_context_ids,
|
|
1740
|
+
targetChangeSlugs: receipt.target_change_slugs,
|
|
1741
|
+
targetContextSnapshot: receipt.target_context_snapshot,
|
|
1742
|
+
worktreePath: mutation.report.entry.path,
|
|
1743
|
+
subjectHash: receipt.id,
|
|
1744
|
+
actorContextId: receipt.actor_context_id,
|
|
1745
|
+
mode: receipt.mode,
|
|
1746
|
+
authority: receipt.authority,
|
|
1747
|
+
head: receipt.head,
|
|
1748
|
+
slug: receipt.slug,
|
|
1749
|
+
pullRequestNumber: receipt.pull_request_number,
|
|
1750
|
+
pullRequestRepository: receipt.pull_request_repository,
|
|
1751
|
+
headRefOid: receipt.head_ref_oid,
|
|
1752
|
+
mergeCommitOid: receipt.merge_commit_oid,
|
|
1753
|
+
attemptToken,
|
|
1754
|
+
now: at,
|
|
1755
|
+
});
|
|
1756
|
+
invokeFault(faultInjection, 'afterTerminal', { operationId, receipt, slug });
|
|
1757
|
+
finalize(repository, slug, {
|
|
1758
|
+
receipt,
|
|
1759
|
+
now: at,
|
|
1760
|
+
expectedOperationId: operationId,
|
|
1761
|
+
expectedAttemptToken: attemptToken,
|
|
1762
|
+
expectedSubjectHash: receipt.id,
|
|
1763
|
+
});
|
|
1764
|
+
invokeFault(faultInjection, 'afterFinalize', { operationId, receipt, slug });
|
|
1765
|
+
releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
|
|
1766
|
+
repositoryId: identity.repository_id,
|
|
1767
|
+
worktreeId: identity.worktree_id,
|
|
1768
|
+
attemptToken,
|
|
1769
|
+
});
|
|
1770
|
+
contextReservationAcquired = false;
|
|
1771
|
+
releaseCleanupOperation(repository, slug);
|
|
519
1772
|
return { state: 'completed', idempotent: false, receipt };
|
|
520
1773
|
} catch (error) {
|
|
521
|
-
|
|
1774
|
+
let failedRegistry = null;
|
|
1775
|
+
try { failedRegistry = failReservation(repository, slug, error, String(now())); } catch { /* preserve root error */ }
|
|
1776
|
+
error.operationId = error.operationId || operationId;
|
|
1777
|
+
const failedEntry = failedRegistry?.entries?.[slug];
|
|
1778
|
+
error.state = failedEntry?.cleanup?.state || 'failed';
|
|
1779
|
+
if (error.state === 'failed') error.recovery = cleanupRecovery(failedEntry || initial.entry);
|
|
1780
|
+
if (irreversibleStarted) {
|
|
1781
|
+
try {
|
|
1782
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
1783
|
+
operationId,
|
|
1784
|
+
repositoryId: identity.repository_id,
|
|
1785
|
+
worktreeId: identity.worktree_id,
|
|
1786
|
+
attemptToken,
|
|
1787
|
+
phase: failedEntry?.cleanup?.state === 'completed' ? 'finalized' : 'failed',
|
|
1788
|
+
now: String(now()),
|
|
1789
|
+
});
|
|
1790
|
+
} catch { /* preserve root */ }
|
|
1791
|
+
}
|
|
1792
|
+
if (contextReservationAcquired && !irreversibleStarted) {
|
|
1793
|
+
try {
|
|
1794
|
+
releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
|
|
1795
|
+
repositoryId: identity.repository_id,
|
|
1796
|
+
worktreeId: identity.worktree_id,
|
|
1797
|
+
attemptToken,
|
|
1798
|
+
});
|
|
1799
|
+
} catch { /* preserve root */ }
|
|
1800
|
+
}
|
|
1801
|
+
releaseCleanupOperation(repository, slug);
|
|
522
1802
|
throw error;
|
|
523
1803
|
}
|
|
524
1804
|
}
|
|
@@ -529,43 +1809,289 @@ export async function removeManagedWorktree({
|
|
|
529
1809
|
reason,
|
|
530
1810
|
spawn = spawnSync,
|
|
531
1811
|
now = () => new Date().toISOString(),
|
|
1812
|
+
actorContext = '',
|
|
1813
|
+
faultInjection = {},
|
|
1814
|
+
provenanceGate = evaluateProvenanceGate,
|
|
1815
|
+
receiptClassifier = classifyReceipt,
|
|
532
1816
|
} = {}) {
|
|
533
|
-
const
|
|
534
|
-
if (!
|
|
1817
|
+
const reasonEvidence = normalizedReason(reason);
|
|
1818
|
+
if (!String(reason || '').trim()) {
|
|
535
1819
|
throw cleanupError('WENDKEEP_WORKTREE_REASON_REQUIRED', '`worktree remove` exige --reason.');
|
|
536
1820
|
}
|
|
537
1821
|
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
538
1822
|
const initial = requiredEntry(repository, slug);
|
|
539
|
-
|
|
1823
|
+
readReceipts(repository);
|
|
1824
|
+
const completed = existingCompletion(repository, initial.entry, {
|
|
1825
|
+
provenanceGate, receiptClassifier,
|
|
1826
|
+
});
|
|
540
1827
|
if (completed) return completed;
|
|
541
1828
|
const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
|
|
542
1829
|
if (!report.ok) throw blockerError(report);
|
|
543
1830
|
const at = String(now());
|
|
544
|
-
const authority =
|
|
545
|
-
const
|
|
546
|
-
|
|
1831
|
+
const authority = reasonEvidence.authority;
|
|
1832
|
+
const resumed = isResumableCleanup(initial.entry, 'remove');
|
|
1833
|
+
if (resumed && initial.entry.cleanup.authority !== authority) {
|
|
1834
|
+
throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', 'O motivo não corresponde à operação reservada.');
|
|
1835
|
+
}
|
|
1836
|
+
const initialDerivedHead = branchHead(repository, report.entry.branch, spawn);
|
|
1837
|
+
const initialHead = initialDerivedHead || (resumed
|
|
1838
|
+
? String(initial.entry.cleanup?.head || report.entry.head || '') : '');
|
|
1839
|
+
if (!initialHead) {
|
|
1840
|
+
throw cleanupError(
|
|
1841
|
+
'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
|
|
1842
|
+
'Não foi possível provar o head atual da branch antes do cleanup.',
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const identity = cleanupIdentity(report, {
|
|
1846
|
+
authority, actorContext, prior: initial.entry.cleanup,
|
|
1847
|
+
});
|
|
1848
|
+
requireCleanupOperationGate({
|
|
1849
|
+
mode: 'remove', report, authority, head: initialHead, identity, provenanceGate,
|
|
1850
|
+
});
|
|
1851
|
+
const orphanedOperationId = orphanedReservationOperationId(report, identity, {
|
|
1852
|
+
mode: 'remove', authority, head: initialHead, slug, resumed,
|
|
547
1853
|
});
|
|
1854
|
+
const reservationOperationId = resumed
|
|
1855
|
+
? String(initial.entry.cleanup.operationId)
|
|
1856
|
+
: (orphanedOperationId || randomUUID());
|
|
1857
|
+
const attemptToken = randomUUID();
|
|
1858
|
+
let reservation;
|
|
1859
|
+
let contextReservationAcquired = false;
|
|
1860
|
+
try {
|
|
1861
|
+
reserveContextCausally({
|
|
1862
|
+
vaultPath: report.registry.vaultPath,
|
|
1863
|
+
repository,
|
|
1864
|
+
slug,
|
|
1865
|
+
baselineReport: report,
|
|
1866
|
+
baselineIdentity: identity,
|
|
1867
|
+
priorCleanup: initial.entry.cleanup,
|
|
1868
|
+
authority,
|
|
1869
|
+
actorContext,
|
|
1870
|
+
spawn,
|
|
1871
|
+
reserveOptions: {
|
|
1872
|
+
operationId: reservationOperationId,
|
|
1873
|
+
projectId: identity.project_id,
|
|
1874
|
+
repositoryId: identity.repository_id,
|
|
1875
|
+
worktreeId: identity.worktree_id,
|
|
1876
|
+
workSessionId: identity.work_session_id,
|
|
1877
|
+
changeSlug: identity.change_slug,
|
|
1878
|
+
targetContextIds: identity.target_context_ids,
|
|
1879
|
+
targetChangeSlugs: identity.target_change_slugs,
|
|
1880
|
+
targetContextSnapshot: identity.target_context_snapshot,
|
|
1881
|
+
allowClosedContexts: resumed,
|
|
1882
|
+
allowActiveSessions: resumed && !existsSync(report.entry.path),
|
|
1883
|
+
actorContextId: identity.actor_context_id,
|
|
1884
|
+
worktreePath: report.entry.path,
|
|
1885
|
+
mode: 'remove',
|
|
1886
|
+
authority,
|
|
1887
|
+
head: initialHead,
|
|
1888
|
+
slug,
|
|
1889
|
+
pullRequestNumber: identity.pull_request_number,
|
|
1890
|
+
pullRequestRepository: identity.pull_request_repository,
|
|
1891
|
+
headRefOid: identity.head_ref_oid,
|
|
1892
|
+
mergeCommitOid: identity.merge_commit_oid,
|
|
1893
|
+
ownerPid: process.pid,
|
|
1894
|
+
attemptToken,
|
|
1895
|
+
phase: 'reserved-before-worktree',
|
|
1896
|
+
now: at,
|
|
1897
|
+
},
|
|
1898
|
+
});
|
|
1899
|
+
contextReservationAcquired = true;
|
|
1900
|
+
reservation = reserve(repository, slug, {
|
|
1901
|
+
mode: 'remove', authority, reason: reasonEvidence.label,
|
|
1902
|
+
reasonDigest: reasonEvidence.digest, identity, head: initialHead, now: at,
|
|
1903
|
+
operationId: reservationOperationId, attemptToken,
|
|
1904
|
+
});
|
|
1905
|
+
if (reservation.previous?.state === 'cleaned') {
|
|
1906
|
+
releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
|
|
1907
|
+
repositoryId: identity.repository_id,
|
|
1908
|
+
worktreeId: identity.worktree_id,
|
|
1909
|
+
attemptToken,
|
|
1910
|
+
});
|
|
1911
|
+
contextReservationAcquired = false;
|
|
1912
|
+
return existingCompletion(repository, requiredEntry(repository, slug).entry, {
|
|
1913
|
+
provenanceGate, receiptClassifier,
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
if (contextReservationAcquired) {
|
|
1918
|
+
try {
|
|
1919
|
+
releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
|
|
1920
|
+
repositoryId: identity.repository_id,
|
|
1921
|
+
worktreeId: identity.worktree_id,
|
|
1922
|
+
attemptToken,
|
|
1923
|
+
});
|
|
1924
|
+
} catch { /* preserve root */ }
|
|
1925
|
+
}
|
|
1926
|
+
if (!contextReservationAcquired
|
|
1927
|
+
&& error?.code === 'WENDKEEP_PROVENANCE_GATE_BLOCKED'
|
|
1928
|
+
&& error?.blocker === 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH') {
|
|
1929
|
+
try {
|
|
1930
|
+
const failed = recordPreReservationFailure({
|
|
1931
|
+
repository,
|
|
1932
|
+
slug,
|
|
1933
|
+
mode: 'remove',
|
|
1934
|
+
authority,
|
|
1935
|
+
reason: reasonEvidence.label,
|
|
1936
|
+
reasonDigest: reasonEvidence.digest,
|
|
1937
|
+
identity,
|
|
1938
|
+
head: initialHead,
|
|
1939
|
+
now: at,
|
|
1940
|
+
operationId: reservationOperationId,
|
|
1941
|
+
error,
|
|
1942
|
+
});
|
|
1943
|
+
error.operationId = failed.operationId;
|
|
1944
|
+
error.state = failed.entry?.cleanup?.state || 'failed';
|
|
1945
|
+
error.recovery = cleanupRecovery(failed.entry || initial.entry);
|
|
1946
|
+
} catch { /* preserve the causal conflict */ }
|
|
1947
|
+
}
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
const { operationId } = reservation;
|
|
1951
|
+
let irreversibleStarted = false;
|
|
548
1952
|
try {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1953
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
1954
|
+
operationId: operationId || reservationOperationId,
|
|
1955
|
+
repositoryId: identity.repository_id,
|
|
1956
|
+
worktreeId: identity.worktree_id,
|
|
1957
|
+
attemptToken,
|
|
1958
|
+
phase: 'ready',
|
|
1959
|
+
now: at,
|
|
1960
|
+
});
|
|
1961
|
+
invokeFault(faultInjection, 'beforePathRemoval', { operationId, slug });
|
|
1962
|
+
const mutation = revalidateCleanupBeforeMutation({
|
|
1963
|
+
repository,
|
|
1964
|
+
slug,
|
|
1965
|
+
baselineReport: report,
|
|
1966
|
+
baselineIdentity: identity,
|
|
1967
|
+
priorCleanup: initial.entry.cleanup,
|
|
1968
|
+
authority,
|
|
1969
|
+
actorContext,
|
|
1970
|
+
expectedHead: initialHead,
|
|
1971
|
+
spawn,
|
|
1972
|
+
provenanceGate,
|
|
1973
|
+
});
|
|
1974
|
+
const expectedHead = mutation.head;
|
|
1975
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
1976
|
+
operationId: reservation.operationId,
|
|
1977
|
+
repositoryId: identity.repository_id,
|
|
1978
|
+
worktreeId: identity.worktree_id,
|
|
1979
|
+
attemptToken,
|
|
1980
|
+
phase: 'removing',
|
|
1981
|
+
now: at,
|
|
1982
|
+
});
|
|
1983
|
+
irreversibleStarted = true;
|
|
1984
|
+
removePath(repository, mutation.report.entry, spawn);
|
|
1985
|
+
invokeFault(faultInjection, 'afterPathRemoval', { operationId, slug });
|
|
1986
|
+
const postRemoval = assertPostRemovalCausalSnapshot({
|
|
1987
|
+
beforeReport: mutation.report,
|
|
1988
|
+
beforeIdentity: mutation.identity,
|
|
1989
|
+
repository,
|
|
1990
|
+
slug,
|
|
1991
|
+
authority,
|
|
1992
|
+
actorContext,
|
|
1993
|
+
spawn,
|
|
1994
|
+
priorCleanup: initial.entry.cleanup,
|
|
1995
|
+
});
|
|
1996
|
+
closeContexts(postRemoval.report.registry.vaultPath, postRemoval.report.contexts, at, operationId);
|
|
1997
|
+
assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
|
|
1998
|
+
invokeFault(faultInjection, 'beforeAppend', { operationId, slug });
|
|
1999
|
+
const receipt = receiptForOperation(repository, operationId, {
|
|
2000
|
+
slug, mode: 'remove', authority,
|
|
2001
|
+
}) || appendReceipt(repository, {
|
|
2002
|
+
...identity,
|
|
554
2003
|
operationId,
|
|
2004
|
+
reservationId: operationId,
|
|
555
2005
|
slug,
|
|
556
2006
|
mode: 'remove',
|
|
2007
|
+
authority,
|
|
557
2008
|
outcome: 'completed',
|
|
558
|
-
branch: report.entry.branch,
|
|
559
|
-
head:
|
|
560
|
-
reason:
|
|
2009
|
+
branch: mutation.report.entry.branch,
|
|
2010
|
+
head: expectedHead,
|
|
2011
|
+
reason: reasonEvidence.label,
|
|
2012
|
+
reason_digest: reasonEvidence.digest,
|
|
561
2013
|
local_branch_deleted: false,
|
|
562
2014
|
remote_branch_deleted: false,
|
|
2015
|
+
worktree_path: mutation.report.entry.path,
|
|
2016
|
+
phase: 'finalized',
|
|
563
2017
|
finished_at: at,
|
|
564
2018
|
});
|
|
565
|
-
|
|
2019
|
+
requireCleanupReceiptGate(receipt, repository, slug, {
|
|
2020
|
+
receiptClassifier, provenanceGate,
|
|
2021
|
+
});
|
|
2022
|
+
invokeFault(faultInjection, 'afterAppend', { operationId, receipt, slug });
|
|
2023
|
+
invokeFault(faultInjection, 'beforeFinalize', { operationId, receipt, slug });
|
|
2024
|
+
assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
|
|
2025
|
+
markActiveContextCleanupTerminal(mutation.report.registry.vaultPath, {
|
|
2026
|
+
operationId,
|
|
2027
|
+
projectId: receipt.project_id,
|
|
2028
|
+
repositoryId: receipt.repository_id,
|
|
2029
|
+
worktreeId: receipt.worktree_id,
|
|
2030
|
+
workSessionId: receipt.work_session_id,
|
|
2031
|
+
changeSlug: receipt.change_slug,
|
|
2032
|
+
targetContextIds: receipt.target_context_ids,
|
|
2033
|
+
targetChangeSlugs: receipt.target_change_slugs,
|
|
2034
|
+
targetContextSnapshot: receipt.target_context_snapshot,
|
|
2035
|
+
worktreePath: mutation.report.entry.path,
|
|
2036
|
+
subjectHash: receipt.id,
|
|
2037
|
+
actorContextId: receipt.actor_context_id,
|
|
2038
|
+
mode: receipt.mode,
|
|
2039
|
+
authority: receipt.authority,
|
|
2040
|
+
head: receipt.head,
|
|
2041
|
+
slug: receipt.slug,
|
|
2042
|
+
pullRequestNumber: receipt.pull_request_number,
|
|
2043
|
+
pullRequestRepository: receipt.pull_request_repository,
|
|
2044
|
+
headRefOid: receipt.head_ref_oid,
|
|
2045
|
+
mergeCommitOid: receipt.merge_commit_oid,
|
|
2046
|
+
attemptToken,
|
|
2047
|
+
now: at,
|
|
2048
|
+
});
|
|
2049
|
+
invokeFault(faultInjection, 'afterTerminal', { operationId, receipt, slug });
|
|
2050
|
+
finalize(repository, slug, {
|
|
2051
|
+
receipt,
|
|
2052
|
+
now: at,
|
|
2053
|
+
expectedOperationId: operationId,
|
|
2054
|
+
expectedAttemptToken: attemptToken,
|
|
2055
|
+
expectedSubjectHash: receipt.id,
|
|
2056
|
+
});
|
|
2057
|
+
invokeFault(faultInjection, 'afterFinalize', { operationId, receipt, slug });
|
|
2058
|
+
releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
|
|
2059
|
+
repositoryId: identity.repository_id,
|
|
2060
|
+
worktreeId: identity.worktree_id,
|
|
2061
|
+
attemptToken,
|
|
2062
|
+
});
|
|
2063
|
+
contextReservationAcquired = false;
|
|
2064
|
+
releaseCleanupOperation(repository, slug);
|
|
566
2065
|
return { state: 'completed', idempotent: false, receipt };
|
|
567
2066
|
} catch (error) {
|
|
568
|
-
|
|
2067
|
+
let failedRegistry = null;
|
|
2068
|
+
try { failedRegistry = failReservation(repository, slug, error, String(now())); } catch { /* preserve root error */ }
|
|
2069
|
+
error.operationId = error.operationId || operationId;
|
|
2070
|
+
const failedEntry = failedRegistry?.entries?.[slug];
|
|
2071
|
+
error.state = failedEntry?.cleanup?.state || 'failed';
|
|
2072
|
+
if (error.state === 'failed') error.recovery = cleanupRecovery(failedEntry || initial.entry);
|
|
2073
|
+
if (irreversibleStarted) {
|
|
2074
|
+
try {
|
|
2075
|
+
updateActiveContextCleanupPhase(report.registry.vaultPath, {
|
|
2076
|
+
operationId,
|
|
2077
|
+
repositoryId: identity.repository_id,
|
|
2078
|
+
worktreeId: identity.worktree_id,
|
|
2079
|
+
attemptToken,
|
|
2080
|
+
phase: failedEntry?.cleanup?.state === 'completed' ? 'finalized' : 'failed',
|
|
2081
|
+
now: String(now()),
|
|
2082
|
+
});
|
|
2083
|
+
} catch { /* preserve root */ }
|
|
2084
|
+
}
|
|
2085
|
+
if (contextReservationAcquired && !irreversibleStarted) {
|
|
2086
|
+
try {
|
|
2087
|
+
releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
|
|
2088
|
+
repositoryId: identity.repository_id,
|
|
2089
|
+
worktreeId: identity.worktree_id,
|
|
2090
|
+
attemptToken,
|
|
2091
|
+
});
|
|
2092
|
+
} catch { /* preserve root */ }
|
|
2093
|
+
}
|
|
2094
|
+
releaseCleanupOperation(repository, slug);
|
|
569
2095
|
throw error;
|
|
570
2096
|
}
|
|
571
2097
|
}
|
|
@@ -576,13 +2102,37 @@ export async function cleanupMergedWorktrees({
|
|
|
576
2102
|
github = defaultGithub,
|
|
577
2103
|
spawn = spawnSync,
|
|
578
2104
|
now = () => new Date().toISOString(),
|
|
2105
|
+
actorContext = '',
|
|
2106
|
+
faultInjection = {},
|
|
2107
|
+
provenanceGate = evaluateProvenanceGate,
|
|
2108
|
+
receiptClassifier = classifyReceipt,
|
|
579
2109
|
} = {}) {
|
|
580
2110
|
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
2111
|
+
readReceipts(repository);
|
|
581
2112
|
const { registry } = readWorktreeRegistry(repository);
|
|
582
2113
|
const actions = [];
|
|
583
2114
|
for (const slug of Object.keys(registry.entries || {}).sort()) {
|
|
584
2115
|
const entry = registry.entries[slug];
|
|
585
|
-
if (entry.state === 'cleaned')
|
|
2116
|
+
if (entry.state === 'cleaned') {
|
|
2117
|
+
try {
|
|
2118
|
+
const completed = existingCompletion(repository, entry, {
|
|
2119
|
+
provenanceGate, receiptClassifier,
|
|
2120
|
+
});
|
|
2121
|
+
if (completed) continue;
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
actions.push({
|
|
2124
|
+
slug,
|
|
2125
|
+
outcome: 'blocked',
|
|
2126
|
+
blockers: [String(error?.code || 'WENDKEEP_PROVENANCE_GATE_BLOCKED')],
|
|
2127
|
+
state: error?.state || 'unproven',
|
|
2128
|
+
blocker: error?.blocker || '',
|
|
2129
|
+
recovery: error?.recovery || 'wendkeep verify --deep',
|
|
2130
|
+
expected: error?.expected || null,
|
|
2131
|
+
observed: error?.observed || null,
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
586
2136
|
const pullRequest = entry.pullRequest?.number || entry.pullRequest?.url;
|
|
587
2137
|
if (!pullRequest) {
|
|
588
2138
|
actions.push({
|
|
@@ -609,6 +2159,10 @@ export async function cleanupMergedWorktrees({
|
|
|
609
2159
|
github,
|
|
610
2160
|
spawn,
|
|
611
2161
|
now,
|
|
2162
|
+
actorContext,
|
|
2163
|
+
faultInjection,
|
|
2164
|
+
provenanceGate,
|
|
2165
|
+
receiptClassifier,
|
|
612
2166
|
});
|
|
613
2167
|
actions.push({ slug, outcome: result.state, receipt: result.receipt });
|
|
614
2168
|
}
|
|
@@ -623,6 +2177,10 @@ export function pruneManagedWorktrees({
|
|
|
623
2177
|
startDir = process.cwd(), apply = false, spawn = spawnSync,
|
|
624
2178
|
} = {}) {
|
|
625
2179
|
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
2180
|
+
// Even metadata-only pruning mutates the shared Git worktree state. Validate
|
|
2181
|
+
// the receipt chain first so a corrupt/truncated ledger never gets hidden by
|
|
2182
|
+
// a successful prune.
|
|
2183
|
+
readReceipts(repository);
|
|
626
2184
|
const { registry } = readWorktreeRegistry(repository);
|
|
627
2185
|
const listed = git(repository.mainWorktree, ['worktree', 'list', '--porcelain'], { spawn });
|
|
628
2186
|
const registeredPaths = new Set(String(listed.stdout || '').split(/\r?\n/)
|
|
@@ -642,11 +2200,33 @@ export function pruneManagedWorktrees({
|
|
|
642
2200
|
|
|
643
2201
|
function cleanupRecovery(entry) {
|
|
644
2202
|
if (entry.cleanup?.mode === 'remove') {
|
|
645
|
-
const reason =
|
|
646
|
-
return `wendkeep worktree remove ${entry.slug} --reason
|
|
2203
|
+
const reason = recoverySegment(entry.cleanup.reason, 'confirme-o-abandono');
|
|
2204
|
+
return `wendkeep worktree remove ${recoverySegment(entry.slug, 'worktree')} --reason ${reason}`;
|
|
647
2205
|
}
|
|
648
|
-
const pullRequest = entry.pullRequest?.number ||
|
|
649
|
-
|
|
2206
|
+
const pullRequest = /^\d+$/.test(String(entry.pullRequest?.number || ''))
|
|
2207
|
+
? String(entry.pullRequest.number) : 'pr';
|
|
2208
|
+
return `wendkeep worktree finish ${recoverySegment(entry.slug, 'worktree')} --pr ${pullRequest}`;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function hasValidLegacyReceiptAnchor(repository) {
|
|
2212
|
+
const path = cleanupReceiptLegacyPath(repository);
|
|
2213
|
+
if (!existsSync(path)) return false;
|
|
2214
|
+
let text;
|
|
2215
|
+
try { text = readFileSync(path, 'utf8'); } catch { return false; }
|
|
2216
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2217
|
+
if (!lines.length) return false;
|
|
2218
|
+
return lines.every((line) => {
|
|
2219
|
+
try {
|
|
2220
|
+
const value = JSON.parse(line);
|
|
2221
|
+
const schemaVersion = Number(value?.schema_version ?? value?.schemaVersion ?? 0);
|
|
2222
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
2223
|
+
&& String(value.id || value.receipt_id || '').trim()
|
|
2224
|
+
&& ((schemaVersion === 1 && String(value.outcome || '').trim())
|
|
2225
|
+
|| (String(value.slug || '').trim()
|
|
2226
|
+
&& String(value.mode || '').trim()
|
|
2227
|
+
&& String(value.outcome || '').trim()));
|
|
2228
|
+
} catch { return false; }
|
|
2229
|
+
});
|
|
650
2230
|
}
|
|
651
2231
|
|
|
652
2232
|
export function diagnoseManagedWorktreeCleanups({
|
|
@@ -667,17 +2247,24 @@ export function diagnoseManagedWorktreeCleanups({
|
|
|
667
2247
|
let receipts = [];
|
|
668
2248
|
try {
|
|
669
2249
|
receipts = readReceipts(repository);
|
|
670
|
-
} catch {
|
|
2250
|
+
} catch (error) {
|
|
2251
|
+
const ledgerCode = [
|
|
2252
|
+
'WENDKEEP_RECEIPT_LEDGER_CORRUPT',
|
|
2253
|
+
'WENDKEEP_RECEIPT_LEDGER_TRUNCATED',
|
|
2254
|
+
'WENDKEEP_RECEIPT_LEDGER_BUSY',
|
|
2255
|
+
'WENDKEEP_RECEIPT_LEDGER_CONFLICT',
|
|
2256
|
+
].includes(error?.code) ? error.code : 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_INVALID';
|
|
671
2257
|
return {
|
|
672
2258
|
initialized: true,
|
|
673
2259
|
issues: [{
|
|
674
2260
|
slug: '*',
|
|
675
2261
|
state: 'receipt-invalid',
|
|
676
|
-
errorCode:
|
|
2262
|
+
errorCode: ledgerCode,
|
|
677
2263
|
repair: 'revise o receipt store append-only antes de retomar o cleanup',
|
|
678
2264
|
}],
|
|
679
2265
|
};
|
|
680
2266
|
}
|
|
2267
|
+
const hasLegacyReceipts = hasValidLegacyReceiptAnchor(repository);
|
|
681
2268
|
const receiptIds = new Set(receipts.map((receipt) => receipt.id));
|
|
682
2269
|
const issues = [];
|
|
683
2270
|
for (const slug of Object.keys(registry.entries || {}).sort()) {
|
|
@@ -702,10 +2289,38 @@ export function diagnoseManagedWorktreeCleanups({
|
|
|
702
2289
|
&& (!entry.cleanup?.receiptId || !receiptIds.has(entry.cleanup.receiptId))) {
|
|
703
2290
|
issues.push({
|
|
704
2291
|
slug,
|
|
705
|
-
state: entry.state,
|
|
706
|
-
errorCode:
|
|
707
|
-
|
|
2292
|
+
state: hasLegacyReceipts ? 'legacy-unbound' : entry.state,
|
|
2293
|
+
errorCode: hasLegacyReceipts
|
|
2294
|
+
? 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_LEGACY_UNBOUND'
|
|
2295
|
+
: 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_MISSING',
|
|
2296
|
+
repair: hasLegacyReceipts
|
|
2297
|
+
? 'rode wendkeep verify novamente para emitir receipts v2'
|
|
2298
|
+
: 'revise o registry e o receipt store; não invente um receipt retroativo',
|
|
708
2299
|
});
|
|
2300
|
+
} else if (entry.state === 'cleaned' && entry.cleanup?.receiptId) {
|
|
2301
|
+
const receipt = receipts.find((item) => item.id === entry.cleanup.receiptId);
|
|
2302
|
+
if (receipt) {
|
|
2303
|
+
const { gate, subject } = cleanupReceiptAssessment(receipt, registry, entry);
|
|
2304
|
+
const matches = cleanupReceiptMatches(receipt, registry, entry);
|
|
2305
|
+
if (!gate.ok || !matches) {
|
|
2306
|
+
const effectiveGate = gate.ok ? {
|
|
2307
|
+
state: 'conflict',
|
|
2308
|
+
reasonCodes: ['PROV_RECEIPT_CONFLICT'],
|
|
2309
|
+
diagnostics: [{ blocker: 'PROV_RECEIPT_CONFLICT', expected: subject, observed: receipt }],
|
|
2310
|
+
repair: { command: 'wendkeep verify --deep' },
|
|
2311
|
+
} : gate;
|
|
2312
|
+
issues.push({
|
|
2313
|
+
slug,
|
|
2314
|
+
state: effectiveGate.state,
|
|
2315
|
+
errorCode: 'WENDKEEP_PROVENANCE_GATE_BLOCKED',
|
|
2316
|
+
reasonCodes: effectiveGate.reasonCodes || [],
|
|
2317
|
+
blocker: effectiveGate.diagnostics?.[0]?.blocker || null,
|
|
2318
|
+
expected: effectiveGate.diagnostics?.[0]?.expected || subject,
|
|
2319
|
+
observed: effectiveGate.diagnostics?.[0]?.observed || receipt,
|
|
2320
|
+
repair: effectiveGate.repair?.command || 'wendkeep verify --deep',
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
709
2324
|
}
|
|
710
2325
|
}
|
|
711
2326
|
return { initialized: true, issues };
|