dsh-git-worktree 0.4.0 → 0.7.1

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 (95) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/NOTICE +14 -0
  3. package/README.en.md +74 -266
  4. package/README.md +78 -286
  5. package/cordis.patch.yml +7 -3
  6. package/lib/adapters/git.js +8 -0
  7. package/lib/adapters/git.js.map +1 -1
  8. package/lib/adapters/registry.js +100 -1
  9. package/lib/adapters/registry.js.map +1 -1
  10. package/lib/checkpoint.d.ts +3 -0
  11. package/lib/checkpoint.js +29 -0
  12. package/lib/checkpoint.js.map +1 -0
  13. package/lib/client/WorktreeReviewRow.d.ts +1 -1
  14. package/lib/client/WorktreeReviewRow.js +32 -4
  15. package/lib/client/WorktreeReviewRow.js.map +1 -1
  16. package/lib/client/actions.d.ts +25 -1
  17. package/lib/client/actions.js.map +1 -1
  18. package/lib/client/console-remote/adapter.js +11 -3
  19. package/lib/client/console-remote/adapter.js.map +1 -1
  20. package/lib/client/index.d.ts +1 -1
  21. package/lib/client/index.js +3 -1
  22. package/lib/client/index.js.map +1 -1
  23. package/lib/client/pre-session/controller.js +9 -0
  24. package/lib/client/pre-session/controller.js.map +1 -1
  25. package/lib/client/review-console/PreflightStatus.d.ts +3 -2
  26. package/lib/client/review-console/PreflightStatus.js +3 -3
  27. package/lib/client/review-console/PreflightStatus.js.map +1 -1
  28. package/lib/client/review-console/ReviewActions.js +566 -27
  29. package/lib/client/review-console/ReviewActions.js.map +1 -1
  30. package/lib/client/review-console/WorktreeReviewPanel.js +18 -3
  31. package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -1
  32. package/lib/client/review-console/preflight-cache.d.ts +2 -0
  33. package/lib/client/review-console/preflight-cache.js +18 -5
  34. package/lib/client/review-console/preflight-cache.js.map +1 -1
  35. package/lib/client/review-console/preview-recovery-cache.d.ts +20 -0
  36. package/lib/client/review-console/preview-recovery-cache.js +103 -0
  37. package/lib/client/review-console/preview-recovery-cache.js.map +1 -0
  38. package/lib/client/review-console/recovery-continuation.d.ts +53 -0
  39. package/lib/client/review-console/recovery-continuation.js +483 -0
  40. package/lib/client/review-console/recovery-continuation.js.map +1 -0
  41. package/lib/client/review-console/review-console.styles.js +9 -1
  42. package/lib/client/review-console/review-console.styles.js.map +1 -1
  43. package/lib/client/styles.js +61 -0
  44. package/lib/client/styles.js.map +1 -1
  45. package/lib/client/target-console/TargetStatusAction.js +2 -2
  46. package/lib/client/target-console/TargetStatusAction.js.map +1 -1
  47. package/lib/client/target-console/WorktreeConsoleView.js +1 -1
  48. package/lib/client/target-console/WorktreeConsoleView.js.map +1 -1
  49. package/lib/client/target-console/WorktreeReviewStatus.js +19 -6
  50. package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -1
  51. package/lib/client/workspace-sidebar/index.d.ts +33 -0
  52. package/lib/client/workspace-sidebar/index.js +127 -0
  53. package/lib/client/workspace-sidebar/index.js.map +1 -0
  54. package/lib/client/workspace-sidebar/model.d.ts +37 -0
  55. package/lib/client/workspace-sidebar/model.js +88 -0
  56. package/lib/client/workspace-sidebar/model.js.map +1 -0
  57. package/lib/client.js +4511 -106
  58. package/lib/console-contract.d.ts +124 -1
  59. package/lib/console-contract.js.map +1 -1
  60. package/lib/console-host/control-plane.d.ts +7 -1
  61. package/lib/console-host/control-plane.js +264 -9
  62. package/lib/console-host/control-plane.js.map +1 -1
  63. package/lib/console-host/errors.d.ts +2 -2
  64. package/lib/console-host/errors.js +21 -5
  65. package/lib/console-host/errors.js.map +1 -1
  66. package/lib/console-host/projection.js +60 -1
  67. package/lib/console-host/projection.js.map +1 -1
  68. package/lib/console-host/service.d.ts +11 -5
  69. package/lib/console-host/service.js +54 -6
  70. package/lib/console-host/service.js.map +1 -1
  71. package/lib/console-remote/descriptors.js +40 -3
  72. package/lib/console-remote/descriptors.js.map +1 -1
  73. package/lib/console-remote/remote.d.ts +11 -5
  74. package/lib/console-remote/remote.js.map +1 -1
  75. package/lib/console-remote/schemas.d.ts +104 -1
  76. package/lib/console-remote/schemas.js +161 -5
  77. package/lib/console-remote/schemas.js.map +1 -1
  78. package/lib/index.d.ts +13 -2
  79. package/lib/index.js.map +1 -1
  80. package/lib/ports.d.ts +76 -1
  81. package/lib/session-checkout-apply.d.ts +76 -0
  82. package/lib/session-checkout-apply.js +608 -143
  83. package/lib/session-checkout-apply.js.map +1 -1
  84. package/lib/session-checkout-module.js +759 -124
  85. package/lib/session-checkout-module.js.map +1 -1
  86. package/lib/tools.js +1 -1
  87. package/lib/tools.js.map +1 -1
  88. package/lib/types.d.ts +93 -2
  89. package/lib/types.js.map +1 -1
  90. package/package.json +22 -14
  91. package/scripts/check-publish.mjs +17 -2
  92. package/scripts/check-workspace-sidebar-version.mjs +14 -0
  93. package/scripts/dev-dsh-lib.mjs +1 -1
  94. package/scripts/workspace-sidebar-upstream.d.mts +8 -0
  95. package/scripts/workspace-sidebar-upstream.mjs +160 -0
@@ -1,6 +1,8 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { realpathSync } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
4
  import { SessionCheckoutError } from './index.js';
5
+ import { checkpointGenerationForRecord } from './checkpoint.js';
4
6
  import { createManagedWorktreePathCandidates, createManagedWorktreeRepositoryKey, } from './managed-worktree-path.js';
5
7
  const UNVERSIONED_OID = 'unversioned';
6
8
  const UNVERSIONED_REF = 'WORKING_TREE';
@@ -140,6 +142,26 @@ export function createSessionCheckoutModule(dependencies) {
140
142
  revision: binding.revision,
141
143
  };
142
144
  }
145
+ function projectPreviousReview(review) {
146
+ return {
147
+ reviewId: review.reviewId,
148
+ iteration: review.iteration,
149
+ summary: review.summary,
150
+ suggestedCommitMessage: review.suggestedCommitMessage,
151
+ changedFiles: review.changedFiles.slice(0, 50),
152
+ };
153
+ }
154
+ function projectCheckpoint(checkpoint) {
155
+ return {
156
+ checkpointId: checkpoint.checkpointId,
157
+ sequence: checkpoint.sequence,
158
+ reviewId: checkpoint.reviewId,
159
+ createdAt: checkpoint.createdAt,
160
+ summary: checkpoint.summary,
161
+ validationStatus: checkpoint.validationStatus,
162
+ changedFiles: [...checkpoint.changedFiles],
163
+ };
164
+ }
143
165
  function projectDelivery(record, commitInLocalHistory = null) {
144
166
  const delivery = record.delivery;
145
167
  const projectProof = (proof) => proof
@@ -236,6 +258,8 @@ export function createSessionCheckoutModule(dependencies) {
236
258
  dirty,
237
259
  revision: record?.revision ?? binding.revision,
238
260
  ...(record ? { delivery: projectDelivery(record) } : {}),
261
+ ...((record?.checkpoints?.length ?? 0) > 0 ? { checkpoints: record.checkpoints.map(projectCheckpoint) } : {}),
262
+ ...(record && checkpointGenerationForRecord(record) ? { checkpointGeneration: checkpointGenerationForRecord(record) } : {}),
239
263
  };
240
264
  }
