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.
Files changed (92) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.en.md +48 -18
  3. package/README.md +49 -18
  4. package/lib/adapters/git.js +8 -0
  5. package/lib/adapters/git.js.map +1 -1
  6. package/lib/adapters/registry.js +107 -2
  7. package/lib/adapters/registry.js.map +1 -1
  8. package/lib/checkpoint.d.ts +3 -0
  9. package/lib/checkpoint.js +29 -0
  10. package/lib/checkpoint.js.map +1 -0
  11. package/lib/client/WorktreeReviewRow.d.ts +1 -1
  12. package/lib/client/WorktreeReviewRow.js +26 -6
  13. package/lib/client/WorktreeReviewRow.js.map +1 -1
  14. package/lib/client/actions.d.ts +49 -4
  15. package/lib/client/actions.js +104 -0
  16. package/lib/client/actions.js.map +1 -1
  17. package/lib/client/console-remote/adapter.js +10 -3
  18. package/lib/client/console-remote/adapter.js.map +1 -1
  19. package/lib/client/review-console/DeliveryProof.d.ts +5 -0
  20. package/lib/client/review-console/DeliveryProof.js +32 -0
  21. package/lib/client/review-console/DeliveryProof.js.map +1 -0
  22. package/lib/client/review-console/PreflightStatus.d.ts +12 -0
  23. package/lib/client/review-console/PreflightStatus.js +31 -0
  24. package/lib/client/review-console/PreflightStatus.js.map +1 -0
  25. package/lib/client/review-console/ReviewActions.d.ts +4 -1
  26. package/lib/client/review-console/ReviewActions.js +657 -31
  27. package/lib/client/review-console/ReviewActions.js.map +1 -1
  28. package/lib/client/review-console/WorktreeReviewPanel.d.ts +3 -1
  29. package/lib/client/review-console/WorktreeReviewPanel.js +35 -5
  30. package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -1
  31. package/lib/client/review-console/preflight-cache.d.ts +21 -0
  32. package/lib/client/review-console/preflight-cache.js +98 -0
  33. package/lib/client/review-console/preflight-cache.js.map +1 -0
  34. package/lib/client/review-console/preview-recovery-cache.d.ts +20 -0
  35. package/lib/client/review-console/preview-recovery-cache.js +103 -0
  36. package/lib/client/review-console/preview-recovery-cache.js.map +1 -0
  37. package/lib/client/review-console/recovery-continuation.d.ts +53 -0
  38. package/lib/client/review-console/recovery-continuation.js +483 -0
  39. package/lib/client/review-console/recovery-continuation.js.map +1 -0
  40. package/lib/client/review-console/review-console.styles.js +36 -4
  41. package/lib/client/review-console/review-console.styles.js.map +1 -1
  42. package/lib/client/target-console/TargetStatusAction.d.ts +4 -2
  43. package/lib/client/target-console/TargetStatusAction.js +196 -23
  44. package/lib/client/target-console/TargetStatusAction.js.map +1 -1
  45. package/lib/client/target-console/WorktreeConsoleView.d.ts +4 -2
  46. package/lib/client/target-console/WorktreeConsoleView.js +33 -73
  47. package/lib/client/target-console/WorktreeConsoleView.js.map +1 -1
  48. package/lib/client/target-console/WorktreeManagerModal.d.ts +13 -0
  49. package/lib/client/target-console/WorktreeManagerModal.js +8 -0
  50. package/lib/client/target-console/WorktreeManagerModal.js.map +1 -0
  51. package/lib/client/target-console/WorktreeReviewStatus.d.ts +3 -1
  52. package/lib/client/target-console/WorktreeReviewStatus.js +35 -15
  53. package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -1
  54. package/lib/client/target-console/index.d.ts +2 -1
  55. package/lib/client/target-console/index.js +4 -3
  56. package/lib/client/target-console/index.js.map +1 -1
  57. package/lib/client/target-console/target-console.styles.js +22 -3
  58. package/lib/client/target-console/target-console.styles.js.map +1 -1
  59. package/lib/client.js +2976 -211
  60. package/lib/console-contract.d.ts +110 -1
  61. package/lib/console-contract.js.map +1 -1
  62. package/lib/console-host/control-plane.d.ts +6 -1
  63. package/lib/console-host/control-plane.js +363 -44
  64. package/lib/console-host/control-plane.js.map +1 -1
  65. package/lib/console-host/errors.d.ts +2 -2
  66. package/lib/console-host/errors.js +21 -5
  67. package/lib/console-host/errors.js.map +1 -1
  68. package/lib/console-host/projection.d.ts +5 -3
  69. package/lib/console-host/projection.js +103 -11
  70. package/lib/console-host/projection.js.map +1 -1
  71. package/lib/console-host/service.d.ts +10 -5
  72. package/lib/console-host/service.js +48 -6
  73. package/lib/console-host/service.js.map +1 -1
  74. package/lib/console-remote/descriptors.js +37 -1
  75. package/lib/console-remote/descriptors.js.map +1 -1
  76. package/lib/console-remote/remote.d.ts +10 -5
  77. package/lib/console-remote/remote.js.map +1 -1
  78. package/lib/console-remote/schemas.d.ts +103 -1
  79. package/lib/console-remote/schemas.js +166 -6
  80. package/lib/console-remote/schemas.js.map +1 -1
  81. package/lib/index.d.ts +13 -2
  82. package/lib/index.js.map +1 -1
  83. package/lib/ports.d.ts +79 -1
  84. package/lib/session-checkout-apply.d.ts +76 -0
  85. package/lib/session-checkout-apply.js +608 -143
  86. package/lib/session-checkout-apply.js.map +1 -1
  87. package/lib/session-checkout-module.js +775 -117
  88. package/lib/session-checkout-module.js.map +1 -1
  89. package/lib/types.d.ts +104 -3
  90. package/lib/types.js.map +1 -1
  91. package/package.json +14 -14
  92. 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' || record.delivery.state !== 'preview_active') {
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 === 'stale_target' ? 'stale_target'
31
- : view.reason === 'project_acceptance_busy' ? 'project_acceptance_busy'
32
- : view.reason === 'not_owner' ? 'not_owner'
33
- : view.reason === 'not_ready_for_review' ? 'operation_not_allowed'
34
- : view.reason === 'checkout_unavailable' ? 'checkout_mismatch'
35
- : 'git_error';
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 authorize(sessionId, checkoutId) {
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 (!cleanedOwnerCwd) {
97
- if (!options.files.exists(project.root)) {
98
- throw domainError('project_mismatch', '当前 Session Workspace 无法证明属于该 Worktree 的原始项目');
99
- }
100
- const workspaceRoot = await options.files.canonicalize(project.root);
101
- if (!sameLocalRoot(workspaceRoot, expectedRoot)) {
102
- throw domainError('project_mismatch', '当前 Session cwd 与 Worktree 授权边界不一致');
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
- capabilities: { ...target.capabilities, preflight: false, preview: false },
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
- async function details(sessionId, checkoutId) {
153
- const record = await authorize(sessionId, checkoutId);
154
- const observed = await observe(record);
155
- return projectReviewSlot(record, projectDetails(record, sessionId, observed.managedRoot, observed.snapshot, observed.dirty, ownerSessionAvailable(record)));
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 mutationResponse(sessionId, checkoutId) {
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 { target: projectReviewSlot(record, projectRecord(record, sessionId, { ownerSessionAvailable: ownerSessionAvailable(record) })) };
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 { target: projectReviewSlot(record, projectRecord(record, sessionId, { ...observed, ownerSessionAvailable: ownerSessionAvailable(record) })) };
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 { target: await details(sessionId, target.checkout.id) };
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
- const active = await options.module.listManagedWorktreesForSession(request.sessionId, {
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.ownerSessionId === request.sessionId || record.sourceSessionId === request.sessionId)
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 target = await options.module.resumeRevision(request.sessionId, request.expectedRevision, request.expectedReviewId);
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
- return mutationResponse(request.sessionId, request.checkoutId);
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
  },