dsh-git-worktree 0.3.2 → 0.6.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 +83 -0
- package/README.en.md +48 -18
- package/README.md +49 -18
- package/lib/adapters/git.js +8 -0
- package/lib/adapters/git.js.map +1 -1
- package/lib/adapters/registry.js +107 -2
- package/lib/adapters/registry.js.map +1 -1
- package/lib/checkpoint.d.ts +3 -0
- package/lib/checkpoint.js +29 -0
- package/lib/checkpoint.js.map +1 -0
- package/lib/client/WorktreeReviewRow.d.ts +1 -1
- package/lib/client/WorktreeReviewRow.js +26 -6
- package/lib/client/WorktreeReviewRow.js.map +1 -1
- package/lib/client/actions.d.ts +49 -4
- package/lib/client/actions.js +104 -0
- package/lib/client/actions.js.map +1 -1
- package/lib/client/console-remote/adapter.js +10 -3
- package/lib/client/console-remote/adapter.js.map +1 -1
- package/lib/client/review-console/DeliveryProof.d.ts +5 -0
- package/lib/client/review-console/DeliveryProof.js +32 -0
- package/lib/client/review-console/DeliveryProof.js.map +1 -0
- package/lib/client/review-console/PreflightStatus.d.ts +12 -0
- package/lib/client/review-console/PreflightStatus.js +31 -0
- package/lib/client/review-console/PreflightStatus.js.map +1 -0
- package/lib/client/review-console/ReviewActions.d.ts +4 -1
- package/lib/client/review-console/ReviewActions.js +657 -31
- package/lib/client/review-console/ReviewActions.js.map +1 -1
- package/lib/client/review-console/WorktreeReviewPanel.d.ts +3 -1
- package/lib/client/review-console/WorktreeReviewPanel.js +35 -5
- package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -1
- package/lib/client/review-console/preflight-cache.d.ts +21 -0
- package/lib/client/review-console/preflight-cache.js +98 -0
- package/lib/client/review-console/preflight-cache.js.map +1 -0
- package/lib/client/review-console/preview-recovery-cache.d.ts +20 -0
- package/lib/client/review-console/preview-recovery-cache.js +103 -0
- package/lib/client/review-console/preview-recovery-cache.js.map +1 -0
- package/lib/client/review-console/recovery-continuation.d.ts +53 -0
- package/lib/client/review-console/recovery-continuation.js +483 -0
- package/lib/client/review-console/recovery-continuation.js.map +1 -0
- package/lib/client/review-console/review-console.styles.js +36 -4
- package/lib/client/review-console/review-console.styles.js.map +1 -1
- package/lib/client/target-console/TargetStatusAction.d.ts +4 -2
- package/lib/client/target-console/TargetStatusAction.js +196 -23
- package/lib/client/target-console/TargetStatusAction.js.map +1 -1
- package/lib/client/target-console/WorktreeConsoleView.d.ts +4 -2
- package/lib/client/target-console/WorktreeConsoleView.js +33 -73
- package/lib/client/target-console/WorktreeConsoleView.js.map +1 -1
- package/lib/client/target-console/WorktreeManagerModal.d.ts +13 -0
- package/lib/client/target-console/WorktreeManagerModal.js +8 -0
- package/lib/client/target-console/WorktreeManagerModal.js.map +1 -0
- package/lib/client/target-console/WorktreeReviewStatus.d.ts +3 -1
- package/lib/client/target-console/WorktreeReviewStatus.js +35 -15
- package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -1
- package/lib/client/target-console/index.d.ts +2 -1
- package/lib/client/target-console/index.js +4 -3
- package/lib/client/target-console/index.js.map +1 -1
- package/lib/client/target-console/target-console.styles.js +22 -3
- package/lib/client/target-console/target-console.styles.js.map +1 -1
- package/lib/client.js +2976 -211
- package/lib/console-contract.d.ts +110 -1
- package/lib/console-contract.js.map +1 -1
- package/lib/console-host/control-plane.d.ts +6 -1
- package/lib/console-host/control-plane.js +363 -44
- package/lib/console-host/control-plane.js.map +1 -1
- package/lib/console-host/errors.d.ts +2 -2
- package/lib/console-host/errors.js +21 -5
- package/lib/console-host/errors.js.map +1 -1
- package/lib/console-host/projection.d.ts +5 -3
- package/lib/console-host/projection.js +103 -11
- package/lib/console-host/projection.js.map +1 -1
- package/lib/console-host/service.d.ts +10 -5
- package/lib/console-host/service.js +48 -6
- package/lib/console-host/service.js.map +1 -1
- package/lib/console-remote/descriptors.js +37 -1
- package/lib/console-remote/descriptors.js.map +1 -1
- package/lib/console-remote/remote.d.ts +10 -5
- package/lib/console-remote/remote.js.map +1 -1
- package/lib/console-remote/schemas.d.ts +103 -1
- package/lib/console-remote/schemas.js +166 -6
- package/lib/console-remote/schemas.js.map +1 -1
- package/lib/index.d.ts +13 -2
- package/lib/index.js.map +1 -1
- package/lib/ports.d.ts +79 -1
- package/lib/session-checkout-apply.d.ts +76 -0
- package/lib/session-checkout-apply.js +608 -143
- package/lib/session-checkout-apply.js.map +1 -1
- package/lib/session-checkout-module.js +775 -117
- package/lib/session-checkout-module.js.map +1 -1
- package/lib/types.d.ts +104 -3
- package/lib/types.js.map +1 -1
- package/package.json +14 -14
- package/scripts/check-publish.mjs +2 -2
|
@@ -16,23 +16,56 @@ function readyReview(record) {
|
|
|
16
16
|
return record.delivery.review;
|
|
17
17
|
}
|
|
18
18
|
function previewReview(record) {
|
|
19
|
-
if (record.phase !== 'ready'
|
|
19
|
+
if (record.phase !== 'ready'
|
|
20
|
+
|| (record.delivery.state !== 'preview_active' && record.delivery.state !== 'preview_detached')) {
|
|
20
21
|
throw domainError('preview_not_active', '当前没有等待验收的 Local Preview');
|
|
21
22
|
}
|
|
22
23
|
return record.delivery.review;
|
|
23
24
|
}
|
|
25
|
+
function safeRecoveryRequestId(value) {
|
|
26
|
+
return value.length > 0 && value.length <= 500 && !/[\0\r\n]/u.test(value);
|
|
27
|
+
}
|
|
28
|
+
function safeConflictFile(file) {
|
|
29
|
+
if (!file || file.length > 1000 || /[\0-\x1f\x7f]/u.test(file))
|
|
30
|
+
return false;
|
|
31
|
+
if (/^(?:[A-Za-z]:[\\/]|[\\/])/u.test(file))
|
|
32
|
+
return false;
|
|
33
|
+
return !file.split(/[\\/]/u).some(segment => segment === '' || segment === '.' || segment === '..');
|
|
34
|
+
}
|
|
35
|
+
function applyConflictContinuation(result, reviewId) {
|
|
36
|
+
const checkoutId = result.target.checkout.id;
|
|
37
|
+
const revision = result.target.revision;
|
|
38
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(result.localHeadOid)) {
|
|
39
|
+
throw domainError('git_error', '冲突恢复缺少有效的 Local HEAD 身份');
|
|
40
|
+
}
|
|
41
|
+
const localHeadOid = result.localHeadOid;
|
|
42
|
+
if (result.conflictingFiles.length > 500 || !result.conflictingFiles.every(safeConflictFile)) {
|
|
43
|
+
throw domainError('git_error', '冲突恢复包含不安全或越界的文件身份');
|
|
44
|
+
}
|
|
45
|
+
const conflictingFiles = [...result.conflictingFiles];
|
|
46
|
+
return {
|
|
47
|
+
kind: 'worktree_apply_conflict',
|
|
48
|
+
requestId: `apply-conflict:${checkoutId}:${reviewId}:${revision}:${localHeadOid}`,
|
|
49
|
+
checkoutId,
|
|
50
|
+
reviewId,
|
|
51
|
+
revision,
|
|
52
|
+
localHeadOid,
|
|
53
|
+
conflictingFiles,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
24
56
|
function preflightFailure(view) {
|
|
25
57
|
if (view === undefined)
|
|
26
58
|
return failure('git_error', '当前 SessionCheckoutModule 不支持验收预检');
|
|
27
59
|
if (view.status !== 'blocked')
|
|
28
60
|
return undefined;
|
|
29
61
|
const code = view.reason === 'stale_isolated' ? 'stale_isolated'
|
|
30
|
-
: view.reason === '
|
|
31
|
-
: view.reason === '
|
|
32
|
-
: view.reason === '
|
|
33
|
-
: view.reason === '
|
|
34
|
-
: view.reason === '
|
|
35
|
-
: '
|
|
62
|
+
: view.reason === 'stale_local' ? 'stale_local'
|
|
63
|
+
: view.reason === 'stale_target' ? 'stale_target'
|
|
64
|
+
: view.reason === 'project_acceptance_busy' ? 'project_acceptance_busy'
|
|
65
|
+
: view.reason === 'not_owner' ? 'not_owner'
|
|
66
|
+
: view.reason === 'not_ready_for_review' ? 'operation_not_allowed'
|
|
67
|
+
: view.reason === 'checkout_unavailable' ? 'checkout_mismatch'
|
|
68
|
+
: 'git_error';
|
|
36
69
|
return failure(code, view.message);
|
|
37
70
|
}
|
|
38
71
|
export function createWorktreeConsoleControlPlane(options) {
|
|
@@ -73,35 +106,38 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
73
106
|
throw error;
|
|
74
107
|
}
|
|
75
108
|
}
|
|
76
|
-
async function
|
|
77
|
-
const caller = await options.module.inspect(sessionId);
|
|
78
|
-
const record = recordOf(options.registry, checkoutId);
|
|
79
|
-
if (record.ownerSessionId !== sessionId && record.sourceSessionId !== sessionId) {
|
|
80
|
-
throw domainError('not_owner', '当前 Session 无权访问该 Worktree');
|
|
81
|
-
}
|
|
82
|
-
if (record.projectId !== caller.project.id) {
|
|
83
|
-
throw domainError('project_mismatch', 'Worktree 与当前 Session 项目不一致');
|
|
84
|
-
}
|
|
109
|
+
async function verifyCallerRoot(sessionId, record, expectedRoot) {
|
|
85
110
|
const session = options.lookup.getSession(sessionId);
|
|
86
111
|
const project = session?.projectId === undefined ? undefined : options.lookup.getProject(session.projectId);
|
|
87
112
|
if (project === undefined) {
|
|
88
113
|
throw domainError('project_mismatch', '当前 Session Workspace 无法证明属于该 Worktree 的原始项目');
|
|
89
114
|
}
|
|
90
|
-
const expectedRoot = record.ownerSessionId === sessionId ? record.managedRoot : record.localRoot;
|
|
91
115
|
const cleanedOwnerCwd = record.ownerSessionId === sessionId
|
|
92
116
|
&& record.phase === 'discarded'
|
|
93
117
|
&& record.delivery.state === 'delivered'
|
|
94
118
|
&& !options.files.exists(project.root)
|
|
95
119
|
&& sameLocalRoot(resolve(project.root), resolve(expectedRoot));
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
if (cleanedOwnerCwd)
|
|
121
|
+
return;
|
|
122
|
+
if (!options.files.exists(project.root)) {
|
|
123
|
+
throw domainError('project_mismatch', '当前 Session Workspace 无法证明属于该 Worktree 的原始项目');
|
|
124
|
+
}
|
|
125
|
+
const workspaceRoot = await options.files.canonicalize(project.root);
|
|
126
|
+
if (!sameLocalRoot(workspaceRoot, expectedRoot)) {
|
|
127
|
+
throw domainError('project_mismatch', '当前 Session cwd 与 Worktree 授权边界不一致');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function authorize(sessionId, checkoutId) {
|
|
131
|
+
const caller = await options.module.inspect(sessionId);
|
|
132
|
+
const record = recordOf(options.registry, checkoutId);
|
|
133
|
+
if (record.ownerSessionId !== sessionId && record.sourceSessionId !== sessionId) {
|
|
134
|
+
throw domainError('not_owner', '当前 Session 无权访问该 Worktree');
|
|
135
|
+
}
|
|
136
|
+
if (record.projectId !== caller.project.id) {
|
|
137
|
+
throw domainError('project_mismatch', 'Worktree 与当前 Session 项目不一致');
|
|
104
138
|
}
|
|
139
|
+
const expectedRoot = record.ownerSessionId === sessionId ? record.managedRoot : record.localRoot;
|
|
140
|
+
await verifyCallerRoot(sessionId, record, expectedRoot);
|
|
105
141
|
if (record.phase !== 'discarded') {
|
|
106
142
|
const visible = await options.module.listManagedWorktreesForSession(sessionId, { checkoutId });
|
|
107
143
|
if (!visible.some(item => item.checkoutId === checkoutId)) {
|
|
@@ -110,6 +146,56 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
110
146
|
}
|
|
111
147
|
return record;
|
|
112
148
|
}
|
|
149
|
+
async function linkedReadAccess(sessionId, checkoutId) {
|
|
150
|
+
const requested = recordOf(options.registry, checkoutId);
|
|
151
|
+
if (requested.ownerSessionId === sessionId || requested.sourceSessionId === sessionId) {
|
|
152
|
+
return { record: await authorize(sessionId, checkoutId), linkedRead: false };
|
|
153
|
+
}
|
|
154
|
+
const caller = await callerTarget(sessionId);
|
|
155
|
+
if (caller.checkout.kind !== 'isolated') {
|
|
156
|
+
throw domainError('not_owner', '当前 Session 无权访问该 Worktree');
|
|
157
|
+
}
|
|
158
|
+
const anchor = await authorize(sessionId, caller.checkout.id);
|
|
159
|
+
const sourceSessionId = anchor.sourceSessionId ?? anchor.ownerSessionId;
|
|
160
|
+
const requestedSourceSessionId = requested.sourceSessionId ?? requested.ownerSessionId;
|
|
161
|
+
const sameLinkedGroup = requested.projectId === anchor.projectId
|
|
162
|
+
&& requestedSourceSessionId === sourceSessionId
|
|
163
|
+
&& sameLocalRoot(requested.localRoot, anchor.localRoot);
|
|
164
|
+
const holder = anchor.ownerSessionId === sessionId
|
|
165
|
+
&& anchor.phase === 'ready'
|
|
166
|
+
&& anchor.delivery.state === 'ready_for_review'
|
|
167
|
+
? acceptanceHolder(anchor)
|
|
168
|
+
: undefined;
|
|
169
|
+
const exactAcceptanceHolder = holder?.checkoutId === requested.checkoutId
|
|
170
|
+
&& requested.projectId === anchor.projectId
|
|
171
|
+
&& sameLocalRoot(requested.localRoot, anchor.localRoot);
|
|
172
|
+
if (!sameLinkedGroup && !exactAcceptanceHolder) {
|
|
173
|
+
throw domainError('not_owner', '当前 Session 无权访问该 Worktree');
|
|
174
|
+
}
|
|
175
|
+
if (!ownerSessionAvailable(requested)) {
|
|
176
|
+
throw domainError('checkout_missing', '关联 Worktree 的 owner Session 不可用');
|
|
177
|
+
}
|
|
178
|
+
await verifyCallerRoot(requested.ownerSessionId, requested, requested.managedRoot);
|
|
179
|
+
if (exactAcceptanceHolder) {
|
|
180
|
+
const currentAnchor = recordOf(options.registry, anchor.checkoutId);
|
|
181
|
+
const currentRequested = recordOf(options.registry, requested.checkoutId);
|
|
182
|
+
const currentHolder = currentAnchor.ownerSessionId === sessionId
|
|
183
|
+
&& currentAnchor.phase === 'ready'
|
|
184
|
+
&& currentAnchor.delivery.state === 'ready_for_review'
|
|
185
|
+
? acceptanceHolder(currentAnchor)
|
|
186
|
+
: undefined;
|
|
187
|
+
if (currentHolder?.checkoutId !== currentRequested.checkoutId
|
|
188
|
+
|| currentRequested.ownerSessionId !== requested.ownerSessionId
|
|
189
|
+
|| !sameLocalRoot(currentRequested.localRoot, currentAnchor.localRoot))
|
|
190
|
+
throw domainError('not_owner', '验收槽位占用者已变化,请重新检查');
|
|
191
|
+
return {
|
|
192
|
+
record: currentRequested,
|
|
193
|
+
linkedRead: true,
|
|
194
|
+
acceptanceAnchorCheckoutId: currentAnchor.checkoutId,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { record: requested, linkedRead: true };
|
|
198
|
+
}
|
|
113
199
|
async function observe(record) {
|
|
114
200
|
if (record.phase === 'discarded')
|
|
115
201
|
return { managedRoot: null };
|
|
@@ -145,48 +231,111 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
145
231
|
reviewSlot: holder ? 'waiting' : 'available',
|
|
146
232
|
...(holder ? {
|
|
147
233
|
reviewSlotOwnerSessionId: holder.ownerSessionId,
|
|
148
|
-
|
|
234
|
+
reviewSlotHolder: {
|
|
235
|
+
checkoutId: holder.checkoutId,
|
|
236
|
+
ownerSessionId: holder.ownerSessionId,
|
|
237
|
+
revision: holder.revision,
|
|
238
|
+
state: holder.delivery.state,
|
|
239
|
+
},
|
|
240
|
+
capabilities: { ...target.capabilities, preview: false, checkpoint: false, finalize: false },
|
|
149
241
|
} : {}),
|
|
150
242
|
};
|
|
151
243
|
}
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
244
|
+
function deliveryProofFromTarget(target) {
|
|
245
|
+
const delivery = target.delivery;
|
|
246
|
+
if (!delivery || !('proof' in delivery) || !delivery.proof)
|
|
247
|
+
return undefined;
|
|
248
|
+
return {
|
|
249
|
+
...delivery.proof,
|
|
250
|
+
changedFiles: [...delivery.proof.changedFiles],
|
|
251
|
+
};
|
|
156
252
|
}
|
|
157
|
-
async function
|
|
253
|
+
async function details(sessionId, checkoutId, observedDeliveryProof) {
|
|
254
|
+
const access = await linkedReadAccess(sessionId, checkoutId);
|
|
255
|
+
const observed = await observe(access.record);
|
|
256
|
+
let projectedRecord = access.record;
|
|
257
|
+
if (access.acceptanceAnchorCheckoutId !== undefined) {
|
|
258
|
+
const currentAnchor = recordOf(options.registry, access.acceptanceAnchorCheckoutId);
|
|
259
|
+
const currentRequested = recordOf(options.registry, checkoutId);
|
|
260
|
+
const currentHolder = currentAnchor.ownerSessionId === sessionId
|
|
261
|
+
&& currentAnchor.phase === 'ready'
|
|
262
|
+
&& currentAnchor.delivery.state === 'ready_for_review'
|
|
263
|
+
? acceptanceHolder(currentAnchor)
|
|
264
|
+
: undefined;
|
|
265
|
+
if (currentHolder?.checkoutId !== currentRequested.checkoutId
|
|
266
|
+
|| currentRequested.ownerSessionId !== access.record.ownerSessionId
|
|
267
|
+
|| !sameLocalRoot(currentRequested.localRoot, currentAnchor.localRoot)
|
|
268
|
+
|| observed.managedRoot === null
|
|
269
|
+
|| !sameLocalRoot(observed.managedRoot, currentRequested.managedRoot))
|
|
270
|
+
throw domainError('not_owner', '验收槽位占用者已变化,请重新检查');
|
|
271
|
+
projectedRecord = currentRequested;
|
|
272
|
+
}
|
|
273
|
+
return projectReviewSlot(projectedRecord, projectDetails(projectedRecord, sessionId, observed.managedRoot, observed.snapshot, observed.dirty, ownerSessionAvailable(access.record), access.linkedRead, observedDeliveryProof));
|
|
274
|
+
}
|
|
275
|
+
async function mutationResponse(sessionId, checkoutId, observedDeliveryProof) {
|
|
158
276
|
const record = recordOf(options.registry, checkoutId);
|
|
159
277
|
if (record.phase === 'discarded') {
|
|
160
|
-
return {
|
|
278
|
+
return {
|
|
279
|
+
target: projectReviewSlot(record, projectRecord(record, sessionId, {
|
|
280
|
+
ownerSessionAvailable: ownerSessionAvailable(record),
|
|
281
|
+
deliveryProof: observedDeliveryProof,
|
|
282
|
+
})),
|
|
283
|
+
};
|
|
161
284
|
}
|
|
162
285
|
const observed = await observe(record);
|
|
163
|
-
return {
|
|
286
|
+
return {
|
|
287
|
+
target: projectReviewSlot(record, projectRecord(record, sessionId, {
|
|
288
|
+
...observed,
|
|
289
|
+
ownerSessionAvailable: ownerSessionAvailable(record),
|
|
290
|
+
deliveryProof: observedDeliveryProof,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
164
293
|
}
|
|
165
294
|
return {
|
|
166
295
|
current: sessionId => outcome(async () => {
|
|
167
296
|
const target = await callerTarget(sessionId);
|
|
168
297
|
if (target.checkout.kind === 'local')
|
|
169
298
|
return { target: projectLocal(target, sessionId) };
|
|
170
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
target: await details(sessionId, target.checkout.id, deliveryProofFromTarget(target)),
|
|
301
|
+
};
|
|
171
302
|
}),
|
|
172
303
|
list: request => outcome(async () => {
|
|
173
304
|
const caller = await callerTarget(request.sessionId);
|
|
174
|
-
|
|
305
|
+
let sourceSessionId = request.sessionId;
|
|
306
|
+
let localRoot;
|
|
307
|
+
if (caller.checkout.kind === 'isolated') {
|
|
308
|
+
const anchor = await authorize(request.sessionId, caller.checkout.id);
|
|
309
|
+
sourceSessionId = anchor.sourceSessionId ?? anchor.ownerSessionId;
|
|
310
|
+
localRoot = anchor.localRoot;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const session = options.lookup.getSession(request.sessionId);
|
|
314
|
+
const project = session?.projectId === undefined ? undefined : options.lookup.getProject(session.projectId);
|
|
315
|
+
if (project === undefined || project.id !== caller.project.id || !options.files.exists(project.root)) {
|
|
316
|
+
throw domainError('project_mismatch', '当前 Session Workspace 无法证明关联 Worktree 项目');
|
|
317
|
+
}
|
|
318
|
+
localRoot = await options.files.canonicalize(project.root);
|
|
319
|
+
}
|
|
320
|
+
const active = await options.module.listManagedWorktreesForSession(sourceSessionId, {
|
|
175
321
|
needsAttention: request.needsAttention,
|
|
176
322
|
});
|
|
177
323
|
const activeById = new Map(active.map(summary => [summary.checkoutId, summary]));
|
|
178
324
|
const records = Object.values(options.registry.read().managedCheckouts)
|
|
179
325
|
.filter(record => record.projectId === caller.project.id)
|
|
180
|
-
.filter(record => record.
|
|
326
|
+
.filter(record => (record.sourceSessionId ?? record.ownerSessionId) === sourceSessionId)
|
|
327
|
+
.filter(record => localRoot === undefined || sameLocalRoot(record.localRoot, localRoot))
|
|
181
328
|
.filter(record => request.includeDelivered === true || record.phase !== 'discarded')
|
|
182
329
|
.filter(record => record.phase === 'discarded' || activeById.has(record.checkoutId));
|
|
183
330
|
const worktrees = [];
|
|
184
331
|
for (const record of records) {
|
|
185
332
|
const observed = record.phase === 'discarded' ? undefined : await observe(record);
|
|
333
|
+
const linkedRead = record.ownerSessionId !== request.sessionId && record.sourceSessionId !== request.sessionId;
|
|
186
334
|
const projected = projectReviewSlot(record, projectRecord(record, request.sessionId, {
|
|
187
335
|
...observed,
|
|
188
336
|
summary: activeById.get(record.checkoutId),
|
|
189
337
|
ownerSessionAvailable: ownerSessionAvailable(record),
|
|
338
|
+
linkedRead,
|
|
190
339
|
}));
|
|
191
340
|
if (request.needsAttention !== true || projected.state === 'cleanup_pending' || projected.state === 'recovery_required') {
|
|
192
341
|
worktrees.push(projected);
|
|
@@ -268,6 +417,110 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
268
417
|
throw domainError('git_error', '当前 SessionCheckoutModule 不支持验收预检');
|
|
269
418
|
return { preflight };
|
|
270
419
|
}),
|
|
420
|
+
previewRecoveryPreflight: request => outcome(async () => {
|
|
421
|
+
const record = await authorize(request.sessionId, request.checkoutId);
|
|
422
|
+
if (record.ownerSessionId !== request.sessionId)
|
|
423
|
+
throw domainError('not_owner', '只有 owner Isolated Session 可以检查 Preview 恢复');
|
|
424
|
+
if (record.revision !== request.expectedRevision)
|
|
425
|
+
throw domainError('stale_target', 'Session Target 已变化,请刷新');
|
|
426
|
+
if (record.phase !== 'ready'
|
|
427
|
+
|| record.delivery.state !== 'preview_detached'
|
|
428
|
+
|| record.delivery.review.reviewId !== request.expectedReviewId
|
|
429
|
+
|| record.delivery.preview.previewId !== request.expectedPreviewId)
|
|
430
|
+
throw domainError('stale_target', 'Detached Preview 身份已变化,请刷新');
|
|
431
|
+
const preflight = await options.module.preflightPreviewRecovery?.(request.sessionId, request.expectedRevision, request.expectedReviewId, request.expectedPreviewId);
|
|
432
|
+
if (preflight === undefined)
|
|
433
|
+
throw domainError('git_error', '当前 SessionCheckoutModule 不支持 Preview Recovery 预检');
|
|
434
|
+
return { preflight };
|
|
435
|
+
}),
|
|
436
|
+
preparePreviewRecoveryAnalysis: request => outcome(async () => {
|
|
437
|
+
const record = await authorize(request.sessionId, request.checkoutId);
|
|
438
|
+
if (record.ownerSessionId !== request.sessionId
|
|
439
|
+
|| request.recoveryProof.sessionId !== request.sessionId
|
|
440
|
+
|| request.recoveryProof.checkoutId !== request.checkoutId
|
|
441
|
+
|| request.recoveryProof.revision !== request.expectedRevision
|
|
442
|
+
|| request.recoveryProof.reviewId !== request.expectedReviewId
|
|
443
|
+
|| request.recoveryProof.previewId !== request.expectedPreviewId)
|
|
444
|
+
throw domainError('stale_target', 'Preview Recovery 分析身份不匹配');
|
|
445
|
+
const prepared = await options.module.preparePreviewRecoveryAnalysis(request.sessionId, request.recoveryProof, `preview-recovery-analysis:${randomUUID()}`);
|
|
446
|
+
const response = await mutationResponse(request.sessionId, request.checkoutId);
|
|
447
|
+
return {
|
|
448
|
+
...response,
|
|
449
|
+
recoveryContinuation: {
|
|
450
|
+
kind: prepared.kind,
|
|
451
|
+
requestId: prepared.requestId,
|
|
452
|
+
checkoutId: request.checkoutId,
|
|
453
|
+
reviewId: prepared.reviewId,
|
|
454
|
+
previewId: prepared.previewId,
|
|
455
|
+
revision: prepared.revision,
|
|
456
|
+
generation: prepared.generation,
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}),
|
|
460
|
+
createPreviewRecoveryHandoff: request => outcome(async () => {
|
|
461
|
+
const record = await authorize(request.sessionId, request.checkoutId);
|
|
462
|
+
if (record.ownerSessionId !== request.sessionId
|
|
463
|
+
|| request.recoveryProof.sessionId !== request.sessionId
|
|
464
|
+
|| request.recoveryProof.checkoutId !== request.checkoutId
|
|
465
|
+
|| request.recoveryProof.revision !== request.expectedRevision
|
|
466
|
+
|| request.recoveryProof.reviewId !== request.expectedReviewId
|
|
467
|
+
|| request.recoveryProof.previewId !== request.expectedPreviewId)
|
|
468
|
+
throw domainError('stale_target', 'Preview Recovery handoff 身份不匹配');
|
|
469
|
+
const targetSessionId = createTargetSessionId();
|
|
470
|
+
const launch = await options.module.createPreviewRecoveryHandoff(request.sessionId, request.recoveryProof, targetSessionId, `preview-recovery-handoff:${randomUUID()}`);
|
|
471
|
+
const created = recordOf(options.registry, launch.target.checkout.id);
|
|
472
|
+
const observed = await observe(created);
|
|
473
|
+
if (created.ownerSessionId !== targetSessionId
|
|
474
|
+
|| created.recoveryContinuation?.kind !== 'worktree_preview_recovery_handoff'
|
|
475
|
+
|| created.recoveryContinuation.requestId !== launch.continuation.requestId
|
|
476
|
+
|| observed.managedRoot !== launch.managedRoot)
|
|
477
|
+
throw domainError('checkout_mismatch', 'Recovery handoff Worktree 的 Host 身份校验失败');
|
|
478
|
+
return {
|
|
479
|
+
target: projectDetails(created, targetSessionId, observed.managedRoot, observed.snapshot, observed.dirty, false),
|
|
480
|
+
targetSessionId,
|
|
481
|
+
managedRoot: launch.managedRoot,
|
|
482
|
+
recoveryContinuation: {
|
|
483
|
+
kind: launch.continuation.kind,
|
|
484
|
+
requestId: launch.continuation.requestId,
|
|
485
|
+
checkoutId: created.checkoutId,
|
|
486
|
+
sourceCheckoutId: launch.continuation.sourceCheckoutId,
|
|
487
|
+
reviewId: launch.continuation.reviewId,
|
|
488
|
+
previewId: launch.continuation.previewId,
|
|
489
|
+
revision: launch.continuation.sourceRevision,
|
|
490
|
+
generation: launch.continuation.generation,
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}),
|
|
494
|
+
checkpoint: request => outcome(async () => {
|
|
495
|
+
const record = await authorize(request.sessionId, request.checkoutId);
|
|
496
|
+
if (record.ownerSessionId !== request.sessionId)
|
|
497
|
+
throw domainError('not_owner', '只有 owner Isolated Session 可以保存阶段');
|
|
498
|
+
const commitMessage = request.commitMessage.trim();
|
|
499
|
+
if (!commitMessage || commitMessage.length > 500 || !safeRecoveryRequestId(request.requestId)) {
|
|
500
|
+
throw domainError('invalid_input', 'Checkpoint Commit Message 或 requestId 无效');
|
|
501
|
+
}
|
|
502
|
+
const result = await options.module.operate({
|
|
503
|
+
action: 'checkpoint',
|
|
504
|
+
sessionId: request.sessionId,
|
|
505
|
+
expectedRevision: request.expectedRevision,
|
|
506
|
+
expectedReviewId: request.expectedReviewId,
|
|
507
|
+
expectedGeneration: request.expectedGeneration,
|
|
508
|
+
requestId: request.requestId,
|
|
509
|
+
commitMessage,
|
|
510
|
+
});
|
|
511
|
+
if (result.status === 'error')
|
|
512
|
+
throw domainError(result.code, result.message);
|
|
513
|
+
if (result.status === 'preview_detached') {
|
|
514
|
+
return { ...(await mutationResponse(request.sessionId, request.checkoutId)), changedFiles: [...result.changedFiles] };
|
|
515
|
+
}
|
|
516
|
+
if (result.status !== 'checkpointed')
|
|
517
|
+
throw domainError('operation_not_allowed', 'Checkpoint 返回了非预期状态');
|
|
518
|
+
const response = await mutationResponse(request.sessionId, request.checkoutId);
|
|
519
|
+
if (response.target.state !== 'working' || response.target.revision !== result.target.revision) {
|
|
520
|
+
throw domainError('checkout_mismatch', 'Checkpoint 后 Worktree 状态未收敛到 Working');
|
|
521
|
+
}
|
|
522
|
+
return { ...response, checkpoint: result.checkpoint, changedFiles: [...result.changedFiles] };
|
|
523
|
+
}),
|
|
271
524
|
preview: request => outcome(async () => {
|
|
272
525
|
const record = await authorize(request.sessionId, request.checkoutId);
|
|
273
526
|
if (record.ownerSessionId !== request.sessionId)
|
|
@@ -282,8 +535,9 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
282
535
|
});
|
|
283
536
|
if (result.status === 'error')
|
|
284
537
|
throw domainError(result.code, result.message);
|
|
285
|
-
if (result.status === 'conflict')
|
|
286
|
-
throw domainError('apply_conflict', 'Local Preview 预检发现内容冲突');
|
|
538
|
+
if (result.status === 'conflict') {
|
|
539
|
+
throw domainError('apply_conflict', 'Local Preview 预检发现内容冲突', applyConflictContinuation(result, review.reviewId));
|
|
540
|
+
}
|
|
287
541
|
if (result.status !== 'previewed')
|
|
288
542
|
throw domainError('operation_not_allowed', 'Preview 返回了非预期状态');
|
|
289
543
|
return {
|
|
@@ -300,11 +554,73 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
300
554
|
const review = readyReview(record);
|
|
301
555
|
if (review.reviewId !== request.expectedReviewId)
|
|
302
556
|
throw domainError('stale_target', 'Review 身份已变化,请刷新');
|
|
303
|
-
const
|
|
557
|
+
const requestedRecovery = request.conflictContinuation;
|
|
558
|
+
if (requestedRecovery && (requestedRecovery.kind !== 'worktree_apply_conflict'
|
|
559
|
+
|| !safeRecoveryRequestId(requestedRecovery.requestId)
|
|
560
|
+
|| requestedRecovery.checkoutId !== request.checkoutId
|
|
561
|
+
|| requestedRecovery.reviewId !== request.expectedReviewId
|
|
562
|
+
|| requestedRecovery.revision !== request.expectedRevision
|
|
563
|
+
|| !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(requestedRecovery.localHeadOid)
|
|
564
|
+
|| requestedRecovery.conflictingFiles.length > 500
|
|
565
|
+
|| !requestedRecovery.conflictingFiles.every(safeConflictFile)))
|
|
566
|
+
throw domainError('invalid_input', '冲突恢复请求身份无效');
|
|
567
|
+
const recovery = requestedRecovery ? {
|
|
568
|
+
kind: 'worktree_apply_conflict',
|
|
569
|
+
requestId: `conflict-recovery:${randomUUID()}`,
|
|
570
|
+
reviewId: request.expectedReviewId,
|
|
571
|
+
readyRevision: request.expectedRevision,
|
|
572
|
+
localHeadOid: requestedRecovery.localHeadOid,
|
|
573
|
+
conflictingFiles: [...requestedRecovery.conflictingFiles],
|
|
574
|
+
} : undefined;
|
|
575
|
+
const target = recovery
|
|
576
|
+
? await options.module.resumeRevision(request.sessionId, request.expectedRevision, request.expectedReviewId, recovery)
|
|
577
|
+
: await options.module.resumeRevision(request.sessionId, request.expectedRevision, request.expectedReviewId);
|
|
304
578
|
if (target.checkout.id !== request.checkoutId || target.delivery?.state !== 'working') {
|
|
305
579
|
throw domainError('checkout_mismatch', '恢复编辑后 Worktree 身份或状态不一致');
|
|
306
580
|
}
|
|
307
|
-
|
|
581
|
+
const response = await mutationResponse(request.sessionId, request.checkoutId);
|
|
582
|
+
if (!requestedRecovery)
|
|
583
|
+
return response;
|
|
584
|
+
const current = recordOf(options.registry, request.checkoutId).recoveryContinuation;
|
|
585
|
+
if (!current
|
|
586
|
+
|| current.kind !== 'worktree_apply_conflict'
|
|
587
|
+
|| current.requestId !== recovery.requestId
|
|
588
|
+
|| current.workingRevision !== response.target.revision)
|
|
589
|
+
throw domainError('checkout_mismatch', 'Host 未能持久化精确冲突恢复凭证');
|
|
590
|
+
return {
|
|
591
|
+
...response,
|
|
592
|
+
recoveryContinuation: {
|
|
593
|
+
kind: current.kind,
|
|
594
|
+
requestId: current.requestId,
|
|
595
|
+
checkoutId: request.checkoutId,
|
|
596
|
+
reviewId: current.reviewId,
|
|
597
|
+
revision: current.workingRevision,
|
|
598
|
+
localHeadOid: current.localHeadOid,
|
|
599
|
+
conflictingFiles: [...current.conflictingFiles],
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}),
|
|
603
|
+
prepareReviewRegeneration: request => outcome(async () => {
|
|
604
|
+
const record = await authorize(request.sessionId, request.checkoutId);
|
|
605
|
+
if (record.ownerSessionId !== request.sessionId)
|
|
606
|
+
throw domainError('not_owner', '只有 owner Isolated Session 可以请求重新生成验收结果');
|
|
607
|
+
if (record.revision !== request.expectedRevision)
|
|
608
|
+
throw domainError('stale_target', 'Session Target 已变化,请刷新');
|
|
609
|
+
const review = readyReview(record);
|
|
610
|
+
if (review.reviewId !== request.expectedReviewId)
|
|
611
|
+
throw domainError('stale_target', 'Review 身份已变化,请刷新');
|
|
612
|
+
const prepared = await options.module.prepareReviewRegeneration(request.sessionId, request.expectedRevision, request.expectedReviewId, `review-regeneration:${randomUUID()}`);
|
|
613
|
+
const response = await mutationResponse(request.sessionId, request.checkoutId);
|
|
614
|
+
return {
|
|
615
|
+
...response,
|
|
616
|
+
recoveryContinuation: {
|
|
617
|
+
kind: prepared.kind,
|
|
618
|
+
requestId: prepared.requestId,
|
|
619
|
+
checkoutId: request.checkoutId,
|
|
620
|
+
reviewId: prepared.reviewId,
|
|
621
|
+
revision: prepared.revision,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
308
624
|
}),
|
|
309
625
|
rollbackPreview: request => outcome(async () => {
|
|
310
626
|
const record = await authorize(request.sessionId, request.checkoutId);
|
|
@@ -315,6 +631,7 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
315
631
|
sessionId: request.sessionId,
|
|
316
632
|
expectedRevision: request.expectedRevision,
|
|
317
633
|
...(request.resumeRevision === undefined ? {} : { resumeRevision: request.resumeRevision }),
|
|
634
|
+
...(request.recoveryProof === undefined ? {} : { recoveryProof: request.recoveryProof }),
|
|
318
635
|
});
|
|
319
636
|
if (result.status === 'error')
|
|
320
637
|
throw domainError(result.code, result.message);
|
|
@@ -380,14 +697,15 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
380
697
|
});
|
|
381
698
|
if (result.status === 'error')
|
|
382
699
|
return failure(result.code, result.message);
|
|
383
|
-
if (result.status === 'conflict')
|
|
384
|
-
return failure('apply_conflict', 'Local 应用发生冲突');
|
|
700
|
+
if (result.status === 'conflict') {
|
|
701
|
+
return failure('apply_conflict', 'Local 应用发生冲突', undefined, applyConflictContinuation(result, review.reviewId));
|
|
702
|
+
}
|
|
385
703
|
if (result.status !== 'finished')
|
|
386
704
|
return failure('operation_not_allowed', 'Finalize 返回了非预期状态');
|
|
387
705
|
return {
|
|
388
706
|
ok: true,
|
|
389
707
|
value: {
|
|
390
|
-
...(await mutationResponse(request.sessionId, request.checkoutId)),
|
|
708
|
+
...(await mutationResponse(request.sessionId, request.checkoutId, deliveryProofFromTarget(result.target))),
|
|
391
709
|
changedFiles: [...result.changedFiles],
|
|
392
710
|
commitOid: result.commitOid,
|
|
393
711
|
},
|
|
@@ -416,6 +734,7 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
416
734
|
expectedRevision: request.expectedRevision,
|
|
417
735
|
commitMessage,
|
|
418
736
|
retention: request.retention,
|
|
737
|
+
...(request.recoveryProof === undefined ? {} : { recoveryProof: request.recoveryProof }),
|
|
419
738
|
});
|
|
420
739
|
if (result.status === 'error')
|
|
421
740
|
return failure(result.code, result.message);
|
|
@@ -433,7 +752,7 @@ export function createWorktreeConsoleControlPlane(options) {
|
|
|
433
752
|
return {
|
|
434
753
|
ok: true,
|
|
435
754
|
value: {
|
|
436
|
-
...(await mutationResponse(request.sessionId, request.checkoutId)),
|
|
755
|
+
...(await mutationResponse(request.sessionId, request.checkoutId, deliveryProofFromTarget(result.target))),
|
|
437
756
|
changedFiles: [...result.changedFiles],
|
|
438
757
|
commitOid: result.commitOid,
|
|
439
758
|
},
|