wendkeep 0.78.0 → 0.79.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 +41 -0
- package/README.en.md +57 -2
- package/README.md +57 -2
- package/docs/en/commands/changes-and-verification.md +66 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/verify.md +45 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +65 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/verify.md +45 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +201 -122
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- 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/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
cleanupReservationForWorktree,
|
|
6
|
+
cleanupTombstoneForWorktree,
|
|
7
|
+
comparableCleanupPath,
|
|
8
|
+
mutateSessionRegistry,
|
|
9
|
+
readSessionRegistry,
|
|
10
|
+
} from './obsidian-common.mjs';
|
|
5
11
|
import { mkdirVaultPath, writeVaultFileSync } from './vault-path-safety.mjs';
|
|
6
12
|
|
|
7
13
|
export const ACTIVE_CONTEXTS_SCHEMA_VERSION = 1;
|
|
@@ -23,6 +29,17 @@ function contextError(code, message) {
|
|
|
23
29
|
return error;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
function ownerProcessAlive(ownerPid) {
|
|
33
|
+
const pid = Number(ownerPid);
|
|
34
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return error?.code === 'EPERM';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
function requiredId(value, label) {
|
|
27
44
|
const normalized = String(value || '').trim();
|
|
28
45
|
if (!ID_PATTERN.test(normalized)) {
|
|
@@ -36,6 +53,74 @@ function optionalText(value, maxLength = 240) {
|
|
|
36
53
|
return normalized.length <= maxLength ? normalized : normalized.slice(0, maxLength);
|
|
37
54
|
}
|
|
38
55
|
|
|
56
|
+
function subjectIds(value) {
|
|
57
|
+
return [...new Set(Array.isArray(value) ? value : [])].map(String).sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function subjectSnapshot(value) {
|
|
61
|
+
return (Array.isArray(value) ? value : [])
|
|
62
|
+
.map((item) => Object.fromEntries(
|
|
63
|
+
Object.entries(item || {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
64
|
+
))
|
|
65
|
+
.sort((left, right) => String(left.key || '').localeCompare(String(right.key || '')));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameSubjectField(left, right) {
|
|
69
|
+
return String(left || '') === String(right || '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameCleanupSubject(left, right) {
|
|
73
|
+
return sameSubjectField(left?.project_id, right?.project_id)
|
|
74
|
+
&& sameSubjectField(left?.repository_id, right?.repository_id)
|
|
75
|
+
&& sameSubjectField(left?.worktree_id, right?.worktree_id)
|
|
76
|
+
&& sameSubjectField(left?.work_session_id, right?.work_session_id)
|
|
77
|
+
&& sameSubjectField(left?.change_slug, right?.change_slug)
|
|
78
|
+
&& JSON.stringify(subjectIds(left?.target_context_ids))
|
|
79
|
+
=== JSON.stringify(subjectIds(right?.target_context_ids))
|
|
80
|
+
&& JSON.stringify(subjectIds(left?.target_change_slugs))
|
|
81
|
+
=== JSON.stringify(subjectIds(right?.target_change_slugs))
|
|
82
|
+
&& JSON.stringify(subjectSnapshot(left?.target_context_snapshot))
|
|
83
|
+
=== JSON.stringify(subjectSnapshot(right?.target_context_snapshot))
|
|
84
|
+
&& sameSubjectField(left?.actor_context_id, right?.actor_context_id)
|
|
85
|
+
&& sameSubjectField(left?.worktree_path, right?.worktree_path)
|
|
86
|
+
&& sameSubjectField(left?.mode, right?.mode)
|
|
87
|
+
&& sameSubjectField(left?.authority, right?.authority)
|
|
88
|
+
&& sameSubjectField(left?.head, right?.head)
|
|
89
|
+
&& sameSubjectField(left?.slug, right?.slug)
|
|
90
|
+
&& sameSubjectField(left?.pull_request_number, right?.pull_request_number)
|
|
91
|
+
&& sameSubjectField(left?.pull_request_repository, right?.pull_request_repository)
|
|
92
|
+
&& sameSubjectField(left?.head_ref_oid, right?.head_ref_oid)
|
|
93
|
+
&& sameSubjectField(left?.merge_commit_oid, right?.merge_commit_oid);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cleanupSubjectFromOptions({
|
|
97
|
+
projectId = '', repositoryId = '', worktreeId = '', workSessionId = '', changeSlug = '',
|
|
98
|
+
targetContextIds = [], targetChangeSlugs = [], targetContextSnapshot = [], actorContextId = '',
|
|
99
|
+
worktreePath = '', mode = '', authority = '', head = '', slug = '',
|
|
100
|
+
pullRequestNumber = '', pullRequestRepository = '', headRefOid = '', mergeCommitOid = '',
|
|
101
|
+
} = {}) {
|
|
102
|
+
return {
|
|
103
|
+
project_id: String(projectId || ''),
|
|
104
|
+
repository_id: String(repositoryId || ''),
|
|
105
|
+
worktree_id: String(worktreeId || ''),
|
|
106
|
+
work_session_id: String(workSessionId || ''),
|
|
107
|
+
change_slug: String(changeSlug || ''),
|
|
108
|
+
target_context_ids: subjectIds(targetContextIds),
|
|
109
|
+
target_change_slugs: subjectIds(targetChangeSlugs),
|
|
110
|
+
target_context_snapshot: subjectSnapshot(targetContextSnapshot),
|
|
111
|
+
actor_context_id: String(actorContextId || ''),
|
|
112
|
+
worktree_path: String(worktreePath || ''),
|
|
113
|
+
mode: String(mode || ''),
|
|
114
|
+
authority: String(authority || ''),
|
|
115
|
+
head: String(head || ''),
|
|
116
|
+
slug: String(slug || ''),
|
|
117
|
+
pull_request_number: String(pullRequestNumber || ''),
|
|
118
|
+
pull_request_repository: String(pullRequestRepository || ''),
|
|
119
|
+
head_ref_oid: String(headRefOid || ''),
|
|
120
|
+
merge_commit_oid: String(mergeCommitOid || ''),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
39
124
|
function normalizeIdentity(identity = {}, { requireWorkSession = true } = {}) {
|
|
40
125
|
const workSessionId = requireWorkSession
|
|
41
126
|
? requiredId(identity.workSessionId ?? identity.work_session_id, 'work_session_id')
|
|
@@ -75,6 +160,22 @@ function activeContexts(registry) {
|
|
|
75
160
|
return Object.entries(contextsOf(registry)).filter(([, context]) => context?.state === 'active');
|
|
76
161
|
}
|
|
77
162
|
|
|
163
|
+
function contextCausalSnapshot(entries = []) {
|
|
164
|
+
return entries.map(([key, context]) => ({
|
|
165
|
+
key: String(key),
|
|
166
|
+
project_id: String(context?.project_id || ''),
|
|
167
|
+
repository_id: String(context?.repository_id || ''),
|
|
168
|
+
worktree_id: String(context?.worktree_id || ''),
|
|
169
|
+
work_session_id: String(context?.work_session_id || ''),
|
|
170
|
+
change_slug: String(context?.change_slug || ''),
|
|
171
|
+
branch: String(context?.branch || ''),
|
|
172
|
+
head_sha: String(context?.head_sha || ''),
|
|
173
|
+
delivery_id: String(context?.delivery_id || ''),
|
|
174
|
+
state: String(context?.state || ''),
|
|
175
|
+
revision: Number(context?.revision || 0),
|
|
176
|
+
})).sort((left, right) => left.key.localeCompare(right.key));
|
|
177
|
+
}
|
|
178
|
+
|
|
78
179
|
function legacyProjection(registry) {
|
|
79
180
|
const active = activeContexts(registry);
|
|
80
181
|
if (active.length !== 1) return '';
|
|
@@ -146,10 +247,31 @@ export function mutateActiveContext(vaultBase, identity, updater, {
|
|
|
146
247
|
now = new Date().toISOString(),
|
|
147
248
|
mutateRegistry = mutateSessionRegistry,
|
|
148
249
|
projectLegacy = true,
|
|
250
|
+
cleanupOperationId = '',
|
|
149
251
|
} = {}) {
|
|
150
252
|
const normalized = normalizeIdentity(identity);
|
|
151
253
|
const key = activeContextKey(normalized);
|
|
152
254
|
const result = mutateRegistry(vaultBase, (registry) => {
|
|
255
|
+
const terminal = cleanupTombstoneForWorktree(
|
|
256
|
+
registry, normalized.worktreeId, normalized.repositoryId,
|
|
257
|
+
) || cleanupTombstoneForWorktree(registry, normalized.worktreeId);
|
|
258
|
+
if (terminal
|
|
259
|
+
&& String(terminal.operation_id || '') !== String(cleanupOperationId || '')) {
|
|
260
|
+
throw contextError(
|
|
261
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL',
|
|
262
|
+
'a worktree já foi finalizada por um cleanup anterior',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const cleanupReservation = cleanupReservationForWorktree(
|
|
266
|
+
registry, normalized.worktreeId, normalized.repositoryId,
|
|
267
|
+
) || cleanupReservationForWorktree(registry, normalized.worktreeId);
|
|
268
|
+
if (cleanupReservation
|
|
269
|
+
&& String(cleanupReservation.operation_id || '') !== String(cleanupOperationId || '')) {
|
|
270
|
+
throw contextError(
|
|
271
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
|
|
272
|
+
'active context está reservado por um cleanup em andamento',
|
|
273
|
+
);
|
|
274
|
+
}
|
|
153
275
|
const contexts = contextsOf(registry);
|
|
154
276
|
const existing = contexts[key] || null;
|
|
155
277
|
const revision = currentContextRevision(existing);
|
|
@@ -197,7 +319,7 @@ export function mutateActiveContext(vaultBase, identity, updater, {
|
|
|
197
319
|
registry.active_contexts_revision = currentGlobalRevision(registry) + 1;
|
|
198
320
|
registry.active_contexts = { ...contexts, [key]: next };
|
|
199
321
|
return { key, context: structuredClone(next), registryRevision: registry.active_contexts_revision };
|
|
200
|
-
});
|
|
322
|
+
}, { cleanupOperationId });
|
|
201
323
|
if (projectLegacy) {
|
|
202
324
|
projectLegacyActiveChange(vaultBase);
|
|
203
325
|
projectLegacyActiveDelivery(vaultBase);
|
|
@@ -205,6 +327,412 @@ export function mutateActiveContext(vaultBase, identity, updater, {
|
|
|
205
327
|
return result;
|
|
206
328
|
}
|
|
207
329
|
|
|
330
|
+
export function reserveActiveContextCleanup(vaultBase, {
|
|
331
|
+
operationId,
|
|
332
|
+
projectId,
|
|
333
|
+
repositoryId,
|
|
334
|
+
worktreeId,
|
|
335
|
+
workSessionId = '',
|
|
336
|
+
changeSlug = '',
|
|
337
|
+
targetContextIds = [],
|
|
338
|
+
targetChangeSlugs = [],
|
|
339
|
+
targetContextSnapshot = [],
|
|
340
|
+
allowClosedContexts = false,
|
|
341
|
+
allowActiveSessions = false,
|
|
342
|
+
actorContextId = '',
|
|
343
|
+
worktreePath = '',
|
|
344
|
+
mode = '',
|
|
345
|
+
authority = '',
|
|
346
|
+
head = '',
|
|
347
|
+
slug = '',
|
|
348
|
+
pullRequestNumber = '',
|
|
349
|
+
pullRequestRepository = '',
|
|
350
|
+
headRefOid = '',
|
|
351
|
+
mergeCommitOid = '',
|
|
352
|
+
ownerPid = process.pid,
|
|
353
|
+
attemptToken = '',
|
|
354
|
+
phase = 'reserved',
|
|
355
|
+
now = new Date().toISOString(),
|
|
356
|
+
} = {}) {
|
|
357
|
+
const operation = requiredId(operationId, 'cleanup operation_id');
|
|
358
|
+
const project = requiredId(projectId, 'project_id');
|
|
359
|
+
const repository = requiredId(repositoryId, 'repository_id');
|
|
360
|
+
const worktree = requiredId(worktreeId, 'worktree_id');
|
|
361
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
362
|
+
const terminal = cleanupTombstoneForWorktree(registry, worktree, repository)
|
|
363
|
+
|| cleanupTombstoneForWorktree(registry, worktree);
|
|
364
|
+
if (terminal && String(terminal.repository_id || '') !== repository) {
|
|
365
|
+
throw contextError(
|
|
366
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
|
|
367
|
+
'o tombstone terminal pertence a outro repositório',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (terminal && String(terminal.operation_id || '') !== operation) {
|
|
371
|
+
throw contextError(
|
|
372
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL',
|
|
373
|
+
'a worktree já foi finalizada por um cleanup anterior',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const existing = cleanupReservationForWorktree(registry, worktree, repository);
|
|
377
|
+
const foreignReservation = cleanupReservationForWorktree(registry, worktree);
|
|
378
|
+
if (foreignReservation && (!existing || foreignReservation !== existing)) {
|
|
379
|
+
throw contextError(
|
|
380
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
|
|
381
|
+
'já existe um cleanup reservado para esta worktree',
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (existing && String(existing.operation_id || '') !== operation) {
|
|
385
|
+
throw contextError(
|
|
386
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
|
|
387
|
+
'já existe um cleanup reservado para esta worktree',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (existing && String(existing.operation_id || '') === operation) {
|
|
391
|
+
const storedAttempt = String(existing.attempt_token || '');
|
|
392
|
+
const requestedAttempt = String(attemptToken || '');
|
|
393
|
+
const sameAttempt = requestedAttempt && storedAttempt
|
|
394
|
+
&& requestedAttempt === storedAttempt;
|
|
395
|
+
const terminalPhase = ['failed', 'finalized'].includes(String(existing.phase || ''));
|
|
396
|
+
if (!sameAttempt && !terminalPhase && ownerProcessAlive(existing.owner_pid)) {
|
|
397
|
+
throw contextError(
|
|
398
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
|
|
399
|
+
'já existe uma tentativa ativa para esta operação de cleanup',
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const active = Object.entries(contextsOf(registry))
|
|
404
|
+
.filter(([, context]) => context?.state === 'active'
|
|
405
|
+
&& context?.worktree_id === worktree);
|
|
406
|
+
if (active.some(([, context]) => String(context?.repository_id || '') !== repository)) {
|
|
407
|
+
throw contextError(
|
|
408
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
|
|
409
|
+
'há um active context de outro repositório com o mesmo worktree_id',
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const scopedActive = active.filter(([, context]) => context?.repository_id === repository);
|
|
413
|
+
const actualContextIds = scopedActive.map(([key]) => String(key)).sort();
|
|
414
|
+
const actualChangeSlugs = [...new Set(scopedActive
|
|
415
|
+
.map(([, context]) => String(context?.change_slug || '').trim())
|
|
416
|
+
.filter(Boolean))].sort();
|
|
417
|
+
const expectedContextIds = subjectIds(targetContextIds);
|
|
418
|
+
const expectedChangeSlugs = subjectIds(targetChangeSlugs);
|
|
419
|
+
const expectedSnapshot = contextCausalSnapshot(
|
|
420
|
+
(targetContextSnapshot || []).map((context) => [context.key, context]),
|
|
421
|
+
);
|
|
422
|
+
const actualSnapshot = contextCausalSnapshot(scopedActive);
|
|
423
|
+
const closedForRetry = allowClosedContexts
|
|
424
|
+
&& actualContextIds.length === 0
|
|
425
|
+
&& expectedContextIds.length > 0;
|
|
426
|
+
if ((!closedForRetry && JSON.stringify(actualContextIds) !== JSON.stringify(expectedContextIds))
|
|
427
|
+
|| (!closedForRetry && JSON.stringify(actualChangeSlugs) !== JSON.stringify(expectedChangeSlugs))
|
|
428
|
+
|| (!closedForRetry && expectedSnapshot.length
|
|
429
|
+
&& JSON.stringify(actualSnapshot) !== JSON.stringify(expectedSnapshot))) {
|
|
430
|
+
throw contextError(
|
|
431
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH',
|
|
432
|
+
'active contexts mudaram antes da reserva do cleanup',
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
const comparablePath = comparableCleanupPath(worktreePath);
|
|
436
|
+
const activeSessions = Object.entries(registry.sessions || {}).filter(([, session]) => {
|
|
437
|
+
if (session?.status !== 'active') return false;
|
|
438
|
+
const sameSession = workSessionId && String(session.work_session_id || '') === String(workSessionId);
|
|
439
|
+
const sessionPath = comparableCleanupPath(session?.project_scope?.repoRoot);
|
|
440
|
+
return sameSession || (comparablePath && sessionPath === comparablePath);
|
|
441
|
+
});
|
|
442
|
+
if (activeSessions.length && !allowActiveSessions) {
|
|
443
|
+
throw contextError(
|
|
444
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_SESSION_RESERVED',
|
|
445
|
+
'sessão ativa está reservada por um cleanup em andamento',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (allowActiveSessions) {
|
|
449
|
+
// A path ausente só é retomável quando a sessão causal é fechada na
|
|
450
|
+
// mesma transação que reinstala a reserva. O retry não pode deixar uma
|
|
451
|
+
// sessão ativa apontando para uma worktree já removida.
|
|
452
|
+
for (const [sessionId, session] of activeSessions) {
|
|
453
|
+
registry.sessions[sessionId] = {
|
|
454
|
+
...session,
|
|
455
|
+
status: 'done',
|
|
456
|
+
active_activation_id: '',
|
|
457
|
+
ended_at: String(now),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const reservations = {
|
|
462
|
+
...(registry.cleanup_reservations || {}),
|
|
463
|
+
[`${repository}:${worktree}`]: {
|
|
464
|
+
state: 'cleaning',
|
|
465
|
+
operation_id: operation,
|
|
466
|
+
project_id: project,
|
|
467
|
+
repository_id: repository,
|
|
468
|
+
worktree_id: worktree,
|
|
469
|
+
work_session_id: optionalText(workSessionId, 160),
|
|
470
|
+
change_slug: optionalText(changeSlug, 160),
|
|
471
|
+
target_context_ids: expectedContextIds,
|
|
472
|
+
target_change_slugs: expectedChangeSlugs,
|
|
473
|
+
target_context_snapshot: subjectSnapshot(expectedSnapshot),
|
|
474
|
+
actor_context_id: optionalText(actorContextId, 160),
|
|
475
|
+
worktree_path: optionalText(worktreePath, 1024),
|
|
476
|
+
mode: optionalText(mode, 32),
|
|
477
|
+
authority: optionalText(authority, 240),
|
|
478
|
+
head: optionalText(head, 80),
|
|
479
|
+
slug: optionalText(slug, 160),
|
|
480
|
+
pull_request_number: optionalText(pullRequestNumber, 80),
|
|
481
|
+
pull_request_repository: optionalText(pullRequestRepository, 240),
|
|
482
|
+
head_ref_oid: optionalText(headRefOid, 160),
|
|
483
|
+
merge_commit_oid: optionalText(mergeCommitOid, 160),
|
|
484
|
+
owner_pid: Number.isSafeInteger(Number(ownerPid)) ? Number(ownerPid) : process.pid,
|
|
485
|
+
phase: optionalText(phase, 48),
|
|
486
|
+
...(attemptToken ? { attempt_token: optionalText(attemptToken, 160) } : {}),
|
|
487
|
+
updated_at: String(now),
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
registry.cleanup_reservations = reservations;
|
|
491
|
+
return structuredClone(reservations[`${repository}:${worktree}`]);
|
|
492
|
+
}, { cleanupOperationId: operation });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function releaseActiveContextCleanup(vaultBase, operationId, {
|
|
496
|
+
repositoryId = '', worktreeId = '', attemptToken = '',
|
|
497
|
+
} = {}) {
|
|
498
|
+
const operation = String(operationId || '').trim();
|
|
499
|
+
if (!operation) return false;
|
|
500
|
+
let released = false;
|
|
501
|
+
mutateSessionRegistry(vaultBase, (registry) => {
|
|
502
|
+
const reservations = { ...(registry.cleanup_reservations || {}) };
|
|
503
|
+
const matchingKeys = Object.keys(reservations).filter((key) => (
|
|
504
|
+
String(reservations[key]?.operation_id || '') === operation
|
|
505
|
+
));
|
|
506
|
+
if ((!repositoryId || !worktreeId) && matchingKeys.length !== 1) return false;
|
|
507
|
+
const candidates = repositoryId && worktreeId
|
|
508
|
+
? [`${String(repositoryId).trim()}:${String(worktreeId).trim()}`]
|
|
509
|
+
: matchingKeys;
|
|
510
|
+
for (const key of candidates) {
|
|
511
|
+
const reservation = reservations[key];
|
|
512
|
+
if (String(reservation?.operation_id || '') !== operation) continue;
|
|
513
|
+
if (reservation?.attempt_token
|
|
514
|
+
&& String(reservation.attempt_token) !== String(attemptToken || '')) continue;
|
|
515
|
+
delete reservations[key];
|
|
516
|
+
released = true;
|
|
517
|
+
}
|
|
518
|
+
if (Object.keys(reservations).length) registry.cleanup_reservations = reservations;
|
|
519
|
+
else delete registry.cleanup_reservations;
|
|
520
|
+
return released;
|
|
521
|
+
}, { cleanupOperationId: operation });
|
|
522
|
+
return released;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function updateActiveContextCleanupPhase(vaultBase, {
|
|
526
|
+
operationId,
|
|
527
|
+
repositoryId,
|
|
528
|
+
worktreeId,
|
|
529
|
+
attemptToken = '',
|
|
530
|
+
phase,
|
|
531
|
+
now = new Date().toISOString(),
|
|
532
|
+
} = {}) {
|
|
533
|
+
const operation = requiredId(operationId, 'cleanup operation_id');
|
|
534
|
+
const repository = requiredId(repositoryId, 'repository_id');
|
|
535
|
+
const worktree = requiredId(worktreeId, 'worktree_id');
|
|
536
|
+
const requestedPhase = optionalText(phase, 48);
|
|
537
|
+
let updated = false;
|
|
538
|
+
mutateSessionRegistry(vaultBase, (registry) => {
|
|
539
|
+
const key = `${repository}:${worktree}`;
|
|
540
|
+
const reservations = { ...(registry.cleanup_reservations || {}) };
|
|
541
|
+
const reservation = reservations[key];
|
|
542
|
+
if (String(reservation?.operation_id || '') !== operation
|
|
543
|
+
|| (attemptToken && String(reservation?.attempt_token || '') !== String(attemptToken))) {
|
|
544
|
+
throw contextError(
|
|
545
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
|
|
546
|
+
'a tentativa de cleanup não possui mais a reserva CAS esperada',
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
reservations[key] = {
|
|
550
|
+
...reservation,
|
|
551
|
+
phase: requestedPhase,
|
|
552
|
+
updated_at: String(now),
|
|
553
|
+
};
|
|
554
|
+
registry.cleanup_reservations = reservations;
|
|
555
|
+
updated = true;
|
|
556
|
+
return structuredClone(reservations[key]);
|
|
557
|
+
}, { cleanupOperationId: operation });
|
|
558
|
+
return updated;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function markActiveContextCleanupTerminal(vaultBase, {
|
|
562
|
+
operationId,
|
|
563
|
+
projectId,
|
|
564
|
+
repositoryId,
|
|
565
|
+
worktreeId,
|
|
566
|
+
workSessionId = '',
|
|
567
|
+
changeSlug = '',
|
|
568
|
+
targetContextIds = [],
|
|
569
|
+
targetChangeSlugs = [],
|
|
570
|
+
targetContextSnapshot = [],
|
|
571
|
+
worktreePath = '',
|
|
572
|
+
subjectHash = '',
|
|
573
|
+
actorContextId = '',
|
|
574
|
+
mode = '',
|
|
575
|
+
authority = '',
|
|
576
|
+
head = '',
|
|
577
|
+
slug = '',
|
|
578
|
+
pullRequestNumber = '',
|
|
579
|
+
pullRequestRepository = '',
|
|
580
|
+
headRefOid = '',
|
|
581
|
+
mergeCommitOid = '',
|
|
582
|
+
attemptToken = '',
|
|
583
|
+
allowMissingReservation = false,
|
|
584
|
+
now = new Date().toISOString(),
|
|
585
|
+
} = {}) {
|
|
586
|
+
const operation = requiredId(operationId, 'cleanup operation_id');
|
|
587
|
+
const project = requiredId(projectId, 'project_id');
|
|
588
|
+
const repository = requiredId(repositoryId, 'repository_id');
|
|
589
|
+
const worktree = requiredId(worktreeId, 'worktree_id');
|
|
590
|
+
const subject = String(subjectHash || '').trim();
|
|
591
|
+
if (!subject) {
|
|
592
|
+
throw contextError('WENDKEEP_ACTIVE_CONTEXT_CLEANUP_SUBJECT_INVALID', 'cleanup subject_hash inválido ou ausente');
|
|
593
|
+
}
|
|
594
|
+
const requestedSubject = cleanupSubjectFromOptions({
|
|
595
|
+
projectId, repositoryId, worktreeId, workSessionId, changeSlug,
|
|
596
|
+
targetContextIds, targetChangeSlugs, targetContextSnapshot, actorContextId,
|
|
597
|
+
worktreePath, mode, authority, head, slug,
|
|
598
|
+
pullRequestNumber, pullRequestRepository, headRefOid, mergeCommitOid,
|
|
599
|
+
});
|
|
600
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
601
|
+
const existing = cleanupTombstoneForWorktree(registry, worktree, repository)
|
|
602
|
+
|| cleanupTombstoneForWorktree(registry, worktree);
|
|
603
|
+
if (existing && String(existing.operation_id || '') !== operation) {
|
|
604
|
+
throw contextError(
|
|
605
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
|
|
606
|
+
'o tombstone terminal pertence a outra operação',
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const reservationKey = `${repository}:${worktree}`;
|
|
610
|
+
const reservation = registry.cleanup_reservations?.[reservationKey]
|
|
611
|
+
|| cleanupReservationForWorktree(registry, worktree);
|
|
612
|
+
if (!reservation && !allowMissingReservation && !existing) {
|
|
613
|
+
throw contextError(
|
|
614
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
|
|
615
|
+
'a reserva owner da operação não está mais disponível',
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const reservationSubject = reservation && cleanupSubjectFromOptions({
|
|
619
|
+
projectId: reservation.project_id,
|
|
620
|
+
repositoryId: reservation.repository_id,
|
|
621
|
+
worktreeId: reservation.worktree_id,
|
|
622
|
+
workSessionId: reservation.work_session_id,
|
|
623
|
+
changeSlug: reservation.change_slug,
|
|
624
|
+
targetContextIds: reservation.target_context_ids,
|
|
625
|
+
targetChangeSlugs: reservation.target_change_slugs,
|
|
626
|
+
targetContextSnapshot: reservation.target_context_snapshot,
|
|
627
|
+
actorContextId: reservation.actor_context_id,
|
|
628
|
+
worktreePath: reservation.worktree_path,
|
|
629
|
+
mode: reservation.mode,
|
|
630
|
+
authority: reservation.authority,
|
|
631
|
+
head: reservation.head,
|
|
632
|
+
slug: reservation.slug,
|
|
633
|
+
pullRequestNumber: reservation.pull_request_number,
|
|
634
|
+
pullRequestRepository: reservation.pull_request_repository,
|
|
635
|
+
headRefOid: reservation.head_ref_oid,
|
|
636
|
+
mergeCommitOid: reservation.merge_commit_oid,
|
|
637
|
+
});
|
|
638
|
+
if (reservation) {
|
|
639
|
+
const sameOperation = String(reservation.operation_id || '') === operation;
|
|
640
|
+
const sameOwner = String(reservation.attempt_token || '') === String(attemptToken || '');
|
|
641
|
+
if (!sameOperation || (reservation.attempt_token && !sameOwner)) {
|
|
642
|
+
throw contextError(
|
|
643
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
|
|
644
|
+
'a reserva owner da operação mudou antes do tombstone terminal',
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (!sameCleanupSubject(reservationSubject, requestedSubject)) {
|
|
648
|
+
throw contextError(
|
|
649
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CAS_CONFLICT',
|
|
650
|
+
'o subject reservado mudou antes do tombstone terminal',
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
let adoptTombstoneAttempt = false;
|
|
655
|
+
if (existing) {
|
|
656
|
+
const existingSubject = cleanupSubjectFromOptions({
|
|
657
|
+
projectId: existing.project_id,
|
|
658
|
+
repositoryId: existing.repository_id,
|
|
659
|
+
worktreeId: existing.worktree_id,
|
|
660
|
+
workSessionId: existing.work_session_id,
|
|
661
|
+
changeSlug: existing.change_slug,
|
|
662
|
+
targetContextIds: existing.target_context_ids,
|
|
663
|
+
targetChangeSlugs: existing.target_change_slugs,
|
|
664
|
+
targetContextSnapshot: existing.target_context_snapshot,
|
|
665
|
+
actorContextId: existing.actor_context_id,
|
|
666
|
+
worktreePath: existing.worktree_path,
|
|
667
|
+
mode: existing.mode,
|
|
668
|
+
authority: existing.authority,
|
|
669
|
+
head: existing.head,
|
|
670
|
+
slug: existing.slug,
|
|
671
|
+
pullRequestNumber: existing.pull_request_number,
|
|
672
|
+
pullRequestRepository: existing.pull_request_repository,
|
|
673
|
+
headRefOid: existing.head_ref_oid,
|
|
674
|
+
mergeCommitOid: existing.merge_commit_oid,
|
|
675
|
+
});
|
|
676
|
+
const sameSubject = String(existing.subject_hash || '') === subject
|
|
677
|
+
&& sameCleanupSubject(existingSubject, requestedSubject);
|
|
678
|
+
const sameOwner = !existing.attempt_token
|
|
679
|
+
|| String(existing.attempt_token) === String(attemptToken || '');
|
|
680
|
+
const canAdopt = Boolean(
|
|
681
|
+
reservation
|
|
682
|
+
&& String(reservation.operation_id || '') === operation
|
|
683
|
+
&& String(reservation.attempt_token || '') === String(attemptToken || '')
|
|
684
|
+
&& sameSubject
|
|
685
|
+
&& sameCleanupSubject(existingSubject, reservationSubject),
|
|
686
|
+
);
|
|
687
|
+
adoptTombstoneAttempt = canAdopt && !sameOwner;
|
|
688
|
+
if (!sameSubject || (!sameOwner && !adoptTombstoneAttempt)) {
|
|
689
|
+
throw contextError(
|
|
690
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
|
|
691
|
+
'o tombstone terminal não corresponde ao owner/subject reservado',
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const active = Object.values(contextsOf(registry)).filter((context) => (
|
|
696
|
+
context?.state === 'active' && String(context?.worktree_id || '') === worktree
|
|
697
|
+
));
|
|
698
|
+
if (active.length) {
|
|
699
|
+
throw contextError(
|
|
700
|
+
'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH',
|
|
701
|
+
'não é seguro criar tombstone enquanto há active context',
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
registry.cleanup_tombstones = {
|
|
705
|
+
...(registry.cleanup_tombstones || {}),
|
|
706
|
+
[`${repository}:${worktree}`]: {
|
|
707
|
+
state: 'cleaned',
|
|
708
|
+
operation_id: operation,
|
|
709
|
+
project_id: project,
|
|
710
|
+
repository_id: repository,
|
|
711
|
+
worktree_id: worktree,
|
|
712
|
+
work_session_id: optionalText(workSessionId, 160),
|
|
713
|
+
change_slug: optionalText(changeSlug, 160),
|
|
714
|
+
target_context_ids: subjectIds(targetContextIds),
|
|
715
|
+
target_change_slugs: subjectIds(targetChangeSlugs),
|
|
716
|
+
target_context_snapshot: subjectSnapshot(targetContextSnapshot),
|
|
717
|
+
worktree_path: optionalText(worktreePath, 1024),
|
|
718
|
+
subject_hash: optionalText(subject, 240),
|
|
719
|
+
actor_context_id: optionalText(actorContextId, 160),
|
|
720
|
+
mode: optionalText(mode, 32),
|
|
721
|
+
authority: optionalText(authority, 240),
|
|
722
|
+
head: optionalText(head, 80),
|
|
723
|
+
slug: optionalText(slug, 160),
|
|
724
|
+
pull_request_number: optionalText(pullRequestNumber, 80),
|
|
725
|
+
pull_request_repository: optionalText(pullRequestRepository, 240),
|
|
726
|
+
head_ref_oid: optionalText(headRefOid, 160),
|
|
727
|
+
merge_commit_oid: optionalText(mergeCommitOid, 160),
|
|
728
|
+
attempt_token: optionalText(attemptToken, 160),
|
|
729
|
+
updated_at: String(now),
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
return structuredClone(registry.cleanup_tombstones[`${repository}:${worktree}`]);
|
|
733
|
+
}, { cleanupOperationId: operation });
|
|
734
|
+
}
|
|
735
|
+
|
|
208
736
|
export function setActiveContextChange(vaultBase, identity, slug, options = {}) {
|
|
209
737
|
const normalizedSlug = String(slug || '').trim();
|
|
210
738
|
if (!SLUG_PATTERN.test(normalizedSlug)) {
|