dsh-git-worktree 0.1.2 → 0.2.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 (133) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -21
  3. package/README.md +115 -43
  4. package/README.zh.md +115 -41
  5. package/cordis.patch.yml +7 -7
  6. package/lib/adapters/registry.js +40 -0
  7. package/lib/adapters/registry.js.map +1 -1
  8. package/lib/adapters/safe-file.js +34 -4
  9. package/lib/adapters/safe-file.js.map +1 -1
  10. package/lib/client/WorktreeCreateRow.d.ts +7 -0
  11. package/lib/client/WorktreeCreateRow.js +26 -0
  12. package/lib/client/WorktreeCreateRow.js.map +1 -0
  13. package/lib/client/WorktreeReviewRow.d.ts +11 -0
  14. package/lib/client/WorktreeReviewRow.js +67 -0
  15. package/lib/client/WorktreeReviewRow.js.map +1 -0
  16. package/lib/client/actions.d.ts +86 -0
  17. package/lib/client/actions.js +35 -0
  18. package/lib/client/actions.js.map +1 -0
  19. package/lib/client/console-remote/adapter.d.ts +3 -0
  20. package/lib/client/console-remote/adapter.js +59 -0
  21. package/lib/client/console-remote/adapter.js.map +1 -0
  22. package/lib/client/console-remote/index.d.ts +18 -0
  23. package/lib/client/console-remote/index.js +17 -0
  24. package/lib/client/console-remote/index.js.map +1 -0
  25. package/lib/client/index.d.ts +17 -0
  26. package/lib/client/index.js +44 -0
  27. package/lib/client/index.js.map +1 -0
  28. package/lib/client/model.d.ts +68 -0
  29. package/lib/client/model.js +127 -0
  30. package/lib/client/model.js.map +1 -0
  31. package/lib/client/pre-session/PreSessionWorktreeToggle.d.ts +16 -0
  32. package/lib/client/pre-session/PreSessionWorktreeToggle.js +128 -0
  33. package/lib/client/pre-session/PreSessionWorktreeToggle.js.map +1 -0
  34. package/lib/client/pre-session/controller.d.ts +42 -0
  35. package/lib/client/pre-session/controller.js +149 -0
  36. package/lib/client/pre-session/controller.js.map +1 -0
  37. package/lib/client/pre-session/index.d.ts +15 -0
  38. package/lib/client/pre-session/index.js +16 -0
  39. package/lib/client/pre-session/index.js.map +1 -0
  40. package/lib/client/review-console/ReviewActions.d.ts +15 -0
  41. package/lib/client/review-console/ReviewActions.js +219 -0
  42. package/lib/client/review-console/ReviewActions.js.map +1 -0
  43. package/lib/client/review-console/WorktreeReviewPanel.d.ts +20 -0
  44. package/lib/client/review-console/WorktreeReviewPanel.js +75 -0
  45. package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -0
  46. package/lib/client/review-console/index.d.ts +8 -0
  47. package/lib/client/review-console/index.js +22 -0
  48. package/lib/client/review-console/index.js.map +1 -0
  49. package/lib/client/review-console/review-console.styles.d.ts +1 -0
  50. package/lib/client/review-console/review-console.styles.js +81 -0
  51. package/lib/client/review-console/review-console.styles.js.map +1 -0
  52. package/lib/client/review-console/status-events.d.ts +3 -0
  53. package/lib/client/review-console/status-events.js +6 -0
  54. package/lib/client/review-console/status-events.js.map +1 -0
  55. package/lib/client/styles.d.ts +1 -0
  56. package/lib/client/styles.js +78 -0
  57. package/lib/client/styles.js.map +1 -0
  58. package/lib/client/target-console/TargetStatusAction.d.ts +7 -0
  59. package/lib/client/target-console/TargetStatusAction.js +46 -0
  60. package/lib/client/target-console/TargetStatusAction.js.map +1 -0
  61. package/lib/client/target-console/WorktreeConsoleView.d.ts +9 -0
  62. package/lib/client/target-console/WorktreeConsoleView.js +362 -0
  63. package/lib/client/target-console/WorktreeConsoleView.js.map +1 -0
  64. package/lib/client/target-console/WorktreeReviewStatus.d.ts +9 -0
  65. package/lib/client/target-console/WorktreeReviewStatus.js +91 -0
  66. package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -0
  67. package/lib/client/target-console/index.d.ts +14 -0
  68. package/lib/client/target-console/index.js +23 -0
  69. package/lib/client/target-console/index.js.map +1 -0
  70. package/lib/client/target-console/target-console.styles.d.ts +1 -0
  71. package/lib/client/target-console/target-console.styles.js +130 -0
  72. package/lib/client/target-console/target-console.styles.js.map +1 -0
  73. package/lib/client.js +6813 -0
  74. package/lib/commands.d.ts +2 -4
  75. package/lib/commands.js +54 -28
  76. package/lib/commands.js.map +1 -1
  77. package/lib/console-contract.d.ts +236 -0
  78. package/lib/console-contract.js +86 -0
  79. package/lib/console-contract.js.map +1 -0
  80. package/lib/console-host/control-plane.d.ts +29 -0
  81. package/lib/console-host/control-plane.js +443 -0
  82. package/lib/console-host/control-plane.js.map +1 -0
  83. package/lib/console-host/errors.d.ts +6 -0
  84. package/lib/console-host/errors.js +31 -0
  85. package/lib/console-host/errors.js.map +1 -0
  86. package/lib/console-host/projection.d.ts +12 -0
  87. package/lib/console-host/projection.js +131 -0
  88. package/lib/console-host/projection.js.map +1 -0
  89. package/lib/console-host/review-diff.d.ts +18 -0
  90. package/lib/console-host/review-diff.js +176 -0
  91. package/lib/console-host/review-diff.js.map +1 -0
  92. package/lib/console-host/service.d.ts +24 -0
  93. package/lib/console-host/service.js +131 -0
  94. package/lib/console-host/service.js.map +1 -0
  95. package/lib/console-remote/descriptors.d.ts +3 -0
  96. package/lib/console-remote/descriptors.js +96 -0
  97. package/lib/console-remote/descriptors.js.map +1 -0
  98. package/lib/console-remote/remote.d.ts +27 -0
  99. package/lib/console-remote/remote.js +4 -0
  100. package/lib/console-remote/remote.js.map +1 -0
  101. package/lib/console-remote/schemas.d.ts +29 -0
  102. package/lib/console-remote/schemas.js +143 -0
  103. package/lib/console-remote/schemas.js.map +1 -0
  104. package/lib/console-remote/typert.d.ts +3 -0
  105. package/lib/console-remote/typert.js +14 -0
  106. package/lib/console-remote/typert.js.map +1 -0
  107. package/lib/index.d.ts +22 -6
  108. package/lib/index.js +28 -10
  109. package/lib/index.js.map +1 -1
  110. package/lib/managed-worktree-path.d.ts +14 -13
  111. package/lib/managed-worktree-path.js +32 -22
  112. package/lib/managed-worktree-path.js.map +1 -1
  113. package/lib/ports.d.ts +37 -1
  114. package/lib/session-checkout-apply.d.ts +54 -1
  115. package/lib/session-checkout-apply.js +318 -28
  116. package/lib/session-checkout-apply.js.map +1 -1
  117. package/lib/session-checkout-module.js +853 -33
  118. package/lib/session-checkout-module.js.map +1 -1
  119. package/lib/session-target-context.d.ts +6 -0
  120. package/lib/session-target-context.js +16 -0
  121. package/lib/session-target-context.js.map +1 -0
  122. package/lib/tools.d.ts +5 -6
  123. package/lib/tools.js +43 -233
  124. package/lib/tools.js.map +1 -1
  125. package/lib/types.d.ts +57 -11
  126. package/lib/types.js +6 -6
  127. package/lib/types.js.map +1 -1
  128. package/package.json +116 -69
  129. package/scripts/check-publish.mjs +193 -0
  130. package/scripts/clean-lib.mjs +7 -0
  131. package/scripts/dev-dsh-lib.mjs +453 -0
  132. package/scripts/dev-dsh.mjs +89 -0
  133. package/scripts/dsh-source-runner.mjs +14 -0
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { SessionCheckoutError } from './index.js';
4
+ import { createManagedWorktreePathCandidates, createManagedWorktreeRepositoryKey, } from './managed-worktree-path.js';
4
5
  const UNVERSIONED_OID = 'unversioned';
