@fitlab-ai/agent-infra 0.9.13 → 0.9.14
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/bin/cli.ts +26 -9
- package/dist/bin/cli.js +173 -157
- package/dist/bin/internal-cli.js +42 -21
- package/dist/lib/agent-client.js +5 -0
- package/dist/lib/internal/agent-client.js +5 -2
- package/dist/lib/internal/cli-route-inventory.js +152 -0
- package/dist/lib/internal/codex-lifecycle.js +9 -6
- package/dist/lib/internal/codex-sandbox-controller.js +8 -2
- package/dist/lib/internal/git-workflow.js +10 -6
- package/dist/lib/internal/platform-checks.js +16 -4
- package/dist/lib/internal/platform-comment.js +16 -4
- package/dist/lib/internal/platform-context.js +4 -1
- package/dist/lib/internal/platform-issue.js +12 -4
- package/dist/lib/internal/platform-metadata.js +88 -0
- package/dist/lib/internal/platform-pr-review.js +16 -3
- package/dist/lib/internal/platform-pr.js +24 -10
- package/dist/lib/internal/platform-release-notes.js +6 -3
- package/dist/lib/internal/platform-security.js +93 -0
- package/dist/lib/internal/pr-review-grade.js +14 -3
- package/dist/lib/internal/release-workflow.js +19 -5
- package/dist/lib/internal/sandbox-control.js +22 -5
- package/dist/lib/internal/task-activity.js +13 -4
- package/dist/lib/internal/task-artifact.js +186 -27
- package/dist/lib/internal/task-context.js +4 -1
- package/dist/lib/internal/task-create.js +48 -15
- package/dist/lib/internal/task-delivery.js +4 -1
- package/dist/lib/internal/task-event.js +4 -1
- package/dist/lib/internal/task-finalization.js +4 -1
- package/dist/lib/internal/task-invalidation.js +4 -1
- package/dist/lib/internal/task-ledger.js +12 -1
- package/dist/lib/internal/task-lifecycle.js +4 -1
- package/dist/lib/internal/task-operation-registry.js +421 -0
- package/dist/lib/internal/task-orchestration.js +12 -0
- package/dist/lib/internal/task-override.js +10 -3
- package/dist/lib/internal/task-qualification.js +90 -0
- package/dist/lib/internal/task-review.js +4 -1
- package/dist/lib/internal/task-short-id.js +11 -1
- package/dist/lib/internal/task-snapshot.js +4 -1
- package/dist/lib/internal/task-validate.js +7 -2
- package/dist/lib/internal/task-verify.js +5 -1
- package/dist/lib/internal/task-warning.js +8 -1
- package/dist/lib/platform/comment-safety.js +226 -0
- package/dist/lib/platform/github-provider.js +233 -6
- package/dist/lib/platform/issue-comments.js +146 -34
- package/dist/lib/platform/pr-change-report.js +6 -12
- package/dist/lib/platform/pr-summary.js +28 -7
- package/dist/lib/platform/provider-contract.d.ts +67 -1
- package/dist/lib/platform/provider-contract.js +6 -2
- package/dist/lib/platform/provider-validation.js +76 -3
- package/dist/lib/platform/pull-requests.js +115 -170
- package/dist/lib/platform/repository-metadata.js +377 -0
- package/dist/lib/platform/security-alerts.js +155 -0
- package/dist/lib/platform/verification-sync.js +33 -26
- package/dist/lib/process-data/index.js +7 -0
- package/dist/lib/run/index.js +5 -0
- package/dist/lib/sandbox/commands/control-status.js +22 -0
- package/dist/lib/sandbox/commands/create.js +32 -6
- package/dist/lib/sandbox/commands/ls.js +5 -0
- package/dist/lib/sandbox/commands/rm.js +827 -120
- package/dist/lib/sandbox/commands/show.js +16 -0
- package/dist/lib/sandbox/control/client.js +83 -9
- package/dist/lib/sandbox/control/completed-reentry.js +85 -0
- package/dist/lib/sandbox/control/container-identity.js +28 -31
- package/dist/lib/sandbox/control/executor.js +149 -14
- package/dist/lib/sandbox/control/lifecycle.js +621 -127
- package/dist/lib/sandbox/control/native-file-lock.js +177 -0
- package/dist/lib/sandbox/control/server.js +305 -35
- package/dist/lib/sandbox/control/state.js +5 -3
- package/dist/lib/sandbox/control/task-view.js +183 -0
- package/dist/lib/sandbox/engines/authority.js +289 -0
- package/dist/lib/sandbox/engines/docker-exact-discovery.js +97 -0
- package/dist/lib/sandbox/index.js +5 -0
- package/dist/lib/sandbox/recovery.js +21 -1
- package/dist/lib/sandbox/workspace-view.js +2 -1
- package/dist/lib/sandbox/worktree-safety.js +4 -1
- package/dist/lib/server/index.js +8 -0
- package/dist/lib/task/artifact-lifecycle.js +42 -13
- package/dist/lib/task/artifact-operations.js +520 -0
- package/dist/lib/task/artifact-repair-intent.js +59 -0
- package/dist/lib/task/artifact-schema.js +135 -0
- package/dist/lib/task/capabilities.js +72 -1
- package/dist/lib/task/control-authority.js +4 -0
- package/dist/lib/task/create-service.js +103 -4
- package/dist/lib/task/create.js +25 -0
- package/dist/lib/task/events.js +186 -67
- package/dist/lib/task/index.js +11 -3
- package/dist/lib/task/local-artifact-finalization.js +75 -150
- package/dist/lib/task/orchestration.js +192 -52
- package/dist/lib/task/qualification-audit.js +490 -0
- package/dist/lib/task/qualification-confirmation.js +109 -0
- package/dist/lib/task/qualification-intents.js +148 -0
- package/dist/lib/task/review-artifacts.js +22 -23
- package/dist/lib/task/review-finalization.js +121 -4
- package/dist/lib/task/sections.js +6 -4
- package/dist/lib/task/verification-config.js +11 -0
- package/dist/lib/task/verification-engine.js +54 -4
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-client.ts +9 -0
- package/lib/internal/agent-client.ts +4 -2
- package/lib/internal/cli-route-inventory.ts +140 -0
- package/lib/internal/codex-lifecycle.ts +8 -6
- package/lib/internal/codex-sandbox-controller.ts +7 -2
- package/lib/internal/git-workflow.ts +9 -6
- package/lib/internal/platform-checks.ts +12 -4
- package/lib/internal/platform-comment.ts +12 -4
- package/lib/internal/platform-context.ts +3 -1
- package/lib/internal/platform-issue.ts +11 -4
- package/lib/internal/platform-metadata.ts +70 -0
- package/lib/internal/platform-pr-review.ts +14 -3
- package/lib/internal/platform-pr.ts +23 -10
- package/lib/internal/platform-release-notes.ts +5 -3
- package/lib/internal/platform-security.ts +83 -0
- package/lib/internal/pr-review-grade.ts +10 -3
- package/lib/internal/release-workflow.ts +17 -5
- package/lib/internal/sandbox-control.ts +19 -5
- package/lib/internal/task-activity.ts +12 -4
- package/lib/internal/task-artifact.ts +164 -25
- package/lib/internal/task-context.ts +3 -1
- package/lib/internal/task-create.ts +76 -15
- package/lib/internal/task-delivery.ts +3 -1
- package/lib/internal/task-event.ts +3 -1
- package/lib/internal/task-finalization.ts +3 -1
- package/lib/internal/task-invalidation.ts +3 -1
- package/lib/internal/task-ledger.ts +11 -1
- package/lib/internal/task-lifecycle.ts +3 -1
- package/lib/internal/task-operation-registry.ts +482 -0
- package/lib/internal/task-orchestration.ts +12 -0
- package/lib/internal/task-override.ts +9 -3
- package/lib/internal/task-qualification.ts +61 -0
- package/lib/internal/task-review.ts +3 -1
- package/lib/internal/task-short-id.ts +10 -1
- package/lib/internal/task-snapshot.ts +3 -1
- package/lib/internal/task-validate.ts +6 -2
- package/lib/internal/task-verify.ts +4 -1
- package/lib/internal/task-warning.ts +7 -1
- package/lib/platform/comment-safety.ts +250 -0
- package/lib/platform/github-provider.ts +229 -7
- package/lib/platform/issue-comments.ts +160 -35
- package/lib/platform/pr-change-report.ts +6 -12
- package/lib/platform/pr-summary.ts +27 -6
- package/lib/platform/provider-contract.ts +78 -3
- package/lib/platform/provider-validation.ts +85 -3
- package/lib/platform/pull-requests.ts +126 -178
- package/lib/platform/repository-metadata.ts +453 -0
- package/lib/platform/security-alerts.ts +191 -0
- package/lib/platform/verification-sync.ts +32 -28
- package/lib/process-data/index.ts +7 -0
- package/lib/run/index.ts +5 -0
- package/lib/sandbox/commands/control-status.ts +26 -0
- package/lib/sandbox/commands/create.ts +46 -6
- package/lib/sandbox/commands/ls.ts +7 -0
- package/lib/sandbox/commands/rm.ts +1000 -87
- package/lib/sandbox/commands/show.ts +16 -0
- package/lib/sandbox/control/client.ts +103 -9
- package/lib/sandbox/control/completed-reentry.ts +98 -0
- package/lib/sandbox/control/container-identity.ts +27 -43
- package/lib/sandbox/control/executor.ts +177 -12
- package/lib/sandbox/control/lifecycle.ts +629 -17
- package/lib/sandbox/control/native-file-lock.ts +209 -0
- package/lib/sandbox/control/protocol.ts +5 -2
- package/lib/sandbox/control/server.ts +319 -37
- package/lib/sandbox/control/state.ts +6 -3
- package/lib/sandbox/control/task-view.ts +240 -0
- package/lib/sandbox/engines/authority.ts +365 -0
- package/lib/sandbox/engines/docker-exact-discovery.ts +132 -0
- package/lib/sandbox/index.ts +7 -0
- package/lib/sandbox/recovery.ts +21 -1
- package/lib/sandbox/workspace-view.ts +9 -3
- package/lib/sandbox/worktree-safety.ts +4 -1
- package/lib/server/index.ts +10 -0
- package/lib/task/artifact-lifecycle.ts +38 -16
- package/lib/task/artifact-operations.ts +626 -0
- package/lib/task/artifact-repair-intent.ts +71 -0
- package/lib/task/artifact-schema.ts +192 -0
- package/lib/task/capabilities.ts +70 -1
- package/lib/task/control-authority.ts +11 -1
- package/lib/task/create-service.ts +131 -6
- package/lib/task/create.ts +27 -0
- package/lib/task/events.ts +161 -65
- package/lib/task/index.ts +16 -3
- package/lib/task/local-artifact-finalization.ts +87 -218
- package/lib/task/orchestration.ts +172 -49
- package/lib/task/qualification-audit.ts +599 -0
- package/lib/task/qualification-confirmation.ts +117 -0
- package/lib/task/qualification-intents.ts +150 -0
- package/lib/task/review-artifacts.ts +21 -22
- package/lib/task/review-finalization.ts +133 -4
- package/lib/task/sections.ts +6 -4
- package/lib/task/verification-config.ts +12 -0
- package/lib/task/verification-engine.ts +55 -4
- package/package.json +6 -1
- package/templates/.agents/rules/README.en.md +2 -0
- package/templates/.agents/rules/README.zh-CN.md +2 -0
- package/templates/.agents/rules/decision-qualification.en.md +22 -0
- package/templates/.agents/rules/decision-qualification.zh-CN.md +22 -0
- package/templates/.agents/rules/evidence-reporting.en.md +28 -0
- package/templates/.agents/rules/evidence-reporting.zh-CN.md +28 -0
- package/templates/.agents/rules/label-milestone-setup.en.md +7 -7
- package/templates/.agents/rules/label-milestone-setup.zh-CN.md +7 -7
- package/templates/.agents/rules/local-artifact-repair.en.md +5 -1
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +5 -1
- package/templates/.agents/rules/security-alerts.en.md +8 -8
- package/templates/.agents/rules/security-alerts.zh-CN.md +8 -8
- package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -2
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +19 -2
- package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -12
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -12
- package/templates/.agents/skills/close-codescan/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/SKILL.en.md +14 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/code-task/config/verify.en.json +5 -14
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +5 -14
- package/templates/.agents/skills/code-task/reference/code-rules.en.md +5 -4
- package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +5 -4
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/reference/report-template.en.md +42 -15
- package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +39 -12
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +5 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +5 -1
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +2 -0
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +2 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +9 -22
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +9 -22
- package/templates/.agents/skills/create-task/SKILL.en.md +8 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +7 -0
- package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/init-labels/SKILL.en.md +8 -8
- package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +8 -8
- package/templates/.agents/skills/init-milestones/SKILL.en.md +9 -11
- package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +9 -11
- package/templates/.agents/skills/plan-task/SKILL.en.md +14 -1
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +16 -1
- package/templates/.agents/skills/plan-task/config/verify.en.json +4 -14
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +4 -14
- package/templates/.agents/skills/review-analysis/SKILL.en.md +14 -1
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -18
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -18
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +50 -23
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +45 -18
- package/templates/.agents/skills/review-code/SKILL.en.md +14 -1
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -23
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -23
- package/templates/.agents/skills/review-code/reference/report-template.en.md +49 -22
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +45 -18
- package/templates/.agents/skills/review-plan/SKILL.en.md +14 -1
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -18
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -18
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +49 -22
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +45 -18
- package/templates/.agents/skills/review-pr/SKILL.en.md +5 -1
- package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +5 -1
- package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -3
- package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -3
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +5 -1
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +5 -1
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +3 -1
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +3 -1
- package/templates/.agents/skills/test/SKILL.en.md +13 -2
- package/templates/.agents/skills/test/SKILL.zh-CN.md +13 -2
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +14 -0
- package/templates/.agents/templates/task.en.md +6 -0
- package/templates/.agents/templates/task.zh-CN.md +6 -0
- package/templates/.agents/scripts/security-alerts.github.sh +0 -151
- package/templates/.agents/scripts/security-alerts.sh +0 -31
- package/templates/.agents/skills/init-labels/scripts/init-labels.github.sh +0 -146
- package/templates/.agents/skills/init-labels/scripts/init-labels.sh +0 -32
- package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +0 -294
- package/templates/.agents/skills/init-milestones/scripts/init-milestones.sh +0 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
4
5
|
import * as p from '@clack/prompts';
|
|
5
6
|
import pc from 'picocolors';
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
|
|
20
21
|
import { pruneSandboxDanglingImages } from '../image-prune.ts';
|
|
21
22
|
import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
|
|
22
|
-
import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
|
|
23
|
+
import { run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
|
|
23
24
|
import {
|
|
24
25
|
parseSandboxWorkspaceIdentity,
|
|
25
26
|
resolveSandboxCleanupTarget,
|
|
@@ -28,8 +29,21 @@ import {
|
|
|
28
29
|
type SandboxWorkspaceKey
|
|
29
30
|
} from '../workspace-identity.ts';
|
|
30
31
|
import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
SANDBOX_REMOVAL_JOURNAL_PHASES,
|
|
34
|
+
advanceSandboxRemovalJournalPhase,
|
|
35
|
+
claimSandboxRemovalJournal,
|
|
36
|
+
clearSandboxRemovalJournalRecord,
|
|
37
|
+
listSandboxRemovalJournals,
|
|
38
|
+
removeSandboxControlRoot,
|
|
39
|
+
readSandboxControlManifest,
|
|
40
|
+
sandboxRemovalPhaseIndex,
|
|
41
|
+
type SandboxRemovalJournal,
|
|
42
|
+
type SandboxRemovalTargetCommit
|
|
43
|
+
} from '../control/lifecycle.ts';
|
|
32
44
|
import { inspectSandboxControlContainer } from '../control/container-identity.ts';
|
|
45
|
+
import { commandForSandboxAuthority } from '../engines/authority.ts';
|
|
46
|
+
import { acquireSandboxResourceLock, type SandboxResourceLock } from '../control/native-file-lock.ts';
|
|
33
47
|
import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
|
|
34
48
|
import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
|
|
35
49
|
import type { SandboxTool } from '../tools.ts';
|
|
@@ -63,6 +77,649 @@ function isMissingPathError(error: unknown): boolean {
|
|
|
63
77
|
&& (error as { code?: unknown }).code === 'ENOENT');
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
function digestCleanupValue(value: unknown): string {
|
|
81
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function removalTargetDigest(config: SandboxConfig, target: RmTarget): string {
|
|
85
|
+
return digestCleanupValue({
|
|
86
|
+
project: config.project,
|
|
87
|
+
branch: target.effectiveBranch,
|
|
88
|
+
workspace: target.workspace,
|
|
89
|
+
controlRoots: target.controlRoots.map((candidate) => path.resolve(candidate)),
|
|
90
|
+
workspaceViewRoots: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)),
|
|
91
|
+
managedPathCandidates: (target.managedPathCandidates ?? []).map((candidate) => path.resolve(candidate)).sort()
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removalPermitDigest(permits: ReadonlyMap<string, WorktreeRemovalPermit>): string {
|
|
96
|
+
return digestCleanupValue([...permits.entries()]
|
|
97
|
+
.map(([worktree, permit]) => [path.resolve(worktree), permit.mode, permit.snapshot.identity])
|
|
98
|
+
.sort(([left], [right]) => String(left).localeCompare(String(right))));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function removalPermitCommits(permits: ReadonlyMap<string, WorktreeRemovalPermit>): SandboxRemovalTargetCommit['permits'] {
|
|
102
|
+
return [...permits.entries()]
|
|
103
|
+
.map(([worktree, permit]) => ({
|
|
104
|
+
path: path.resolve(worktree),
|
|
105
|
+
mode: permit.mode,
|
|
106
|
+
snapshot: permit.snapshot
|
|
107
|
+
}))
|
|
108
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removalTargetCommit(
|
|
112
|
+
config: SandboxConfig,
|
|
113
|
+
target: RmTarget,
|
|
114
|
+
permits: ReadonlyMap<string, WorktreeRemovalPermit>,
|
|
115
|
+
removeWorktree: boolean,
|
|
116
|
+
removeBranch: boolean,
|
|
117
|
+
removeShare: boolean
|
|
118
|
+
): SandboxRemovalTargetCommit {
|
|
119
|
+
return {
|
|
120
|
+
branch: target.effectiveBranch,
|
|
121
|
+
project: config.project,
|
|
122
|
+
controlRoot: path.resolve(target.controlRoots[0] ?? path.join(config.controlBase, config.project)),
|
|
123
|
+
targetDigest: removalTargetDigest(config, target),
|
|
124
|
+
permitDigest: removalPermitDigest(permits),
|
|
125
|
+
removeWorktree,
|
|
126
|
+
removeBranch,
|
|
127
|
+
removeShare,
|
|
128
|
+
worktreePaths: target.existingWorktrees.map((candidate) => path.resolve(candidate)).sort(),
|
|
129
|
+
workspaceViewPaths: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)).sort(),
|
|
130
|
+
toolPaths: target.toolCandidates.flatMap(({ candidates }) => candidates.map((candidate) => path.resolve(candidate))).sort(),
|
|
131
|
+
shellPaths: shellConfigDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate)).sort(),
|
|
132
|
+
sharePath: path.resolve(shareBranchDir(config, target.effectiveBranch)),
|
|
133
|
+
permits: removalPermitCommits(permits)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertRemovalSelectionMatches(
|
|
138
|
+
expected: SandboxRemovalTargetCommit,
|
|
139
|
+
actual: SandboxRemovalTargetCommit
|
|
140
|
+
): void {
|
|
141
|
+
const normalize = (value: SandboxRemovalTargetCommit): unknown => ({
|
|
142
|
+
...value,
|
|
143
|
+
controlRoot: '',
|
|
144
|
+
permits: value.permits
|
|
145
|
+
});
|
|
146
|
+
if (JSON.stringify(normalize(expected)) !== JSON.stringify(normalize(actual))) {
|
|
147
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function advanceRemovalJournals(
|
|
152
|
+
project: string,
|
|
153
|
+
target: RmTarget,
|
|
154
|
+
targetDigest: string,
|
|
155
|
+
phase: SandboxRemovalJournal['phase'],
|
|
156
|
+
resourceLocks: ReadonlyMap<string, SandboxResourceLock>
|
|
157
|
+
): SandboxRemovalJournal[] {
|
|
158
|
+
const targetIndex = sandboxRemovalPhaseIndex(phase);
|
|
159
|
+
return listSandboxRemovalJournals({
|
|
160
|
+
branch: target.effectiveBranch,
|
|
161
|
+
project,
|
|
162
|
+
targetDigest
|
|
163
|
+
}).map((journal) => {
|
|
164
|
+
let current = journal;
|
|
165
|
+
while (sandboxRemovalPhaseIndex(current.phase) < targetIndex) {
|
|
166
|
+
if (current.phase === 'target-committed' && phase === 'container-absent') {
|
|
167
|
+
const lock = resourceLocks.get(current.target.controlRoot);
|
|
168
|
+
if (!lock) throw new Error('SANDBOX_CONTROL_REMOVAL_LOCK_MISMATCH');
|
|
169
|
+
current = advanceSandboxRemovalJournalPhase(current, phase, { resourceLock: lock });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const next = SANDBOX_REMOVAL_JOURNAL_PHASES[sandboxRemovalPhaseIndex(current.phase) + 1];
|
|
173
|
+
if (!next) throw new Error('SANDBOX_CONTROL_REMOVAL_PHASE_TRANSITION_INVALID');
|
|
174
|
+
const lock = resourceLocks.get(current.target.controlRoot);
|
|
175
|
+
if (!lock) throw new Error('SANDBOX_CONTROL_REMOVAL_LOCK_MISMATCH');
|
|
176
|
+
current = advanceSandboxRemovalJournalPhase(current, next, { resourceLock: lock });
|
|
177
|
+
}
|
|
178
|
+
return current;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function prepareRemovalAction(
|
|
183
|
+
project: string,
|
|
184
|
+
target: RmTarget,
|
|
185
|
+
targetDigest: string,
|
|
186
|
+
startPhase: SandboxRemovalJournal['phase'],
|
|
187
|
+
completedPhase: SandboxRemovalJournal['phase'],
|
|
188
|
+
resourceLocks: ReadonlyMap<string, SandboxResourceLock>
|
|
189
|
+
): RemovalActionPreparation {
|
|
190
|
+
const journals = listSandboxRemovalJournals({
|
|
191
|
+
branch: target.effectiveBranch,
|
|
192
|
+
project,
|
|
193
|
+
targetDigest
|
|
194
|
+
});
|
|
195
|
+
const startIndex = sandboxRemovalPhaseIndex(startPhase);
|
|
196
|
+
const completedIndex = sandboxRemovalPhaseIndex(completedPhase);
|
|
197
|
+
const shouldRun = journals.length === 0
|
|
198
|
+
|| journals.some((journal) => sandboxRemovalPhaseIndex(journal.phase) < completedIndex);
|
|
199
|
+
const recovering = journals.some((journal) => {
|
|
200
|
+
const index = sandboxRemovalPhaseIndex(journal.phase);
|
|
201
|
+
return index >= startIndex && index < completedIndex;
|
|
202
|
+
});
|
|
203
|
+
advanceRemovalJournals(project, target, targetDigest, startPhase, resourceLocks);
|
|
204
|
+
return { run: shouldRun, recovering };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function assertRemovalPathsAbsent(paths: readonly string[]): void {
|
|
208
|
+
const remaining = paths.filter((candidate) => fs.existsSync(candidate));
|
|
209
|
+
if (remaining.length > 0) {
|
|
210
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${remaining[0]}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertBranchRemovalIdentity(
|
|
215
|
+
config: SandboxConfig,
|
|
216
|
+
branch: string,
|
|
217
|
+
permits: ReadonlyMap<string, WorktreeRemovalPermit>
|
|
218
|
+
): void {
|
|
219
|
+
if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${branch}`])) return;
|
|
220
|
+
const expectedHeads = new Set(
|
|
221
|
+
[...permits.values()]
|
|
222
|
+
.filter((permit) => permit.snapshot.branch === branch)
|
|
223
|
+
.map((permit) => permit.snapshot.head)
|
|
224
|
+
);
|
|
225
|
+
if (expectedHeads.size === 0) {
|
|
226
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
227
|
+
}
|
|
228
|
+
const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', `refs/heads/${branch}`]);
|
|
229
|
+
if (!expectedHeads.has(actualHead)) {
|
|
230
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function removalTombstonePath(targetDigest: string, kind: RemovalActionKind, source: string): string {
|
|
235
|
+
const sourceDigest = createHash('sha256')
|
|
236
|
+
.update(`${kind}\0${path.resolve(source)}`)
|
|
237
|
+
.digest('hex')
|
|
238
|
+
.slice(0, 24);
|
|
239
|
+
return path.join(
|
|
240
|
+
path.dirname(path.resolve(source)),
|
|
241
|
+
`.agent-infra-removal-${targetDigest.slice(0, 16)}-${kind}-${sourceDigest}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type RemovalTombstoneOwnership = Readonly<{
|
|
246
|
+
version: 1;
|
|
247
|
+
targetDigest: string;
|
|
248
|
+
permitDigest: string;
|
|
249
|
+
kind: RemovalActionKind;
|
|
250
|
+
source: string;
|
|
251
|
+
}>;
|
|
252
|
+
|
|
253
|
+
type RemovalSourceIdentity = Readonly<{
|
|
254
|
+
dev: string;
|
|
255
|
+
ino: string;
|
|
256
|
+
}>;
|
|
257
|
+
|
|
258
|
+
type RemovalTombstoneRecord = RemovalTombstoneOwnership & Readonly<{
|
|
259
|
+
sourceIdentity: RemovalSourceIdentity;
|
|
260
|
+
}>;
|
|
261
|
+
|
|
262
|
+
const REMOVAL_TOMBSTONE_MARKER = '.agent-infra-removal-ownership.json';
|
|
263
|
+
const REMOVAL_TOMBSTONE_PAYLOAD = 'payload';
|
|
264
|
+
|
|
265
|
+
function removalSourceClaimPath(source: string, ownership: RemovalTombstoneOwnership): string {
|
|
266
|
+
const claimDigest = createHash('sha256')
|
|
267
|
+
.update(JSON.stringify(ownership))
|
|
268
|
+
.digest('hex')
|
|
269
|
+
.slice(0, 24);
|
|
270
|
+
return path.join(source, `.agent-infra-removal-source-${claimDigest}.json`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function removalSourceIdentity(source: string): RemovalSourceIdentity {
|
|
274
|
+
const stat = fs.lstatSync(source, { bigint: true });
|
|
275
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev === 0n || stat.ino === 0n) {
|
|
276
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
277
|
+
}
|
|
278
|
+
return { dev: String(stat.dev), ino: String(stat.ino) };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sameRemovalSourceIdentity(left: RemovalSourceIdentity, right: RemovalSourceIdentity): boolean {
|
|
282
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function removalSourceClaimMatches(
|
|
286
|
+
source: string,
|
|
287
|
+
ownership: RemovalTombstoneOwnership,
|
|
288
|
+
identity: RemovalSourceIdentity
|
|
289
|
+
): boolean {
|
|
290
|
+
try {
|
|
291
|
+
const claimPath = removalSourceClaimPath(source, ownership);
|
|
292
|
+
const claimStat = fs.lstatSync(claimPath);
|
|
293
|
+
if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
|
|
294
|
+
const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
|
|
295
|
+
return record.version === ownership.version
|
|
296
|
+
&& record.targetDigest === ownership.targetDigest
|
|
297
|
+
&& record.permitDigest === ownership.permitDigest
|
|
298
|
+
&& record.kind === ownership.kind
|
|
299
|
+
&& typeof record.source === 'string'
|
|
300
|
+
&& path.resolve(record.source) === path.resolve(ownership.source)
|
|
301
|
+
&& record.sourceIdentity?.dev === identity.dev
|
|
302
|
+
&& record.sourceIdentity?.ino === identity.ino;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isOwnedRemovalSourceClaim(source: string, ownership: RemovalTombstoneOwnership): boolean {
|
|
309
|
+
try {
|
|
310
|
+
const claimPath = removalSourceClaimPath(source, ownership);
|
|
311
|
+
const claimStat = fs.lstatSync(claimPath);
|
|
312
|
+
if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
|
|
313
|
+
const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
|
|
314
|
+
return record.version === ownership.version
|
|
315
|
+
&& record.targetDigest === ownership.targetDigest
|
|
316
|
+
&& record.permitDigest === ownership.permitDigest
|
|
317
|
+
&& record.kind === ownership.kind
|
|
318
|
+
&& typeof record.source === 'string'
|
|
319
|
+
&& path.resolve(record.source) === path.resolve(ownership.source);
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function removeOwnedSourceClaim(source: string, ownership: RemovalTombstoneOwnership): void {
|
|
326
|
+
try {
|
|
327
|
+
const claimPath = removalSourceClaimPath(source, ownership);
|
|
328
|
+
if (isOwnedRemovalSourceClaim(source, ownership)) fs.unlinkSync(claimPath);
|
|
329
|
+
} catch {
|
|
330
|
+
// Preserve the source when the claim cannot be revalidated.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function readRemovalTombstoneRecord(root: string, tombstone: string): RemovalTombstoneRecord | null {
|
|
335
|
+
assertManagedPath(root, tombstone);
|
|
336
|
+
try {
|
|
337
|
+
const tombstoneStat = fs.lstatSync(tombstone);
|
|
338
|
+
const markerStat = fs.lstatSync(removalTombstoneMarker(tombstone));
|
|
339
|
+
if (!tombstoneStat.isDirectory() || tombstoneStat.isSymbolicLink()
|
|
340
|
+
|| !markerStat.isFile() || markerStat.isSymbolicLink()) return null;
|
|
341
|
+
const record = JSON.parse(fs.readFileSync(removalTombstoneMarker(tombstone), 'utf8')) as Partial<RemovalTombstoneRecord>;
|
|
342
|
+
if (record.version !== 1
|
|
343
|
+
|| typeof record.targetDigest !== 'string'
|
|
344
|
+
|| typeof record.permitDigest !== 'string'
|
|
345
|
+
|| typeof record.kind !== 'string'
|
|
346
|
+
|| typeof record.source !== 'string'
|
|
347
|
+
|| record.sourceIdentity?.dev === undefined
|
|
348
|
+
|| record.sourceIdentity.ino === undefined) return null;
|
|
349
|
+
return record as RemovalTombstoneRecord;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function removalTombstonePayload(tombstone: string): string {
|
|
356
|
+
return path.join(tombstone, REMOVAL_TOMBSTONE_PAYLOAD);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function removalTombstoneMarker(tombstone: string): string {
|
|
360
|
+
return path.join(tombstone, REMOVAL_TOMBSTONE_MARKER);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isOwnedRemovalTombstone(
|
|
364
|
+
root: string,
|
|
365
|
+
tombstone: string,
|
|
366
|
+
ownership: RemovalTombstoneOwnership
|
|
367
|
+
): boolean {
|
|
368
|
+
const record = readRemovalTombstoneRecord(root, tombstone);
|
|
369
|
+
return record !== null
|
|
370
|
+
&& record.version === 1
|
|
371
|
+
&& record.targetDigest === ownership.targetDigest
|
|
372
|
+
&& record.permitDigest === ownership.permitDigest
|
|
373
|
+
&& record.kind === ownership.kind
|
|
374
|
+
&& typeof record.source === 'string'
|
|
375
|
+
&& path.resolve(record.source) === path.resolve(ownership.source);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isOwnedRemovalPayload(
|
|
379
|
+
root: string,
|
|
380
|
+
tombstone: string,
|
|
381
|
+
ownership: RemovalTombstoneOwnership
|
|
382
|
+
): boolean {
|
|
383
|
+
const record = readRemovalTombstoneRecord(root, tombstone);
|
|
384
|
+
if (!record || record.targetDigest !== ownership.targetDigest
|
|
385
|
+
|| record.permitDigest !== ownership.permitDigest
|
|
386
|
+
|| record.kind !== ownership.kind
|
|
387
|
+
|| path.resolve(record.source) !== path.resolve(ownership.source)) return false;
|
|
388
|
+
try {
|
|
389
|
+
const payload = removalTombstonePayload(tombstone);
|
|
390
|
+
const payloadIdentity = removalSourceIdentity(payload);
|
|
391
|
+
return sameRemovalSourceIdentity(payloadIdentity, record.sourceIdentity)
|
|
392
|
+
&& removalSourceClaimMatches(payload, ownership, record.sourceIdentity);
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function assertOwnedRemovalTombstone(
|
|
399
|
+
root: string,
|
|
400
|
+
tombstone: string,
|
|
401
|
+
ownership: RemovalTombstoneOwnership
|
|
402
|
+
): void {
|
|
403
|
+
if (!isOwnedRemovalTombstone(root, tombstone, ownership)) {
|
|
404
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${ownership.source}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function shouldStageManagedRemoval(source: string, tombstone: string, recovering: boolean): boolean {
|
|
409
|
+
return fs.existsSync(source) || (recovering && fs.existsSync(tombstone));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function stageManagedRemoval(
|
|
413
|
+
root: string,
|
|
414
|
+
source: string,
|
|
415
|
+
tombstone: string,
|
|
416
|
+
recovering: boolean,
|
|
417
|
+
ownership: RemovalTombstoneOwnership
|
|
418
|
+
): void {
|
|
419
|
+
assertManagedPath(root, source);
|
|
420
|
+
assertManagedPath(root, tombstone);
|
|
421
|
+
const sourceExists = fs.existsSync(source);
|
|
422
|
+
const tombstoneExists = fs.existsSync(tombstone);
|
|
423
|
+
if (tombstoneExists) {
|
|
424
|
+
assertOwnedRemovalTombstone(root, tombstone, ownership);
|
|
425
|
+
if (sourceExists) {
|
|
426
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
427
|
+
}
|
|
428
|
+
if (recovering) {
|
|
429
|
+
if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
|
|
430
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
435
|
+
}
|
|
436
|
+
if (recovering) {
|
|
437
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${source}`);
|
|
438
|
+
}
|
|
439
|
+
if (!sourceExists) return;
|
|
440
|
+
// The operator authorizes cleanup of this managed target after execution stops.
|
|
441
|
+
// Path checks detect conflicts; they do not provide an atomic source-instance move.
|
|
442
|
+
const expectedIdentity = removalSourceIdentity(source);
|
|
443
|
+
const claimPath = removalSourceClaimPath(source, ownership);
|
|
444
|
+
try {
|
|
445
|
+
fs.writeFileSync(claimPath, `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
|
|
446
|
+
encoding: 'utf8',
|
|
447
|
+
mode: 0o600,
|
|
448
|
+
flag: 'wx'
|
|
449
|
+
});
|
|
450
|
+
} catch {
|
|
451
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
452
|
+
}
|
|
453
|
+
if (!sameRemovalSourceIdentity(expectedIdentity, removalSourceIdentity(source))) {
|
|
454
|
+
removeOwnedSourceClaim(source, ownership);
|
|
455
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
456
|
+
}
|
|
457
|
+
let moved = false;
|
|
458
|
+
try {
|
|
459
|
+
fs.mkdirSync(tombstone, { mode: 0o700 });
|
|
460
|
+
fs.writeFileSync(removalTombstoneMarker(tombstone), `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
|
|
461
|
+
encoding: 'utf8',
|
|
462
|
+
mode: 0o600,
|
|
463
|
+
flag: 'wx'
|
|
464
|
+
});
|
|
465
|
+
fs.renameSync(source, removalTombstonePayload(tombstone));
|
|
466
|
+
moved = true;
|
|
467
|
+
if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
|
|
468
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (!moved) removeOwnedSourceClaim(source, ownership);
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function cleanupManagedTombstones(
|
|
477
|
+
root: string,
|
|
478
|
+
targetDigest: string,
|
|
479
|
+
permitDigest: string,
|
|
480
|
+
kind: RemovalActionKind,
|
|
481
|
+
sources: readonly string[]
|
|
482
|
+
): void {
|
|
483
|
+
for (const source of sources) {
|
|
484
|
+
const tombstone = removalTombstonePath(targetDigest, kind, source);
|
|
485
|
+
if (!fs.existsSync(tombstone)) continue;
|
|
486
|
+
const ownership = { version: 1, targetDigest, permitDigest, kind, source } as const;
|
|
487
|
+
if (!isOwnedRemovalTombstone(root, tombstone, ownership)) continue;
|
|
488
|
+
if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
|
|
489
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
|
|
490
|
+
}
|
|
491
|
+
removeManagedDir(root, tombstone);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function branchRemovalTombstoneRef(targetDigest: string): string {
|
|
496
|
+
return `refs/agent-infra/sandbox-removal/${targetDigest}/branch`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function branchPermitHead(
|
|
500
|
+
branch: string,
|
|
501
|
+
permits: ReadonlyMap<string, WorktreeRemovalPermit>
|
|
502
|
+
): string | null {
|
|
503
|
+
const heads = new Set(
|
|
504
|
+
[...permits.values()]
|
|
505
|
+
.filter((permit) => permit.snapshot.branch === branch)
|
|
506
|
+
.map((permit) => permit.snapshot.head)
|
|
507
|
+
);
|
|
508
|
+
return heads.size === 1 ? [...heads][0]! : null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function stageBranchRemoval(
|
|
512
|
+
config: SandboxConfig,
|
|
513
|
+
branch: string,
|
|
514
|
+
targetDigest: string,
|
|
515
|
+
permits: ReadonlyMap<string, WorktreeRemovalPermit>,
|
|
516
|
+
recovering: boolean
|
|
517
|
+
): void {
|
|
518
|
+
const expectedHead = branchPermitHead(branch, permits);
|
|
519
|
+
if (!expectedHead) throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
520
|
+
const branchName = `refs/heads/${branch}`;
|
|
521
|
+
const tombstone = branchRemovalTombstoneRef(targetDigest);
|
|
522
|
+
const branchExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', branchName]);
|
|
523
|
+
const tombstoneExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone]);
|
|
524
|
+
if (branchExists && tombstoneExists) {
|
|
525
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
526
|
+
}
|
|
527
|
+
if (recovering) {
|
|
528
|
+
if (branchExists && !tombstoneExists) {
|
|
529
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${branch}`);
|
|
530
|
+
}
|
|
531
|
+
if (tombstoneExists && runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
|
|
532
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (!branchExists) return;
|
|
537
|
+
assertBranchRemovalIdentity(config, branch, permits);
|
|
538
|
+
const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', branchName]);
|
|
539
|
+
if (!runOk('git', ['-C', config.repoRoot, 'update-ref', tombstone, actualHead, '0'.repeat(40)])) {
|
|
540
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
541
|
+
}
|
|
542
|
+
if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', branchName, actualHead])) {
|
|
543
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function cleanupBranchTombstone(
|
|
548
|
+
config: SandboxConfig,
|
|
549
|
+
branch: string,
|
|
550
|
+
targetDigest: string,
|
|
551
|
+
permits: ReadonlyMap<string, WorktreeRemovalPermit>
|
|
552
|
+
): void {
|
|
553
|
+
const tombstone = branchRemovalTombstoneRef(targetDigest);
|
|
554
|
+
if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone])) return;
|
|
555
|
+
const expectedHead = branchPermitHead(branch, permits);
|
|
556
|
+
if (!expectedHead || runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
|
|
557
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
558
|
+
}
|
|
559
|
+
if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', tombstone, expectedHead])) {
|
|
560
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function worktreeRegistration(
|
|
565
|
+
config: SandboxConfig,
|
|
566
|
+
worktree: string
|
|
567
|
+
): { head: string; branch: string | null } | null {
|
|
568
|
+
const lines = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain']).split('\n');
|
|
569
|
+
let current: { path: string; head: string; branch: string | null } | null = null;
|
|
570
|
+
const flush = (): { head: string; branch: string | null } | null => {
|
|
571
|
+
if (!current || path.resolve(current.path) !== path.resolve(worktree)) return null;
|
|
572
|
+
return { head: current.head, branch: current.branch };
|
|
573
|
+
};
|
|
574
|
+
for (const line of lines) {
|
|
575
|
+
if (line.startsWith('worktree ')) {
|
|
576
|
+
const previous = flush();
|
|
577
|
+
if (previous) return previous;
|
|
578
|
+
current = { path: line.slice('worktree '.length), head: '', branch: null };
|
|
579
|
+
} else if (current && line.startsWith('HEAD ')) {
|
|
580
|
+
current.head = line.slice('HEAD '.length);
|
|
581
|
+
} else if (current && line.startsWith('branch ')) {
|
|
582
|
+
current.branch = line.slice('branch '.length);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return flush();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function assertWorktreeRegistrationIdentity(
|
|
589
|
+
config: SandboxConfig,
|
|
590
|
+
worktree: string,
|
|
591
|
+
permit: WorktreeRemovalPermit
|
|
592
|
+
): void {
|
|
593
|
+
const registration = worktreeRegistration(config, worktree);
|
|
594
|
+
if (!registration) return;
|
|
595
|
+
if (registration.head !== permit.snapshot.head
|
|
596
|
+
|| registration.branch !== `refs/heads/${permit.snapshot.branch}`) {
|
|
597
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function pruneWorktreeRegistration(config: SandboxConfig, worktree: string): void {
|
|
602
|
+
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
603
|
+
if (worktreeRegistration(config, worktree)) {
|
|
604
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function stageWorktreeRemoval(
|
|
609
|
+
config: SandboxConfig,
|
|
610
|
+
worktree: string,
|
|
611
|
+
permit: WorktreeRemovalPermit,
|
|
612
|
+
tombstone: string,
|
|
613
|
+
recovering: boolean,
|
|
614
|
+
ownership: RemovalTombstoneOwnership
|
|
615
|
+
): void {
|
|
616
|
+
assertManagedPath(config.worktreeBase, worktree);
|
|
617
|
+
assertManagedPath(config.worktreeBase, tombstone);
|
|
618
|
+
const worktreeExists = fs.existsSync(worktree);
|
|
619
|
+
const tombstoneExists = fs.existsSync(tombstone);
|
|
620
|
+
if (worktreeExists || !recovering) verifyWorktreePermit(permit);
|
|
621
|
+
if (recovering) {
|
|
622
|
+
if (worktreeExists || !tombstoneExists) {
|
|
623
|
+
if (worktreeExists && tombstoneExists) {
|
|
624
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
|
|
625
|
+
}
|
|
626
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
|
|
627
|
+
}
|
|
628
|
+
assertOwnedRemovalTombstone(config.worktreeBase, tombstone, ownership);
|
|
629
|
+
assertWorktreeRegistrationIdentity(config, worktree, permit);
|
|
630
|
+
pruneWorktreeRegistration(config, worktree);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (!worktreeExists) return;
|
|
634
|
+
stageManagedRemoval(config.worktreeBase, worktree, tombstone, false, ownership);
|
|
635
|
+
pruneWorktreeRegistration(config, worktree);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function cleanupCompletedRemovalArtifacts(
|
|
639
|
+
config: SandboxConfig,
|
|
640
|
+
target: RmTarget,
|
|
641
|
+
committedTarget: SandboxRemovalTargetCommit,
|
|
642
|
+
targetDigest: string
|
|
643
|
+
): void {
|
|
644
|
+
const journals = listSandboxRemovalJournals({
|
|
645
|
+
branch: target.effectiveBranch,
|
|
646
|
+
project: config.project,
|
|
647
|
+
targetDigest
|
|
648
|
+
});
|
|
649
|
+
if (journals.length === 0) return;
|
|
650
|
+
const completed = (phase: SandboxRemovalJournal['phase']): boolean => journals.every((journal) => (
|
|
651
|
+
sandboxRemovalPhaseIndex(journal.phase) >= sandboxRemovalPhaseIndex(phase)
|
|
652
|
+
));
|
|
653
|
+
if (completed('workspace-removed')) {
|
|
654
|
+
cleanupManagedTombstones(
|
|
655
|
+
config.workspaceViewBase, targetDigest, committedTarget.permitDigest, 'workspace', target.workspaceViewRoots
|
|
656
|
+
);
|
|
657
|
+
cleanupManagedTombstones(
|
|
658
|
+
path.join(config.controlBase, config.project), targetDigest, committedTarget.permitDigest, 'workspace', target.controlRoots
|
|
659
|
+
);
|
|
660
|
+
cleanupManagedTombstones(
|
|
661
|
+
config.worktreeBase, targetDigest, committedTarget.permitDigest, 'worktree', committedTarget.worktreePaths
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
if (completed('branch-removed')) {
|
|
665
|
+
cleanupBranchTombstone(config, target.effectiveBranch, targetDigest, new Map(
|
|
666
|
+
committedTarget.permits.map((permit) => [path.resolve(permit.path), {
|
|
667
|
+
mode: permit.mode,
|
|
668
|
+
snapshot: permit.snapshot
|
|
669
|
+
}])
|
|
670
|
+
));
|
|
671
|
+
}
|
|
672
|
+
if (completed('tool-removed')) {
|
|
673
|
+
cleanupManagedTombstones(config.home, targetDigest, committedTarget.permitDigest, 'tool', committedTarget.toolPaths);
|
|
674
|
+
}
|
|
675
|
+
if (completed('shell-removed')) {
|
|
676
|
+
cleanupManagedTombstones(config.shellConfigBase, targetDigest, committedTarget.permitDigest, 'shell', committedTarget.shellPaths);
|
|
677
|
+
}
|
|
678
|
+
if (completed('share-removed')) {
|
|
679
|
+
cleanupManagedTombstones(config.shareBase, targetDigest, committedTarget.permitDigest, 'share', [committedTarget.sharePath]);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function removalActionPaths(target: RmTarget, config: SandboxConfig, phase: SandboxRemovalJournal['phase']): string[] {
|
|
684
|
+
if (phase === 'workspace-removed') {
|
|
685
|
+
return [...target.workspaceViewRoots, ...target.controlRoots];
|
|
686
|
+
}
|
|
687
|
+
if (phase === 'tool-removed') {
|
|
688
|
+
return target.toolCandidates.flatMap(({ candidates }) => candidates);
|
|
689
|
+
}
|
|
690
|
+
if (phase === 'shell-removed') {
|
|
691
|
+
return shellConfigDirCandidates(config, target.effectiveBranch);
|
|
692
|
+
}
|
|
693
|
+
if (phase === 'share-removed') {
|
|
694
|
+
return [shareBranchDir(config, target.effectiveBranch)];
|
|
695
|
+
}
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function permitsFromJournal(
|
|
700
|
+
journals: readonly SandboxRemovalJournal[],
|
|
701
|
+
target: RmTarget,
|
|
702
|
+
expectedTargetDigest: string
|
|
703
|
+
): Map<string, WorktreeRemovalPermit> {
|
|
704
|
+
const permits = new Map<string, WorktreeRemovalPermit>();
|
|
705
|
+
for (const journal of journals) {
|
|
706
|
+
if (journal.target.targetDigest !== expectedTargetDigest) continue;
|
|
707
|
+
if (journal.target.branch !== target.effectiveBranch) {
|
|
708
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
|
|
709
|
+
}
|
|
710
|
+
for (const entry of journal.target.permits) {
|
|
711
|
+
if (path.resolve(entry.path) !== path.resolve(entry.snapshot.worktree)) {
|
|
712
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
|
|
713
|
+
}
|
|
714
|
+
permits.set(path.resolve(entry.path), {
|
|
715
|
+
mode: entry.mode,
|
|
716
|
+
snapshot: entry.snapshot
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return permits;
|
|
721
|
+
}
|
|
722
|
+
|
|
66
723
|
export function sandboxManagedPathKey(
|
|
67
724
|
candidate: string,
|
|
68
725
|
platform: NodeJS.Platform = process.platform,
|
|
@@ -95,6 +752,13 @@ type RmTarget = {
|
|
|
95
752
|
workspaceViewRoots: string[];
|
|
96
753
|
};
|
|
97
754
|
|
|
755
|
+
type RemovalActionKind = 'workspace' | 'worktree' | 'tool' | 'shell' | 'share';
|
|
756
|
+
|
|
757
|
+
type RemovalActionPreparation = Readonly<{
|
|
758
|
+
run: boolean;
|
|
759
|
+
recovering: boolean;
|
|
760
|
+
}>;
|
|
761
|
+
|
|
98
762
|
type CleanupCandidate = Readonly<{
|
|
99
763
|
row: SandboxRow;
|
|
100
764
|
cleanupTarget: SandboxCleanupTarget;
|
|
@@ -425,15 +1089,17 @@ async function removeExactSandboxContainer(
|
|
|
425
1089
|
const deadlineAt = Date.now() + timeoutMs;
|
|
426
1090
|
const remaining = (): number => Math.max(1, deadlineAt - Date.now());
|
|
427
1091
|
const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
|
|
1092
|
+
const runAuthorityCommand = (args: string[]): boolean => {
|
|
1093
|
+
const command = manifest.authorityEvidence
|
|
1094
|
+
? commandForSandboxAuthority(manifest.authorityEvidence, 'docker', args)
|
|
1095
|
+
: null;
|
|
1096
|
+
return command ? runOk(command.cmd, command.args, { timeout: remaining() })
|
|
1097
|
+
: runOkEngine(engine, 'docker', args, { timeout: remaining() });
|
|
1098
|
+
};
|
|
428
1099
|
const before = await inspect();
|
|
429
1100
|
if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
|
|
430
1101
|
if (before.state === 'absent') return;
|
|
431
|
-
if (before.running && !
|
|
432
|
-
engine,
|
|
433
|
-
'docker',
|
|
434
|
-
['stop', '--timeout', '1', manifest.containerIdentity.id],
|
|
435
|
-
{ timeout: remaining() }
|
|
436
|
-
)) {
|
|
1102
|
+
if (before.running && !runAuthorityCommand(['stop', '--timeout', '1', manifest.containerIdentity.id])) {
|
|
437
1103
|
const afterStop = await inspect();
|
|
438
1104
|
if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
|
|
439
1105
|
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
@@ -444,7 +1110,7 @@ async function removeExactSandboxContainer(
|
|
|
444
1110
|
}
|
|
445
1111
|
const afterStop = await inspect();
|
|
446
1112
|
if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
447
|
-
if (afterStop.state === 'found' && !
|
|
1113
|
+
if (afterStop.state === 'found' && !runAuthorityCommand(['rm', manifest.containerIdentity.id])) {
|
|
448
1114
|
const afterRemove = await inspect();
|
|
449
1115
|
if (afterRemove.state !== 'absent') {
|
|
450
1116
|
throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
|
|
@@ -524,7 +1190,7 @@ async function authorizeWorktrees(
|
|
|
524
1190
|
}
|
|
525
1191
|
p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
|
|
526
1192
|
const confirmed = await confirm({
|
|
527
|
-
message: 'Discard
|
|
1193
|
+
message: 'Discard this worktree and all its uncommitted changes, including later changes?',
|
|
528
1194
|
initialValue: false
|
|
529
1195
|
});
|
|
530
1196
|
if (isCancel(confirmed) || !confirmed) throw new Error('Dirty worktree removal cancelled; nothing was deleted');
|
|
@@ -554,54 +1220,193 @@ async function rmOne(
|
|
|
554
1220
|
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
555
1221
|
}
|
|
556
1222
|
|
|
1223
|
+
const targetDigest = removalTargetDigest(config, target);
|
|
1224
|
+
const existingJournals = listSandboxRemovalJournals({
|
|
1225
|
+
branch: effectiveBranch,
|
|
1226
|
+
project: config.project,
|
|
1227
|
+
targetDigest
|
|
1228
|
+
});
|
|
1229
|
+
const persistedTarget = existingJournals[0]?.target;
|
|
1230
|
+
for (const journal of existingJournals) {
|
|
1231
|
+
if (!persistedTarget) break;
|
|
1232
|
+
assertRemovalSelectionMatches(persistedTarget, journal.target);
|
|
1233
|
+
}
|
|
557
1234
|
const recovery = options.permits
|
|
558
1235
|
? new Map<string, WorktreeRecoveryContext>()
|
|
559
1236
|
: recoveryContexts(config, target, existingWorktrees);
|
|
1237
|
+
const journalPermits = options.permits ? new Map<string, WorktreeRemovalPermit>()
|
|
1238
|
+
: permitsFromJournal(existingJournals, target, targetDigest);
|
|
560
1239
|
const permits = options.permits
|
|
561
1240
|
? new Map(options.permits)
|
|
562
|
-
:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1241
|
+
: journalPermits.size > 0
|
|
1242
|
+
? journalPermits
|
|
1243
|
+
: await authorizeWorktrees(existingWorktrees, {
|
|
1244
|
+
allowDirtyDiscard: options.allowDirtyDiscard ?? true,
|
|
1245
|
+
assumeYes: Boolean(options.assumeYes)
|
|
1246
|
+
}, {
|
|
1247
|
+
...options.prompt,
|
|
1248
|
+
interactive: options.interactive ?? Boolean(process.stdin.isTTY),
|
|
1249
|
+
recovery
|
|
1250
|
+
});
|
|
570
1251
|
for (const worktree of existingWorktrees) {
|
|
571
1252
|
const permit = permits.get(path.resolve(worktree));
|
|
572
1253
|
if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
573
|
-
|
|
1254
|
+
const worktreeTombstone = removalTombstonePath(targetDigest, 'worktree', worktree);
|
|
1255
|
+
const workspacePhasePending = existingJournals.some((journal) => (
|
|
1256
|
+
sandboxRemovalPhaseIndex(journal.phase) < sandboxRemovalPhaseIndex('workspace-removed')
|
|
1257
|
+
));
|
|
1258
|
+
if (fs.existsSync(worktree)
|
|
1259
|
+
|| (workspacePhasePending
|
|
1260
|
+
&& !isOwnedRemovalTombstone(config.worktreeBase, worktreeTombstone, {
|
|
1261
|
+
version: 1,
|
|
1262
|
+
targetDigest,
|
|
1263
|
+
permitDigest: removalPermitDigest(permits),
|
|
1264
|
+
kind: 'worktree',
|
|
1265
|
+
source: worktree
|
|
1266
|
+
}))) {
|
|
1267
|
+
verifyWorktreePermit(permit);
|
|
1268
|
+
}
|
|
574
1269
|
}
|
|
575
1270
|
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1271
|
+
const recoveredRemovalChoice = persistedTarget;
|
|
1272
|
+
const shouldRemoveWorktree = recoveredRemovalChoice
|
|
1273
|
+
? recoveredRemovalChoice.removeWorktree
|
|
1274
|
+
: existingWorktrees.length === 0
|
|
1275
|
+
? false
|
|
1276
|
+
: options.assumeYes
|
|
1277
|
+
? true
|
|
1278
|
+
: await confirm({
|
|
1279
|
+
message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
|
|
1280
|
+
initialValue: true
|
|
1281
|
+
});
|
|
584
1282
|
if (isCancel(shouldRemoveWorktree)) {
|
|
585
1283
|
p.outro('Cancelled');
|
|
586
1284
|
return;
|
|
587
1285
|
}
|
|
588
1286
|
|
|
589
|
-
|
|
590
|
-
|
|
1287
|
+
const shouldDeleteBranch = recoveredRemovalChoice
|
|
1288
|
+
? recoveredRemovalChoice.removeBranch
|
|
1289
|
+
: shouldRemoveWorktree && existingWorktrees.length > 0
|
|
1290
|
+
? options.assumeYes
|
|
1291
|
+
? true
|
|
1292
|
+
: await confirm({
|
|
1293
|
+
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
1294
|
+
initialValue: true
|
|
1295
|
+
})
|
|
1296
|
+
: false;
|
|
1297
|
+
if (isCancel(shouldDeleteBranch)) {
|
|
1298
|
+
p.outro('Cancelled');
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const sharePath = path.resolve(shareBranchDir(config, effectiveBranch));
|
|
1303
|
+
const shouldRemoveShare = recoveredRemovalChoice
|
|
1304
|
+
? recoveredRemovalChoice.removeShare
|
|
1305
|
+
: fs.existsSync(sharePath)
|
|
1306
|
+
? options.assumeYes
|
|
1307
|
+
? true
|
|
1308
|
+
: await confirm({
|
|
1309
|
+
message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`,
|
|
1310
|
+
initialValue: true
|
|
1311
|
+
})
|
|
1312
|
+
: false;
|
|
1313
|
+
if (isCancel(shouldRemoveShare)) {
|
|
1314
|
+
p.outro('Cancelled');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const committedTarget = persistedTarget
|
|
1319
|
+
? {
|
|
1320
|
+
...persistedTarget,
|
|
1321
|
+
permitDigest: removalPermitDigest(permits),
|
|
1322
|
+
permits: removalPermitCommits(permits)
|
|
1323
|
+
}
|
|
1324
|
+
: removalTargetCommit(
|
|
1325
|
+
config,
|
|
1326
|
+
target,
|
|
1327
|
+
permits,
|
|
1328
|
+
Boolean(shouldRemoveWorktree),
|
|
1329
|
+
Boolean(shouldDeleteBranch),
|
|
1330
|
+
Boolean(shouldRemoveShare)
|
|
1331
|
+
);
|
|
1332
|
+
if (persistedTarget) assertRemovalSelectionMatches(persistedTarget, committedTarget);
|
|
1333
|
+
|
|
1334
|
+
const coordinatedManifests = controlRoots
|
|
1335
|
+
.filter((candidate) => fs.existsSync(candidate))
|
|
1336
|
+
.flatMap((root) => {
|
|
1337
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
1338
|
+
return fs.existsSync(manifestPath)
|
|
1339
|
+
? [[root, readSandboxControlManifest(manifestPath), fs.readFileSync(manifestPath, 'utf8')] as const]
|
|
1340
|
+
: [];
|
|
1341
|
+
});
|
|
1342
|
+
const resourceLocks = new Map<string, SandboxResourceLock>();
|
|
1343
|
+
const recoveredContainerNames = new Set<string>();
|
|
1344
|
+
try {
|
|
1345
|
+
for (const [root, manifest] of [...coordinatedManifests].sort(([left], [right]) => left.localeCompare(right))) {
|
|
1346
|
+
resourceLocks.set(root, acquireSandboxResourceLock(
|
|
1347
|
+
`${manifest.engine}:${manifest.containerIdentity.id}`,
|
|
1348
|
+
{ lockDomain: manifest.authorityEvidence.lockDomain }
|
|
1349
|
+
));
|
|
1350
|
+
}
|
|
1351
|
+
for (const journal of existingJournals.filter((candidate) => !fs.existsSync(candidate.target.controlRoot))) {
|
|
1352
|
+
if (!target.controlRoots.some((root) => path.resolve(root) === path.resolve(journal.target.controlRoot))) {
|
|
1353
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
|
|
1354
|
+
}
|
|
1355
|
+
assertRemovalSelectionMatches(committedTarget, journal.target);
|
|
1356
|
+
if (journal.target.permitDigest !== removalPermitDigest(permits)) {
|
|
1357
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
|
|
1358
|
+
}
|
|
1359
|
+
const lock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
|
|
1360
|
+
lockDomain: journal.lockDomain
|
|
1361
|
+
});
|
|
1362
|
+
resourceLocks.set(journal.target.controlRoot, lock);
|
|
1363
|
+
const current = listSandboxRemovalJournals({
|
|
1364
|
+
branch: journal.target.branch,
|
|
1365
|
+
project: journal.target.project,
|
|
1366
|
+
targetDigest: journal.target.targetDigest
|
|
1367
|
+
}).find((candidate) => candidate.carrierIdentityDigest === journal.carrierIdentityDigest);
|
|
1368
|
+
if (!current || current.revision !== journal.revision || current.handoffId !== journal.handoffId) {
|
|
1369
|
+
throw new Error('SANDBOX_CONTROL_REMOVAL_JOURNAL_REVISION_MISMATCH');
|
|
1370
|
+
}
|
|
1371
|
+
const claimed = claimSandboxRemovalJournal(current, { resourceLock: lock });
|
|
1372
|
+
if (!['carrier-finalizing', 'carrier-removed', 'workspace-finalizing', 'workspace-removed',
|
|
1373
|
+
'branch-finalizing', 'branch-removed', 'tool-finalizing', 'tool-removed',
|
|
1374
|
+
'shell-finalizing', 'shell-removed', 'share-finalizing', 'share-removed', 'completed']
|
|
1375
|
+
.includes(claimed.phase)) {
|
|
1376
|
+
throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
|
|
1377
|
+
}
|
|
1378
|
+
const recovered = claimed.phase === 'carrier-finalizing'
|
|
1379
|
+
? advanceSandboxRemovalJournalPhase(claimed, 'carrier-removed', { resourceLock: lock })
|
|
1380
|
+
: claimed;
|
|
1381
|
+
recoveredContainerNames.add(controlRootContainer(config, journal.target.controlRoot));
|
|
1382
|
+
}
|
|
1383
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1384
|
+
preflightRmTarget(config, target);
|
|
1385
|
+
const coordinatedContainers = new Set(recoveredContainerNames);
|
|
591
1386
|
for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
592
1387
|
assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
|
|
593
1388
|
assertControlRootMatchesTarget(root, effectiveBranch, workspace);
|
|
594
1389
|
const manifestPath = path.join(root, 'manifest.json');
|
|
595
1390
|
if (!fs.existsSync(manifestPath)) continue;
|
|
1391
|
+
const currentRaw = fs.readFileSync(manifestPath, 'utf8');
|
|
596
1392
|
const manifest = readSandboxControlManifest(manifestPath);
|
|
1393
|
+
const initial = coordinatedManifests.find(([candidate]) => candidate === root);
|
|
1394
|
+
if (!initial || currentRaw !== initial[2]) throw new Error('SANDBOX_CONTROL_MANIFEST_CHANGED');
|
|
597
1395
|
coordinatedContainers.add(manifest.container);
|
|
598
1396
|
await removeSandboxControlRoot(root, {
|
|
599
1397
|
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
600
|
-
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
|
|
1398
|
+
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
|
|
1399
|
+
resourceLock: resourceLocks.get(root),
|
|
1400
|
+
retainRemovalJournal: true,
|
|
1401
|
+
removalTarget: { ...committedTarget, controlRoot: path.resolve(root) }
|
|
601
1402
|
});
|
|
602
1403
|
removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
|
|
603
1404
|
}
|
|
604
1405
|
|
|
1406
|
+
if ([...recoveredContainerNames].some((name) => matchedContainers.includes(name))) {
|
|
1407
|
+
throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
605
1410
|
const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
|
|
606
1411
|
if (uncoordinatedContainers.length > 0) {
|
|
607
1412
|
if (workspace.mode === 'task-bound') {
|
|
@@ -621,82 +1426,190 @@ async function rmOne(
|
|
|
621
1426
|
p.log.warn(`No sandbox container found for '${branch}'`);
|
|
622
1427
|
}
|
|
623
1428
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1429
|
+
const runWorkspaceCleanup = prepareRemovalAction(
|
|
1430
|
+
config.project, target, targetDigest, 'workspace-finalizing', 'workspace-removed', resourceLocks
|
|
1431
|
+
);
|
|
1432
|
+
if (runWorkspaceCleanup.run) {
|
|
1433
|
+
for (const [roots, base, label] of [
|
|
1434
|
+
[workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
|
|
1435
|
+
[controlRoots, path.join(config.controlBase, config.project), 'Control channel']
|
|
1436
|
+
] as const) {
|
|
1437
|
+
for (const directory of roots.filter((candidate) => shouldStageManagedRemoval(
|
|
1438
|
+
candidate,
|
|
1439
|
+
removalTombstonePath(targetDigest, 'workspace', candidate),
|
|
1440
|
+
runWorkspaceCleanup.recovering
|
|
1441
|
+
))) {
|
|
1442
|
+
assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
|
|
1443
|
+
? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
|
|
1444
|
+
stageManagedRemoval(
|
|
1445
|
+
base,
|
|
1446
|
+
directory,
|
|
1447
|
+
removalTombstonePath(targetDigest, 'workspace', directory),
|
|
1448
|
+
runWorkspaceCleanup.recovering,
|
|
1449
|
+
{
|
|
1450
|
+
version: 1,
|
|
1451
|
+
targetDigest,
|
|
1452
|
+
permitDigest: committedTarget.permitDigest,
|
|
1453
|
+
kind: 'workspace',
|
|
1454
|
+
source: directory
|
|
1455
|
+
}
|
|
1456
|
+
);
|
|
1457
|
+
if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (shouldRemoveWorktree) {
|
|
1462
|
+
for (const worktree of existingWorktrees) {
|
|
1463
|
+
const permit = permits.get(path.resolve(worktree));
|
|
1464
|
+
if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
1465
|
+
stageWorktreeRemoval(
|
|
1466
|
+
config,
|
|
1467
|
+
worktree,
|
|
1468
|
+
permit,
|
|
1469
|
+
removalTombstonePath(targetDigest, 'worktree', worktree),
|
|
1470
|
+
runWorkspaceCleanup.recovering,
|
|
1471
|
+
{
|
|
1472
|
+
version: 1,
|
|
1473
|
+
targetDigest,
|
|
1474
|
+
permitDigest: committedTarget.permitDigest,
|
|
1475
|
+
kind: 'worktree',
|
|
1476
|
+
source: worktree
|
|
1477
|
+
}
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
} else {
|
|
1482
|
+
assertRemovalPathsAbsent([
|
|
1483
|
+
...removalActionPaths(target, config, 'workspace-removed'),
|
|
1484
|
+
...(shouldRemoveWorktree ? existingWorktrees : [])
|
|
1485
|
+
]);
|
|
1486
|
+
}
|
|
1487
|
+
advanceRemovalJournals(config.project, target, targetDigest, 'workspace-removed', resourceLocks);
|
|
1488
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1489
|
+
for (const [roots, base] of [
|
|
1490
|
+
[workspaceViewRoots, path.join(config.workspaceViewBase, config.project)],
|
|
1491
|
+
[controlRoots, path.join(config.controlBase, config.project)]
|
|
627
1492
|
] as const) {
|
|
628
|
-
for (const directory of roots
|
|
629
|
-
|
|
630
|
-
? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
|
|
631
|
-
removeManagedDir(base, directory);
|
|
632
|
-
assertRemoved(directory, label);
|
|
633
|
-
removeEmptyManagedParent(base, directory);
|
|
634
|
-
if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
|
|
1493
|
+
for (const directory of roots) {
|
|
1494
|
+
if (!fs.existsSync(directory)) removeEmptyManagedParent(base, directory);
|
|
635
1495
|
}
|
|
636
1496
|
}
|
|
637
1497
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
});
|
|
645
|
-
assertRemoved(worktree, 'Worktree');
|
|
646
|
-
const registered = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
|
|
647
|
-
.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9)));
|
|
648
|
-
if (registered.includes(path.resolve(worktree))) throw new Error(`Worktree is still registered after removal: ${worktree}`);
|
|
1498
|
+
const runBranchCleanup = prepareRemovalAction(
|
|
1499
|
+
config.project, target, targetDigest, 'branch-finalizing', 'branch-removed', resourceLocks
|
|
1500
|
+
);
|
|
1501
|
+
if (runBranchCleanup.run) {
|
|
1502
|
+
if (shouldRemoveWorktree && shouldDeleteBranch) {
|
|
1503
|
+
stageBranchRemoval(config, effectiveBranch, targetDigest, permits, runBranchCleanup.recovering);
|
|
649
1504
|
}
|
|
1505
|
+
} else if (shouldRemoveWorktree && shouldDeleteBranch
|
|
1506
|
+
&& runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
|
|
1507
|
+
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${effectiveBranch}`);
|
|
1508
|
+
}
|
|
1509
|
+
advanceRemovalJournals(config.project, target, targetDigest, 'branch-removed', resourceLocks);
|
|
1510
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
650
1511
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1512
|
+
const runToolCleanup = prepareRemovalAction(
|
|
1513
|
+
config.project, target, targetDigest, 'tool-finalizing', 'tool-removed', resourceLocks
|
|
1514
|
+
);
|
|
1515
|
+
if (runToolCleanup.run) {
|
|
1516
|
+
for (const { tool, candidates } of toolCandidates) {
|
|
1517
|
+
for (const dir of candidates.filter((candidate) => shouldStageManagedRemoval(
|
|
1518
|
+
candidate,
|
|
1519
|
+
removalTombstonePath(targetDigest, 'tool', candidate),
|
|
1520
|
+
runToolCleanup.recovering
|
|
1521
|
+
))) {
|
|
1522
|
+
stageManagedRemoval(
|
|
1523
|
+
tool.sandboxBase,
|
|
1524
|
+
dir,
|
|
1525
|
+
removalTombstonePath(targetDigest, 'tool', dir),
|
|
1526
|
+
runToolCleanup.recovering,
|
|
1527
|
+
{
|
|
1528
|
+
version: 1,
|
|
1529
|
+
targetDigest,
|
|
1530
|
+
permitDigest: committedTarget.permitDigest,
|
|
1531
|
+
kind: 'tool',
|
|
1532
|
+
source: dir
|
|
1533
|
+
}
|
|
1534
|
+
);
|
|
1535
|
+
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
664
1536
|
}
|
|
665
1537
|
}
|
|
1538
|
+
} else {
|
|
1539
|
+
assertRemovalPathsAbsent(removalActionPaths(target, config, 'tool-removed'));
|
|
666
1540
|
}
|
|
1541
|
+
advanceRemovalJournals(config.project, target, targetDigest, 'tool-removed', resourceLocks);
|
|
1542
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
667
1543
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1544
|
+
const runShellCleanup = prepareRemovalAction(
|
|
1545
|
+
config.project, target, targetDigest, 'shell-finalizing', 'shell-removed', resourceLocks
|
|
1546
|
+
);
|
|
1547
|
+
if (runShellCleanup.run) {
|
|
1548
|
+
for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => shouldStageManagedRemoval(
|
|
1549
|
+
candidate,
|
|
1550
|
+
removalTombstonePath(targetDigest, 'shell', candidate),
|
|
1551
|
+
runShellCleanup.recovering
|
|
1552
|
+
))) {
|
|
1553
|
+
stageManagedRemoval(
|
|
1554
|
+
config.shellConfigBase,
|
|
1555
|
+
dir,
|
|
1556
|
+
removalTombstonePath(targetDigest, 'shell', dir),
|
|
1557
|
+
runShellCleanup.recovering,
|
|
1558
|
+
{
|
|
1559
|
+
version: 1,
|
|
1560
|
+
targetDigest,
|
|
1561
|
+
permitDigest: committedTarget.permitDigest,
|
|
1562
|
+
kind: 'shell',
|
|
1563
|
+
source: dir
|
|
1564
|
+
}
|
|
1565
|
+
);
|
|
1566
|
+
p.log.success(`Shell config removed: ${dir}`);
|
|
673
1567
|
}
|
|
1568
|
+
} else {
|
|
1569
|
+
assertRemovalPathsAbsent(removalActionPaths(target, config, 'shell-removed'));
|
|
674
1570
|
}
|
|
1571
|
+
advanceRemovalJournals(config.project, target, targetDigest, 'shell-removed', resourceLocks);
|
|
1572
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
675
1573
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1574
|
+
const runShareCleanup = prepareRemovalAction(
|
|
1575
|
+
config.project, target, targetDigest, 'share-finalizing', 'share-removed', resourceLocks
|
|
1576
|
+
);
|
|
1577
|
+
if (runShareCleanup.run) {
|
|
1578
|
+
if (shouldRemoveShare && shouldStageManagedRemoval(
|
|
1579
|
+
sharePath,
|
|
1580
|
+
removalTombstonePath(targetDigest, 'share', sharePath),
|
|
1581
|
+
runShareCleanup.recovering
|
|
1582
|
+
)) {
|
|
1583
|
+
stageManagedRemoval(
|
|
1584
|
+
config.shareBase,
|
|
1585
|
+
sharePath,
|
|
1586
|
+
removalTombstonePath(targetDigest, 'share', sharePath),
|
|
1587
|
+
runShareCleanup.recovering,
|
|
1588
|
+
{
|
|
1589
|
+
version: 1,
|
|
1590
|
+
targetDigest,
|
|
1591
|
+
permitDigest: committedTarget.permitDigest,
|
|
1592
|
+
kind: 'share',
|
|
1593
|
+
source: sharePath
|
|
1594
|
+
}
|
|
1595
|
+
);
|
|
1596
|
+
p.log.success(`Share dir removed: ${sharePath}`);
|
|
694
1597
|
}
|
|
1598
|
+
} else if (shouldRemoveShare) {
|
|
1599
|
+
assertRemovalPathsAbsent(removalActionPaths(target, config, 'share-removed'));
|
|
695
1600
|
}
|
|
1601
|
+
advanceRemovalJournals(config.project, target, targetDigest, 'share-removed', resourceLocks);
|
|
1602
|
+
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1603
|
+
|
|
1604
|
+
const completedJournals = advanceRemovalJournals(config.project, target, targetDigest, 'completed', resourceLocks);
|
|
1605
|
+
for (const journal of completedJournals) clearSandboxRemovalJournalRecord(journal);
|
|
696
1606
|
|
|
697
1607
|
if (!options.quiet) {
|
|
698
1608
|
p.outro(pc.green('Sandbox removed'));
|
|
699
1609
|
}
|
|
1610
|
+
} finally {
|
|
1611
|
+
for (const lock of [...resourceLocks.values()].reverse()) lock.release();
|
|
1612
|
+
}
|
|
700
1613
|
}
|
|
701
1614
|
|
|
702
1615
|
async function rmPurge(
|