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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -21
- package/README.md +115 -43
- package/README.zh.md +115 -41
- package/cordis.patch.yml +7 -7
- package/lib/adapters/registry.js +40 -0
- package/lib/adapters/registry.js.map +1 -1
- package/lib/adapters/safe-file.js +34 -4
- package/lib/adapters/safe-file.js.map +1 -1
- package/lib/client/WorktreeCreateRow.d.ts +7 -0
- package/lib/client/WorktreeCreateRow.js +26 -0
- package/lib/client/WorktreeCreateRow.js.map +1 -0
- package/lib/client/WorktreeReviewRow.d.ts +11 -0
- package/lib/client/WorktreeReviewRow.js +67 -0
- package/lib/client/WorktreeReviewRow.js.map +1 -0
- package/lib/client/actions.d.ts +86 -0
- package/lib/client/actions.js +35 -0
- package/lib/client/actions.js.map +1 -0
- package/lib/client/console-remote/adapter.d.ts +3 -0
- package/lib/client/console-remote/adapter.js +59 -0
- package/lib/client/console-remote/adapter.js.map +1 -0
- package/lib/client/console-remote/index.d.ts +18 -0
- package/lib/client/console-remote/index.js +17 -0
- package/lib/client/console-remote/index.js.map +1 -0
- package/lib/client/index.d.ts +17 -0
- package/lib/client/index.js +44 -0
- package/lib/client/index.js.map +1 -0
- package/lib/client/model.d.ts +68 -0
- package/lib/client/model.js +127 -0
- package/lib/client/model.js.map +1 -0
- package/lib/client/pre-session/PreSessionWorktreeToggle.d.ts +16 -0
- package/lib/client/pre-session/PreSessionWorktreeToggle.js +128 -0
- package/lib/client/pre-session/PreSessionWorktreeToggle.js.map +1 -0
- package/lib/client/pre-session/controller.d.ts +42 -0
- package/lib/client/pre-session/controller.js +149 -0
- package/lib/client/pre-session/controller.js.map +1 -0
- package/lib/client/pre-session/index.d.ts +15 -0
- package/lib/client/pre-session/index.js +16 -0
- package/lib/client/pre-session/index.js.map +1 -0
- package/lib/client/review-console/ReviewActions.d.ts +15 -0
- package/lib/client/review-console/ReviewActions.js +219 -0
- package/lib/client/review-console/ReviewActions.js.map +1 -0
- package/lib/client/review-console/WorktreeReviewPanel.d.ts +20 -0
- package/lib/client/review-console/WorktreeReviewPanel.js +75 -0
- package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -0
- package/lib/client/review-console/index.d.ts +8 -0
- package/lib/client/review-console/index.js +22 -0
- package/lib/client/review-console/index.js.map +1 -0
- package/lib/client/review-console/review-console.styles.d.ts +1 -0
- package/lib/client/review-console/review-console.styles.js +81 -0
- package/lib/client/review-console/review-console.styles.js.map +1 -0
- package/lib/client/review-console/status-events.d.ts +3 -0
- package/lib/client/review-console/status-events.js +6 -0
- package/lib/client/review-console/status-events.js.map +1 -0
- package/lib/client/styles.d.ts +1 -0
- package/lib/client/styles.js +78 -0
- package/lib/client/styles.js.map +1 -0
- package/lib/client/target-console/TargetStatusAction.d.ts +7 -0
- package/lib/client/target-console/TargetStatusAction.js +46 -0
- package/lib/client/target-console/TargetStatusAction.js.map +1 -0
- package/lib/client/target-console/WorktreeConsoleView.d.ts +9 -0
- package/lib/client/target-console/WorktreeConsoleView.js +362 -0
- package/lib/client/target-console/WorktreeConsoleView.js.map +1 -0
- package/lib/client/target-console/WorktreeReviewStatus.d.ts +9 -0
- package/lib/client/target-console/WorktreeReviewStatus.js +91 -0
- package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -0
- package/lib/client/target-console/index.d.ts +14 -0
- package/lib/client/target-console/index.js +23 -0
- package/lib/client/target-console/index.js.map +1 -0
- package/lib/client/target-console/target-console.styles.d.ts +1 -0
- package/lib/client/target-console/target-console.styles.js +130 -0
- package/lib/client/target-console/target-console.styles.js.map +1 -0
- package/lib/client.js +6813 -0
- package/lib/commands.d.ts +2 -4
- package/lib/commands.js +54 -28
- package/lib/commands.js.map +1 -1
- package/lib/console-contract.d.ts +236 -0
- package/lib/console-contract.js +86 -0
- package/lib/console-contract.js.map +1 -0
- package/lib/console-host/control-plane.d.ts +29 -0
- package/lib/console-host/control-plane.js +443 -0
- package/lib/console-host/control-plane.js.map +1 -0
- package/lib/console-host/errors.d.ts +6 -0
- package/lib/console-host/errors.js +31 -0
- package/lib/console-host/errors.js.map +1 -0
- package/lib/console-host/projection.d.ts +12 -0
- package/lib/console-host/projection.js +131 -0
- package/lib/console-host/projection.js.map +1 -0
- package/lib/console-host/review-diff.d.ts +18 -0
- package/lib/console-host/review-diff.js +176 -0
- package/lib/console-host/review-diff.js.map +1 -0
- package/lib/console-host/service.d.ts +24 -0
- package/lib/console-host/service.js +131 -0
- package/lib/console-host/service.js.map +1 -0
- package/lib/console-remote/descriptors.d.ts +3 -0
- package/lib/console-remote/descriptors.js +96 -0
- package/lib/console-remote/descriptors.js.map +1 -0
- package/lib/console-remote/remote.d.ts +27 -0
- package/lib/console-remote/remote.js +4 -0
- package/lib/console-remote/remote.js.map +1 -0
- package/lib/console-remote/schemas.d.ts +29 -0
- package/lib/console-remote/schemas.js +143 -0
- package/lib/console-remote/schemas.js.map +1 -0
- package/lib/console-remote/typert.d.ts +3 -0
- package/lib/console-remote/typert.js +14 -0
- package/lib/console-remote/typert.js.map +1 -0
- package/lib/index.d.ts +22 -6
- package/lib/index.js +28 -10
- package/lib/index.js.map +1 -1
- package/lib/managed-worktree-path.d.ts +14 -13
- package/lib/managed-worktree-path.js +32 -22
- package/lib/managed-worktree-path.js.map +1 -1
- package/lib/ports.d.ts +37 -1
- package/lib/session-checkout-apply.d.ts +54 -1
- package/lib/session-checkout-apply.js +318 -28
- package/lib/session-checkout-apply.js.map +1 -1
- package/lib/session-checkout-module.js +853 -33
- package/lib/session-checkout-module.js.map +1 -1
- package/lib/session-target-context.d.ts +6 -0
- package/lib/session-target-context.js +16 -0
- package/lib/session-target-context.js.map +1 -0
- package/lib/tools.d.ts +5 -6
- package/lib/tools.js +43 -233
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +57 -11
- package/lib/types.js +6 -6
- package/lib/types.js.map +1 -1
- package/package.json +116 -69
- package/scripts/check-publish.mjs +193 -0
- package/scripts/clean-lib.mjs +7 -0
- package/scripts/dev-dsh-lib.mjs +453 -0
- package/scripts/dev-dsh.mjs +89 -0
- 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
|
-
|
|
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 === '
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
1404
|
-
|| finishResult.error.code === '
|
|
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 === '
|
|
1408
|
-
|| finishResult.error.code === '
|
|
1409
|
-
|
|
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:
|
|
1424
|
-
?
|
|
2065
|
+
localBranch: receipt.localHeadRef?.startsWith('refs/heads/')
|
|
2066
|
+
? receipt.localHeadRef.slice('refs/heads/'.length)
|
|
1425
2067
|
: null,
|
|
1426
|
-
localHeadBefore:
|
|
1427
|
-
localHeadAfter: finishResult.commitOid ??
|
|
2068
|
+
localHeadBefore: receipt.localHeadOid,
|
|
2069
|
+
localHeadAfter: finishResult.commitOid ?? receipt.localHeadOid,
|
|
1428
2070
|
changedFiles: [...finishResult.changedFiles],
|
|
1429
2071
|
},
|
|
1430
|
-
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 === '
|
|
1668
|
-
? '
|
|
1669
|
-
: delivery.state === '
|
|
1670
|
-
? '
|
|
1671
|
-
: '
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1983
|
-
const
|
|
1984
|
-
|
|
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)),
|