5
6
  const UNVERSIONED_REF = 'WORKING_TREE';
6
7
  const RETENTION_24H_MS = 24 * 60 * 60 * 1000;
@@ -154,6 +155,19 @@ export function createSessionCheckoutModule(dependencies) {
154
155
  return delivery;
155
156
  if (delivery.state === 'ready_for_review')
156
157
  return { state: delivery.state, review: delivery.review };
158
+ if (delivery.state === 'preview_active') {
159
+ return { state: delivery.state, review: delivery.review, previewedAt: delivery.preview.previewedAt };
160
+ }
161
+ if (delivery.state === 'preview_detached') {
162
+ return {
163
+ state: delivery.state,
164
+ review: delivery.review,
165
+ previewedAt: delivery.preview.previewedAt,
166
+ detachedAt: delivery.detachedAt,
167
+ reason: delivery.reason,
168
+ attemptedAction: delivery.attemptedAction,
169
+ };
170
+ }
157
171
  if (delivery.state === 'finalized') {
158
172
  return {
159
173
  state: delivery.state,
@@ -546,6 +560,12 @@ export function createSessionCheckoutModule(dependencies) {
546
560
  }
547
561
  const { snapshot, status } = validation.checkout;
548
562
  const delivery = projectDelivery(record);
563
+ const activePreview = delivery?.state === 'ready_for_review'
564
+ ? Object.values(registry.managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
565
+ && candidate.phase !== 'discarded'
566
+ && pathsEqual(candidate.localRoot, record.localRoot)
567
+ && holdsProjectAcceptanceSlot(candidate)))
568
+ : undefined;
549
569
  return {
550
570
  project: { id: record.projectId, name: record.projectName },
551
571
  checkout: {
@@ -560,17 +580,114 @@ export function createSessionCheckoutModule(dependencies) {
560
580
  dirty: status.dirty,
561
581
  revision: record.revision,
562
582
  delivery,
583
+ ...(delivery?.state === 'ready_for_review'
584
+ ? {
585
+ reviewSlot: activePreview ? 'waiting' : 'available',
586
+ ...(activePreview ? { reviewSlotOwnerSessionId: activePreview.ownerSessionId } : {}),
587
+ }
588
+ : {}),
563
589
  };
564
590
  }
565
591
  function getPersistedBinding(sessionId) {
566
592
  return dependencies.registry.read().sessionBindings[sessionId];
567
593
  }
594
+ function runtimeContext(sessionId) {
595
+ const registry = dependencies.registry.read();
596
+ const binding = registry.sessionBindings[sessionId];
597
+ if (!binding)
598
+ return '';
599
+ if (binding.target.kind === 'local') {
600
+ const projectPreview = Object.values(registry.managedCheckouts)
601
+ .filter((record) => record.projectId === binding.projectId && record.phase !== 'discarded')
602
+ .filter((record) => record.delivery.state === 'preview_active'
603
+ || record.delivery.state === 'preview_detached')
604
+ .sort((left, right) => managedUpdatedAt(right) - managedUpdatedAt(left))[0];
605
+ const pending = projectPreview ?? Object.values(registry.managedCheckouts)
606
+ .filter((record) => record.sourceSessionId === sessionId && record.phase !== 'discarded')
607
+ .filter((record) => record.delivery.state === 'working'
608
+ || record.delivery.state === 'ready_for_review'
609
+ || record.delivery.state === 'preview_active'
610
+ || record.delivery.state === 'preview_detached'
611
+ || record.phase === 'preparing'
612
+ || record.phase === 'mutating'
613
+ || record.phase === 'recovery_required')
614
+ .sort((left, right) => managedUpdatedAt(right) - managedUpdatedAt(left))[0];
615
+ if (!pending) {
616
+ return 'Session Target: Local Checkout. No isolated target is active for this Session.';
617
+ }
618
+ if (pending.delivery.state === 'preview_active') {
619
+ return [
620
+ 'Session Target: Local Checkout (Worktree Preview active).',
621
+ `Managed checkout ${pending.checkoutId} has a reversible Preview in this Local boundary.`,
622
+ 'Do not modify project files or run repository-changing commands. Only the user may accept, rollback, or discard the Preview through the Worktree acceptance UI.',
623
+ ].join('\n');
624
+ }
625
+ if (pending.delivery.state === 'preview_detached') {
626
+ return [
627
+ 'Session Target: Local Checkout (Worktree Preview recovery required).',
628
+ `Managed checkout ${pending.checkoutId} still has preserved Preview recovery evidence.`,
629
+ 'Do not modify project files or attempt automatic cleanup. The user must retry rollback; the Worktree stays preserved until rollback succeeds.',
630
+ ].join('\n');
631
+ }
632
+ return [
633
+ 'Session Target: Local Checkout (handoff pending).',
634
+ `Managed checkout ${pending.checkoutId} is reserved for Session ${pending.ownerSessionId}.`,
635
+ 'Stop code modifications in this Local Session. Ask the user to open the isolated Session from the Worktree card.',
636
+ ].join('\n');
637
+ }
638
+ if (binding.target.kind !== 'isolated')
639
+ return 'Session Target is unselected. Do not mutate project files until a target is chosen.';
640
+ const record = registry.managedCheckouts[binding.target.checkoutId];
641
+ if (!record)
642
+ return 'Session Target: Isolated Checkout record missing. Stop mutations and request recovery.';
643
+ if (record.phase === 'recovery_required') {
644
+ return `Session Target: Isolated Checkout ${record.checkoutId} requires recovery. Do not modify Local or clean up automatically.`;
645
+ }
646
+ const lines = [
647
+ `Session Target: Isolated Checkout ${record.checkoutId}.`,
648
+ `Authoritative cwd: ${record.managedRoot}`,
649
+ `Original Local boundary: ${record.localRoot}`,
650
+ 'Make task changes only in the authoritative cwd; never write directly to the Original Local boundary.',
651
+ ];
652
+ if (record.delivery.state === 'ready_for_review') {
653
+ lines.push('Delivery state: Ready for Review. The model must stop; only the user may preview, directly finish, discard, or clean up through the Worktree acceptance UI.');
654
+ }
655
+ else if (record.delivery.state === 'preview_active') {
656
+ lines.push('Delivery state: Local Preview active. The model must remain read-only; only the user may accept and commit, rollback, or discard through the Worktree acceptance UI.');
657
+ }
658
+ else if (record.delivery.state === 'preview_detached') {
659
+ lines.push('Delivery state: Preview detached after Local drift. Do not mutate Local or the Worktree; the user must retry rollback, and Discard remains blocked until recovery succeeds.');
660
+ }
661
+ else if (record.delivery.state === 'working') {
662
+ lines.push('Delivery state: Working. When implementation and validation are complete, put the complete report only in worktree_ready_for_review and call it as the final model tool. Do not duplicate that report in ordinary assistant prose; at most one short sentence may point the user to the bottom acceptance bar.');
663
+ }
664
+ else {
665
+ lines.push(`Delivery state: ${record.delivery.state}. Treat this Session as terminal unless the user starts a new iteration.`);
666
+ }
667
+ return lines.join('\n');
668
+ }
568
669
  async function resolveBinding(sessionId) {
569
670
  const session = requireSession(sessionId);
570
671
  const persisted = getPersistedBinding(sessionId);
571
672
  if (persisted) {
572
673
  if (session.projectId !== persisted.projectId) {
573
- throw new SessionCheckoutError('project_mismatch', '会话当前项目与已绑定 Session Target 不一致,已停止访问 checkout');
674
+ const record = persisted.target.kind === 'isolated'
675
+ ? dependencies.registry.read().managedCheckouts[persisted.target.checkoutId]
676
+ : undefined;
677
+ const workspace = session.projectId ? dependencies.lookup.getProject(session.projectId) : undefined;
678
+ let isolatedWorkspaceMatches = false;
679
+ if (record && workspace && dependencies.files.exists(workspace.root)) {
680
+ try {
681
+ const workspaceRoot = await dependencies.files.canonicalize(workspace.root);
682
+ isolatedWorkspaceMatches = pathsEqual(workspaceRoot, record.managedRoot);
683
+ }
684
+ catch {
685
+ isolatedWorkspaceMatches = false;
686
+ }
687
+ }
688
+ if (!isolatedWorkspaceMatches) {
689
+ throw new SessionCheckoutError('project_mismatch', '会话当前 Workspace 与已绑定 Session Target 不一致,已停止访问 checkout');
690
+ }
574
691
  }
575
692
  return persisted;
576
693
  }
@@ -691,9 +808,109 @@ export function createSessionCheckoutModule(dependencies) {
691
808
  continue;
692
809
  }
693
810
  if (current.phase === 'mutating'
694
- && (current.journal?.operation === 'apply' || current.journal?.operation === 'finish')
811
+ && (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
812
+ && current.journal.step === 'updating_ref'
813
+ && typeof current.journal.commitOid === 'string'
814
+ && current.delivery.state === 'preview_active') {
815
+ const local = await dependencies.git.inspect(current.localRoot);
816
+ if (local?.headOid === current.journal.commitOid) {
817
+ const retention = current.journal.retention ?? 'cleanup';
818
+ const recoveredAt = Date.now();
819
+ record = retention === 'cleanup'
820
+ ? {
821
+ ...current,
822
+ phase: 'finalized',
823
+ delivery: {
824
+ state: 'finalized',
825
+ review: current.delivery.review,
826
+ commitOid: current.journal.commitOid,
827
+ proof: {
828
+ localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
829
+ ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
830
+ : null,
831
+ localHeadBefore: current.delivery.preview.localHeadOid,
832
+ localHeadAfter: current.journal.commitOid,
833
+ changedFiles: [...current.delivery.preview.changedFiles],
834
+ },
835
+ isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
836
+ finalizedAt: recoveredAt,
837
+ cleanup: 'blocked',
838
+ cleanupMessage: 'Commit 已创建,但进程在 Local index 完成前中断;请确认 Local 状态后再处理 Worktree。',
839
+ },
840
+ journal: null,
841
+ revision: current.revision + 1,
842
+ }
843
+ : {
844
+ ...current,
845
+ phase: 'retained',
846
+ delivery: {
847
+ state: 'retained',
848
+ review: current.delivery.review,
849
+ commitOid: current.journal.commitOid,
850
+ proof: {
851
+ localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
852
+ ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
853
+ : null,
854
+ localHeadBefore: current.delivery.preview.localHeadOid,
855
+ localHeadAfter: current.journal.commitOid,
856
+ changedFiles: [...current.delivery.preview.changedFiles],
857
+ },
858
+ isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
859
+ retention,
860
+ retainedAt: recoveredAt,
861
+ expiresAt: retentionExpiresAt(retention, recoveredAt),
862
+ cleanup: 'blocked',
863
+ cleanupMessage: 'Commit 已创建并保留 Worktree,但进程在 Local index 完成前中断;请确认 Local 状态。',
864
+ },
865
+ journal: null,
866
+ revision: current.revision + 1,
867
+ };
868
+ }
869
+ else {
870
+ record = {
871
+ ...current,
872
+ phase: 'ready',
873
+ journal: null,
874
+ revision: current.revision + 1,
875
+ };
876
+ }
877
+ registry.managedCheckouts[checkoutId] = record;
878
+ registry.revision += 1;
879
+ changed = true;
880
+ }
881
+ else if (current.phase === 'mutating'
882
+ && current.journal?.operation === 'preview'
883
+ && (current.journal.step === 'planning'
884
+ || (current.journal.step === 'writing_local' && current.delivery.state === 'ready_for_review'))) {
885
+ // Preview receipt 尚未保留,apply patch 也尚未执行;可证明 Local 未被触碰。
886
+ record = {
887
+ ...current,
888
+ phase: 'ready',
889
+ journal: null,
890
+ revision: current.revision + 1,
891
+ };
892
+ registry.managedCheckouts[checkoutId] = record;
893
+ registry.revision += 1;
894
+ changed = true;
895
+ }
896
+ else if (current.phase === 'mutating'
897
+ && (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
695
898
  && current.journal.step === 'planning') {
696
- // planning 阶段尚未触碰 Local,可安全重试。
899
+ // Finalize 的 branch/index 写入发生在 updating_ref 之后;planning 中断可安全重试。
900
+ record = {
901
+ ...current,
902
+ phase: 'ready',
903
+ journal: null,
904
+ revision: current.revision + 1,
905
+ };
906
+ registry.managedCheckouts[checkoutId] = record;
907
+ registry.revision += 1;
908
+ changed = true;
909
+ }
910
+ else if (current.phase === 'mutating'
911
+ && (current.journal?.operation === 'preview' || current.journal?.operation === 'finish')
912
+ && current.journal.step === 'artifacts_retained'
913
+ && current.delivery.state === 'preview_active') {
697
914
  record = {
698
915
  ...current,
699
916
  phase: 'ready',
@@ -780,7 +997,7 @@ export function createSessionCheckoutModule(dependencies) {
780
997
  const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
781
998
  if (!record)
782
999
  throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
783
- if (record.phase !== 'ready') {
1000
+ if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
784
1001
  throw new SessionCheckoutError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能准备验收`);
785
1002
  }
786
1003
  const inspected = await inspectIsolated(binding);
@@ -964,7 +1181,11 @@ export function createSessionCheckoutModule(dependencies) {
964
1181
  return { status: 'applied', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
965
1182
  }
966
1183
  function holdsProjectAcceptanceSlot(record) {
967
- return record.journal?.operation === 'apply' || record.journal?.operation === 'finish';
1184
+ return record.delivery.state === 'preview_active'
1185
+ || record.journal?.operation === 'preview'
1186
+ || record.journal?.operation === 'rollback_preview'
1187
+ || record.journal?.operation === 'finalize_preview'
1188
+ || record.journal?.operation === 'finish';
968
1189
  }
969
1190
  function findProjectAcceptanceHolder(record) {
970
1191
  return Object.values(dependencies.registry.read().managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
@@ -972,6 +1193,21 @@ export function createSessionCheckoutModule(dependencies) {
972
1193
  && pathsEqual(candidate.localRoot, record.localRoot)
973
1194
  && holdsProjectAcceptanceSlot(candidate)));
974
1195
  }
1196
+ async function retainPreviewArtifacts(record, receipt) {
1197
+ const prefix = `previews/${receipt.previewId}`;
1198
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-working`, receipt.localWorkingTreeOid);
1199
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-index`, receipt.localIndexTreeOid);
1200
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/preview-working`, receipt.previewWorkingTreeOid);
1201
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/isolated-snapshot`, receipt.isolatedSnapshotOid);
1202
+ }
1203
+ async function releasePreviewArtifactsBestEffort(record, previewId) {
1204
+ try {
1205
+ await dependencies.git.releaseInternalArtifacts(record.localRoot, record.checkoutId, `previews/${previewId}`);
1206
+ }
1207
+ catch {
1208
+ console.warn('[session-checkout] 清理 Preview refs 失败,已保守保留不可见引用');
1209
+ }
1210
+ }
975
1211
  function blockedPreflight(record, reason, message) {
976
1212
  return {
977
1213
  status: 'blocked',
@@ -1051,6 +1287,244 @@ export function createSessionCheckoutModule(dependencies) {
1051
1287
  : 'ready';
1052
1288
  return { ...common, status };
1053
1289
  }
1290
+ async function operatePreview(input, binding) {
1291
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1292
+ return operationError('not_owner', '只有 owner Isolated 会话可以同步验收');
1293
+ }
1294
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1295
+ if (!record)
1296
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1297
+ if (record.revision !== input.expectedRevision) {
1298
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1299
+ }
1300
+ if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
1301
+ return operationError('operation_not_allowed', '当前 Worktree 尚未处于可验收状态', await inspectIsolated(binding));
1302
+ }
1303
+ const holder = findProjectAcceptanceHolder(record);
1304
+ if (holder) {
1305
+ return operationError('project_acceptance_busy', `同一项目已有验收任务正在占用 Local:${holder.projectName}`, await inspectIsolated(binding));
1306
+ }
1307
+ const inspected = await inspectIsolated(binding);
1308
+ if (inspected.checkout.phase !== 'ready') {
1309
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
1310
+ }
1311
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1312
+ if (!record || record.delivery.state !== 'ready_for_review') {
1313
+ return operationError('stale_target', '验收状态已变化,请刷新后重试');
1314
+ }
1315
+ const review = record.delivery.review;
1316
+ const operationId = dependencies.createCheckoutId();
1317
+ const previewId = dependencies.createCheckoutId();
1318
+ const startedAt = Date.now();
1319
+ const applyBaseOid = record.applyBaseOid ?? record.baseOid;
1320
+ const mutating = updateManagedCheckout(record.checkoutId, (current) => ({
1321
+ ...current,
1322
+ phase: 'mutating',
1323
+ journal: {
1324
+ operation: 'preview',
1325
+ operationId,
1326
+ step: 'planning',
1327
+ startedAt,
1328
+ baseOid: applyBaseOid,
1329
+ previewId,
1330
+ reviewId: review.reviewId,
1331
+ },
1332
+ revision: current.revision + 1,
1333
+ }));
1334
+ if (!mutating)
1335
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1336
+ const planResult = await dependencies.applyEngine.plan({
1337
+ baseOid: applyBaseOid,
1338
+ isolatedPath: mutating.managedRoot,
1339
+ localPath: mutating.localRoot,
1340
+ });
1341
+ if (planResult.status === 'conflict') {
1342
+ updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1343
+ return {
1344
+ status: 'conflict',
1345
+ code: 'apply_conflict',
1346
+ reason: 'content_conflict',
1347
+ target: await inspectIsolated(binding),
1348
+ baseStrategy: planResult.baseStrategy,
1349
+ effectiveBaseOid: planResult.effectiveBaseOid,
1350
+ localHeadOid: planResult.localHeadOid,
1351
+ isolatedHeadOid: planResult.isolatedHeadOid,
1352
+ canRetryAfterRefresh: false,
1353
+ conflictingFiles: planResult.conflictingFiles,
1354
+ };
1355
+ }
1356
+ if (planResult.status === 'error') {
1357
+ updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1358
+ return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
1359
+ }
1360
+ if (planResult.plan.isolatedFingerprint !== review.isolatedFingerprint) {
1361
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1362
+ ...current,
1363
+ phase: 'ready',
1364
+ delivery: { state: 'working', iteration: review.iteration },
1365
+ journal: null,
1366
+ revision: current.revision + 1,
1367
+ }));
1368
+ return operationError('stale_isolated', 'Worktree 在标记可验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
1369
+ }
1370
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1371
+ ...current,
1372
+ journal: {
1373
+ operation: 'preview',
1374
+ operationId,
1375
+ step: 'writing_local',
1376
+ startedAt,
1377
+ baseOid: applyBaseOid,
1378
+ previewId,
1379
+ reviewId: review.reviewId,
1380
+ planRevision: planResult.plan.revision,
1381
+ localFingerprint: planResult.plan.localFingerprint,
1382
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
1383
+ effectiveBaseOid: planResult.plan.effectiveBaseOid,
1384
+ baseStrategy: planResult.plan.baseStrategy,
1385
+ localHeadOid: planResult.plan.localHeadOid,
1386
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
1387
+ changedFiles: [...planResult.plan.changedFiles],
1388
+ },
1389
+ revision: current.revision + 1,
1390
+ }));
1391
+ const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
1392
+ previewId,
1393
+ reviewId: review.reviewId,
1394
+ iteration: review.iteration,
1395
+ beforeWrite: async (receipt) => {
1396
+ await retainPreviewArtifacts(mutating, receipt);
1397
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1398
+ ...current,
1399
+ delivery: { state: 'preview_active', review, preview: receipt },
1400
+ journal: current.journal?.operation === 'preview'
1401
+ ? { ...current.journal, step: 'artifacts_retained' }
1402
+ : current.journal,
1403
+ revision: current.revision + 1,
1404
+ }));
1405
+ },
1406
+ });
1407
+ if (previewResult.status === 'error') {
1408
+ const current = dependencies.registry.read().managedCheckouts[mutating.checkoutId];
1409
+ const definitelyUnchanged = previewResult.error.code === 'stale_local'
1410
+ || previewResult.error.code === 'stale_isolated'
1411
+ || previewResult.error.code === 'invalid_plan'
1412
+ || previewResult.error.code === 'invalid_input'
1413
+ || current?.journal?.step === 'writing_local';
1414
+ if (current?.journal?.step === 'writing_local')
1415
+ await releasePreviewArtifactsBestEffort(mutating, previewId);
1416
+ if (definitelyUnchanged || current?.journal?.step === 'artifacts_retained') {
1417
+ updateManagedCheckout(mutating.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
1418
+ }
1419
+ return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
1420
+ }
1421
+ const receipt = previewResult.receipt;
1422
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1423
+ ...current,
1424
+ phase: 'ready',
1425
+ delivery: { state: 'preview_active', review, preview: receipt },
1426
+ journal: null,
1427
+ revision: current.revision + 1,
1428
+ }));
1429
+ return { status: 'previewed', target: await inspectIsolated(binding), changedFiles: previewResult.changedFiles };
1430
+ }
1431
+ async function detachPreviewAfterLocalDrift(record, binding, reason, attemptedAction) {
1432
+ if (record.delivery.state !== 'preview_active') {
1433
+ return operationError('preview_not_active', '当前没有可解除的 Local Preview', await inspectIsolated(binding));
1434
+ }
1435
+ const { review, preview } = record.delivery;
1436
+ const detached = updateManagedCheckout(record.checkoutId, (current) => {
1437
+ if (current.delivery.state !== 'preview_active')
1438
+ return current;
1439
+ return {
1440
+ ...current,
1441
+ phase: 'ready',
1442
+ delivery: {
1443
+ state: 'preview_detached',
1444
+ review: current.delivery.review,
1445
+ preview: current.delivery.preview,
1446
+ detachedAt: Date.now(),
1447
+ reason,
1448
+ attemptedAction,
1449
+ },
1450
+ journal: null,
1451
+ revision: current.revision + 1,
1452
+ };
1453
+ });
1454
+ if (!detached || detached.delivery.state !== 'preview_detached') {
1455
+ return operationError('stale_target', 'Preview 状态已变化,请刷新后重试', await inspectIsolated(binding));
1456
+ }
1457
+ return {
1458
+ status: 'preview_detached',
1459
+ target: await inspectIsolated(binding),
1460
+ changedFiles: [...preview.changedFiles],
1461
+ reason,
1462
+ attemptedAction,
1463
+ };
1464
+ }
1465
+ async function operateRollbackPreview(input, binding) {
1466
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1467
+ return operationError('not_owner', '只有 owner Isolated 会话可以撤回验收');
1468
+ }
1469
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1470
+ if (!record)
1471
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1472
+ if (record.revision !== input.expectedRevision) {
1473
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1474
+ }
1475
+ const retryingRecovery = record.phase === 'recovery_required'
1476
+ && record.delivery.state === 'preview_active'
1477
+ && record.journal?.operation === 'rollback_preview';
1478
+ if ((record.phase !== 'ready' && !retryingRecovery)
1479
+ || (record.delivery.state !== 'preview_active' && record.delivery.state !== 'preview_detached')) {
1480
+ return operationError('preview_not_active', '当前没有可撤回的 Local Preview', await inspectIsolated(binding));
1481
+ }
1482
+ const retryingDetached = record.delivery.state === 'preview_detached';
1483
+ const resumeRevision = input.resumeRevision ?? (retryingRecovery && record.journal?.operation === 'rollback_preview'
1484
+ ? record.journal.resumeRevision ?? false
1485
+ : false);
1486
+ const { preview, review } = record.delivery;
1487
+ const operationId = dependencies.createCheckoutId();
1488
+ const startedAt = Date.now();
1489
+ updateManagedCheckout(record.checkoutId, (current) => ({
1490
+ ...current,
1491
+ phase: 'mutating',
1492
+ journal: {
1493
+ operation: 'rollback_preview',
1494
+ operationId,
1495
+ step: 'planning',
1496
+ startedAt,
1497
+ previewId: preview.previewId,
1498
+ reviewId: review.reviewId,
1499
+ resumeRevision,
1500
+ },
1501
+ revision: current.revision + 1,
1502
+ }));
1503
+ const result = await dependencies.applyEngine.rollback({ localPath: record.localRoot, receipt: preview });
1504
+ if (result.status === 'error') {
1505
+ if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
1506
+ if (!retryingDetached) {
1507
+ return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'rollback_preview');
1508
+ }
1509
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1510
+ }
1511
+ else if (result.error.code === 'invalid_input') {
1512
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1513
+ }
1514
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1515
+ }
1516
+ updateManagedCheckout(record.checkoutId, (current) => ({
1517
+ ...current,
1518
+ phase: 'ready',
1519
+ delivery: resumeRevision
1520
+ ? { state: 'working', iteration: review.iteration }
1521
+ : { state: 'ready_for_review', review },
1522
+ journal: null,
1523
+ revision: current.revision + 1,
1524
+ }));
1525
+ await releasePreviewArtifactsBestEffort(record, preview.previewId);
1526
+ return { status: 'preview_rolled_back', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
1527
+ }
1054
1528
  function retainFinalized(record, retention) {
1055
1529
  if (record.delivery.state !== 'finalized')
1056
1530
  return undefined;
@@ -1254,6 +1728,100 @@ export function createSessionCheckoutModule(dependencies) {
1254
1728
  return { cleaned: false, message, reason };
1255
1729
  }
1256
1730
  }
1731
+ async function operateFinalizePreview(input, binding) {
1732
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1733
+ return operationError('not_owner', '只有 owner Isolated 会话可以完成验收提交');
1734
+ }
1735
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1736
+ if (!record)
1737
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1738
+ if (record.revision !== input.expectedRevision) {
1739
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1740
+ }
1741
+ if (record.phase !== 'ready' || record.delivery.state !== 'preview_active') {
1742
+ return operationError('preview_not_active', '当前没有等待验收的 Local Preview', await inspectIsolated(binding));
1743
+ }
1744
+ // Harness plugin does not expose Domi collaborator checkout inheritance.
1745
+ const { preview, review } = record.delivery;
1746
+ const operationId = dependencies.createCheckoutId();
1747
+ const startedAt = Date.now();
1748
+ updateManagedCheckout(record.checkoutId, (current) => ({
1749
+ ...current,
1750
+ phase: 'mutating',
1751
+ journal: { operation: 'finalize_preview', operationId, step: 'planning', startedAt, previewId: preview.previewId, reviewId: review.reviewId, retention: input.retention ?? 'cleanup' },
1752
+ revision: current.revision + 1,
1753
+ }));
1754
+ const result = await dependencies.applyEngine.finalize({
1755
+ localPath: record.localRoot,
1756
+ receipt: preview,
1757
+ commitMessage: input.commitMessage,
1758
+ beforeCommit: async (commitOid) => {
1759
+ updateManagedCheckout(record.checkoutId, (current) => ({
1760
+ ...current,
1761
+ journal: current.journal?.operation === 'finalize_preview'
1762
+ ? { ...current.journal, step: 'updating_ref', commitOid }
1763
+ : current.journal,
1764
+ revision: current.revision + 1,
1765
+ }));
1766
+ },
1767
+ });
1768
+ if (result.status === 'error') {
1769
+ if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
1770
+ return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'finalize_preview');
1771
+ }
1772
+ if (result.error.code === 'commit_isolation_conflict' || result.error.code === 'operation_not_allowed' || result.error.code === 'invalid_input') {
1773
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1774
+ }
1775
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1776
+ }
1777
+ const finalized = updateManagedCheckout(record.checkoutId, (current) => ({
1778
+ ...current,
1779
+ phase: 'finalized',
1780
+ delivery: {
1781
+ state: 'finalized',
1782
+ review,
1783
+ commitOid: result.commitOid,
1784
+ proof: {
1785
+ localBranch: preview.localHeadRef?.startsWith('refs/heads/')
1786
+ ? preview.localHeadRef.slice('refs/heads/'.length)
1787
+ : null,
1788
+ localHeadBefore: preview.localHeadOid,
1789
+ localHeadAfter: result.commitOid ?? preview.localHeadOid,
1790
+ changedFiles: [...result.changedFiles],
1791
+ },
1792
+ isolatedFingerprint: preview.isolatedFingerprint,
1793
+ finalizedAt: Date.now(),
1794
+ cleanup: 'pending',
1795
+ },
1796
+ journal: null,
1797
+ revision: current.revision + 1,
1798
+ }));
1799
+ if (!finalized)
1800
+ return operationError('checkout_missing', '提交已创建,但 Checkout 记录丢失');
1801
+ const retention = input.retention ?? 'cleanup';
1802
+ if (retention !== 'cleanup') {
1803
+ const retained = retainFinalized(finalized, retention);
1804
+ if (!retained)
1805
+ return operationError('checkout_missing', '提交已创建,但保留 Worktree 状态写入失败');
1806
+ return {
1807
+ status: 'finished',
1808
+ target: await inspectIsolated(binding),
1809
+ changedFiles: result.changedFiles,
1810
+ commitOid: result.commitOid,
1811
+ cleanup: 'retained',
1812
+ };
1813
+ }
1814
+ const cleanup = await cleanupFinalized(finalized);
1815
+ return {
1816
+ status: 'finished',
1817
+ target: await inspectIsolated(binding),
1818
+ changedFiles: result.changedFiles,
1819
+ commitOid: result.commitOid,
1820
+ cleanup: cleanup.cleaned ? 'discarded' : 'pending',
1821
+ ...(cleanup.message ? { cleanupMessage: cleanup.message } : {}),
1822
+ ...(cleanup.reason ? { cleanupReason: cleanup.reason } : {}),
1823
+ };
1824
+ }
1257
1825
  async function operateRetryCleanup(input, binding) {
1258
1826
  if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1259
1827
  return operationError('not_owner', '只有 owner Isolated 会话可以重试清理');
@@ -1291,7 +1859,14 @@ export function createSessionCheckoutModule(dependencies) {
1291
1859
  if (record.revision !== input.expectedRevision) {
1292
1860
  return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1293
1861
  }
1294
- if (record.phase !== 'ready') {
1862
+ if (input.expectedReviewId !== undefined && (record.delivery.state !== 'ready_for_review'
1863
+ || record.delivery.review.reviewId !== input.expectedReviewId)) {
1864
+ return operationError('stale_target', '该验收卡已不是当前 Review,请刷新并确认最新交付', await inspectIsolated(binding));
1865
+ }
1866
+ if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
1867
+ return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;为避免遗漏或重复提交,已禁止自动 Finish,请先人工核对 Local 后再清理记录。', await inspectIsolated(binding));
1868
+ }
1869
+ if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
1295
1870
  return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能直接 Finish`, await inspectIsolated(binding));
1296
1871
  }
1297
1872
  const holder = findProjectAcceptanceHolder(record);
@@ -1357,6 +1932,21 @@ export function createSessionCheckoutModule(dependencies) {
1357
1932
  }));
1358
1933
  return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
1359
1934
  }
1935
+ if (input.expectedReviewId !== undefined
1936
+ && applying.delivery.state === 'ready_for_review'
1937
+ && (applying.delivery.review.reviewId !== input.expectedReviewId
1938
+ || planResult.plan.isolatedFingerprint !== applying.delivery.review.isolatedFingerprint
1939
+ || planResult.plan.isolatedHeadOid !== applying.delivery.review.isolatedHeadOid)) {
1940
+ const reviewIteration = applying.delivery.review.iteration;
1941
+ updateManagedCheckout(applying.checkoutId, (current) => ({
1942
+ ...current,
1943
+ phase: 'ready',
1944
+ delivery: { state: 'working', iteration: reviewIteration },
1945
+ journal: null,
1946
+ revision: current.revision + 1,
1947
+ }));
1948
+ return operationError('stale_isolated', 'Worktree 在验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
1949
+ }
1360
1950
  const iteration = applying.delivery.state === 'working'
1361
1951
  ? applying.delivery.iteration
1362
1952
  : applying.delivery.state === 'delivered'
@@ -1376,6 +1966,7 @@ export function createSessionCheckoutModule(dependencies) {
1376
1966
  isolatedFingerprint: planResult.plan.isolatedFingerprint,
1377
1967
  isolatedHeadOid: planResult.plan.isolatedHeadOid,
1378
1968
  };
1969
+ const previewId = dependencies.createCheckoutId();
1379
1970
  updateManagedCheckout(applying.checkoutId, (current) => ({
1380
1971
  ...current,
1381
1972
  delivery: { state: 'ready_for_review', review },
@@ -1396,38 +1987,89 @@ export function createSessionCheckoutModule(dependencies) {
1396
1987
  },
1397
1988
  revision: current.revision + 1,
1398
1989
  }));
1399
- const finishResult = await dependencies.applyEngine.finish(planResult.plan, {
1990
+ const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
1991
+ previewId,
1992
+ reviewId: review.reviewId,
1993
+ iteration: review.iteration,
1994
+ beforeWrite: async (receipt) => {
1995
+ await retainPreviewArtifacts(applying, receipt);
1996
+ updateManagedCheckout(applying.checkoutId, (current) => ({
1997
+ ...current,
1998
+ delivery: { state: 'preview_active', review, preview: receipt },
1999
+ journal: current.journal?.operation === 'finish'
2000
+ ? { ...current.journal, step: 'artifacts_retained', previewId, reviewId: review.reviewId }
2001
+ : current.journal,
2002
+ revision: current.revision + 1,
2003
+ }));
2004
+ },
2005
+ });
2006
+ if (previewResult.status === 'error') {
2007
+ const current = dependencies.registry.read().managedCheckouts[applying.checkoutId];
2008
+ if (current?.journal?.step === 'writing_local') {
2009
+ await releasePreviewArtifactsBestEffort(applying, previewId);
2010
+ updateManagedCheckout(applying.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
2011
+ }
2012
+ return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
2013
+ }
2014
+ const receipt = previewResult.receipt;
2015
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2016
+ ...current,
2017
+ phase: 'mutating',
2018
+ delivery: { state: 'preview_active', review, preview: receipt },
2019
+ journal: {
2020
+ operation: 'finish',
2021
+ operationId,
2022
+ step: 'planning',
2023
+ startedAt,
2024
+ previewId,
2025
+ reviewId: review.reviewId,
2026
+ isolatedFingerprint: receipt.isolatedFingerprint,
2027
+ retention: input.retention ?? 'cleanup',
2028
+ changedFiles: [...receipt.changedFiles],
2029
+ },
2030
+ revision: current.revision + 1,
2031
+ }));
2032
+ const finishResult = await dependencies.applyEngine.finalize({
2033
+ localPath: applying.localRoot,
2034
+ receipt,
1400
2035
  commitMessage: input.commitMessage,
2036
+ beforeCommit: async (commitOid) => {
2037
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2038
+ ...current,
2039
+ journal: current.journal?.operation === 'finish'
2040
+ ? { ...current.journal, step: 'updating_ref', commitOid }
2041
+ : current.journal,
2042
+ revision: current.revision + 1,
2043
+ }));
2044
+ },
1401
2045
  });