241
265
  async function validateCommittedLocalCheckout(binding, record) {
@@ -322,6 +346,29 @@ export function createSessionCheckoutModule(dependencies) {
322
346
  dependencies.registry.write(registry);
323
347
  return recovered;
324
348
  }
349
+ async function checkpointHeadInvariantHolds(record, managedRoot, headOid) {
350
+ const checkpoints = record.checkpoints ?? [];
351
+ if (checkpoints.length === 0)
352
+ return true;
353
+ const checkpointIds = new Set();
354
+ const reviewIds = new Set();
355
+ for (let index = 0; index < checkpoints.length; index += 1) {
356
+ const checkpoint = checkpoints[index];
357
+ if (checkpoint.sequence !== index + 1
358
+ || checkpointIds.has(checkpoint.checkpointId)
359
+ || reviewIds.has(checkpoint.reviewId))
360
+ return false;
361
+ checkpointIds.add(checkpoint.checkpointId);
362
+ reviewIds.add(checkpoint.reviewId);
363
+ if (await dependencies.git.readInternalArtifact(record.localRoot, record.checkoutId, `checkpoints/${checkpoint.checkpointId}`) !== checkpoint.commitOid)
364
+ return false;
365
+ if (!(await dependencies.git.isAncestor(managedRoot, checkpoint.parentOid, checkpoint.commitOid)))
366
+ return false;
367
+ if (index > 0 && !(await dependencies.git.isAncestor(managedRoot, checkpoints[index - 1].commitOid, checkpoint.parentOid)))
368
+ return false;
369
+ }
370
+ return dependencies.git.isAncestor(managedRoot, checkpoints[checkpoints.length - 1].commitOid, headOid);
371
+ }
325
372
  async function validateManagedCheckoutDetailed(binding, record, requireCreateBase) {
326
373
  if (binding.target.kind !== 'isolated'
327
374
  || binding.target.checkoutId !== record.checkoutId
@@ -356,7 +403,8 @@ export function createSessionCheckoutModule(dependencies) {
356
403
  || !pathsEqual(localSnapshot.commonDir, record.gitCommonDir)
357
404
  || !pathsEqual(resolve(snapshot.root, projectRelativePath), canonicalManagedRoot)
358
405
  || (!requireCreateBase && !pathsEqual(snapshot.gitDir, record.gitDir))
359
- || (requireCreateBase && snapshot.headOid !== record.baseOid))
406
+ || (requireCreateBase && snapshot.headOid !== record.baseOid)
407
+ || (!requireCreateBase && !(await checkpointHeadInvariantHolds(record, canonicalManagedRoot, snapshot.headOid))))
360
408
  return { status: 'invalid' };
361
409
  const status = await dependencies.git.status(canonicalManagedRoot);
362
410
  return {
@@ -582,6 +630,8 @@ export function createSessionCheckoutModule(dependencies) {
582
630
  dirty: status.dirty,
583
631
  revision: record.revision,
584
632
  delivery,
633
+ ...((record.checkpoints?.length ?? 0) > 0 ? { checkpoints: record.checkpoints.map(projectCheckpoint) } : {}),
634
+ ...(checkpointGenerationForRecord(record) ? { checkpointGeneration: checkpointGenerationForRecord(record) } : {}),
585
635
  ...(delivery?.state === 'ready_for_review'
586
636
  ? {
587
637
  reviewSlot: activePreview ? 'waiting' : 'available',
@@ -615,7 +665,11 @@ export function createSessionCheckoutModule(dependencies) {
615
665
  || record.phase === 'recovery_required')
616
666
  .sort((left, right) => managedUpdatedAt(right) - managedUpdatedAt(left))[0];
617
667
  if (!pending) {
618
- return 'Session Target: Local Checkout. No isolated target is active for this Session.';
668
+ return [
669
+ 'Session Target: Local Checkout. No isolated target is active for this Session.',
670
+ 'Complete ordinary Local work in the current cwd and finish with an ordinary assistant reply.',
671
+ 'Do not call Worktree-only tools such as worktree_ready_for_review, worktree_resume_revision, or worktree_begin_next_iteration unless this Session first selects an Isolated Checkout.',
672
+ ].join('\n');
619
673
  }
620
674
  if (pending.delivery.state === 'preview_active') {
621
675
  return [
@@ -651,6 +705,9 @@ export function createSessionCheckoutModule(dependencies) {
651
705
  `Original Local boundary: ${record.localRoot}`,
652
706
  'Make task changes only in the authoritative cwd; never write directly to the Original Local boundary.',
653
707
  ];
708
+ if ((record.checkpoints?.length ?? 0) > 0) {
709
+ lines.push(`Saved Worktree checkpoints: ${record.checkpoints.length}. They remain unpublished to Local.`, 'Final review and commit wording must summarize the cumulative diff from the original delivery base to the final Worktree snapshot. Do not manually reset, rewrite, or publish checkpoint commits.');
710
+ }
654
711
  if (record.delivery.state === 'ready_for_review') {
655
712
  lines.push('Delivery state: Ready for Review, not yet synchronized to Local. Continue ordinary discussion and answer questions without changing this Review. If the user requests new code or file changes, call worktree_resume_revision first, then continue the requested work in this same Session; do not ask the user to click a recovery control and do not synchronize Local. Until that tool succeeds, treat the Worktree as read-only. The user may still preview, directly finish, or discard through the Worktree acceptance UI.');
656
713
  }
@@ -846,78 +903,96 @@ export function createSessionCheckoutModule(dependencies) {
846
903
  }
847
904
  if (current.phase === 'mutating'
848
905
  && (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
849
- && current.journal.step === 'updating_ref'
850
- && typeof current.journal.commitOid === 'string'
851
- && current.delivery.state === 'preview_active') {
852
- const local = await dependencies.git.inspect(current.localRoot);
853
- if (local?.headOid === current.journal.commitOid) {
854
- const retention = current.journal.retention ?? 'cleanup';
855
- const recoveredAt = Date.now();
856
- record = retention === 'cleanup'
857
- ? {
858
- ...current,
859
- phase: 'finalized',
860
- delivery: {
861
- state: 'finalized',
862
- review: current.delivery.review,
863
- commitOid: current.journal.commitOid,
864
- proof: {
865
- localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
866
- ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
867
- : null,
868
- localHeadBefore: current.delivery.preview.localHeadOid,
869
- localHeadAfter: current.journal.commitOid,
870
- changedFiles: [...current.delivery.preview.changedFiles],
871
- validationStatus: current.delivery.review.validationStatus,
872
- ...(current.delivery.review.validationSummary === undefined
873
- ? {}
874
- : { validationSummary: current.delivery.review.validationSummary }),
875
- },
876
- isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
877
- finalizedAt: recoveredAt,
878
- cleanup: 'blocked',
879
- cleanupMessage: 'Commit 已创建,但进程在 Local index 完成前中断;请确认 Local 状态后再处理 Worktree。',
880
- },
881
- journal: null,
882
- revision: current.revision + 1,
906
+ && (current.journal.step === 'updating_ref' || current.journal.step === 'replacing_index')) {
907
+ // HEAD/ref alone cannot prove that the adjacent index replacement and working-tree preservation completed.
908
+ // Preserve delivery, receipt, retained artifacts and journal until an explicit recovery path can verify all facts.
909
+ record = {
910
+ ...current,
911
+ phase: 'recovery_required',
912
+ revision: current.revision + 1,
913
+ };
914
+ registry.managedCheckouts[checkoutId] = record;
915
+ registry.revision += 1;
916
+ recoveryRequiredCheckoutIds.push(checkoutId);
917
+ changed = true;
918
+ }
919
+ else if ((current.phase === 'mutating' || current.phase === 'recovery_required')
920
+ && current.journal?.operation === 'checkpoint'
921
+ && current.delivery.state === 'ready_for_review') {
922
+ const journal = current.journal;
923
+ if (journal.step === 'planning') {
924
+ record = { ...current, phase: 'ready', journal: null, revision: current.revision + 1 };
925
+ }
926
+ else if (journal.step === 'updating_ref'
927
+ && typeof journal.commitOid === 'string'
928
+ && typeof journal.parentOid === 'string'
929
+ && typeof journal.checkpointId === 'string'
930
+ && typeof journal.checkpointSequence === 'number'
931
+ && typeof journal.checkpointRequestId === 'string'
932
+ && typeof journal.checkpointRequestedRevision === 'number'
933
+ && typeof journal.recoveryGeneration === 'string'
934
+ && typeof journal.checkpointMessage === 'string'
935
+ && typeof journal.checkpointIndexTreeOid === 'string'
936
+ && Array.isArray(journal.changedFiles)) {
937
+ const recovered = await dependencies.applyEngine.recoverCheckpoint({
938
+ isolatedPath: current.managedRoot,
939
+ commitOid: journal.commitOid,
940
+ parentOid: journal.parentOid,
941
+ expectedIndexTreeOid: journal.checkpointIndexTreeOid,
942
+ });
943
+ if (recovered.status === 'checkpoint_aborted') {
944
+ try {
945
+ await dependencies.git.releaseInternalArtifacts(current.localRoot, current.checkoutId, `checkpoints/${journal.checkpointId}`);
946
+ }
947
+ catch {
948
+ console.warn('[session-checkout] 清理未生效 Checkpoint ref 失败,已保守保留不可见引用');
949
+ }
950
+ record = { ...current, phase: 'ready', journal: null, revision: current.revision + 1 };
951
+ }
952
+ else if (recovered.status === 'checkpoint_recovered') {
953
+ const retained = await dependencies.git.readInternalArtifact(current.localRoot, current.checkoutId, `checkpoints/${journal.checkpointId}`);
954
+ if (retained !== journal.commitOid) {
955
+ record = { ...current, phase: 'recovery_required', revision: current.revision + 1 };
956
+ recoveryRequiredCheckoutIds.push(checkoutId);
957
+ }
958
+ else {
959
+ const review = current.delivery.review;
960
+ const checkpoint = {
961
+ checkpointId: journal.checkpointId,
962
+ sequence: journal.checkpointSequence,
963
+ reviewId: review.reviewId,
964
+ requestId: journal.checkpointRequestId,
965
+ requestedRevision: journal.checkpointRequestedRevision,
966
+ generation: journal.recoveryGeneration,
967
+ iteration: review.iteration,
968
+ createdAt: journal.startedAt,
969
+ commitOid: journal.commitOid,
970
+ parentOid: journal.parentOid,
971
+ summary: review.summary,
972
+ commitMessage: journal.checkpointMessage,
973
+ validationStatus: review.validationStatus,
974
+ changedFiles: [...journal.changedFiles],
975
+ };
976
+ record = {
977
+ ...current,
978
+ phase: 'ready',
979
+ previousReview: projectPreviousReview(review),
980
+ checkpoints: [...(current.checkpoints ?? []), checkpoint],
981
+ delivery: { state: 'working', iteration: review.iteration },
982
+ recoveryContinuation: undefined,
983
+ journal: null,
984
+ revision: current.revision + 1,
985
+ };
883
986
  }
884
- : {
885
- ...current,
886
- phase: 'retained',
887
- delivery: {
888
- state: 'retained',
889
- review: current.delivery.review,
890
- commitOid: current.journal.commitOid,
891
- proof: {
892
- localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
893
- ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
894
- : null,
895
- localHeadBefore: current.delivery.preview.localHeadOid,
896
- localHeadAfter: current.journal.commitOid,
897
- changedFiles: [...current.delivery.preview.changedFiles],
898
- validationStatus: current.delivery.review.validationStatus,
899
- ...(current.delivery.review.validationSummary === undefined
900
- ? {}
901
- : { validationSummary: current.delivery.review.validationSummary }),
902
- },
903
- isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
904
- retention,
905
- retainedAt: recoveredAt,
906
- expiresAt: retentionExpiresAt(retention, recoveredAt),
907
- cleanup: 'blocked',
908
- cleanupMessage: 'Commit 已创建并保留 Worktree,但进程在 Local index 完成前中断;请确认 Local 状态。',
909
- },
910
- journal: null,
911
- revision: current.revision + 1,
912
- };
987
+ }
988
+ else {
989
+ record = { ...current, phase: 'recovery_required', revision: current.revision + 1 };
990
+ recoveryRequiredCheckoutIds.push(checkoutId);
991
+ }
913
992
  }
914
993
  else {
915
- record = {
916
- ...current,
917
- phase: 'ready',
918
- journal: null,
919
- revision: current.revision + 1,
920
- };
994
+ record = { ...current, phase: 'recovery_required', revision: current.revision + 1 };
995
+ recoveryRequiredCheckoutIds.push(checkoutId);
921
996
  }
922
997
  registry.managedCheckouts[checkoutId] = record;
923
998
  registry.revision += 1;
@@ -1058,38 +1133,271 @@ export function createSessionCheckoutModule(dependencies) {
1058
1133
  throw new SessionCheckoutError(snapshot.error.code, snapshot.error.message);
1059
1134
  }
1060
1135
  const reviewId = dependencies.createCheckoutId();
1061
- updateManagedCheckout(record.checkoutId, (current) => ({
1062
- ...current,
1063
- delivery: {
1064
- state: 'ready_for_review',
1065
- review: {
1066
- reviewId,
1067
- iteration: current.delivery.state === 'working'
1068
- ? current.delivery.iteration
1069
- : current.delivery.state === 'delivered'
1070
- ? current.delivery.iteration + 1
1071
- : current.delivery.review.iteration,
1072
- preparedAt: Date.now(),
1073
- ...(input.detailsMarkdown?.trim() ? { detailsMarkdown: input.detailsMarkdown.trim() } : {}),
1074
- summary,
1075
- validationStatus: input.validationStatus,
1076
- ...(input.validationSummary?.trim() ? { validationSummary: input.validationSummary.trim() } : {}),
1077
- tests: input.tests.map((test) => ({
1078
- command: test.command.trim(),
1079
- status: test.status,
1080
- ...(test.summary?.trim() ? { summary: test.summary.trim() } : {}),
1081
- })),
1082
- changedFiles: [...snapshot.changedFiles],
1083
- suggestedCommitMessage,
1084
- isolatedFingerprint: snapshot.isolatedFingerprint,
1085
- isolatedHeadOid: snapshot.isolatedHeadOid,
1136
+ updateManagedCheckout(record.checkoutId, (current) => {
1137
+ const { recoveryContinuation: _recovery, ...withoutRecovery } = current;
1138
+ return {
1139
+ ...withoutRecovery,
1140
+ ...(current.delivery.state !== 'working' && current.delivery.state !== 'delivered'
1141
+ ? { previousReview: projectPreviousReview(current.delivery.review) }
1142
+ : {}),
1143
+ delivery: {
1144
+ state: 'ready_for_review',
1145
+ review: {
1146
+ reviewId,
1147
+ iteration: current.delivery.state === 'working'
1148
+ ? current.delivery.iteration
1149
+ : current.delivery.state === 'delivered'
1150
+ ? current.delivery.iteration + 1
1151
+ : current.delivery.review.iteration,
1152
+ preparedAt: Date.now(),
1153
+ ...(input.detailsMarkdown?.trim() ? { detailsMarkdown: input.detailsMarkdown.trim() } : {}),
1154
+ summary,
1155
+ validationStatus: input.validationStatus,
1156
+ ...(input.validationSummary?.trim() ? { validationSummary: input.validationSummary.trim() } : {}),
1157
+ tests: input.tests.map((test) => ({
1158
+ command: test.command.trim(),
1159
+ status: test.status,
1160
+ ...(test.summary?.trim() ? { summary: test.summary.trim() } : {}),
1161
+ })),
1162
+ changedFiles: [...snapshot.changedFiles],
1163
+ suggestedCommitMessage,
1164
+ isolatedFingerprint: snapshot.isolatedFingerprint,
1165
+ isolatedHeadOid: snapshot.isolatedHeadOid,
1166
+ },
1167
+ },
1168
+ revision: current.revision + 1,
1169
+ };
1170
+ });
1171
+ return inspectIsolated(binding);
1172
+ }
1173
+ async function operateCheckpoint(input, binding) {
1174
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1175
+ return operationError('not_owner', '只有 owner Isolated 会话可以保存阶段');
1176
+ }
1177
+ const commitMessage = input.commitMessage.trim();
1178
+ if (!commitMessage
1179
+ || commitMessage.length > 500
1180
+ || !input.expectedReviewId.trim()
1181
+ || !/^[0-9a-f]{64}$/u.test(input.expectedGeneration)
1182
+ || !input.requestId.trim()
1183
+ || input.requestId.length > 200
1184
+ || /[\0\r\n]/u.test(input.requestId))
1185
+ return operationError('invalid_input', 'Checkpoint 请求或 Commit Message 无效');
1186
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1187
+ if (!record)
1188
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1189
+ const previousRequest = record.checkpoints?.find(checkpoint => checkpoint.requestId === input.requestId);
1190
+ if (previousRequest) {
1191
+ const exactReplay = record.delivery.state === 'working'
1192
+ && record.checkpoints?.at(-1)?.checkpointId === previousRequest.checkpointId
1193
+ && previousRequest.reviewId === input.expectedReviewId
1194
+ && previousRequest.requestedRevision === input.expectedRevision
1195
+ && previousRequest.generation === input.expectedGeneration
1196
+ && previousRequest.commitMessage === commitMessage;
1197
+ if (!exactReplay)
1198
+ return operationError('stale_target', 'Checkpoint requestId 已被其他状态使用,请刷新');
1199
+ return {
1200
+ status: 'checkpointed',
1201
+ target: await inspectIsolated(binding),
1202
+ checkpoint: projectCheckpoint(previousRequest),
1203
+ changedFiles: [...previousRequest.changedFiles],
1204
+ };
1205
+ }
1206
+ if (record.revision !== input.expectedRevision) {
1207
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1208
+ }
1209
+ if (record.phase !== 'ready' || (record.delivery.state !== 'ready_for_review' && record.delivery.state !== 'preview_active')) {
1210
+ return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能保存阶段`, await inspectIsolated(binding));
1211
+ }
1212
+ if (record.delivery.review.reviewId !== input.expectedReviewId || checkpointGenerationForRecord(record) !== input.expectedGeneration) {
1213
+ return operationError('stale_target', 'Checkpoint Review 或 generation 已变化,请刷新后重试', await inspectIsolated(binding));
1214
+ }
1215
+ const holder = findProjectAcceptanceHolder(record);
1216
+ if (holder) {
1217
+ return operationError('project_acceptance_busy', '另一个任务正在占用该项目的 Local 验收槽位', await inspectIsolated(binding));
1218
+ }
1219
+ const inspected = await inspectIsolated(binding);
1220
+ if (inspected.checkout.phase !== 'ready') {
1221
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
1222
+ }
1223
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1224
+ if (!record
1225
+ || record.revision !== input.expectedRevision
1226
+ || record.phase !== 'ready'
1227
+ || (record.delivery.state !== 'ready_for_review' && record.delivery.state !== 'preview_active')
1228
+ || record.delivery.review.reviewId !== input.expectedReviewId
1229
+ || checkpointGenerationForRecord(record) !== input.expectedGeneration)
1230
+ return operationError('stale_target', 'Checkpoint 状态在 Host CAS 前发生变化,请刷新后重试');
1231
+ if (record.delivery.state === 'preview_active') {
1232
+ const { preview, review } = record.delivery;
1233
+ const rollbackOperationId = dependencies.createCheckoutId();
1234
+ updateManagedCheckout(record.checkoutId, (current) => ({
1235
+ ...current,
1236
+ phase: 'mutating',
1237
+ journal: {
1238
+ operation: 'rollback_preview',
1239
+ operationId: rollbackOperationId,
1240
+ step: 'planning',
1241
+ startedAt: Date.now(),
1242
+ previewId: preview.previewId,
1243
+ reviewId: review.reviewId,
1244
+ resumeRevision: false,
1245
+ },
1246
+ revision: current.revision + 1,
1247
+ }));
1248
+ const rollback = await dependencies.applyEngine.rollback({
1249
+ localPath: record.localRoot,
1250
+ receipt: preview,
1251
+ beforeWrite: async () => {
1252
+ updateManagedCheckout(record.checkoutId, (current) => ({
1253
+ ...current,
1254
+ journal: current.journal?.operation === 'rollback_preview'
1255
+ ? { ...current.journal, step: 'writing_local' }
1256
+ : current.journal,
1257
+ revision: current.revision + 1,
1258
+ }));
1086
1259
  },
1260
+ });
1261
+ if (rollback.status === 'error') {
1262
+ const current = dependencies.registry.read().managedCheckouts[record.checkoutId];
1263
+ const writeStarted = current?.journal?.operation === 'rollback_preview' && current.journal.step === 'writing_local';
1264
+ if (rollback.error.code === 'stale_local' || rollback.error.code === 'preview_modified') {
1265
+ return detachPreviewAfterLocalDrift(record, binding, rollback.error.code, 'rollback_preview');
1266
+ }
1267
+ if (!writeStarted || (rollback.error.code === 'git_error' && rollback.error.recoveryState === 'unchanged')) {
1268
+ updateManagedCheckout(record.checkoutId, (currentRecord) => ({ ...currentRecord, phase: 'ready', journal: null, revision: currentRecord.revision + 1 }));
1269
+ }
1270
+ else {
1271
+ updateManagedCheckout(record.checkoutId, (currentRecord) => ({ ...currentRecord, phase: 'recovery_required', revision: currentRecord.revision + 1 }));
1272
+ }
1273
+ return operationError(rollback.error.code, rollback.error.message, await inspectIsolated(binding));
1274
+ }
1275
+ const restored = updateManagedCheckout(record.checkoutId, (current) => ({
1276
+ ...current,
1277
+ phase: 'ready',
1278
+ delivery: { state: 'ready_for_review', review },
1279
+ journal: null,
1280
+ revision: current.revision + 1,
1281
+ }));
1282
+ if (!restored)
1283
+ return operationError('checkout_missing', 'Preview 已撤回,但 Checkout 记录丢失');
1284
+ await releasePreviewArtifactsBestEffort(record, preview.previewId);
1285
+ record = restored;
1286
+ }
1287
+ if (record.delivery.state !== 'ready_for_review') {
1288
+ return operationError('operation_not_allowed', '当前没有可保存的验收阶段', await inspectIsolated(binding));
1289
+ }
1290
+ const review = record.delivery.review;
1291
+ const checkpointId = dependencies.createCheckoutId();
1292
+ const checkpointSequence = (record.checkpoints?.length ?? 0) + 1;
1293
+ const operationId = dependencies.createCheckoutId();
1294
+ const startedAt = Date.now();
1295
+ const mutating = updateManagedCheckout(record.checkoutId, (current) => ({
1296
+ ...current,
1297
+ phase: 'mutating',
1298
+ journal: {
1299
+ operation: 'checkpoint',
1300
+ operationId,
1301
+ step: 'planning',
1302
+ startedAt,
1303
+ reviewId: review.reviewId,
1304
+ checkpointId,
1305
+ checkpointSequence,
1306
+ checkpointRequestId: input.requestId,
1307
+ checkpointRequestedRevision: input.expectedRevision,
1308
+ checkpointMessage: commitMessage,
1309
+ recoveryGeneration: input.expectedGeneration,
1310
+ isolatedFingerprint: review.isolatedFingerprint,
1311
+ isolatedHeadOid: review.isolatedHeadOid,
1087
1312
  },
1088
1313
  revision: current.revision + 1,
1089
1314
  }));
1090
- return inspectIsolated(binding);
1315
+ if (!mutating)
1316
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1317
+ const result = await dependencies.applyEngine.checkpoint({
1318
+ isolatedPath: mutating.managedRoot,
1319
+ expectedFingerprint: review.isolatedFingerprint,
1320
+ expectedHeadOid: review.isolatedHeadOid,
1321
+ commitMessage,
1322
+ beforeCommit: async (prepared) => {
1323
+ const journaled = updateManagedCheckout(mutating.checkoutId, (current) => ({
1324
+ ...current,
1325
+ journal: current.journal?.operation === 'checkpoint'
1326
+ ? {
1327
+ ...current.journal,
1328
+ step: 'updating_ref',
1329
+ commitOid: prepared.commitOid,
1330
+ parentOid: prepared.parentOid,
1331
+ checkpointIndexTreeOid: prepared.indexTreeOid,
1332
+ changedFiles: [...prepared.changedFiles],
1333
+ }
1334
+ : current.journal,
1335
+ revision: current.revision + 1,
1336
+ }));
1337
+ if (!journaled?.journal || journaled.journal.operation !== 'checkpoint' || journaled.journal.commitOid !== prepared.commitOid) {
1338
+ throw new SessionCheckoutError('stale_target', 'Checkpoint journal 在保留内部 ref 前发生变化');
1339
+ }
1340
+ await dependencies.git.retainInternalArtifact(mutating.localRoot, mutating.checkoutId, `checkpoints/${checkpointId}`, prepared.commitOid);
1341
+ },
1342
+ });
1343
+ if (result.status === 'error') {
1344
+ const current = dependencies.registry.read().managedCheckouts[mutating.checkoutId];
1345
+ const commitPrepared = current?.journal?.operation === 'checkpoint' && typeof current.journal.commitOid === 'string';
1346
+ if (!commitPrepared) {
1347
+ updateManagedCheckout(mutating.checkoutId, (checkout) => ({
1348
+ ...checkout,
1349
+ phase: 'ready',
1350
+ ...(result.error.code === 'stale_isolated' ? { previousReview: projectPreviousReview(review) } : {}),
1351
+ delivery: result.error.code === 'stale_isolated'
1352
+ ? { state: 'working', iteration: review.iteration }
1353
+ : { state: 'ready_for_review', review },
1354
+ journal: null,
1355
+ revision: checkout.revision + 1,
1356
+ }));
1357
+ }
1358
+ else {
1359
+ updateManagedCheckout(mutating.checkoutId, (checkout) => ({ ...checkout, phase: 'recovery_required', revision: checkout.revision + 1 }));
1360
+ }
1361
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding, false));
1362
+ }
1363
+ const checkpoint = {
1364
+ checkpointId,
1365
+ sequence: checkpointSequence,
1366
+ reviewId: review.reviewId,
1367
+ requestId: input.requestId,
1368
+ requestedRevision: input.expectedRevision,
1369
+ generation: input.expectedGeneration,
1370
+ iteration: review.iteration,
1371
+ createdAt: startedAt,
1372
+ commitOid: result.commitOid,
1373
+ parentOid: result.parentOid,
1374
+ summary: review.summary,
1375
+ commitMessage,
1376
+ validationStatus: review.validationStatus,
1377
+ changedFiles: [...result.changedFiles],
1378
+ };
1379
+ const completed = updateManagedCheckout(mutating.checkoutId, (current) => {
1380
+ const { recoveryContinuation: _continuation, ...withoutContinuation } = current;
1381
+ return {
1382
+ ...withoutContinuation,
1383
+ phase: 'ready',
1384
+ previousReview: projectPreviousReview(review),
1385
+ checkpoints: [...(current.checkpoints ?? []), checkpoint],
1386
+ delivery: { state: 'working', iteration: review.iteration },
1387
+ journal: null,
1388
+ revision: current.revision + 1,
1389
+ };
1390
+ });
1391
+ if (!completed)
1392
+ return operationError('checkout_missing', 'Checkpoint 已创建,但 Checkout 记录丢失');
1393
+ return {
1394
+ status: 'checkpointed',
1395
+ target: await inspectIsolated(binding),
1396
+ checkpoint: projectCheckpoint(checkpoint),
1397
+ changedFiles: [...checkpoint.changedFiles],
1398
+ };
1091
1399
  }
1092
- async function resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId) {
1400
+ async function resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId, recovery) {
1093
1401
  const binding = await resolveBinding(sessionId);
1094
1402
  if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
1095
1403
  throw new SessionCheckoutError('not_owner', '只有 owner Isolated 会话可以恢复编辑');
@@ -1118,14 +1426,131 @@ export function createSessionCheckoutModule(dependencies) {
1118
1426
  || record.delivery.review.reviewId !== expectedReviewId) {
1119
1427
  throw new SessionCheckoutError('stale_target', '验收状态在恢复编辑前发生变化,请刷新后重试');
1120
1428
  }
1121
- const iteration = record.delivery.review.iteration;
1122
- updateManagedCheckout(record.checkoutId, (current) => ({
1123
- ...current,
1124
- delivery: { state: 'working', iteration },
1125
- revision: current.revision + 1,
1126
- }));
1429
+ if (recovery) {
1430
+ const preflight = await preflightTarget(sessionId, expectedRevision);
1431
+ const sameFiles = preflight.status === 'conflict'
1432
+ && preflight.conflictingFiles.length === recovery.conflictingFiles.length
1433
+ && preflight.conflictingFiles.every((file, index) => file === recovery.conflictingFiles[index]);
1434
+ if (recovery.kind !== 'worktree_apply_conflict'
1435
+ || recovery.reviewId !== expectedReviewId
1436
+ || recovery.readyRevision !== expectedRevision
1437
+ || preflight.status !== 'conflict'
1438
+ || preflight.localHeadOid !== recovery.localHeadOid
1439
+ || !sameFiles)
1440
+ throw new SessionCheckoutError('stale_target', '冲突恢复身份在 Host CAS 前已变化,请重新预检');
1441
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1442
+ if (!record
1443
+ || record.revision !== expectedRevision
1444
+ || record.delivery.state !== 'ready_for_review'
1445
+ || record.delivery.review.reviewId !== expectedReviewId)
1446
+ throw new SessionCheckoutError('stale_target', '验收状态在冲突恢复前发生变化,请刷新后重试');
1447
+ }
1448
+ const reviewBeforeResume = record.delivery.review;
1449
+ const iteration = reviewBeforeResume.iteration;
1450
+ updateManagedCheckout(record.checkoutId, (current) => {
1451
+ const { recoveryContinuation: _previousRecovery, ...withoutRecovery } = current;
1452
+ return {
1453
+ ...withoutRecovery,
1454
+ previousReview: projectPreviousReview(reviewBeforeResume),
1455
+ ...(recovery ? {
1456
+ recoveryContinuation: { ...recovery, workingRevision: current.revision + 1 },
1457
+ } : {}),
1458
+ delivery: { state: 'working', iteration },
1459
+ revision: current.revision + 1,
1460
+ };
1461
+ });
1127
1462
  return inspectIsolated(binding);
1128
1463
  }
1464
+ async function prepareReviewRegenerationTarget(sessionId, expectedRevision, expectedReviewId, requestId) {
1465
+ const binding = await resolveBinding(sessionId);
1466
+ if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
1467
+ throw new SessionCheckoutError('not_owner', '只有 owner Isolated 会话可以请求重新生成验收结果');
1468
+ }
1469
+ const preflight = await preflightTarget(sessionId, expectedRevision);
1470
+ if (preflight.status !== 'blocked'
1471
+ || preflight.reason !== 'stale_isolated'
1472
+ || preflight.checkoutId !== binding.target.checkoutId
1473
+ || preflight.reviewId !== expectedReviewId
1474
+ || preflight.revision !== expectedRevision)
1475
+ throw new SessionCheckoutError('stale_target', '只读验收再生成身份在 Host 授权前已变化');
1476
+ const continuation = {
1477
+ kind: 'worktree_review_regeneration', requestId, reviewId: expectedReviewId, revision: expectedRevision,
1478
+ };
1479
+ const updated = updateManagedCheckout(binding.target.checkoutId, (current) => {
1480
+ if (current.revision !== expectedRevision
1481
+ || current.delivery.state !== 'ready_for_review'
1482
+ || current.delivery.review.reviewId !== expectedReviewId)
1483
+ throw new SessionCheckoutError('stale_target', 'Ready Review 在只读授权前发生变化');
1484
+ return { ...current, recoveryContinuation: continuation };
1485
+ });
1486
+ if (!updated)
1487
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
1488
+ return continuation;
1489
+ }
1490
+ async function preparePreviewRecoveryAnalysisTarget(sessionId, proof, requestId) {
1491
+ const recovery = await preflightPreviewRecoveryTarget(sessionId, proof.revision, proof.reviewId, proof.previewId);
1492
+ if (recovery.status !== 'assessed' || !recoveryProofMatches(proof, recovery.proof)) {
1493
+ throw new SessionCheckoutError('stale_target', 'Detached Preview Recovery proof 已变化,请重新检查');
1494
+ }
1495
+ const continuation = {
1496
+ kind: 'worktree_preview_recovery_analysis',
1497
+ requestId,
1498
+ reviewId: proof.reviewId,
1499
+ previewId: proof.previewId,
1500
+ revision: proof.revision,
1501
+ generation: proof.generation,
1502
+ };
1503
+ const updated = updateManagedCheckout(proof.checkoutId, (current) => {
1504
+ if (current.ownerSessionId !== sessionId
1505
+ || current.revision !== proof.revision
1506
+ || current.delivery.state !== 'preview_detached'
1507
+ || current.delivery.review.reviewId !== proof.reviewId
1508
+ || current.delivery.preview.previewId !== proof.previewId)
1509
+ throw new SessionCheckoutError('stale_target', 'Detached Preview 在分析授权前发生变化');
1510
+ return { ...current, recoveryContinuation: continuation };
1511
+ });
1512
+ if (!updated)
1513
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
1514
+ return continuation;
1515
+ }
1516
+ async function createPreviewRecoveryHandoffTarget(sessionId, proof, targetSessionId, requestId) {
1517
+ if (!targetSessionId || targetSessionId === sessionId) {
1518
+ throw new SessionCheckoutError('invalid_input', 'Recovery handoff 必须使用新的预分配 Session ID');
1519
+ }
1520
+ if (dependencies.lookup.getSession(targetSessionId) || getPersistedBinding(targetSessionId)) {
1521
+ throw new SessionCheckoutError('target_already_bound', 'Recovery handoff Session ID 已被占用');
1522
+ }
1523
+ const recovery = await preflightPreviewRecoveryTarget(sessionId, proof.revision, proof.reviewId, proof.previewId);
1524
+ if (recovery.status !== 'assessed' || !recoveryProofMatches(proof, recovery.proof)) {
1525
+ throw new SessionCheckoutError('stale_target', 'Detached Preview Recovery proof 已变化,请重新检查');
1526
+ }
1527
+ const source = dependencies.registry.read().managedCheckouts[proof.checkoutId];
1528
+ if (!source || source.ownerSessionId !== sessionId || source.delivery.state !== 'preview_detached') {
1529
+ throw new SessionCheckoutError('stale_target', '旧 Detached Preview 身份已变化');
1530
+ }
1531
+ const originSessionId = source.sourceSessionId ?? source.ownerSessionId;
1532
+ if (!dependencies.lookup.getSession(originSessionId)) {
1533
+ throw new SessionCheckoutError('session_not_found', '原始 source Session 不可用,未创建 handoff Worktree');
1534
+ }
1535
+ const target = await bindTarget(targetSessionId, { kind: 'isolated' }, 0, Date.now(), originSessionId);
1536
+ const binding = getPersistedBinding(targetSessionId);
1537
+ const created = binding?.target.kind === 'isolated'
1538
+ ? dependencies.registry.read().managedCheckouts[binding.target.checkoutId]
1539
+ : undefined;
1540
+ if (!created)
1541
+ throw new SessionCheckoutError('checkout_missing', 'Recovery handoff Worktree 创建后记录缺失');
1542
+ const continuation = {
1543
+ kind: 'worktree_preview_recovery_handoff',
1544
+ requestId,
1545
+ sourceCheckoutId: source.checkoutId,
1546
+ reviewId: proof.reviewId,
1547
+ previewId: proof.previewId,
1548
+ sourceRevision: proof.revision,
1549
+ generation: proof.generation,
1550
+ };
1551
+ updateManagedCheckout(created.checkoutId, (current) => ({ ...current, recoveryContinuation: continuation }));
1552
+ return { targetSessionId, managedRoot: created.managedRoot, target, continuation };
1553
+ }
1129
1554
  async function operateApply(input, binding) {
1130
1555
  if (binding.ownerSessionId !== input.sessionId) {
1131
1556
  return operationError('not_owner', '继承 Session Target 的会话不能执行 Apply');
@@ -1215,7 +1640,7 @@ export function createSessionCheckoutModule(dependencies) {
1215
1640
  reviewId: operationId,
1216
1641
  iteration,
1217
1642
  preparedAt: startedAt,
1218
- summary: 'Worktree 修改已通过 ApplyWorktree 同步到 Local 验收',
1643
+ summary: 'Worktree 修改已通过 ApplyWorktree 写入 Local Preview',
1219
1644
  validationStatus: 'not_run',
1220
1645
  tests: [],
1221
1646
  changedFiles: [...planResult.plan.changedFiles],
@@ -1290,6 +1715,137 @@ export function createSessionCheckoutModule(dependencies) {
1290
1715
  console.warn('[session-checkout] 清理 Preview refs 失败,已保守保留不可见引用');
1291
1716
  }
1292
1717
  }
1718
+ async function previewArtifactsMatch(record, receipt) {
1719
+ const prefix = `previews/${receipt.previewId}`;
1720
+ const expected = [
1721
+ [`${prefix}/local-working`, receipt.localWorkingTreeOid],
1722
+ [`${prefix}/local-index`, receipt.localIndexTreeOid],
1723
+ [`${prefix}/preview-working`, receipt.previewWorkingTreeOid],
1724
+ [`${prefix}/isolated-snapshot`, receipt.isolatedSnapshotOid],
1725
+ ];
1726
+ for (const [name, oid] of expected) {
1727
+ if (await dependencies.git.readInternalArtifact(record.localRoot, record.checkoutId, name) !== oid)
1728
+ return false;
1729
+ }
1730
+ return true;
1731
+ }
1732
+ function blockedPreviewRecovery(record, reason, message) {
1733
+ const delivery = record?.delivery;
1734
+ const detached = delivery?.state === 'preview_detached' ? delivery : undefined;
1735
+ return {
1736
+ status: 'blocked',
1737
+ localModified: false,
1738
+ checkoutId: record?.checkoutId ?? '',
1739
+ reviewId: detached?.review.reviewId ?? null,
1740
+ previewId: detached?.preview.previewId ?? null,
1741
+ revision: record?.revision ?? 0,
1742
+ reason,
1743
+ message,
1744
+ };
1745
+ }
1746
+ function canonicalJson(value) {
1747
+ if (Array.isArray(value))
1748
+ return `[${value.map(canonicalJson).join(',')}]`;
1749
+ if (value !== null && typeof value === 'object') {
1750
+ const entries = Object.entries(value)
1751
+ .filter(([, entry]) => entry !== undefined)
1752
+ .sort(([left], [right]) => left.localeCompare(right));
1753
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}`;
1754
+ }
1755
+ return JSON.stringify(value);
1756
+ }
1757
+ function sha256Facts(value) {
1758
+ return createHash('sha256').update(canonicalJson(value)).digest('hex');
1759
+ }
1760
+ function recoveryGeneration(facts) {
1761
+ return sha256Facts(facts);
1762
+ }
1763
+ async function preflightPreviewRecoveryTarget(sessionId, expectedRevision, expectedReviewId, expectedPreviewId) {
1764
+ const binding = await resolveBinding(sessionId);
1765
+ if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
1766
+ return blockedPreviewRecovery(undefined, 'not_owner', '只有 owner Isolated 会话可以检查 Preview 恢复');
1767
+ }
1768
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1769
+ if (!record)
1770
+ return blockedPreviewRecovery(undefined, 'checkout_unavailable', 'Isolated Checkout 记录不存在');
1771
+ if (record.revision !== expectedRevision) {
1772
+ return blockedPreviewRecovery(record, 'stale_target', 'Session Target 已变化,请刷新后重新检查');
1773
+ }
1774
+ if (record.phase !== 'ready'
1775
+ || record.delivery.state !== 'preview_detached'
1776
+ || record.delivery.review.reviewId !== expectedReviewId
1777
+ || record.delivery.preview.previewId !== expectedPreviewId) {
1778
+ return blockedPreviewRecovery(record, 'not_preview_detached', '当前并非指定的 detached Preview 恢复状态');
1779
+ }
1780
+ const validated = await validateManagedCheckoutDetailed(binding, record, false);
1781
+ if (validated.status !== 'valid') {
1782
+ return blockedPreviewRecovery(record, 'checkout_unavailable', 'Worktree 身份、路径或 Git 状态暂时无法确认');
1783
+ }
1784
+ const { review, preview } = record.delivery;
1785
+ try {
1786
+ if (!await previewArtifactsMatch(record, preview)) {
1787
+ return blockedPreviewRecovery(record, 'artifacts_missing', 'Preview retained artifacts 缺失或与 receipt 不一致');
1788
+ }
1789
+ const assessment = await dependencies.applyEngine.assessPreviewRecovery({
1790
+ localPath: record.localRoot,
1791
+ receipt: preview,
1792
+ });
1793
+ if ('error' in assessment) {
1794
+ return blockedPreviewRecovery(record, 'git_error', assessment.error.message);
1795
+ }
1796
+ const holder = findProjectAcceptanceHolder(record);
1797
+ const blocker = holder
1798
+ ? { checkoutId: holder.checkoutId, ownerSessionId: holder.ownerSessionId, revision: holder.revision, state: holder.delivery.state }
1799
+ : undefined;
1800
+ const rollback = holder
1801
+ ? { status: 'blocked', code: 'project_acceptance_busy', message: '另一个任务正在占用该项目的 Local 验收槽位' }
1802
+ : assessment.rollback;
1803
+ const finalize = holder
1804
+ ? { status: 'blocked', code: 'project_acceptance_busy', message: '另一个任务正在占用该项目的 Local 验收槽位' }
1805
+ : assessment.finalize;
1806
+ const facts = {
1807
+ sessionId,
1808
+ checkoutId: record.checkoutId,
1809
+ reviewId: review.reviewId,
1810
+ previewId: preview.previewId,
1811
+ revision: record.revision,
1812
+ receiptFingerprint: sha256Facts(preview),
1813
+ localFingerprint: assessment.localFingerprint,
1814
+ localHeadOid: assessment.localHeadOid,
1815
+ localHeadRef: assessment.localHeadRef,
1816
+ localHeadTreeOid: assessment.localHeadTreeOid,
1817
+ localIndexTreeOid: assessment.localIndexTreeOid,
1818
+ localWorkingTreeOid: assessment.localWorkingTreeOid,
1819
+ rollback,
1820
+ finalize,
1821
+ ...(blocker ? { blocker } : {}),
1822
+ };
1823
+ const current = dependencies.registry.read().managedCheckouts[record.checkoutId];
1824
+ if (!current
1825
+ || current.revision !== record.revision
1826
+ || current.phase !== 'ready'
1827
+ || current.delivery.state !== 'preview_detached'
1828
+ || current.delivery.review.reviewId !== review.reviewId
1829
+ || current.delivery.preview.previewId !== preview.previewId) {
1830
+ return blockedPreviewRecovery(current, 'stale_target', 'Preview 恢复状态在检查期间发生变化');
1831
+ }
1832
+ if (!await previewArtifactsMatch(current, current.delivery.preview)) {
1833
+ return blockedPreviewRecovery(current, 'artifacts_missing', 'Preview retained artifacts 在检查期间发生变化');
1834
+ }
1835
+ const proof = { ...facts, generation: recoveryGeneration(facts) };
1836
+ return { status: 'assessed', localModified: false, proof };
1837
+ }
1838
+ catch (error) {
1839
+ const message = error instanceof Error ? error.message : 'Preview 恢复检查失败';
1840
+ return blockedPreviewRecovery(record, 'git_error', message);
1841
+ }
1842
+ }
1843
+ function recoveryProofMatches(left, right) {
1844
+ if (left === undefined)
1845
+ return false;
1846
+ const { generation: leftGeneration, ...leftFacts } = left;
1847
+ return leftGeneration === recoveryGeneration(leftFacts) && leftGeneration === right.generation;
1848
+ }
1293
1849
  function blockedPreflight(record, reason, message, blocker) {
1294
1850
  return {
1295
1851
  status: 'blocked',
@@ -1303,6 +1859,7 @@ export function createSessionCheckoutModule(dependencies) {
1303
1859
  blocker: {
1304
1860
  checkoutId: blocker.checkoutId,
1305
1861
  ownerSessionId: blocker.ownerSessionId,
1862
+ revision: blocker.revision,
1306
1863
  state: blocker.delivery.state,
1307
1864
  },
1308
1865
  }),
@@ -1575,6 +2132,18 @@ export function createSessionCheckoutModule(dependencies) {
1575
2132
  return operationError('preview_not_active', '当前没有可撤回的 Local Preview', await inspectIsolated(binding));
1576
2133
  }
1577
2134
  const retryingDetached = record.delivery.state === 'preview_detached';
2135
+ if (retryingDetached) {
2136
+ const recovery = await preflightPreviewRecoveryTarget(input.sessionId, input.expectedRevision, record.delivery.review.reviewId, record.delivery.preview.previewId);
2137
+ if (recovery.status !== 'assessed') {
2138
+ return operationError('stale_target', recovery.message, await inspectIsolated(binding));
2139
+ }
2140
+ if (!recoveryProofMatches(input.recoveryProof, recovery.proof)) {
2141
+ return operationError('stale_target', 'Preview Recovery proof 已过期或不匹配,请重新检查', await inspectIsolated(binding));
2142
+ }
2143
+ if (recovery.proof.rollback.status !== 'safe') {
2144
+ return operationError(recovery.proof.rollback.code, recovery.proof.rollback.message, await inspectIsolated(binding));
2145
+ }
2146
+ }
1578
2147
  const resumeRevision = input.resumeRevision ?? (retryingRecovery && record.journal?.operation === 'rollback_preview'
1579
2148
  ? record.journal.resumeRevision ?? false
1580
2149
  : false);
@@ -1592,19 +2161,39 @@ export function createSessionCheckoutModule(dependencies) {
1592
2161
  previewId: preview.previewId,
1593
2162
  reviewId: review.reviewId,
1594
2163
  resumeRevision,
2164
+ ...(input.recoveryProof ? { recoveryGeneration: input.recoveryProof.generation } : {}),
1595
2165
  },
1596
2166
  revision: current.revision + 1,
1597
2167
  }));
1598
- const result = await dependencies.applyEngine.rollback({ localPath: record.localRoot, receipt: preview });
2168
+ const result = await dependencies.applyEngine.rollback({
2169
+ localPath: record.localRoot,
2170
+ receipt: preview,
2171
+ beforeWrite: async () => {
2172
+ updateManagedCheckout(record.checkoutId, (current) => ({
2173
+ ...current,
2174
+ journal: current.journal?.operation === 'rollback_preview'
2175
+ ? { ...current.journal, step: 'writing_local' }
2176
+ : current.journal,
2177
+ revision: current.revision + 1,
2178
+ }));
2179
+ },
2180
+ });
1599
2181
  if (result.status === 'error') {
2182
+ const current = dependencies.registry.read().managedCheckouts[record.checkoutId];
2183
+ const writeStarted = current?.journal?.operation === 'rollback_preview' && current.journal.step === 'writing_local';
1600
2184
  if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
1601
2185
  if (!retryingDetached) {
1602
2186
  return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'rollback_preview');
1603
2187
  }
1604
- updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
2188
+ updateManagedCheckout(record.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
2189
+ }
2190
+ else if (result.error.code === 'invalid_input'
2191
+ || !writeStarted
2192
+ || (result.error.code === 'git_error' && result.error.recoveryState === 'unchanged')) {
2193
+ updateManagedCheckout(record.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
1605
2194
  }
1606
- else if (result.error.code === 'invalid_input') {
1607
- updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
2195
+ else {
2196
+ updateManagedCheckout(record.checkoutId, (checkout) => ({ ...checkout, phase: 'recovery_required', revision: checkout.revision + 1 }));
1608
2197
  }
1609
2198
  return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1610
2199
  }
@@ -1833,9 +2422,23 @@ export function createSessionCheckoutModule(dependencies) {
1833
2422
  if (record.revision !== input.expectedRevision) {
1834
2423
  return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1835
2424
  }
1836
- if (record.phase !== 'ready' || record.delivery.state !== 'preview_active') {
2425
+ if (record.phase !== 'ready'
2426
+ || (record.delivery.state !== 'preview_active' && record.delivery.state !== 'preview_detached')) {
1837
2427
  return operationError('preview_not_active', '当前没有等待验收的 Local Preview', await inspectIsolated(binding));
1838
2428
  }
2429
+ const retryingDetached = record.delivery.state === 'preview_detached';
2430
+ if (retryingDetached) {
2431
+ const recovery = await preflightPreviewRecoveryTarget(input.sessionId, input.expectedRevision, record.delivery.review.reviewId, record.delivery.preview.previewId);
2432
+ if (recovery.status !== 'assessed') {
2433
+ return operationError('stale_target', recovery.message, await inspectIsolated(binding));
2434
+ }
2435
+ if (!recoveryProofMatches(input.recoveryProof, recovery.proof)) {
2436
+ return operationError('stale_target', 'Preview Recovery proof 已过期或不匹配,请重新检查', await inspectIsolated(binding));
2437
+ }
2438
+ if (recovery.proof.finalize.status !== 'safe') {
2439
+ return operationError(recovery.proof.finalize.code, recovery.proof.finalize.message, await inspectIsolated(binding));
2440
+ }
2441
+ }
1839
2442
  // Harness plugin does not expose Domi collaborator checkout inheritance.
1840
2443
  const { preview, review } = record.delivery;
1841
2444
  const operationId = dependencies.createCheckoutId();
@@ -1843,7 +2446,16 @@ export function createSessionCheckoutModule(dependencies) {
1843
2446
  updateManagedCheckout(record.checkoutId, (current) => ({
1844
2447
  ...current,
1845
2448
  phase: 'mutating',
1846
- journal: { operation: 'finalize_preview', operationId, step: 'planning', startedAt, previewId: preview.previewId, reviewId: review.reviewId, retention: input.retention ?? 'cleanup' },
2449
+ journal: {
2450
+ operation: 'finalize_preview',
2451
+ operationId,
2452
+ step: 'planning',
2453
+ startedAt,
2454
+ previewId: preview.previewId,
2455
+ reviewId: review.reviewId,
2456
+ retention: input.retention ?? 'cleanup',
2457
+ ...(input.recoveryProof ? { recoveryGeneration: input.recoveryProof.generation } : {}),
2458
+ },
1847
2459
  revision: current.revision + 1,
1848
2460
  }));
1849
2461
  const result = await dependencies.applyEngine.finalize({
@@ -1861,11 +2473,22 @@ export function createSessionCheckoutModule(dependencies) {
1861
2473
  },
1862
2474
  });
1863
2475
  if (result.status === 'error') {
1864
- if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
2476
+ if ((result.error.code === 'stale_local' || result.error.code === 'preview_modified') && !retryingDetached) {
1865
2477
  return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'finalize_preview');
1866
2478
  }
1867
- if (result.error.code === 'commit_isolation_conflict' || result.error.code === 'operation_not_allowed' || result.error.code === 'invalid_input') {
1868
- updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
2479
+ const current = dependencies.registry.read().managedCheckouts[record.checkoutId];
2480
+ const writeStarted = current?.journal?.operation === 'finalize_preview' && current.journal.step === 'updating_ref';
2481
+ if (result.error.code === 'commit_isolation_conflict'
2482
+ || result.error.code === 'operation_not_allowed'
2483
+ || result.error.code === 'invalid_input'
2484
+ || result.error.code === 'stale_local'
2485
+ || result.error.code === 'preview_modified'
2486
+ || !writeStarted
2487
+ || (result.error.code === 'git_error' && result.error.recoveryState === 'unchanged')) {
2488
+ updateManagedCheckout(record.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
2489
+ }
2490
+ else {
2491
+ updateManagedCheckout(record.checkoutId, (checkout) => ({ ...checkout, phase: 'recovery_required', revision: checkout.revision + 1 }));
1869
2492
  }
1870
2493
  return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1871
2494
  }
@@ -1877,11 +2500,13 @@ export function createSessionCheckoutModule(dependencies) {
1877
2500
  review,
1878
2501
  commitOid: result.commitOid,
1879
2502
  proof: {
1880
- localBranch: preview.localHeadRef?.startsWith('refs/heads/')
1881
- ? preview.localHeadRef.slice('refs/heads/'.length)
1882
- : null,
1883
- localHeadBefore: preview.localHeadOid,
1884
- localHeadAfter: result.commitOid ?? preview.localHeadOid,
2503
+ localBranch: input.recoveryProof?.localHeadRef?.startsWith('refs/heads/')
2504
+ ? input.recoveryProof.localHeadRef.slice('refs/heads/'.length)
2505
+ : preview.localHeadRef?.startsWith('refs/heads/')
2506
+ ? preview.localHeadRef.slice('refs/heads/'.length)
2507
+ : null,
2508
+ localHeadBefore: input.recoveryProof?.localHeadOid ?? preview.localHeadOid,
2509
+ localHeadAfter: result.commitOid ?? input.recoveryProof?.localHeadOid ?? preview.localHeadOid,
1885
2510
  changedFiles: [...result.changedFiles],
1886
2511
  validationStatus: review.validationStatus,
1887
2512
  ...(review.validationSummary === undefined ? {} : { validationSummary: review.validationSummary }),
@@ -2233,7 +2858,7 @@ export function createSessionCheckoutModule(dependencies) {
2233
2858
  }
2234
2859
  if (record.delivery.state === 'preview_active') {
2235
2860
  if (!input.rollbackPreview) {
2236
- return operationError('preview_not_active', 'Local 正在验收本任务;放弃任务前必须先安全撤回 Preview', await inspectIsolated(binding));
2861
+ return operationError('preview_not_active', '本任务正在 Local 预览;放弃任务前必须先安全撤回 Preview', await inspectIsolated(binding));
2237
2862
  }
2238
2863
  const rollback = await dependencies.applyEngine.rollback({
2239
2864
  localPath: record.localRoot,
@@ -2283,11 +2908,11 @@ export function createSessionCheckoutModule(dependencies) {
2283
2908
  if (!record || record.phase !== 'ready') {
2284
2909
  return operationError('recovery_required', 'Isolated Checkout 状态已变化,需要恢复');
2285
2910
  }
2286
- dirty = (await dependencies.git.status(record.managedRoot)).dirty;
2911
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty || (record.checkpoints?.length ?? 0) > 0;
2287
2912
  }
2288
2913
  else {
2289
2914
  try {
2290
- dirty = (await dependencies.git.status(record.managedRoot)).dirty;
2915
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty || (record.checkpoints?.length ?? 0) > 0;
2291
2916
  }
2292
2917
  catch {
2293
2918
  dirty = true;
@@ -2295,7 +2920,10 @@ export function createSessionCheckoutModule(dependencies) {
2295
2920
  inspected = recoveryView(binding, record, dirty);
2296
2921
  }
2297
2922
  if (dirty && !input.confirmDirty) {
2298
- return operationError('dirty_confirmation_required', 'Isolated Checkout 含未提交修改或状态无法确认,需要明确确认', inspected);
2923
+ const checkpointDetail = (record.checkpoints?.length ?? 0) > 0
2924
+ ? `;确认后会永久删除 ${record.checkpoints.length} 个尚未交付到 Local 的阶段`
2925
+ : '';
2926
+ return operationError('dirty_confirmation_required', `Isolated Checkout 含未提交修改、未交付阶段或状态无法确认,需要明确确认${checkpointDetail}`, inspected);
2299
2927
  }
2300
2928
  try {
2301
2929
  await dependencies.git.removeWorktree(record.localRoot, record.managedGitRoot);
@@ -2356,6 +2984,8 @@ export function createSessionCheckoutModule(dependencies) {
2356
2984
  return await operateFinish(input, binding);
2357
2985
  if (input.action === 'preview')
2358
2986
  return await operatePreview(input, binding);
2987
+ if (input.action === 'checkpoint')
2988
+ return await operateCheckpoint(input, binding);
2359
2989
  if (input.action === 'rollback_preview')
2360
2990
  return await operateRollbackPreview(input, binding);
2361
2991
  if (input.action === 'finalize_preview')
@@ -2478,6 +3108,7 @@ export function createSessionCheckoutModule(dependencies) {
2478
3108
  phase: record.phase,
2479
3109
  dirty,
2480
3110
  commitOid,
3111
+ ...((record.checkpoints?.length ?? 0) > 0 ? { checkpointCount: record.checkpoints.length } : {}),
2481
3112
  ...(delivery.state === 'retained' ? {
2482
3113
  retention: delivery.retention,
2483
3114
  retainedAt: delivery.retainedAt,
@@ -3164,6 +3795,7 @@ export function createSessionCheckoutModule(dependencies) {
3164
3795
  inspect: inspectAvailable,
3165
3796
  runtimeContext,
3166
3797
  preflight: (sessionId, expectedRevision) => withBindingLock(() => preflightTarget(sessionId, expectedRevision)),
3798
+ preflightPreviewRecovery: (sessionId, expectedRevision, expectedReviewId, expectedPreviewId) => withBindingLock(() => preflightPreviewRecoveryTarget(sessionId, expectedRevision, expectedReviewId, expectedPreviewId), { allowConcurrentInspect: true }),
3167
3799
  runExclusiveSessionMutation: (sessionId, operation) => withBindingLock(async () => (operation(await inspectTarget(sessionId, true)))),
3168
3800
  bind: (sessionId, choice) => {
3169
3801
  const requestStartedAt = Date.now();
@@ -3193,7 +3825,10 @@ export function createSessionCheckoutModule(dependencies) {
3193
3825
  return { targetSessionId, managedRoot: record.managedRoot, target };
3194
3826
  }),
3195
3827
  beginNextIteration: (sessionId, expectedRevision) => withBindingLock(() => beginNextIterationTarget(sessionId, expectedRevision)),
3196
- resumeRevision: (sessionId, expectedRevision, expectedReviewId) => withBindingLock(() => resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId)),
3828
+ resumeRevision: (sessionId, expectedRevision, expectedReviewId, recovery) => withBindingLock(() => resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId, recovery)),
3829
+ prepareReviewRegeneration: (sessionId, expectedRevision, expectedReviewId, requestId) => withBindingLock(() => prepareReviewRegenerationTarget(sessionId, expectedRevision, expectedReviewId, requestId)),
3830
+ preparePreviewRecoveryAnalysis: (sessionId, proof, requestId) => withBindingLock(() => preparePreviewRecoveryAnalysisTarget(sessionId, proof, requestId)),
3831
+ createPreviewRecoveryHandoff: (sessionId, proof, targetSessionId, requestId) => withBindingLock(() => createPreviewRecoveryHandoffTarget(sessionId, proof, targetSessionId, requestId)),
3197
3832
  markReadyForReview: (sessionId, input) => withBindingLock(() => markReadyForReviewTarget(sessionId, input)),
3198
3833
  operate: (input) => withBindingLock(() => operateTarget(input)),
3199
3834
  // 只读管理列表不占用全局 mutation lock;慢速目录诊断与用户操作互不阻塞。