dsh-git-worktree 0.1.2 → 0.3.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 +93 -0
- package/LICENSE +21 -21
- package/README.md +187 -43
- package/README.zh.md +187 -41
- package/cordis.patch.yml +7 -7
- package/lib/adapters/lookup.d.ts +4 -2
- package/lib/adapters/lookup.js +31 -3
- package/lib/adapters/lookup.js.map +1 -1
- package/lib/adapters/registry.js +41 -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 +61 -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 +234 -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 +125 -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 +6902 -0
- package/lib/commands.d.ts +2 -4
- package/lib/commands.js +69 -28
- package/lib/commands.js.map +1 -1
- package/lib/console-contract.d.ts +253 -0
- package/lib/console-contract.js +86 -0
- package/lib/console-contract.js.map +1 -0
- package/lib/console-host/control-plane.d.ts +31 -0
- package/lib/console-host/control-plane.js +502 -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 +136 -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 +26 -0
- package/lib/console-host/service.js +143 -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 +105 -0
- package/lib/console-remote/descriptors.js.map +1 -0
- package/lib/console-remote/remote.d.ts +29 -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 +145 -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 +26 -8
- 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 +39 -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 +1098 -36
- 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 +6 -6
- package/lib/tools.js +107 -218
- 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 +509 -0
- package/scripts/dev-dsh.mjs +95 -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,125 @@ 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, not yet synchronized to Local. Continue ordinary discussion and answer questions without changing this Review. If the user requests new code or file changes, call worktree_resume_revision first, then continue the requested work in this same Session; do not ask the user to click a recovery control and do not synchronize Local. Until that tool succeeds, treat the Worktree as read-only. The user may still preview, directly finish, or discard through the Worktree acceptance UI.');
|
|
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 if (record.delivery.state === 'delivered') {
|
|
665
|
+
lines.push('Delivery state: Delivered and cleaned. If the user requests new code or file changes in this conversation, call worktree_begin_next_iteration first; it safely recreates this Session immutable cwd for the next iteration. Do not call worktree_create.');
|
|
666
|
+
}
|
|
667
|
+
else if (record.delivery.state === 'retained') {
|
|
668
|
+
lines.push('Delivery state: Retained. This frozen environment must be cleaned through the user controls before a next iteration can start.');
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
lines.push(`Delivery state: ${record.delivery.state}. Treat this Session as terminal until cleanup or recovery completes.`);
|
|
672
|
+
}
|
|
673
|
+
return lines.join('\n');
|
|
674
|
+
}
|
|
568
675
|
async function resolveBinding(sessionId) {
|
|
569
676
|
const session = requireSession(sessionId);
|
|
570
677
|
const persisted = getPersistedBinding(sessionId);
|
|
571
678
|
if (persisted) {
|
|
572
679
|
if (session.projectId !== persisted.projectId) {
|
|
573
|
-
|
|
680
|
+
const record = persisted.target.kind === 'isolated'
|
|
681
|
+
? dependencies.registry.read().managedCheckouts[persisted.target.checkoutId]
|
|
682
|
+
: undefined;
|
|
683
|
+
const workspace = session.projectId ? dependencies.lookup.getProject(session.projectId) : undefined;
|
|
684
|
+
let isolatedWorkspaceMatches = Boolean(record
|
|
685
|
+
&& workspace
|
|
686
|
+
&& record.phase === 'discarded'
|
|
687
|
+
&& record.delivery.state === 'delivered'
|
|
688
|
+
&& !dependencies.files.exists(workspace.root)
|
|
689
|
+
&& resolvedPathsEqual(workspace.root, record.managedRoot));
|
|
690
|
+
if (!isolatedWorkspaceMatches && record && workspace && dependencies.files.exists(workspace.root)) {
|
|
691
|
+
try {
|
|
692
|
+
const workspaceRoot = await dependencies.files.canonicalize(workspace.root);
|
|
693
|
+
isolatedWorkspaceMatches = pathsEqual(workspaceRoot, record.managedRoot);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
isolatedWorkspaceMatches = false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (!isolatedWorkspaceMatches) {
|
|
700
|
+
throw new SessionCheckoutError('project_mismatch', '会话当前 Workspace 与已绑定 Session Target 不一致,已停止访问 checkout');
|
|
701
|
+
}
|
|
574
702
|
}
|
|
575
703
|
return persisted;
|
|
576
704
|
}
|
|
@@ -684,6 +812,30 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
684
812
|
for (const [checkoutId, current] of Object.entries(registry.managedCheckouts)) {
|
|
685
813
|
if (current.phase === 'discarded')
|
|
686
814
|
continue;
|
|
815
|
+
if (current.predecessorCheckoutId
|
|
816
|
+
&& current.journal?.operation === 'create'
|
|
817
|
+
&& (current.phase === 'preparing' || current.phase === 'recovery_required')
|
|
818
|
+
&& !dependencies.files.exists(current.managedGitRoot)
|
|
819
|
+
&& !dependencies.files.exists(current.managedRoot)) {
|
|
820
|
+
const predecessor = registry.managedCheckouts[current.predecessorCheckoutId];
|
|
821
|
+
const binding = registry.sessionBindings[current.ownerSessionId];
|
|
822
|
+
if (predecessor?.phase === 'discarded'
|
|
823
|
+
&& predecessor.delivery.state === 'delivered'
|
|
824
|
+
&& binding?.target.kind === 'isolated'
|
|
825
|
+
&& binding.target.checkoutId === current.checkoutId) {
|
|
826
|
+
delete registry.managedCheckouts[checkoutId];
|
|
827
|
+
registry.sessionBindings[current.ownerSessionId] = {
|
|
828
|
+
...binding,
|
|
829
|
+
target: { kind: 'isolated', checkoutId: predecessor.checkoutId },
|
|
830
|
+
sourceRef: predecessor.sourceRef,
|
|
831
|
+
sourceOid: predecessor.baseOid,
|
|
832
|
+
revision: binding.revision + 1,
|
|
833
|
+
};
|
|
834
|
+
registry.revision += 1;
|
|
835
|
+
changed = true;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
687
839
|
let record = current;
|
|
688
840
|
if (current.delivery.state === 'finalized') {
|
|
689
841
|
// 兼容旧版本曾把 cleanup 残余错误持久化为 recovery_required;Commit 事实优先,继续收口资源即可。
|
|
@@ -691,9 +843,109 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
691
843
|
continue;
|
|
692
844
|
}
|
|
693
845
|
if (current.phase === 'mutating'
|
|
694
|
-
&& (current.journal?.operation === '
|
|
846
|
+
&& (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
|
|
847
|
+
&& current.journal.step === 'updating_ref'
|
|
848
|
+
&& typeof current.journal.commitOid === 'string'
|
|
849
|
+
&& current.delivery.state === 'preview_active') {
|
|
850
|
+
const local = await dependencies.git.inspect(current.localRoot);
|
|
851
|
+
if (local?.headOid === current.journal.commitOid) {
|
|
852
|
+
const retention = current.journal.retention ?? 'cleanup';
|
|
853
|
+
const recoveredAt = Date.now();
|
|
854
|
+
record = retention === 'cleanup'
|
|
855
|
+
? {
|
|
856
|
+
...current,
|
|
857
|
+
phase: 'finalized',
|
|
858
|
+
delivery: {
|
|
859
|
+
state: 'finalized',
|
|
860
|
+
review: current.delivery.review,
|
|
861
|
+
commitOid: current.journal.commitOid,
|
|
862
|
+
proof: {
|
|
863
|
+
localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
|
|
864
|
+
? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
|
|
865
|
+
: null,
|
|
866
|
+
localHeadBefore: current.delivery.preview.localHeadOid,
|
|
867
|
+
localHeadAfter: current.journal.commitOid,
|
|
868
|
+
changedFiles: [...current.delivery.preview.changedFiles],
|
|
869
|
+
},
|
|
870
|
+
isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
|
|
871
|
+
finalizedAt: recoveredAt,
|
|
872
|
+
cleanup: 'blocked',
|
|
873
|
+
cleanupMessage: 'Commit 已创建,但进程在 Local index 完成前中断;请确认 Local 状态后再处理 Worktree。',
|
|
874
|
+
},
|
|
875
|
+
journal: null,
|
|
876
|
+
revision: current.revision + 1,
|
|
877
|
+
}
|
|
878
|
+
: {
|
|
879
|
+
...current,
|
|
880
|
+
phase: 'retained',
|
|
881
|
+
delivery: {
|
|
882
|
+
state: 'retained',
|
|
883
|
+
review: current.delivery.review,
|
|
884
|
+
commitOid: current.journal.commitOid,
|
|
885
|
+
proof: {
|
|
886
|
+
localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
|
|
887
|
+
? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
|
|
888
|
+
: null,
|
|
889
|
+
localHeadBefore: current.delivery.preview.localHeadOid,
|
|
890
|
+
localHeadAfter: current.journal.commitOid,
|
|
891
|
+
changedFiles: [...current.delivery.preview.changedFiles],
|
|
892
|
+
},
|
|
893
|
+
isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
|
|
894
|
+
retention,
|
|
895
|
+
retainedAt: recoveredAt,
|
|
896
|
+
expiresAt: retentionExpiresAt(retention, recoveredAt),
|
|
897
|
+
cleanup: 'blocked',
|
|
898
|
+
cleanupMessage: 'Commit 已创建并保留 Worktree,但进程在 Local index 完成前中断;请确认 Local 状态。',
|
|
899
|
+
},
|
|
900
|
+
journal: null,
|
|
901
|
+
revision: current.revision + 1,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
record = {
|
|
906
|
+
...current,
|
|
907
|
+
phase: 'ready',
|
|
908
|
+
journal: null,
|
|
909
|
+
revision: current.revision + 1,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
registry.managedCheckouts[checkoutId] = record;
|
|
913
|
+
registry.revision += 1;
|
|
914
|
+
changed = true;
|
|
915
|
+
}
|
|
916
|
+
else if (current.phase === 'mutating'
|
|
917
|
+
&& current.journal?.operation === 'preview'
|
|
918
|
+
&& (current.journal.step === 'planning'
|
|
919
|
+
|| (current.journal.step === 'writing_local' && current.delivery.state === 'ready_for_review'))) {
|
|
920
|
+
// Preview receipt 尚未保留,apply patch 也尚未执行;可证明 Local 未被触碰。
|
|
921
|
+
record = {
|
|
922
|
+
...current,
|
|
923
|
+
phase: 'ready',
|
|
924
|
+
journal: null,
|
|
925
|
+
revision: current.revision + 1,
|
|
926
|
+
};
|
|
927
|
+
registry.managedCheckouts[checkoutId] = record;
|
|
928
|
+
registry.revision += 1;
|
|
929
|
+
changed = true;
|
|
930
|
+
}
|
|
931
|
+
else if (current.phase === 'mutating'
|
|
932
|
+
&& (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
|
|
695
933
|
&& current.journal.step === 'planning') {
|
|
696
|
-
// planning
|
|
934
|
+
// Finalize 的 branch/index 写入发生在 updating_ref 之后;planning 中断可安全重试。
|
|
935
|
+
record = {
|
|
936
|
+
...current,
|
|
937
|
+
phase: 'ready',
|
|
938
|
+
journal: null,
|
|
939
|
+
revision: current.revision + 1,
|
|
940
|
+
};
|
|
941
|
+
registry.managedCheckouts[checkoutId] = record;
|
|
942
|
+
registry.revision += 1;
|
|
943
|
+
changed = true;
|
|
944
|
+
}
|
|
945
|
+
else if (current.phase === 'mutating'
|
|
946
|
+
&& (current.journal?.operation === 'preview' || current.journal?.operation === 'finish')
|
|
947
|
+
&& current.journal.step === 'artifacts_retained'
|
|
948
|
+
&& current.delivery.state === 'preview_active') {
|
|
697
949
|
record = {
|
|
698
950
|
...current,
|
|
699
951
|
phase: 'ready',
|
|
@@ -780,7 +1032,7 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
780
1032
|
const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
|
|
781
1033
|
if (!record)
|
|
782
1034
|
throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
783
|
-
if (record.phase !== 'ready') {
|
|
1035
|
+
if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
|
|
784
1036
|
throw new SessionCheckoutError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能准备验收`);
|
|
785
1037
|
}
|
|
786
1038
|
const inspected = await inspectIsolated(binding);
|
|
@@ -827,6 +1079,43 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
827
1079
|
}));
|
|
828
1080
|
return inspectIsolated(binding);
|
|
829
1081
|
}
|
|
1082
|
+
async function resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId) {
|
|
1083
|
+
const binding = await resolveBinding(sessionId);
|
|
1084
|
+
if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
|
|
1085
|
+
throw new SessionCheckoutError('not_owner', '只有 owner Isolated 会话可以恢复编辑');
|
|
1086
|
+
}
|
|
1087
|
+
let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
|
|
1088
|
+
if (!record)
|
|
1089
|
+
throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
1090
|
+
if (record.revision !== expectedRevision) {
|
|
1091
|
+
throw new SessionCheckoutError('stale_target', 'Session Target 已变化,请刷新后重试');
|
|
1092
|
+
}
|
|
1093
|
+
if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
|
|
1094
|
+
throw new SessionCheckoutError('operation_not_allowed', '当前 Worktree 没有可恢复编辑的未同步验收稿');
|
|
1095
|
+
}
|
|
1096
|
+
if (record.delivery.review.reviewId !== expectedReviewId) {
|
|
1097
|
+
throw new SessionCheckoutError('stale_target', '该验收卡已不是当前 Review,请刷新后重试');
|
|
1098
|
+
}
|
|
1099
|
+
const inspected = await inspectIsolated(binding);
|
|
1100
|
+
if (inspected.checkout.phase !== 'ready') {
|
|
1101
|
+
throw new SessionCheckoutError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复');
|
|
1102
|
+
}
|
|
1103
|
+
record = dependencies.registry.read().managedCheckouts[record.checkoutId];
|
|
1104
|
+
if (!record
|
|
1105
|
+
|| record.revision !== expectedRevision
|
|
1106
|
+
|| record.phase !== 'ready'
|
|
1107
|
+
|| record.delivery.state !== 'ready_for_review'
|
|
1108
|
+
|| record.delivery.review.reviewId !== expectedReviewId) {
|
|
1109
|
+
throw new SessionCheckoutError('stale_target', '验收状态在恢复编辑前发生变化,请刷新后重试');
|
|
1110
|
+
}
|
|
1111
|
+
const iteration = record.delivery.review.iteration;
|
|
1112
|
+
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1113
|
+
...current,
|
|
1114
|
+
delivery: { state: 'working', iteration },
|
|
1115
|
+
revision: current.revision + 1,
|
|
1116
|
+
}));
|
|
1117
|
+
return inspectIsolated(binding);
|
|
1118
|
+
}
|
|
830
1119
|
async function operateApply(input, binding) {
|
|
831
1120
|
if (binding.ownerSessionId !== input.sessionId) {
|
|
832
1121
|
return operationError('not_owner', '继承 Session Target 的会话不能执行 Apply');
|
|
@@ -964,7 +1253,11 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
964
1253
|
return { status: 'applied', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
|
|
965
1254
|
}
|
|
966
1255
|
function holdsProjectAcceptanceSlot(record) {
|
|
967
|
-
return record.
|
|
1256
|
+
return record.delivery.state === 'preview_active'
|
|
1257
|
+
|| record.journal?.operation === 'preview'
|
|
1258
|
+
|| record.journal?.operation === 'rollback_preview'
|
|
1259
|
+
|| record.journal?.operation === 'finalize_preview'
|
|
1260
|
+
|| record.journal?.operation === 'finish';
|
|
968
1261
|
}
|
|
969
1262
|
function findProjectAcceptanceHolder(record) {
|
|
970
1263
|
return Object.values(dependencies.registry.read().managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
|
|
@@ -972,6 +1265,21 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
972
1265
|
&& pathsEqual(candidate.localRoot, record.localRoot)
|
|
973
1266
|
&& holdsProjectAcceptanceSlot(candidate)));
|
|
974
1267
|
}
|
|
1268
|
+
async function retainPreviewArtifacts(record, receipt) {
|
|
1269
|
+
const prefix = `previews/${receipt.previewId}`;
|
|
1270
|
+
await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-working`, receipt.localWorkingTreeOid);
|
|
1271
|
+
await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-index`, receipt.localIndexTreeOid);
|
|
1272
|
+
await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/preview-working`, receipt.previewWorkingTreeOid);
|
|
1273
|
+
await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/isolated-snapshot`, receipt.isolatedSnapshotOid);
|
|
1274
|
+
}
|
|
1275
|
+
async function releasePreviewArtifactsBestEffort(record, previewId) {
|
|
1276
|
+
try {
|
|
1277
|
+
await dependencies.git.releaseInternalArtifacts(record.localRoot, record.checkoutId, `previews/${previewId}`);
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
console.warn('[session-checkout] 清理 Preview refs 失败,已保守保留不可见引用');
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
975
1283
|
function blockedPreflight(record, reason, message) {
|
|
976
1284
|
return {
|
|
977
1285
|
status: 'blocked',
|
|
@@ -1051,6 +1359,244 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1051
1359
|
: 'ready';
|
|
1052
1360
|
return { ...common, status };
|
|
1053
1361
|
}
|
|
1362
|
+
async function operatePreview(input, binding) {
|
|
1363
|
+
if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
|
|
1364
|
+
return operationError('not_owner', '只有 owner Isolated 会话可以同步验收');
|
|
1365
|
+
}
|
|
1366
|
+
let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
|
|
1367
|
+
if (!record)
|
|
1368
|
+
return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
1369
|
+
if (record.revision !== input.expectedRevision) {
|
|
1370
|
+
return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1371
|
+
}
|
|
1372
|
+
if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
|
|
1373
|
+
return operationError('operation_not_allowed', '当前 Worktree 尚未处于可验收状态', await inspectIsolated(binding));
|
|
1374
|
+
}
|
|
1375
|
+
const holder = findProjectAcceptanceHolder(record);
|
|
1376
|
+
if (holder) {
|
|
1377
|
+
return operationError('project_acceptance_busy', `同一项目已有验收任务正在占用 Local:${holder.projectName}`, await inspectIsolated(binding));
|
|
1378
|
+
}
|
|
1379
|
+
const inspected = await inspectIsolated(binding);
|
|
1380
|
+
if (inspected.checkout.phase !== 'ready') {
|
|
1381
|
+
return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
|
|
1382
|
+
}
|
|
1383
|
+
record = dependencies.registry.read().managedCheckouts[record.checkoutId];
|
|
1384
|
+
if (!record || record.delivery.state !== 'ready_for_review') {
|
|
1385
|
+
return operationError('stale_target', '验收状态已变化,请刷新后重试');
|
|
1386
|
+
}
|
|
1387
|
+
const review = record.delivery.review;
|
|
1388
|
+
const operationId = dependencies.createCheckoutId();
|
|
1389
|
+
const previewId = dependencies.createCheckoutId();
|
|
1390
|
+
const startedAt = Date.now();
|
|
1391
|
+
const applyBaseOid = record.applyBaseOid ?? record.baseOid;
|
|
1392
|
+
const mutating = updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1393
|
+
...current,
|
|
1394
|
+
phase: 'mutating',
|
|
1395
|
+
journal: {
|
|
1396
|
+
operation: 'preview',
|
|
1397
|
+
operationId,
|
|
1398
|
+
step: 'planning',
|
|
1399
|
+
startedAt,
|
|
1400
|
+
baseOid: applyBaseOid,
|
|
1401
|
+
previewId,
|
|
1402
|
+
reviewId: review.reviewId,
|
|
1403
|
+
},
|
|
1404
|
+
revision: current.revision + 1,
|
|
1405
|
+
}));
|
|
1406
|
+
if (!mutating)
|
|
1407
|
+
return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
1408
|
+
const planResult = await dependencies.applyEngine.plan({
|
|
1409
|
+
baseOid: applyBaseOid,
|
|
1410
|
+
isolatedPath: mutating.managedRoot,
|
|
1411
|
+
localPath: mutating.localRoot,
|
|
1412
|
+
});
|
|
1413
|
+
if (planResult.status === 'conflict') {
|
|
1414
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1415
|
+
return {
|
|
1416
|
+
status: 'conflict',
|
|
1417
|
+
code: 'apply_conflict',
|
|
1418
|
+
reason: 'content_conflict',
|
|
1419
|
+
target: await inspectIsolated(binding),
|
|
1420
|
+
baseStrategy: planResult.baseStrategy,
|
|
1421
|
+
effectiveBaseOid: planResult.effectiveBaseOid,
|
|
1422
|
+
localHeadOid: planResult.localHeadOid,
|
|
1423
|
+
isolatedHeadOid: planResult.isolatedHeadOid,
|
|
1424
|
+
canRetryAfterRefresh: false,
|
|
1425
|
+
conflictingFiles: planResult.conflictingFiles,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
if (planResult.status === 'error') {
|
|
1429
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1430
|
+
return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
|
|
1431
|
+
}
|
|
1432
|
+
if (planResult.plan.isolatedFingerprint !== review.isolatedFingerprint) {
|
|
1433
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({
|
|
1434
|
+
...current,
|
|
1435
|
+
phase: 'ready',
|
|
1436
|
+
delivery: { state: 'working', iteration: review.iteration },
|
|
1437
|
+
journal: null,
|
|
1438
|
+
revision: current.revision + 1,
|
|
1439
|
+
}));
|
|
1440
|
+
return operationError('stale_isolated', 'Worktree 在标记可验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
|
|
1441
|
+
}
|
|
1442
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({
|
|
1443
|
+
...current,
|
|
1444
|
+
journal: {
|
|
1445
|
+
operation: 'preview',
|
|
1446
|
+
operationId,
|
|
1447
|
+
step: 'writing_local',
|
|
1448
|
+
startedAt,
|
|
1449
|
+
baseOid: applyBaseOid,
|
|
1450
|
+
previewId,
|
|
1451
|
+
reviewId: review.reviewId,
|
|
1452
|
+
planRevision: planResult.plan.revision,
|
|
1453
|
+
localFingerprint: planResult.plan.localFingerprint,
|
|
1454
|
+
isolatedFingerprint: planResult.plan.isolatedFingerprint,
|
|
1455
|
+
effectiveBaseOid: planResult.plan.effectiveBaseOid,
|
|
1456
|
+
baseStrategy: planResult.plan.baseStrategy,
|
|
1457
|
+
localHeadOid: planResult.plan.localHeadOid,
|
|
1458
|
+
isolatedHeadOid: planResult.plan.isolatedHeadOid,
|
|
1459
|
+
changedFiles: [...planResult.plan.changedFiles],
|
|
1460
|
+
},
|
|
1461
|
+
revision: current.revision + 1,
|
|
1462
|
+
}));
|
|
1463
|
+
const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
|
|
1464
|
+
previewId,
|
|
1465
|
+
reviewId: review.reviewId,
|
|
1466
|
+
iteration: review.iteration,
|
|
1467
|
+
beforeWrite: async (receipt) => {
|
|
1468
|
+
await retainPreviewArtifacts(mutating, receipt);
|
|
1469
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({
|
|
1470
|
+
...current,
|
|
1471
|
+
delivery: { state: 'preview_active', review, preview: receipt },
|
|
1472
|
+
journal: current.journal?.operation === 'preview'
|
|
1473
|
+
? { ...current.journal, step: 'artifacts_retained' }
|
|
1474
|
+
: current.journal,
|
|
1475
|
+
revision: current.revision + 1,
|
|
1476
|
+
}));
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1479
|
+
if (previewResult.status === 'error') {
|
|
1480
|
+
const current = dependencies.registry.read().managedCheckouts[mutating.checkoutId];
|
|
1481
|
+
const definitelyUnchanged = previewResult.error.code === 'stale_local'
|
|
1482
|
+
|| previewResult.error.code === 'stale_isolated'
|
|
1483
|
+
|| previewResult.error.code === 'invalid_plan'
|
|
1484
|
+
|| previewResult.error.code === 'invalid_input'
|
|
1485
|
+
|| current?.journal?.step === 'writing_local';
|
|
1486
|
+
if (current?.journal?.step === 'writing_local')
|
|
1487
|
+
await releasePreviewArtifactsBestEffort(mutating, previewId);
|
|
1488
|
+
if (definitelyUnchanged || current?.journal?.step === 'artifacts_retained') {
|
|
1489
|
+
updateManagedCheckout(mutating.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
|
|
1490
|
+
}
|
|
1491
|
+
return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
|
|
1492
|
+
}
|
|
1493
|
+
const receipt = previewResult.receipt;
|
|
1494
|
+
updateManagedCheckout(mutating.checkoutId, (current) => ({
|
|
1495
|
+
...current,
|
|
1496
|
+
phase: 'ready',
|
|
1497
|
+
delivery: { state: 'preview_active', review, preview: receipt },
|
|
1498
|
+
journal: null,
|
|
1499
|
+
revision: current.revision + 1,
|
|
1500
|
+
}));
|
|
1501
|
+
return { status: 'previewed', target: await inspectIsolated(binding), changedFiles: previewResult.changedFiles };
|
|
1502
|
+
}
|
|
1503
|
+
async function detachPreviewAfterLocalDrift(record, binding, reason, attemptedAction) {
|
|
1504
|
+
if (record.delivery.state !== 'preview_active') {
|
|
1505
|
+
return operationError('preview_not_active', '当前没有可解除的 Local Preview', await inspectIsolated(binding));
|
|
1506
|
+
}
|
|
1507
|
+
const { review, preview } = record.delivery;
|
|
1508
|
+
const detached = updateManagedCheckout(record.checkoutId, (current) => {
|
|
1509
|
+
if (current.delivery.state !== 'preview_active')
|
|
1510
|
+
return current;
|
|
1511
|
+
return {
|
|
1512
|
+
...current,
|
|
1513
|
+
phase: 'ready',
|
|
1514
|
+
delivery: {
|
|
1515
|
+
state: 'preview_detached',
|
|
1516
|
+
review: current.delivery.review,
|
|
1517
|
+
preview: current.delivery.preview,
|
|
1518
|
+
detachedAt: Date.now(),
|
|
1519
|
+
reason,
|
|
1520
|
+
attemptedAction,
|
|
1521
|
+
},
|
|
1522
|
+
journal: null,
|
|
1523
|
+
revision: current.revision + 1,
|
|
1524
|
+
};
|
|
1525
|
+
});
|
|
1526
|
+
if (!detached || detached.delivery.state !== 'preview_detached') {
|
|
1527
|
+
return operationError('stale_target', 'Preview 状态已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
status: 'preview_detached',
|
|
1531
|
+
target: await inspectIsolated(binding),
|
|
1532
|
+
changedFiles: [...preview.changedFiles],
|
|
1533
|
+
reason,
|
|
1534
|
+
attemptedAction,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
async function operateRollbackPreview(input, binding) {
|
|
1538
|
+
if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
|
|
1539
|
+
return operationError('not_owner', '只有 owner Isolated 会话可以撤回验收');
|
|
1540
|
+
}
|
|
1541
|
+
const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
|
|
1542
|
+
if (!record)
|
|
1543
|
+
return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
1544
|
+
if (record.revision !== input.expectedRevision) {
|
|
1545
|
+
return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1546
|
+
}
|
|
1547
|
+
const retryingRecovery = record.phase === 'recovery_required'
|
|
1548
|
+
&& record.delivery.state === 'preview_active'
|
|
1549
|
+
&& record.journal?.operation === 'rollback_preview';
|
|
1550
|
+
if ((record.phase !== 'ready' && !retryingRecovery)
|
|
1551
|
+
|| (record.delivery.state !== 'preview_active' && record.delivery.state !== 'preview_detached')) {
|
|
1552
|
+
return operationError('preview_not_active', '当前没有可撤回的 Local Preview', await inspectIsolated(binding));
|
|
1553
|
+
}
|
|
1554
|
+
const retryingDetached = record.delivery.state === 'preview_detached';
|
|
1555
|
+
const resumeRevision = input.resumeRevision ?? (retryingRecovery && record.journal?.operation === 'rollback_preview'
|
|
1556
|
+
? record.journal.resumeRevision ?? false
|
|
1557
|
+
: false);
|
|
1558
|
+
const { preview, review } = record.delivery;
|
|
1559
|
+
const operationId = dependencies.createCheckoutId();
|
|
1560
|
+
const startedAt = Date.now();
|
|
1561
|
+
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1562
|
+
...current,
|
|
1563
|
+
phase: 'mutating',
|
|
1564
|
+
journal: {
|
|
1565
|
+
operation: 'rollback_preview',
|
|
1566
|
+
operationId,
|
|
1567
|
+
step: 'planning',
|
|
1568
|
+
startedAt,
|
|
1569
|
+
previewId: preview.previewId,
|
|
1570
|
+
reviewId: review.reviewId,
|
|
1571
|
+
resumeRevision,
|
|
1572
|
+
},
|
|
1573
|
+
revision: current.revision + 1,
|
|
1574
|
+
}));
|
|
1575
|
+
const result = await dependencies.applyEngine.rollback({ localPath: record.localRoot, receipt: preview });
|
|
1576
|
+
if (result.status === 'error') {
|
|
1577
|
+
if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
|
|
1578
|
+
if (!retryingDetached) {
|
|
1579
|
+
return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'rollback_preview');
|
|
1580
|
+
}
|
|
1581
|
+
updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1582
|
+
}
|
|
1583
|
+
else if (result.error.code === 'invalid_input') {
|
|
1584
|
+
updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1585
|
+
}
|
|
1586
|
+
return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
|
|
1587
|
+
}
|
|
1588
|
+
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1589
|
+
...current,
|
|
1590
|
+
phase: 'ready',
|
|
1591
|
+
delivery: resumeRevision
|
|
1592
|
+
? { state: 'working', iteration: review.iteration }
|
|
1593
|
+
: { state: 'ready_for_review', review },
|
|
1594
|
+
journal: null,
|
|
1595
|
+
revision: current.revision + 1,
|
|
1596
|
+
}));
|
|
1597
|
+
await releasePreviewArtifactsBestEffort(record, preview.previewId);
|
|
1598
|
+
return { status: 'preview_rolled_back', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
|
|
1599
|
+
}
|
|
1054
1600
|
function retainFinalized(record, retention) {
|
|
1055
1601
|
if (record.delivery.state !== 'finalized')
|
|
1056
1602
|
return undefined;
|
|
@@ -1254,6 +1800,100 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1254
1800
|
return { cleaned: false, message, reason };
|
|
1255
1801
|
}
|
|
1256
1802
|
}
|
|
1803
|
+
async function operateFinalizePreview(input, binding) {
|
|
1804
|
+
if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
|
|
1805
|
+
return operationError('not_owner', '只有 owner Isolated 会话可以完成验收提交');
|
|
1806
|
+
}
|
|
1807
|
+
const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
|
|
1808
|
+
if (!record)
|
|
1809
|
+
return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
1810
|
+
if (record.revision !== input.expectedRevision) {
|
|
1811
|
+
return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1812
|
+
}
|
|
1813
|
+
if (record.phase !== 'ready' || record.delivery.state !== 'preview_active') {
|
|
1814
|
+
return operationError('preview_not_active', '当前没有等待验收的 Local Preview', await inspectIsolated(binding));
|
|
1815
|
+
}
|
|
1816
|
+
// Harness plugin does not expose Domi collaborator checkout inheritance.
|
|
1817
|
+
const { preview, review } = record.delivery;
|
|
1818
|
+
const operationId = dependencies.createCheckoutId();
|
|
1819
|
+
const startedAt = Date.now();
|
|
1820
|
+
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1821
|
+
...current,
|
|
1822
|
+
phase: 'mutating',
|
|
1823
|
+
journal: { operation: 'finalize_preview', operationId, step: 'planning', startedAt, previewId: preview.previewId, reviewId: review.reviewId, retention: input.retention ?? 'cleanup' },
|
|
1824
|
+
revision: current.revision + 1,
|
|
1825
|
+
}));
|
|
1826
|
+
const result = await dependencies.applyEngine.finalize({
|
|
1827
|
+
localPath: record.localRoot,
|
|
1828
|
+
receipt: preview,
|
|
1829
|
+
commitMessage: input.commitMessage,
|
|
1830
|
+
beforeCommit: async (commitOid) => {
|
|
1831
|
+
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1832
|
+
...current,
|
|
1833
|
+
journal: current.journal?.operation === 'finalize_preview'
|
|
1834
|
+
? { ...current.journal, step: 'updating_ref', commitOid }
|
|
1835
|
+
: current.journal,
|
|
1836
|
+
revision: current.revision + 1,
|
|
1837
|
+
}));
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
if (result.status === 'error') {
|
|
1841
|
+
if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
|
|
1842
|
+
return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'finalize_preview');
|
|
1843
|
+
}
|
|
1844
|
+
if (result.error.code === 'commit_isolation_conflict' || result.error.code === 'operation_not_allowed' || result.error.code === 'invalid_input') {
|
|
1845
|
+
updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1846
|
+
}
|
|
1847
|
+
return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
|
|
1848
|
+
}
|
|
1849
|
+
const finalized = updateManagedCheckout(record.checkoutId, (current) => ({
|
|
1850
|
+
...current,
|
|
1851
|
+
phase: 'finalized',
|
|
1852
|
+
delivery: {
|
|
1853
|
+
state: 'finalized',
|
|
1854
|
+
review,
|
|
1855
|
+
commitOid: result.commitOid,
|
|
1856
|
+
proof: {
|
|
1857
|
+
localBranch: preview.localHeadRef?.startsWith('refs/heads/')
|
|
1858
|
+
? preview.localHeadRef.slice('refs/heads/'.length)
|
|
1859
|
+
: null,
|
|
1860
|
+
localHeadBefore: preview.localHeadOid,
|
|
1861
|
+
localHeadAfter: result.commitOid ?? preview.localHeadOid,
|
|
1862
|
+
changedFiles: [...result.changedFiles],
|
|
1863
|
+
},
|
|
1864
|
+
isolatedFingerprint: preview.isolatedFingerprint,
|
|
1865
|
+
finalizedAt: Date.now(),
|
|
1866
|
+
cleanup: 'pending',
|
|
1867
|
+
},
|
|
1868
|
+
journal: null,
|
|
1869
|
+
revision: current.revision + 1,
|
|
1870
|
+
}));
|
|
1871
|
+
if (!finalized)
|
|
1872
|
+
return operationError('checkout_missing', '提交已创建,但 Checkout 记录丢失');
|
|
1873
|
+
const retention = input.retention ?? 'cleanup';
|
|
1874
|
+
if (retention !== 'cleanup') {
|
|
1875
|
+
const retained = retainFinalized(finalized, retention);
|
|
1876
|
+
if (!retained)
|
|
1877
|
+
return operationError('checkout_missing', '提交已创建,但保留 Worktree 状态写入失败');
|
|
1878
|
+
return {
|
|
1879
|
+
status: 'finished',
|
|
1880
|
+
target: await inspectIsolated(binding),
|
|
1881
|
+
changedFiles: result.changedFiles,
|
|
1882
|
+
commitOid: result.commitOid,
|
|
1883
|
+
cleanup: 'retained',
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
const cleanup = await cleanupFinalized(finalized);
|
|
1887
|
+
return {
|
|
1888
|
+
status: 'finished',
|
|
1889
|
+
target: await inspectIsolated(binding),
|
|
1890
|
+
changedFiles: result.changedFiles,
|
|
1891
|
+
commitOid: result.commitOid,
|
|
1892
|
+
cleanup: cleanup.cleaned ? 'discarded' : 'pending',
|
|
1893
|
+
...(cleanup.message ? { cleanupMessage: cleanup.message } : {}),
|
|
1894
|
+
...(cleanup.reason ? { cleanupReason: cleanup.reason } : {}),
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1257
1897
|
async function operateRetryCleanup(input, binding) {
|
|
1258
1898
|
if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
|
|
1259
1899
|
return operationError('not_owner', '只有 owner Isolated 会话可以重试清理');
|
|
@@ -1291,7 +1931,14 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1291
1931
|
if (record.revision !== input.expectedRevision) {
|
|
1292
1932
|
return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1293
1933
|
}
|
|
1294
|
-
if (record.
|
|
1934
|
+
if (input.expectedReviewId !== undefined && (record.delivery.state !== 'ready_for_review'
|
|
1935
|
+
|| record.delivery.review.reviewId !== input.expectedReviewId)) {
|
|
1936
|
+
return operationError('stale_target', '该验收卡已不是当前 Review,请刷新并确认最新交付', await inspectIsolated(binding));
|
|
1937
|
+
}
|
|
1938
|
+
if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
|
|
1939
|
+
return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;为避免遗漏或重复提交,已禁止自动 Finish,请先人工核对 Local 后再清理记录。', await inspectIsolated(binding));
|
|
1940
|
+
}
|
|
1941
|
+
if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
|
|
1295
1942
|
return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能直接 Finish`, await inspectIsolated(binding));
|
|
1296
1943
|
}
|
|
1297
1944
|
const holder = findProjectAcceptanceHolder(record);
|
|
@@ -1357,6 +2004,21 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1357
2004
|
}));
|
|
1358
2005
|
return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
|
|
1359
2006
|
}
|
|
2007
|
+
if (input.expectedReviewId !== undefined
|
|
2008
|
+
&& applying.delivery.state === 'ready_for_review'
|
|
2009
|
+
&& (applying.delivery.review.reviewId !== input.expectedReviewId
|
|
2010
|
+
|| planResult.plan.isolatedFingerprint !== applying.delivery.review.isolatedFingerprint
|
|
2011
|
+
|| planResult.plan.isolatedHeadOid !== applying.delivery.review.isolatedHeadOid)) {
|
|
2012
|
+
const reviewIteration = applying.delivery.review.iteration;
|
|
2013
|
+
updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
2014
|
+
...current,
|
|
2015
|
+
phase: 'ready',
|
|
2016
|
+
delivery: { state: 'working', iteration: reviewIteration },
|
|
2017
|
+
journal: null,
|
|
2018
|
+
revision: current.revision + 1,
|
|
2019
|
+
}));
|
|
2020
|
+
return operationError('stale_isolated', 'Worktree 在验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
|
|
2021
|
+
}
|
|
1360
2022
|
const iteration = applying.delivery.state === 'working'
|
|
1361
2023
|
? applying.delivery.iteration
|
|
1362
2024
|
: applying.delivery.state === 'delivered'
|
|
@@ -1376,6 +2038,7 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1376
2038
|
isolatedFingerprint: planResult.plan.isolatedFingerprint,
|
|
1377
2039
|
isolatedHeadOid: planResult.plan.isolatedHeadOid,
|
|
1378
2040
|
};
|
|
2041
|
+
const previewId = dependencies.createCheckoutId();
|
|
1379
2042
|
updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
1380
2043
|
...current,
|
|
1381
2044
|
delivery: { state: 'ready_for_review', review },
|
|
@@ -1396,38 +2059,89 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1396
2059
|
},
|
|
1397
2060
|
revision: current.revision + 1,
|
|
1398
2061
|
}));
|
|
1399
|
-
const
|
|
2062
|
+
const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
|
|
2063
|
+
previewId,
|
|
2064
|
+
reviewId: review.reviewId,
|
|
2065
|
+
iteration: review.iteration,
|
|
2066
|
+
beforeWrite: async (receipt) => {
|
|
2067
|
+
await retainPreviewArtifacts(applying, receipt);
|
|
2068
|
+
updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
2069
|
+
...current,
|
|
2070
|
+
delivery: { state: 'preview_active', review, preview: receipt },
|
|
2071
|
+
journal: current.journal?.operation === 'finish'
|
|
2072
|
+
? { ...current.journal, step: 'artifacts_retained', previewId, reviewId: review.reviewId }
|
|
2073
|
+
: current.journal,
|
|
2074
|
+
revision: current.revision + 1,
|
|
2075
|
+
}));
|
|
2076
|
+
},
|
|
2077
|
+
});
|
|
2078
|
+
if (previewResult.status === 'error') {
|
|
2079
|
+
const current = dependencies.registry.read().managedCheckouts[applying.checkoutId];
|
|
2080
|
+
if (current?.journal?.step === 'writing_local') {
|
|
2081
|
+
await releasePreviewArtifactsBestEffort(applying, previewId);
|
|
2082
|
+
updateManagedCheckout(applying.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
|
|
2083
|
+
}
|
|
2084
|
+
return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
|
|
2085
|
+
}
|
|
2086
|
+
const receipt = previewResult.receipt;
|
|
2087
|
+
updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
2088
|
+
...current,
|
|
2089
|
+
phase: 'mutating',
|
|
2090
|
+
delivery: { state: 'preview_active', review, preview: receipt },
|
|
2091
|
+
journal: {
|
|
2092
|
+
operation: 'finish',
|
|
2093
|
+
operationId,
|
|
2094
|
+
step: 'planning',
|
|
2095
|
+
startedAt,
|
|
2096
|
+
previewId,
|
|
2097
|
+
reviewId: review.reviewId,
|
|
2098
|
+
isolatedFingerprint: receipt.isolatedFingerprint,
|
|
2099
|
+
retention: input.retention ?? 'cleanup',
|
|
2100
|
+
changedFiles: [...receipt.changedFiles],
|
|
2101
|
+
},
|
|
2102
|
+
revision: current.revision + 1,
|
|
2103
|
+
}));
|
|
2104
|
+
const finishResult = await dependencies.applyEngine.finalize({
|
|
2105
|
+
localPath: applying.localRoot,
|
|
2106
|
+
receipt,
|
|
1400
2107
|
commitMessage: input.commitMessage,
|
|
2108
|
+
beforeCommit: async (commitOid) => {
|
|
2109
|
+
updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
2110
|
+
...current,
|
|
2111
|
+
journal: current.journal?.operation === 'finish'
|
|
2112
|
+
? { ...current.journal, step: 'updating_ref', commitOid }
|
|
2113
|
+
: current.journal,
|
|
2114
|
+
revision: current.revision + 1,
|
|
2115
|
+
}));
|
|
2116
|
+
},
|
|
1401
2117
|
});
|
|
1402
2118
|
if (finishResult.status === 'error') {
|
|
1403
|
-
|
|
1404
|
-
|| finishResult.error.code === '
|
|
1405
|
-
|| finishResult.error.code === 'stale_isolated'
|
|
2119
|
+
const safePreview = finishResult.error.code === 'stale_local'
|
|
2120
|
+
|| finishResult.error.code === 'preview_modified'
|
|
1406
2121
|
|| finishResult.error.code === 'invalid_input'
|
|
1407
|
-
|| finishResult.error.code === '
|
|
1408
|
-
|| finishResult.error.code === '
|
|
1409
|
-
|
|
2122
|
+
|| finishResult.error.code === 'commit_isolation_conflict'
|
|
2123
|
+
|| finishResult.error.code === 'operation_not_allowed';
|
|
2124
|
+
if (safePreview) {
|
|
1410
2125
|
updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
|
|
1411
2126
|
}
|
|
1412
2127
|
return operationError(finishResult.error.code, finishResult.error.message, await inspectIsolated(binding));
|
|
1413
2128
|
}
|
|
1414
2129
|
const finalized = updateManagedCheckout(applying.checkoutId, (current) => ({
|
|
1415
2130
|
...current,
|
|
1416
|
-
applyBaseOid: finishResult.nextBaseOid,
|
|
1417
2131
|
phase: 'finalized',
|
|
1418
2132
|
delivery: {
|
|
1419
2133
|
state: 'finalized',
|
|
1420
2134
|
review,
|
|
1421
2135
|
commitOid: finishResult.commitOid,
|
|
1422
2136
|
proof: {
|
|
1423
|
-
localBranch:
|
|
1424
|
-
?
|
|
2137
|
+
localBranch: receipt.localHeadRef?.startsWith('refs/heads/')
|
|
2138
|
+
? receipt.localHeadRef.slice('refs/heads/'.length)
|
|
1425
2139
|
: null,
|
|
1426
|
-
localHeadBefore:
|
|
1427
|
-
localHeadAfter: finishResult.commitOid ??
|
|
2140
|
+
localHeadBefore: receipt.localHeadOid,
|
|
2141
|
+
localHeadAfter: finishResult.commitOid ?? receipt.localHeadOid,
|
|
1428
2142
|
changedFiles: [...finishResult.changedFiles],
|
|
1429
2143
|
},
|
|
1430
|
-
isolatedFingerprint:
|
|
2144
|
+
isolatedFingerprint: receipt.isolatedFingerprint,
|
|
1431
2145
|
finalizedAt: Date.now(),
|
|
1432
2146
|
cleanup: 'pending',
|
|
1433
2147
|
},
|
|
@@ -1484,6 +2198,40 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1484
2198
|
if (record.revision !== input.expectedRevision) {
|
|
1485
2199
|
return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
|
|
1486
2200
|
}
|
|
2201
|
+
if (record.delivery.state === 'preview_detached') {
|
|
2202
|
+
return operationError('preview_modified', 'Local Preview 已因漂移进入 detached 恢复态;为保留恢复证据,不能删除 Worktree。请先成功撤回 Preview。', await inspectIsolated(binding));
|
|
2203
|
+
}
|
|
2204
|
+
if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
|
|
2205
|
+
return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;不会自动 Discard,请先人工核对 Local 的未提交修改。', await inspectIsolated(binding));
|
|
2206
|
+
}
|
|
2207
|
+
if (record.delivery.state === 'preview_active') {
|
|
2208
|
+
if (!input.rollbackPreview) {
|
|
2209
|
+
return operationError('preview_not_active', 'Local 正在验收本任务;放弃任务前必须先安全撤回 Preview', await inspectIsolated(binding));
|
|
2210
|
+
}
|
|
2211
|
+
const rollback = await dependencies.applyEngine.rollback({
|
|
2212
|
+
localPath: record.localRoot,
|
|
2213
|
+
receipt: record.delivery.preview,
|
|
2214
|
+
});
|
|
2215
|
+
if (rollback.status === 'error') {
|
|
2216
|
+
if (rollback.error.code === 'stale_local' || rollback.error.code === 'preview_modified') {
|
|
2217
|
+
return detachPreviewAfterLocalDrift(record, binding, rollback.error.code, 'discard');
|
|
2218
|
+
}
|
|
2219
|
+
return operationError(rollback.error.code, rollback.error.message, await inspectIsolated(binding));
|
|
2220
|
+
}
|
|
2221
|
+
const previewId = record.delivery.preview.previewId;
|
|
2222
|
+
const iteration = record.delivery.review.iteration;
|
|
2223
|
+
const rolledBack = updateManagedCheckout(record.checkoutId, (current) => ({
|
|
2224
|
+
...current,
|
|
2225
|
+
delivery: { state: 'working', iteration },
|
|
2226
|
+
journal: null,
|
|
2227
|
+
revision: current.revision + 1,
|
|
2228
|
+
}));
|
|
2229
|
+
if (!rolledBack)
|
|
2230
|
+
return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
|
|
2231
|
+
await releasePreviewArtifactsBestEffort(record, previewId);
|
|
2232
|
+
record = rolledBack;
|
|
2233
|
+
}
|
|
2234
|
+
// Harness plugin has no inherited collaborator checkout ownership.
|
|
1487
2235
|
if (record.phase === 'recovery_required' && !dependencies.files.exists(record.managedRoot)) {
|
|
1488
2236
|
await releaseApplyBaseBestEffort(record);
|
|
1489
2237
|
updateManagedCheckout(record.checkoutId, (current) => ({
|
|
@@ -1579,6 +2327,12 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1579
2327
|
return await operateApply(input, binding);
|
|
1580
2328
|
if (input.action === 'finish')
|
|
1581
2329
|
return await operateFinish(input, binding);
|
|
2330
|
+
if (input.action === 'preview')
|
|
2331
|
+
return await operatePreview(input, binding);
|
|
2332
|
+
if (input.action === 'rollback_preview')
|
|
2333
|
+
return await operateRollbackPreview(input, binding);
|
|
2334
|
+
if (input.action === 'finalize_preview')
|
|
2335
|
+
return await operateFinalizePreview(input, binding);
|
|
1582
2336
|
if (input.action === 'retry_cleanup')
|
|
1583
2337
|
return await operateRetryCleanup(input, binding);
|
|
1584
2338
|
if (input.action === 'discard')
|
|
@@ -1604,6 +2358,10 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1604
2358
|
return record.journal?.startedAt ?? 0;
|
|
1605
2359
|
if (record.delivery.state === 'ready_for_review')
|
|
1606
2360
|
return record.delivery.review.preparedAt;
|
|
2361
|
+
if (record.delivery.state === 'preview_active')
|
|
2362
|
+
return record.delivery.preview.previewedAt;
|
|
2363
|
+
if (record.delivery.state === 'preview_detached')
|
|
2364
|
+
return record.delivery.detachedAt;
|
|
1607
2365
|
if (record.delivery.state === 'finalized')
|
|
1608
2366
|
return record.delivery.finalizedAt;
|
|
1609
2367
|
if (record.delivery.state === 'retained')
|
|
@@ -1664,11 +2422,15 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1664
2422
|
? delivery.cleanup === 'blocked' ? 'needs_attention' : 'retained'
|
|
1665
2423
|
: delivery.state === 'finalized'
|
|
1666
2424
|
? delivery.cleanup === 'blocked' ? 'needs_attention' : 'cleanup_pending'
|
|
1667
|
-
: delivery.state === '
|
|
1668
|
-
? '
|
|
1669
|
-
: delivery.state === '
|
|
1670
|
-
? '
|
|
1671
|
-
: '
|
|
2425
|
+
: delivery.state === 'preview_active'
|
|
2426
|
+
? 'preview_active'
|
|
2427
|
+
: delivery.state === 'preview_detached'
|
|
2428
|
+
? 'needs_attention'
|
|
2429
|
+
: delivery.state === 'ready_for_review'
|
|
2430
|
+
? 'ready_for_review'
|
|
2431
|
+
: delivery.state === 'delivered'
|
|
2432
|
+
? 'delivered'
|
|
2433
|
+
: 'working';
|
|
1672
2434
|
const commitOid = delivery.state === 'finalized' || delivery.state === 'retained' || delivery.state === 'delivered'
|
|
1673
2435
|
? delivery.commitOid
|
|
1674
2436
|
: null;
|
|
@@ -1709,6 +2471,9 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1709
2471
|
return cleanupBlocked('working', '当前轮次仍在修改,尚未形成可清理的交付环境。', record.revision);
|
|
1710
2472
|
if (record.delivery.state === 'ready_for_review')
|
|
1711
2473
|
return cleanupBlocked('review_pending', '当前轮次正在等待验收,不能清理。', record.revision);
|
|
2474
|
+
if (record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
|
|
2475
|
+
return cleanupBlocked('preview_active', 'Local Preview 尚未完成安全收口,不能清理。', record.revision);
|
|
2476
|
+
}
|
|
1712
2477
|
if (record.delivery.state === 'delivered' || record.phase === 'discarded') {
|
|
1713
2478
|
return cleanupBlocked('unknown', 'Worktree 已交付并解除管理,无需再次清理。', record.revision);
|
|
1714
2479
|
}
|
|
@@ -1835,6 +2600,37 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1835
2600
|
.filter((summary) => !input.needsAttention || summary.state === 'needs_attention' || summary.state === 'cleanup_pending')
|
|
1836
2601
|
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
1837
2602
|
}
|
|
2603
|
+
async function listManagedWorktreesForSession(sessionId, input = {}) {
|
|
2604
|
+
const session = requireSession(sessionId);
|
|
2605
|
+
const persisted = getPersistedBinding(sessionId);
|
|
2606
|
+
const projectId = persisted?.projectId ?? session.projectId;
|
|
2607
|
+
if (!projectId)
|
|
2608
|
+
throw new SessionCheckoutError('project_not_found', '会话尚未关联项目');
|
|
2609
|
+
const allowedCheckoutIds = new Set(Object.values(dependencies.registry.read().managedCheckouts)
|
|
2610
|
+
.filter((record) => record.projectId === projectId)
|
|
2611
|
+
.filter((record) => record.ownerSessionId === sessionId || record.sourceSessionId === sessionId)
|
|
2612
|
+
.map((record) => record.checkoutId));
|
|
2613
|
+
const summaries = await listManagedWorktrees({ ...input, projectId });
|
|
2614
|
+
return summaries.filter((summary) => allowedCheckoutIds.has(summary.checkoutId));
|
|
2615
|
+
}
|
|
2616
|
+
async function manageManagedWorktreeForSession(sessionId, input) {
|
|
2617
|
+
requireSession(sessionId);
|
|
2618
|
+
const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
|
|
2619
|
+
if (!record)
|
|
2620
|
+
throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
|
|
2621
|
+
if (record.ownerSessionId !== sessionId && record.sourceSessionId !== sessionId) {
|
|
2622
|
+
throw new SessionCheckoutError('not_owner', '当前 Session 无权管理该 Worktree');
|
|
2623
|
+
}
|
|
2624
|
+
if (record.ownerSessionId !== sessionId && dependencies.lookup.getSession(record.ownerSessionId)) {
|
|
2625
|
+
throw new SessionCheckoutError('not_owner', 'Owner Session 已接管该 Worktree,只有 owner 可以管理');
|
|
2626
|
+
}
|
|
2627
|
+
const persisted = getPersistedBinding(sessionId);
|
|
2628
|
+
const callerProjectId = persisted?.projectId ?? dependencies.lookup.getSession(sessionId)?.projectId;
|
|
2629
|
+
if (callerProjectId !== record.projectId) {
|
|
2630
|
+
throw new SessionCheckoutError('project_mismatch', '当前 Session 与 Worktree 不属于同一原始项目');
|
|
2631
|
+
}
|
|
2632
|
+
return manageManagedWorktree(input);
|
|
2633
|
+
}
|
|
1838
2634
|
async function manageManagedWorktree(input) {
|
|
1839
2635
|
const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
|
|
1840
2636
|
if (!record)
|
|
@@ -1842,12 +2638,15 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1842
2638
|
if (record.revision !== input.expectedRevision)
|
|
1843
2639
|
throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后重试');
|
|
1844
2640
|
if (input.action === 'discard') {
|
|
1845
|
-
|
|
2641
|
+
// Caller authorization is performed by the scoped wrapper (or a trusted Host manager).
|
|
2642
|
+
// The reserved owner Session may not exist yet, so do not re-resolve it through lookup.
|
|
2643
|
+
const result = await operateDiscard({
|
|
1846
2644
|
action: 'discard',
|
|
1847
2645
|
sessionId: record.ownerSessionId,
|
|
1848
2646
|
expectedRevision: input.expectedRevision,
|
|
1849
2647
|
confirmDirty: input.confirmDirty === true,
|
|
1850
|
-
|
|
2648
|
+
...(input.rollbackPreview === undefined ? {} : { rollbackPreview: input.rollbackPreview }),
|
|
2649
|
+
}, bindingForManagedRecord(record));
|
|
1851
2650
|
if (result.status === 'error')
|
|
1852
2651
|
throw new SessionCheckoutError(result.code, result.message);
|
|
1853
2652
|
const updated = dependencies.registry.read().managedCheckouts[record.checkoutId];
|
|
@@ -1918,8 +2717,180 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1918
2717
|
}
|
|
1919
2718
|
return cleaned;
|
|
1920
2719
|
}
|
|
1921
|
-
async function
|
|
2720
|
+
async function beginNextIterationTarget(sessionId, expectedRevision) {
|
|
1922
2721
|
const session = requireSession(sessionId);
|
|
2722
|
+
const registry = dependencies.registry.read();
|
|
2723
|
+
const previousBinding = registry.sessionBindings[sessionId];
|
|
2724
|
+
if (!previousBinding || previousBinding.target.kind !== 'isolated') {
|
|
2725
|
+
throw new SessionCheckoutError('operation_not_allowed', '只有已交付的 Isolated Session 可以开始下一轮');
|
|
2726
|
+
}
|
|
2727
|
+
const predecessor = registry.managedCheckouts[previousBinding.target.checkoutId];
|
|
2728
|
+
if (!predecessor)
|
|
2729
|
+
throw new SessionCheckoutError('checkout_missing', '上一轮 Worktree 记录不存在');
|
|
2730
|
+
if (predecessor.ownerSessionId !== sessionId || previousBinding.ownerSessionId !== sessionId) {
|
|
2731
|
+
throw new SessionCheckoutError('not_owner', '只有 owner Session 可以开始下一轮');
|
|
2732
|
+
}
|
|
2733
|
+
if (predecessor.revision !== expectedRevision) {
|
|
2734
|
+
throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后再开始下一轮');
|
|
2735
|
+
}
|
|
2736
|
+
if (predecessor.phase !== 'discarded' || predecessor.delivery.state !== 'delivered') {
|
|
2737
|
+
throw new SessionCheckoutError('operation_not_allowed', '只有已成功清理的交付状态可以开始下一轮');
|
|
2738
|
+
}
|
|
2739
|
+
if (!session.projectId)
|
|
2740
|
+
throw new SessionCheckoutError('project_not_found', '当前 Session 尚未关联 Workspace');
|
|
2741
|
+
const ownerWorkspace = dependencies.lookup.getProject(session.projectId);
|
|
2742
|
+
if (!ownerWorkspace || !resolvedPathsEqual(ownerWorkspace.root, predecessor.managedRoot)) {
|
|
2743
|
+
throw new SessionCheckoutError('project_mismatch', '当前 Session 的 immutable cwd 与上一轮 Worktree 不一致');
|
|
2744
|
+
}
|
|
2745
|
+
if (dependencies.files.exists(predecessor.managedGitRoot) || dependencies.files.exists(predecessor.managedRoot)) {
|
|
2746
|
+
throw new SessionCheckoutError('checkout_mismatch', '上一轮 Worktree 路径已重新出现,拒绝覆盖未知内容');
|
|
2747
|
+
}
|
|
2748
|
+
const localProject = dependencies.lookup.getProject(predecessor.projectId);
|
|
2749
|
+
if (!localProject || !dependencies.files.exists(localProject.root) || !dependencies.files.exists(predecessor.localRoot)) {
|
|
2750
|
+
throw new SessionCheckoutError('project_root_missing', '原始 Local 项目已不可用,不能开始下一轮');
|
|
2751
|
+
}
|
|
2752
|
+
const canonicalProjectRoot = await dependencies.files.canonicalize(localProject.root);
|
|
2753
|
+
const canonicalLocalRoot = await dependencies.files.canonicalize(predecessor.localRoot);
|
|
2754
|
+
if (!pathsEqual(canonicalProjectRoot, canonicalLocalRoot)) {
|
|
2755
|
+
throw new SessionCheckoutError('project_mismatch', '原始 Local 项目身份已变化');
|
|
2756
|
+
}
|
|
2757
|
+
const snapshot = await dependencies.git.inspect(canonicalLocalRoot);
|
|
2758
|
+
if (!snapshot || !pathsEqual(snapshot.commonDir, predecessor.gitCommonDir)) {
|
|
2759
|
+
throw new SessionCheckoutError('checkout_mismatch', '原始 Local Git 身份已变化');
|
|
2760
|
+
}
|
|
2761
|
+
const projectRelativePath = relative(snapshot.root, canonicalLocalRoot);
|
|
2762
|
+
if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
|
|
2763
|
+
throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
|
|
2764
|
+
}
|
|
2765
|
+
if (!resolvedPathsEqual(resolve(predecessor.managedGitRoot, projectRelativePath), predecessor.managedRoot)) {
|
|
2766
|
+
throw new SessionCheckoutError('checkout_mismatch', '上一轮 managed project 路径无法从 Local 身份重建');
|
|
2767
|
+
}
|
|
2768
|
+
const managedContainer = dirname(predecessor.managedGitRoot);
|
|
2769
|
+
if (!dependencies.files.exists(managedContainer))
|
|
2770
|
+
dependencies.files.ensureDirectory(managedContainer);
|
|
2771
|
+
const containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
|
|
2772
|
+
if (!containerIdentity)
|
|
2773
|
+
throw new SessionCheckoutError('checkout_mismatch', 'Worktree 容器不是可信目录');
|
|
2774
|
+
const canonicalContainer = await dependencies.files.canonicalize(managedContainer);
|
|
2775
|
+
if (!resolvedPathsEqual(canonicalContainer, managedContainer)) {
|
|
2776
|
+
throw new SessionCheckoutError('checkout_mismatch', 'Worktree 容器路径已被重定向');
|
|
2777
|
+
}
|
|
2778
|
+
const checkoutId = dependencies.createCheckoutId();
|
|
2779
|
+
const iteration = predecessor.delivery.iteration + 1;
|
|
2780
|
+
const record = {
|
|
2781
|
+
checkoutId,
|
|
2782
|
+
predecessorCheckoutId: predecessor.checkoutId,
|
|
2783
|
+
projectId: predecessor.projectId,
|
|
2784
|
+
projectName: predecessor.projectName,
|
|
2785
|
+
ownerSessionId: sessionId,
|
|
2786
|
+
...(predecessor.sourceSessionId ? { sourceSessionId: predecessor.sourceSessionId } : {}),
|
|
2787
|
+
localRoot: canonicalLocalRoot,
|
|
2788
|
+
managedRoot: predecessor.managedRoot,
|
|
2789
|
+
managedGitRoot: predecessor.managedGitRoot,
|
|
2790
|
+
gitCommonDir: snapshot.commonDir,
|
|
2791
|
+
gitDir: '',
|
|
2792
|
+
baseOid: snapshot.headOid,
|
|
2793
|
+
sourceRef: snapshot.headRef,
|
|
2794
|
+
phase: 'preparing',
|
|
2795
|
+
delivery: { state: 'working', iteration },
|
|
2796
|
+
journal: {
|
|
2797
|
+
operation: 'create',
|
|
2798
|
+
operationId: dependencies.createCheckoutId(),
|
|
2799
|
+
step: 'creating_worktree',
|
|
2800
|
+
startedAt: Date.now(),
|
|
2801
|
+
},
|
|
2802
|
+
revision: predecessor.revision + 1,
|
|
2803
|
+
};
|
|
2804
|
+
const binding = {
|
|
2805
|
+
sessionId,
|
|
2806
|
+
projectId: predecessor.projectId,
|
|
2807
|
+
projectName: predecessor.projectName,
|
|
2808
|
+
target: { kind: 'isolated', checkoutId },
|
|
2809
|
+
ownerSessionId: sessionId,
|
|
2810
|
+
sourceRef: snapshot.headRef,
|
|
2811
|
+
sourceOid: snapshot.headOid,
|
|
2812
|
+
revision: previousBinding.revision + 1,
|
|
2813
|
+
};
|
|
2814
|
+
registry.sessionBindings[sessionId] = binding;
|
|
2815
|
+
registry.managedCheckouts[checkoutId] = record;
|
|
2816
|
+
registry.revision += 1;
|
|
2817
|
+
dependencies.registry.write(registry);
|
|
2818
|
+
try {
|
|
2819
|
+
await dependencies.git.createDetachedWorktree(snapshot.root, predecessor.managedGitRoot, snapshot.headOid);
|
|
2820
|
+
const canonicalManagedGitRoot = await dependencies.files.canonicalize(predecessor.managedGitRoot);
|
|
2821
|
+
const canonicalManagedRoot = await dependencies.files.canonicalize(predecessor.managedRoot);
|
|
2822
|
+
const containerAfterCreate = await dependencies.files.inspectDirectoryIdentity(managedContainer);
|
|
2823
|
+
const created = await dependencies.git.inspect(canonicalManagedRoot);
|
|
2824
|
+
if (!containerAfterCreate
|
|
2825
|
+
|| !directoryIdentitiesEqual(containerIdentity, containerAfterCreate)
|
|
2826
|
+
|| !resolvedPathsEqual(canonicalManagedGitRoot, predecessor.managedGitRoot)
|
|
2827
|
+
|| !resolvedPathsEqual(canonicalManagedRoot, predecessor.managedRoot)
|
|
2828
|
+
|| !created
|
|
2829
|
+
|| !pathsEqual(created.root, canonicalManagedGitRoot)
|
|
2830
|
+
|| !pathsEqual(created.commonDir, snapshot.commonDir)
|
|
2831
|
+
|| created.headOid !== snapshot.headOid) {
|
|
2832
|
+
throw new SessionCheckoutError('checkout_mismatch', '下一轮 checkout 的 Git 身份不匹配');
|
|
2833
|
+
}
|
|
2834
|
+
const readyRegistry = dependencies.registry.read();
|
|
2835
|
+
const current = readyRegistry.managedCheckouts[checkoutId];
|
|
2836
|
+
if (!current || current.phase !== 'preparing') {
|
|
2837
|
+
throw new SessionCheckoutError('stale_target', '下一轮 Worktree 创建期间状态已变化');
|
|
2838
|
+
}
|
|
2839
|
+
readyRegistry.managedCheckouts[checkoutId] = {
|
|
2840
|
+
...current,
|
|
2841
|
+
managedRoot: canonicalManagedRoot,
|
|
2842
|
+
managedGitRoot: canonicalManagedGitRoot,
|
|
2843
|
+
gitDir: created.gitDir,
|
|
2844
|
+
phase: 'ready',
|
|
2845
|
+
journal: null,
|
|
2846
|
+
revision: current.revision + 1,
|
|
2847
|
+
};
|
|
2848
|
+
readyRegistry.revision += 1;
|
|
2849
|
+
dependencies.registry.write(readyRegistry);
|
|
2850
|
+
return inspectIsolated(binding);
|
|
2851
|
+
}
|
|
2852
|
+
catch (error) {
|
|
2853
|
+
let partialCheckout = null;
|
|
2854
|
+
try {
|
|
2855
|
+
if (dependencies.files.exists(join(predecessor.managedGitRoot, '.git'))) {
|
|
2856
|
+
partialCheckout = await dependencies.git.inspect(predecessor.managedRoot);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
catch {
|
|
2860
|
+
partialCheckout = null;
|
|
2861
|
+
}
|
|
2862
|
+
if (partialCheckout) {
|
|
2863
|
+
const current = dependencies.registry.read().managedCheckouts[checkoutId];
|
|
2864
|
+
if (current)
|
|
2865
|
+
markRecoveryRequired(current);
|
|
2866
|
+
throw error;
|
|
2867
|
+
}
|
|
2868
|
+
let residueRemoved = false;
|
|
2869
|
+
try {
|
|
2870
|
+
residueRemoved = dependencies.files.removeEmptyDirectoryTree(predecessor.managedGitRoot);
|
|
2871
|
+
}
|
|
2872
|
+
catch {
|
|
2873
|
+
residueRemoved = false;
|
|
2874
|
+
}
|
|
2875
|
+
if (!residueRemoved) {
|
|
2876
|
+
const current = dependencies.registry.read().managedCheckouts[checkoutId];
|
|
2877
|
+
if (current)
|
|
2878
|
+
markRecoveryRequired(current);
|
|
2879
|
+
throw new SessionCheckoutError('recovery_required', '下一轮 Worktree 创建失败且残余目录包含未知内容,已保留现场');
|
|
2880
|
+
}
|
|
2881
|
+
const failedRegistry = dependencies.registry.read();
|
|
2882
|
+
delete failedRegistry.managedCheckouts[checkoutId];
|
|
2883
|
+
const currentBinding = failedRegistry.sessionBindings[sessionId];
|
|
2884
|
+
if (currentBinding?.target.kind === 'isolated' && currentBinding.target.checkoutId === checkoutId) {
|
|
2885
|
+
failedRegistry.sessionBindings[sessionId] = { ...previousBinding, revision: previousBinding.revision + 1 };
|
|
2886
|
+
}
|
|
2887
|
+
failedRegistry.revision += 1;
|
|
2888
|
+
dependencies.registry.write(failedRegistry);
|
|
2889
|
+
throw new SessionCheckoutError('git_operation_failed', error instanceof Error ? error.message : String(error));
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now(), sourceSessionId = sessionId) {
|
|
2893
|
+
const session = requireSession(sourceSessionId);
|
|
1923
2894
|
let nextIteration = 1;
|
|
1924
2895
|
let replacedDeliveredBinding;
|
|
1925
2896
|
const existing = getPersistedBinding(sessionId);
|
|
@@ -1953,7 +2924,7 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1953
2924
|
throw new SessionCheckoutError('target_already_bound', '会话已经绑定 Session Target,不能切换');
|
|
1954
2925
|
}
|
|
1955
2926
|
}
|
|
1956
|
-
const { project } = await resolveSessionProject(
|
|
2927
|
+
const { project } = await resolveSessionProject(sourceSessionId);
|
|
1957
2928
|
const snapshot = await dependencies.git.inspect(project.root);
|
|
1958
2929
|
if (choice.kind === 'local') {
|
|
1959
2930
|
const binding = {
|
|
@@ -1979,9 +2950,75 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1979
2950
|
const checkoutId = dependencies.createCheckoutId();
|
|
1980
2951
|
const localRoot = await dependencies.files.canonicalize(project.root);
|
|
1981
2952
|
const localGitRoot = await dependencies.files.canonicalize(snapshot.root);
|
|
1982
|
-
|
|
1983
|
-
const
|
|
1984
|
-
|
|
2953
|
+
const repositoryKey = createManagedWorktreeRepositoryKey(localGitRoot);
|
|
2954
|
+
const pathCandidates = [8, 12, 32].map(identityLength => createManagedWorktreePathCandidates({
|
|
2955
|
+
localGitRoot,
|
|
2956
|
+
managedCheckoutsRoot: dependencies.managedCheckoutsRoot,
|
|
2957
|
+
repositoryKey,
|
|
2958
|
+
checkoutId,
|
|
2959
|
+
identityLength,
|
|
2960
|
+
}));
|
|
2961
|
+
// Prefer one repository-owned sibling container so Local never sees its
|
|
2962
|
+
// managed worktrees as untracked. A container that is a file/symlink is
|
|
2963
|
+
// untrusted and falls back without modification. Existing child paths
|
|
2964
|
+
// are never reused or cleaned: extend the trusted checkout identity.
|
|
2965
|
+
const outerContainingRoot = await dependencies.git.findContainingWorktreeRoot(dirname(localGitRoot));
|
|
2966
|
+
const siblingWouldPolluteOuter = outerContainingRoot !== null && !pathsEqual(outerContainingRoot, localGitRoot);
|
|
2967
|
+
const usingSibling = createAttempt === 0 && !siblingWouldPolluteOuter;
|
|
2968
|
+
const managedContainer = usingSibling
|
|
2969
|
+
? pathCandidates[0].siblingContainer
|
|
2970
|
+
: pathCandidates[0].fallbackContainer;
|
|
2971
|
+
const knownContainer = Object.values(dependencies.registry.read().managedCheckouts)
|
|
2972
|
+
.some(record => pathsEqual(dirname(record.managedGitRoot), managedContainer));
|
|
2973
|
+
const containerExisted = dependencies.files.exists(managedContainer);
|
|
2974
|
+
let containerIdentity = containerExisted
|
|
2975
|
+
? await dependencies.files.inspectDirectoryIdentity(managedContainer)
|
|
2976
|
+
: null;
|
|
2977
|
+
if (containerExisted && containerIdentity !== null && !knownContainer) {
|
|
2978
|
+
// A same-named directory with unknown content is not ours. An empty
|
|
2979
|
+
// directory has no user bytes to preserve, so remove/recreate it as the
|
|
2980
|
+
// ownership boundary; a non-empty tree is left untouched and rejected.
|
|
2981
|
+
let reclaimedEmpty = false;
|
|
2982
|
+
try {
|
|
2983
|
+
reclaimedEmpty = dependencies.files.removeEmptyDirectoryTree(managedContainer);
|
|
2984
|
+
}
|
|
2985
|
+
catch { /* fail closed below */ }
|
|
2986
|
+
if (!reclaimedEmpty)
|
|
2987
|
+
containerIdentity = null;
|
|
2988
|
+
else {
|
|
2989
|
+
try {
|
|
2990
|
+
dependencies.files.ensureDirectory(managedContainer);
|
|
2991
|
+
containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
|
|
2992
|
+
}
|
|
2993
|
+
catch {
|
|
2994
|
+
containerIdentity = null;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
else if (!containerExisted) {
|
|
2999
|
+
try {
|
|
3000
|
+
dependencies.files.ensureDirectory(managedContainer);
|
|
3001
|
+
containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
|
|
3002
|
+
}
|
|
3003
|
+
catch {
|
|
3004
|
+
containerIdentity = null;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
if (containerIdentity === null) {
|
|
3008
|
+
if (usingSibling) {
|
|
3009
|
+
return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
|
|
3010
|
+
}
|
|
3011
|
+
throw new SessionCheckoutError('checkout_mismatch', 'Worktree 回退容器不是可信目录,未创建或修改任何 checkout');
|
|
3012
|
+
}
|
|
3013
|
+
const managedGitRoot = pathCandidates
|
|
3014
|
+
.map(candidate => usingSibling ? candidate.siblingRoot : candidate.fallbackRoot)
|
|
3015
|
+
.find(candidate => !dependencies.files.exists(candidate));
|
|
3016
|
+
if (managedGitRoot === undefined) {
|
|
3017
|
+
if (usingSibling) {
|
|
3018
|
+
return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
|
|
3019
|
+
}
|
|
3020
|
+
throw new SessionCheckoutError('checkout_mismatch', 'Worktree Checkout identity 路径均已存在,拒绝覆盖未知目录');
|
|
3021
|
+
}
|
|
1985
3022
|
const projectRelativePath = relative(localGitRoot, localRoot);
|
|
1986
3023
|
if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
|
|
1987
3024
|
throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
|
|
@@ -1992,6 +3029,7 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
1992
3029
|
projectId: project.id,
|
|
1993
3030
|
projectName: project.name,
|
|
1994
3031
|
ownerSessionId: sessionId,
|
|
3032
|
+
sourceSessionId,
|
|
1995
3033
|
localRoot,
|
|
1996
3034
|
managedRoot: resolve(managedRoot),
|
|
1997
3035
|
managedGitRoot: resolve(managedGitRoot),
|
|
@@ -2090,27 +3128,51 @@ export function createSessionCheckoutModule(dependencies) {
|
|
|
2090
3128
|
failedRegistry.revision += 1;
|
|
2091
3129
|
dependencies.registry.write(failedRegistry);
|
|
2092
3130
|
if (createAttempt < 1) {
|
|
2093
|
-
return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt);
|
|
3131
|
+
return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
|
|
2094
3132
|
}
|
|
2095
3133
|
throw new SessionCheckoutError('git_operation_failed', 'Worktree 创建失败,已安全清理残余目录,可直接重试');
|
|
2096
3134
|
}
|
|
2097
3135
|
}
|
|
2098
3136
|
return {
|
|
2099
3137
|
inspect: inspectAvailable,
|
|
3138
|
+
runtimeContext,
|
|
2100
3139
|
preflight: (sessionId, expectedRevision) => withBindingLock(() => preflightTarget(sessionId, expectedRevision)),
|
|
2101
3140
|
runExclusiveSessionMutation: (sessionId, operation) => withBindingLock(async () => (operation(await inspectTarget(sessionId, true)))),
|
|
2102
3141
|
bind: (sessionId, choice) => {
|
|
2103
3142
|
const requestStartedAt = Date.now();
|
|
2104
3143
|
return withBindingLock(() => bindTarget(sessionId, choice, 0, requestStartedAt));
|
|
2105
3144
|
},
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
3145
|
+
createIsolatedTarget: (sourceSessionId, targetSessionId) => withBindingLock(async () => {
|
|
3146
|
+
if (!targetSessionId || targetSessionId === sourceSessionId) {
|
|
3147
|
+
throw new SessionCheckoutError('invalid_input', 'Isolated Target 必须使用独立的预分配 Session ID');
|
|
3148
|
+
}
|
|
3149
|
+
if (dependencies.lookup.getSession(targetSessionId) && !getPersistedBinding(targetSessionId)) {
|
|
3150
|
+
throw new SessionCheckoutError('target_already_bound', '目标 Session ID 已被其他 Workspace 使用');
|
|
3151
|
+
}
|
|
3152
|
+
const sourceBinding = getPersistedBinding(sourceSessionId);
|
|
3153
|
+
if (!sourceBinding) {
|
|
3154
|
+
await bindTarget(sourceSessionId, { kind: 'local' });
|
|
3155
|
+
}
|
|
3156
|
+
else if (sourceBinding.target.kind !== 'local') {
|
|
3157
|
+
throw new SessionCheckoutError('operation_not_allowed', '只能从 Local Session 创建新的 Isolated Target');
|
|
3158
|
+
}
|
|
3159
|
+
const target = await bindTarget(targetSessionId, { kind: 'isolated' }, 0, Date.now(), sourceSessionId);
|
|
3160
|
+
const binding = getPersistedBinding(targetSessionId);
|
|
3161
|
+
const record = binding?.target.kind === 'isolated'
|
|
3162
|
+
? dependencies.registry.read().managedCheckouts[binding.target.checkoutId]
|
|
3163
|
+
: undefined;
|
|
3164
|
+
if (!record)
|
|
3165
|
+
throw new SessionCheckoutError('checkout_missing', 'Isolated Target 创建后记录缺失');
|
|
3166
|
+
return { targetSessionId, managedRoot: record.managedRoot, target };
|
|
3167
|
+
}),
|
|
3168
|
+
beginNextIteration: (sessionId, expectedRevision) => withBindingLock(() => beginNextIterationTarget(sessionId, expectedRevision)),
|
|
3169
|
+
resumeRevision: (sessionId, expectedRevision, expectedReviewId) => withBindingLock(() => resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId)),
|
|
2110
3170
|
markReadyForReview: (sessionId, input) => withBindingLock(() => markReadyForReviewTarget(sessionId, input)),
|
|
2111
3171
|
operate: (input) => withBindingLock(() => operateTarget(input)),
|
|
2112
3172
|
// 只读管理列表不占用全局 mutation lock;慢速目录诊断与用户操作互不阻塞。
|
|
2113
3173
|
listManagedWorktrees,
|
|
3174
|
+
listManagedWorktreesForSession,
|
|
3175
|
+
manageManagedWorktreeForSession: (sessionId, input) => withBindingLock(() => manageManagedWorktreeForSession(sessionId, input)),
|
|
2114
3176
|
inspectManagedWorktreeCleanup,
|
|
2115
3177
|
bulkCleanupManagedWorktrees: (candidates) => withBindingLock(() => bulkCleanupManagedWorktrees(candidates)),
|
|
2116
3178
|
manageManagedWorktree: (input) => withBindingLock(() => manageManagedWorktree(input)),
|