1402
2046
  if (finishResult.status === 'error') {
1403
- if (finishResult.error.code === 'invalid_plan'
1404
- || finishResult.error.code === 'stale_local'
1405
- || finishResult.error.code === 'stale_isolated'
2047
+ const safePreview = finishResult.error.code === 'stale_local'
2048
+ || finishResult.error.code === 'preview_modified'
1406
2049
  || finishResult.error.code === 'invalid_input'
1407
- || finishResult.error.code === 'operation_not_allowed'
1408
- || finishResult.error.code === 'commit_isolation_conflict') {
1409
- // 这些错误码可证明未触碰 Local,恢复 ready;git_error 交由 reconcile 兜底 recovery_required。
2050
+ || finishResult.error.code === 'commit_isolation_conflict'
2051
+ || finishResult.error.code === 'operation_not_allowed';
2052
+ if (safePreview) {
1410
2053
  updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1411
2054
  }
1412
2055
  return operationError(finishResult.error.code, finishResult.error.message, await inspectIsolated(binding));
1413
2056
  }
1414
2057
  const finalized = updateManagedCheckout(applying.checkoutId, (current) => ({
1415
2058
  ...current,
1416
- applyBaseOid: finishResult.nextBaseOid,
1417
2059
  phase: 'finalized',
1418
2060
  delivery: {
1419
2061
  state: 'finalized',
1420
2062
  review,
1421
2063
  commitOid: finishResult.commitOid,
1422
2064
  proof: {
1423
- localBranch: planResult.plan.localHeadRef?.startsWith('refs/heads/')
1424
- ? planResult.plan.localHeadRef.slice('refs/heads/'.length)
2065
+ localBranch: receipt.localHeadRef?.startsWith('refs/heads/')
2066
+ ? receipt.localHeadRef.slice('refs/heads/'.length)
1425
2067
  : null,
1426
- localHeadBefore: planResult.plan.localHeadOid,
1427
- localHeadAfter: finishResult.commitOid ?? planResult.plan.localHeadOid,
2068
+ localHeadBefore: receipt.localHeadOid,
2069
+ localHeadAfter: finishResult.commitOid ?? receipt.localHeadOid,
1428
2070
  changedFiles: [...finishResult.changedFiles],
1429
2071
  },
1430
- isolatedFingerprint: planResult.plan.isolatedFingerprint,
2072
+ isolatedFingerprint: receipt.isolatedFingerprint,
1431
2073
  finalizedAt: Date.now(),
1432
2074
  cleanup: 'pending',
1433
2075
  },
@@ -1484,6 +2126,40 @@ export function createSessionCheckoutModule(dependencies) {
1484
2126
  if (record.revision !== input.expectedRevision) {
1485
2127
  return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1486
2128
  }
2129
+ if (record.delivery.state === 'preview_detached') {
2130
+ return operationError('preview_modified', 'Local Preview 已因漂移进入 detached 恢复态;为保留恢复证据,不能删除 Worktree。请先成功撤回 Preview。', await inspectIsolated(binding));
2131
+ }
2132
+ if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
2133
+ return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;不会自动 Discard,请先人工核对 Local 的未提交修改。', await inspectIsolated(binding));
2134
+ }
2135
+ if (record.delivery.state === 'preview_active') {
2136
+ if (!input.rollbackPreview) {
2137
+ return operationError('preview_not_active', 'Local 正在验收本任务;放弃任务前必须先安全撤回 Preview', await inspectIsolated(binding));
2138
+ }
2139
+ const rollback = await dependencies.applyEngine.rollback({
2140
+ localPath: record.localRoot,
2141
+ receipt: record.delivery.preview,
2142
+ });
2143
+ if (rollback.status === 'error') {
2144
+ if (rollback.error.code === 'stale_local' || rollback.error.code === 'preview_modified') {
2145
+ return detachPreviewAfterLocalDrift(record, binding, rollback.error.code, 'discard');
2146
+ }
2147
+ return operationError(rollback.error.code, rollback.error.message, await inspectIsolated(binding));
2148
+ }
2149
+ const previewId = record.delivery.preview.previewId;
2150
+ const iteration = record.delivery.review.iteration;
2151
+ const rolledBack = updateManagedCheckout(record.checkoutId, (current) => ({
2152
+ ...current,
2153
+ delivery: { state: 'working', iteration },
2154
+ journal: null,
2155
+ revision: current.revision + 1,
2156
+ }));
2157
+ if (!rolledBack)
2158
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
2159
+ await releasePreviewArtifactsBestEffort(record, previewId);
2160
+ record = rolledBack;
2161
+ }
2162
+ // Harness plugin has no inherited collaborator checkout ownership.
1487
2163
  if (record.phase === 'recovery_required' && !dependencies.files.exists(record.managedRoot)) {
1488
2164
  await releaseApplyBaseBestEffort(record);
1489
2165
  updateManagedCheckout(record.checkoutId, (current) => ({
@@ -1579,6 +2255,12 @@ export function createSessionCheckoutModule(dependencies) {
1579
2255
  return await operateApply(input, binding);
1580
2256
  if (input.action === 'finish')
1581
2257
  return await operateFinish(input, binding);
2258
+ if (input.action === 'preview')
2259
+ return await operatePreview(input, binding);
2260
+ if (input.action === 'rollback_preview')
2261
+ return await operateRollbackPreview(input, binding);
2262
+ if (input.action === 'finalize_preview')
2263
+ return await operateFinalizePreview(input, binding);
1582
2264
  if (input.action === 'retry_cleanup')
1583
2265
  return await operateRetryCleanup(input, binding);
1584
2266
  if (input.action === 'discard')
@@ -1604,6 +2286,10 @@ export function createSessionCheckoutModule(dependencies) {
1604
2286
  return record.journal?.startedAt ?? 0;
1605
2287
  if (record.delivery.state === 'ready_for_review')
1606
2288
  return record.delivery.review.preparedAt;
2289
+ if (record.delivery.state === 'preview_active')
2290
+ return record.delivery.preview.previewedAt;
2291
+ if (record.delivery.state === 'preview_detached')
2292
+ return record.delivery.detachedAt;
1607
2293
  if (record.delivery.state === 'finalized')
1608
2294
  return record.delivery.finalizedAt;
1609
2295
  if (record.delivery.state === 'retained')
@@ -1664,11 +2350,15 @@ export function createSessionCheckoutModule(dependencies) {
1664
2350
  ? delivery.cleanup === 'blocked' ? 'needs_attention' : 'retained'
1665
2351
  : delivery.state === 'finalized'
1666
2352
  ? delivery.cleanup === 'blocked' ? 'needs_attention' : 'cleanup_pending'
1667
- : delivery.state === 'ready_for_review'
1668
- ? 'ready_for_review'
1669
- : delivery.state === 'delivered'
1670
- ? 'delivered'
1671
- : 'working';
2353
+ : delivery.state === 'preview_active'
2354
+ ? 'preview_active'
2355
+ : delivery.state === 'preview_detached'
2356
+ ? 'needs_attention'
2357
+ : delivery.state === 'ready_for_review'
2358
+ ? 'ready_for_review'
2359
+ : delivery.state === 'delivered'
2360
+ ? 'delivered'
2361
+ : 'working';
1672
2362
  const commitOid = delivery.state === 'finalized' || delivery.state === 'retained' || delivery.state === 'delivered'
1673
2363
  ? delivery.commitOid
1674
2364
  : null;
@@ -1709,6 +2399,9 @@ export function createSessionCheckoutModule(dependencies) {
1709
2399
  return cleanupBlocked('working', '当前轮次仍在修改,尚未形成可清理的交付环境。', record.revision);
1710
2400
  if (record.delivery.state === 'ready_for_review')
1711
2401
  return cleanupBlocked('review_pending', '当前轮次正在等待验收,不能清理。', record.revision);
2402
+ if (record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
2403
+ return cleanupBlocked('preview_active', 'Local Preview 尚未完成安全收口,不能清理。', record.revision);
2404
+ }
1712
2405
  if (record.delivery.state === 'delivered' || record.phase === 'discarded') {
1713
2406
  return cleanupBlocked('unknown', 'Worktree 已交付并解除管理,无需再次清理。', record.revision);
1714
2407
  }
@@ -1835,6 +2528,37 @@ export function createSessionCheckoutModule(dependencies) {
1835
2528
  .filter((summary) => !input.needsAttention || summary.state === 'needs_attention' || summary.state === 'cleanup_pending')
1836
2529
  .sort((left, right) => right.updatedAt - left.updatedAt);
1837
2530
  }
2531
+ async function listManagedWorktreesForSession(sessionId, input = {}) {
2532
+ const session = requireSession(sessionId);
2533
+ const persisted = getPersistedBinding(sessionId);
2534
+ const projectId = persisted?.projectId ?? session.projectId;
2535
+ if (!projectId)
2536
+ throw new SessionCheckoutError('project_not_found', '会话尚未关联项目');
2537
+ const allowedCheckoutIds = new Set(Object.values(dependencies.registry.read().managedCheckouts)
2538
+ .filter((record) => record.projectId === projectId)
2539
+ .filter((record) => record.ownerSessionId === sessionId || record.sourceSessionId === sessionId)
2540
+ .map((record) => record.checkoutId));
2541
+ const summaries = await listManagedWorktrees({ ...input, projectId });
2542
+ return summaries.filter((summary) => allowedCheckoutIds.has(summary.checkoutId));
2543
+ }
2544
+ async function manageManagedWorktreeForSession(sessionId, input) {
2545
+ requireSession(sessionId);
2546
+ const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
2547
+ if (!record)
2548
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
2549
+ if (record.ownerSessionId !== sessionId && record.sourceSessionId !== sessionId) {
2550
+ throw new SessionCheckoutError('not_owner', '当前 Session 无权管理该 Worktree');
2551
+ }
2552
+ if (record.ownerSessionId !== sessionId && dependencies.lookup.getSession(record.ownerSessionId)) {
2553
+ throw new SessionCheckoutError('not_owner', 'Owner Session 已接管该 Worktree,只有 owner 可以管理');
2554
+ }
2555
+ const persisted = getPersistedBinding(sessionId);
2556
+ const callerProjectId = persisted?.projectId ?? dependencies.lookup.getSession(sessionId)?.projectId;
2557
+ if (callerProjectId !== record.projectId) {
2558
+ throw new SessionCheckoutError('project_mismatch', '当前 Session 与 Worktree 不属于同一原始项目');
2559
+ }
2560
+ return manageManagedWorktree(input);
2561
+ }
1838
2562
  async function manageManagedWorktree(input) {
1839
2563
  const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
1840
2564
  if (!record)
@@ -1842,12 +2566,15 @@ export function createSessionCheckoutModule(dependencies) {
1842
2566
  if (record.revision !== input.expectedRevision)
1843
2567
  throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后重试');
1844
2568
  if (input.action === 'discard') {
1845
- const result = await operateTarget({
2569
+ // Caller authorization is performed by the scoped wrapper (or a trusted Host manager).
2570
+ // The reserved owner Session may not exist yet, so do not re-resolve it through lookup.
2571
+ const result = await operateDiscard({
1846
2572
  action: 'discard',
1847
2573
  sessionId: record.ownerSessionId,
1848
2574
  expectedRevision: input.expectedRevision,
1849
2575
  confirmDirty: input.confirmDirty === true,
1850
- });
2576
+ ...(input.rollbackPreview === undefined ? {} : { rollbackPreview: input.rollbackPreview }),
2577
+ }, bindingForManagedRecord(record));
1851
2578
  if (result.status === 'error')
1852
2579
  throw new SessionCheckoutError(result.code, result.message);
1853
2580
  const updated = dependencies.registry.read().managedCheckouts[record.checkoutId];
@@ -1918,8 +2645,8 @@ export function createSessionCheckoutModule(dependencies) {
1918
2645
  }
1919
2646
  return cleaned;
1920
2647
  }
1921
- async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now()) {
1922
- const session = requireSession(sessionId);
2648
+ async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now(), sourceSessionId = sessionId) {
2649
+ const session = requireSession(sourceSessionId);
1923
2650
  let nextIteration = 1;
1924
2651
  let replacedDeliveredBinding;
1925
2652
  const existing = getPersistedBinding(sessionId);
@@ -1953,7 +2680,7 @@ export function createSessionCheckoutModule(dependencies) {
1953
2680
  throw new SessionCheckoutError('target_already_bound', '会话已经绑定 Session Target,不能切换');
1954
2681
  }
1955
2682
  }
1956
- const { project } = await resolveSessionProject(sessionId);
2683
+ const { project } = await resolveSessionProject(sourceSessionId);
1957
2684
  const snapshot = await dependencies.git.inspect(project.root);
1958
2685
  if (choice.kind === 'local') {
1959
2686
  const binding = {
@@ -1979,9 +2706,75 @@ export function createSessionCheckoutModule(dependencies) {
1979
2706
  const checkoutId = dependencies.createCheckoutId();
1980
2707
  const localRoot = await dependencies.files.canonicalize(project.root);
1981
2708
  const localGitRoot = await dependencies.files.canonicalize(snapshot.root);
1982
- // DSH workspace-write 沙箱只允许项目根目录内写入,worktree 必须放在仓库内部。
1983
- const managedGitRoot = join(localGitRoot, '.dsh-worktrees');
1984
- dependencies.files.ensureDirectory(dirname(managedGitRoot));
2709
+ const repositoryKey = createManagedWorktreeRepositoryKey(localGitRoot);
2710
+ const pathCandidates = [8, 12, 32].map(identityLength => createManagedWorktreePathCandidates({
2711
+ localGitRoot,
2712
+ managedCheckoutsRoot: dependencies.managedCheckoutsRoot,
2713
+ repositoryKey,
2714
+ checkoutId,
2715
+ identityLength,
2716
+ }));
2717
+ // Prefer one repository-owned sibling container so Local never sees its
2718
+ // managed worktrees as untracked. A container that is a file/symlink is
2719
+ // untrusted and falls back without modification. Existing child paths
2720
+ // are never reused or cleaned: extend the trusted checkout identity.
2721
+ const outerContainingRoot = await dependencies.git.findContainingWorktreeRoot(dirname(localGitRoot));
2722
+ const siblingWouldPolluteOuter = outerContainingRoot !== null && !pathsEqual(outerContainingRoot, localGitRoot);
2723
+ const usingSibling = createAttempt === 0 && !siblingWouldPolluteOuter;
2724
+ const managedContainer = usingSibling
2725
+ ? pathCandidates[0].siblingContainer
2726
+ : pathCandidates[0].fallbackContainer;
2727
+ const knownContainer = Object.values(dependencies.registry.read().managedCheckouts)
2728
+ .some(record => pathsEqual(dirname(record.managedGitRoot), managedContainer));
2729
+ const containerExisted = dependencies.files.exists(managedContainer);
2730
+ let containerIdentity = containerExisted
2731
+ ? await dependencies.files.inspectDirectoryIdentity(managedContainer)
2732
+ : null;
2733
+ if (containerExisted && containerIdentity !== null && !knownContainer) {
2734
+ // A same-named directory with unknown content is not ours. An empty
2735
+ // directory has no user bytes to preserve, so remove/recreate it as the
2736
+ // ownership boundary; a non-empty tree is left untouched and rejected.
2737
+ let reclaimedEmpty = false;
2738
+ try {
2739
+ reclaimedEmpty = dependencies.files.removeEmptyDirectoryTree(managedContainer);
2740
+ }
2741
+ catch { /* fail closed below */ }
2742
+ if (!reclaimedEmpty)
2743
+ containerIdentity = null;
2744
+ else {
2745
+ try {
2746
+ dependencies.files.ensureDirectory(managedContainer);
2747
+ containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
2748
+ }
2749
+ catch {
2750
+ containerIdentity = null;
2751
+ }
2752
+ }
2753
+ }
2754
+ else if (!containerExisted) {
2755
+ try {
2756
+ dependencies.files.ensureDirectory(managedContainer);
2757
+ containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
2758
+ }
2759
+ catch {
2760
+ containerIdentity = null;
2761
+ }
2762
+ }
2763
+ if (containerIdentity === null) {
2764
+ if (usingSibling) {
2765
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
2766
+ }
2767
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree 回退容器不是可信目录,未创建或修改任何 checkout');
2768
+ }
2769
+ const managedGitRoot = pathCandidates
2770
+ .map(candidate => usingSibling ? candidate.siblingRoot : candidate.fallbackRoot)
2771
+ .find(candidate => !dependencies.files.exists(candidate));
2772
+ if (managedGitRoot === undefined) {
2773
+ if (usingSibling) {
2774
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
2775
+ }
2776
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree Checkout identity 路径均已存在,拒绝覆盖未知目录');
2777
+ }
1985
2778
  const projectRelativePath = relative(localGitRoot, localRoot);
1986
2779
  if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
1987
2780
  throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
@@ -1992,6 +2785,7 @@ export function createSessionCheckoutModule(dependencies) {
1992
2785
  projectId: project.id,
1993
2786
  projectName: project.name,
1994
2787
  ownerSessionId: sessionId,
2788
+ sourceSessionId,
1995
2789
  localRoot,
1996
2790
  managedRoot: resolve(managedRoot),
1997
2791
  managedGitRoot: resolve(managedGitRoot),
@@ -2090,19 +2884,43 @@ export function createSessionCheckoutModule(dependencies) {
2090
2884
  failedRegistry.revision += 1;
2091
2885
  dependencies.registry.write(failedRegistry);
2092
2886
  if (createAttempt < 1) {
2093
- return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt);
2887
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
2094
2888
  }
2095
2889
  throw new SessionCheckoutError('git_operation_failed', 'Worktree 创建失败,已安全清理残余目录,可直接重试');
2096
2890
  }
2097
2891
  }
2098
2892
  return {
2099
2893
  inspect: inspectAvailable,
2894
+ runtimeContext,
2100
2895
  preflight: (sessionId, expectedRevision) => withBindingLock(() => preflightTarget(sessionId, expectedRevision)),
2101
2896
  runExclusiveSessionMutation: (sessionId, operation) => withBindingLock(async () => (operation(await inspectTarget(sessionId, true)))),
2102
2897
  bind: (sessionId, choice) => {
2103
2898
  const requestStartedAt = Date.now();
2104
2899
  return withBindingLock(() => bindTarget(sessionId, choice, 0, requestStartedAt));
2105
2900
  },
2901
+ createIsolatedTarget: (sourceSessionId, targetSessionId) => withBindingLock(async () => {
2902
+ if (!targetSessionId || targetSessionId === sourceSessionId) {
2903
+ throw new SessionCheckoutError('invalid_input', 'Isolated Target 必须使用独立的预分配 Session ID');
2904
+ }
2905
+ if (dependencies.lookup.getSession(targetSessionId) && !getPersistedBinding(targetSessionId)) {
2906
+ throw new SessionCheckoutError('target_already_bound', '目标 Session ID 已被其他 Workspace 使用');
2907
+ }
2908
+ const sourceBinding = getPersistedBinding(sourceSessionId);
2909
+ if (!sourceBinding) {
2910
+ await bindTarget(sourceSessionId, { kind: 'local' });
2911
+ }
2912
+ else if (sourceBinding.target.kind !== 'local') {
2913
+ throw new SessionCheckoutError('operation_not_allowed', '只能从 Local Session 创建新的 Isolated Target');
2914
+ }
2915
+ const target = await bindTarget(targetSessionId, { kind: 'isolated' }, 0, Date.now(), sourceSessionId);
2916
+ const binding = getPersistedBinding(targetSessionId);
2917
+ const record = binding?.target.kind === 'isolated'
2918
+ ? dependencies.registry.read().managedCheckouts[binding.target.checkoutId]
2919
+ : undefined;
2920
+ if (!record)
2921
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Target 创建后记录缺失');
2922
+ return { targetSessionId, managedRoot: record.managedRoot, target };
2923
+ }),
2106
2924
  beginNextIteration: (sessionId) => {
2107
2925
  const requestStartedAt = Date.now();
2108
2926
  return withBindingLock(() => bindTarget(sessionId, { kind: 'isolated' }, 0, requestStartedAt));
@@ -2111,6 +2929,8 @@ export function createSessionCheckoutModule(dependencies) {
2111
2929
  operate: (input) => withBindingLock(() => operateTarget(input)),
2112
2930
  // 只读管理列表不占用全局 mutation lock;慢速目录诊断与用户操作互不阻塞。
2113
2931
  listManagedWorktrees,
2932
+ listManagedWorktreesForSession,
2933
+ manageManagedWorktreeForSession: (sessionId, input) => withBindingLock(() => manageManagedWorktreeForSession(sessionId, input)),
2114
2934
  inspectManagedWorktreeCleanup,
2115
2935
  bulkCleanupManagedWorktrees: (candidates) => withBindingLock(() => bulkCleanupManagedWorktrees(candidates)),
2116
2936
  manageManagedWorktree: (input) => withBindingLock(() => manageManagedWorktree(input)),