@fitlab-ai/agent-infra 0.9.5 → 0.9.7
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/dist/bin/internal-cli.js +8 -3
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
- package/dist/lib/agent-clients/adapters/codex.js +1 -1
- package/dist/lib/defaults.json +10 -2
- package/dist/lib/git/workflow.js +30 -4
- package/dist/lib/internal/codex-lifecycle.js +169 -3
- package/dist/lib/internal/codex-sandbox-controller.js +106 -0
- package/dist/lib/internal/release-workflow.js +108 -11
- package/dist/lib/internal/sandbox-control.js +43 -6
- package/dist/lib/internal/task-create.js +12 -4
- package/dist/lib/internal/task-orchestration.js +40 -7
- package/dist/lib/platform/context.js +6 -4
- package/dist/lib/platform/github-client.js +55 -2
- package/dist/lib/platform/issue-comments.js +2 -1
- package/dist/lib/process-data/checkpoint.js +319 -0
- package/dist/lib/process-data/index.js +128 -60
- package/dist/lib/process-data/materialize.js +84 -0
- package/dist/lib/process-data/normalize.js +34 -1
- package/dist/lib/process-data/reconcile.js +55 -1
- package/dist/lib/process-data/sources.js +422 -1
- package/dist/lib/process-data/store.js +341 -11
- package/dist/lib/release/workflow.js +29 -21
- package/dist/lib/sandbox/commands/create.js +111 -60
- package/dist/lib/sandbox/commands/prune.js +40 -16
- package/dist/lib/sandbox/commands/rm.js +372 -94
- package/dist/lib/sandbox/commands/start.js +4 -3
- package/dist/lib/sandbox/control/client.js +138 -22
- package/dist/lib/sandbox/control/container-identity.js +64 -0
- package/dist/lib/sandbox/control/executor.js +142 -0
- package/dist/lib/sandbox/control/lifecycle.js +557 -0
- package/dist/lib/sandbox/control/protocol.js +69 -26
- package/dist/lib/sandbox/control/server.js +395 -99
- package/dist/lib/sandbox/control/state.js +191 -0
- package/dist/lib/sandbox/control/timing.js +8 -0
- package/dist/lib/sandbox/index.js +2 -2
- package/dist/lib/sandbox/managed-fs.js +14 -2
- package/dist/lib/sandbox/mounts.js +22 -19
- package/dist/lib/sandbox/recovery.js +284 -50
- package/dist/lib/sandbox/workspace-view.js +90 -10
- package/dist/lib/sandbox/worktree-safety.js +196 -0
- package/dist/lib/server/daemon.js +1 -1
- package/dist/lib/server/process-control.js +17 -1
- package/dist/lib/server/process-state.js +57 -20
- package/dist/lib/task/artifact-lifecycle.js +87 -15
- package/dist/lib/task/artifact-receipts.js +128 -0
- package/dist/lib/task/codex-orchestration.js +236 -0
- package/dist/lib/task/commands/status.js +3 -0
- package/dist/lib/task/commands/validate.js +289 -0
- package/dist/lib/task/commit-finalization.js +36 -9
- package/dist/lib/task/create.js +10 -2
- package/dist/lib/task/delegation-receipts.js +109 -2
- package/dist/lib/task/events.js +149 -6
- package/dist/lib/task/finalization-artifacts.js +2 -1
- package/dist/lib/task/implementation-inputs.js +1 -7
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +13 -1
- package/dist/lib/task/orchestration.js +365 -15
- package/dist/lib/task/sections.js +5 -1
- package/dist/lib/task/task-execution-lock.js +5 -4
- package/dist/lib/task/verification-engine.js +78 -14
- package/dist/lib/task/verification.js +1 -0
- package/dist/lifecycle-build-manifest.json +6 -0
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
- package/lib/agent-clients/adapters/codex.ts +1 -1
- package/lib/defaults.json +10 -2
- package/lib/git/workflow.ts +25 -3
- package/lib/internal/codex-lifecycle.ts +175 -4
- package/lib/internal/codex-sandbox-controller.ts +109 -0
- package/lib/internal/release-workflow.ts +107 -11
- package/lib/internal/sandbox-control.ts +38 -6
- package/lib/internal/task-create.ts +16 -4
- package/lib/internal/task-orchestration.ts +34 -7
- package/lib/platform/context.ts +8 -5
- package/lib/platform/github-client.ts +61 -3
- package/lib/platform/issue-comments.ts +2 -1
- package/lib/process-data/checkpoint.ts +335 -0
- package/lib/process-data/index.ts +112 -60
- package/lib/process-data/materialize.ts +83 -0
- package/lib/process-data/normalize.ts +29 -1
- package/lib/process-data/reconcile.ts +64 -2
- package/lib/process-data/sources.ts +449 -2
- package/lib/process-data/store.ts +304 -11
- package/lib/process-data/types.ts +124 -2
- package/lib/release/workflow.ts +69 -20
- package/lib/sandbox/commands/create.ts +70 -25
- package/lib/sandbox/commands/prune.ts +53 -15
- package/lib/sandbox/commands/rm.ts +423 -108
- package/lib/sandbox/commands/start.ts +4 -3
- package/lib/sandbox/control/client.ts +152 -28
- package/lib/sandbox/control/container-identity.ts +93 -0
- package/lib/sandbox/control/executor.ts +167 -0
- package/lib/sandbox/control/lifecycle.ts +573 -0
- package/lib/sandbox/control/protocol.ts +121 -68
- package/lib/sandbox/control/server.ts +398 -109
- package/lib/sandbox/control/state.ts +213 -0
- package/lib/sandbox/control/timing.ts +12 -0
- package/lib/sandbox/index.ts +2 -2
- package/lib/sandbox/managed-fs.ts +17 -2
- package/lib/sandbox/mounts.ts +16 -8
- package/lib/sandbox/recovery.ts +335 -65
- package/lib/sandbox/workspace-view.ts +102 -11
- package/lib/sandbox/worktree-safety.ts +234 -0
- package/lib/server/daemon.ts +1 -1
- package/lib/server/process-control.ts +24 -3
- package/lib/server/process-state.ts +82 -27
- package/lib/task/artifact-lifecycle.ts +78 -14
- package/lib/task/artifact-receipts.ts +161 -0
- package/lib/task/codex-orchestration.ts +319 -0
- package/lib/task/commands/status.ts +2 -0
- package/lib/task/commands/validate.ts +282 -0
- package/lib/task/commit-finalization.ts +52 -9
- package/lib/task/create.ts +11 -2
- package/lib/task/delegation-receipts.ts +208 -7
- package/lib/task/events.ts +131 -5
- package/lib/task/finalization-artifacts.ts +2 -1
- package/lib/task/implementation-inputs.ts +1 -5
- package/lib/task/index.ts +8 -0
- package/lib/task/ledger.ts +16 -1
- package/lib/task/orchestration.ts +552 -18
- package/lib/task/sections.ts +6 -2
- package/lib/task/task-execution-lock.ts +11 -9
- package/lib/task/verification-engine.ts +93 -24
- package/lib/task/verification.ts +2 -1
- package/package.json +1 -1
- package/templates/.agents/README.en.md +7 -0
- package/templates/.agents/README.zh-CN.md +5 -0
- package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
- package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
- package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
- package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
- package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
- package/templates/.agents/rules/review-handshake.en.md +3 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
- package/templates/.claude/commands/run-manual-validation.en.md +9 -0
- package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
- package/templates/.codex/hooks.json +8 -8
- package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
- package/templates/.agents/workspace/README.en.md +0 -26
- package/templates/.agents/workspace/README.zh-CN.md +0 -26
|
@@ -16,18 +16,29 @@ import {
|
|
|
16
16
|
} from '../constants.ts';
|
|
17
17
|
import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
|
|
18
18
|
import { pruneSandboxDanglingImages } from '../image-prune.ts';
|
|
19
|
-
import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
|
|
20
|
-
import { runOk, runSafe, runSafeEngine } from '../shell.ts';
|
|
21
|
-
import { resolveSandboxTarget } from '../workspace-identity.ts';
|
|
19
|
+
import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
|
|
20
|
+
import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
|
|
21
|
+
import { resolveSandboxTarget, type SandboxWorkspaceIdentity } from '../workspace-identity.ts';
|
|
22
|
+
import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
|
|
23
|
+
import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
|
|
24
|
+
import { inspectSandboxControlContainer } from '../control/container-identity.ts';
|
|
22
25
|
import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
|
|
23
26
|
import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
|
|
24
27
|
import type { SandboxTool } from '../tools.ts';
|
|
25
28
|
import { fetchSandboxRows } from './list-running.ts';
|
|
26
29
|
import { lookupShortIdByBranch } from '../../task/short-id.ts';
|
|
30
|
+
import {
|
|
31
|
+
createCleanPermit,
|
|
32
|
+
createDiscardPermit,
|
|
33
|
+
formatWorktreeSnapshot,
|
|
34
|
+
inspectWorktrees,
|
|
35
|
+
verifyWorktreePermit
|
|
36
|
+
} from '../worktree-safety.ts';
|
|
37
|
+
import type { WorktreeInspection, WorktreeRemovalPermit } from '../worktree-safety.ts';
|
|
27
38
|
|
|
28
39
|
const USAGE = `Usage:
|
|
29
40
|
ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
|
|
30
|
-
ai sandbox rm --
|
|
41
|
+
ai sandbox rm --unbound [--dry-run] [--yes] Remove every sandbox not bound to an active task
|
|
31
42
|
ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
|
|
32
43
|
export { assertManagedPath } from '../managed-fs.ts';
|
|
33
44
|
|
|
@@ -35,14 +46,33 @@ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[]
|
|
|
35
46
|
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
type
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
type RmTarget = {
|
|
50
|
+
branch: string;
|
|
51
|
+
effectiveBranch: string;
|
|
52
|
+
engine: string;
|
|
53
|
+
matchedContainers: string[];
|
|
54
|
+
existingWorktrees: string[];
|
|
55
|
+
toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
|
|
56
|
+
workspace: SandboxWorkspaceIdentity;
|
|
57
|
+
controlRoots: string[];
|
|
58
|
+
workspaceViewRoots: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type RmOneOptions = {
|
|
62
|
+
assumeYes?: boolean;
|
|
63
|
+
quiet?: boolean;
|
|
64
|
+
target?: RmTarget;
|
|
65
|
+
permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
|
|
66
|
+
allowDirtyDiscard?: boolean;
|
|
67
|
+
prompt?: PromptDependencies;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type PromptDependencies = {
|
|
71
|
+
confirm?: typeof p.confirm;
|
|
72
|
+
isCancel?: typeof p.isCancel;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function resolveRmTarget(config: SandboxConfig, tools: SandboxTool[], branch: string): RmTarget {
|
|
46
76
|
assertValidBranchName(branch);
|
|
47
77
|
const engine = detectEngine(config);
|
|
48
78
|
let effectiveBranch = branch;
|
|
@@ -51,17 +81,11 @@ async function rmOne(
|
|
|
51
81
|
tool,
|
|
52
82
|
candidates: toolConfigDirCandidates(tool, config.project, branch)
|
|
53
83
|
}));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
60
|
-
const matchedContainers = containerNameCandidates(config, branch)
|
|
61
|
-
.filter((name) => existing.includes(name));
|
|
84
|
+
const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
85
|
+
const matchedContainers = containerNameCandidates(config, branch).filter((name) => existing.includes(name));
|
|
62
86
|
|
|
63
87
|
if (matchedContainers.length > 0) {
|
|
64
|
-
const resolvedBranch =
|
|
88
|
+
const resolvedBranch = runEngine(engine, 'docker', [
|
|
65
89
|
'inspect',
|
|
66
90
|
'-f',
|
|
67
91
|
`{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
|
|
@@ -75,58 +99,293 @@ async function rmOne(
|
|
|
75
99
|
candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
|
|
76
100
|
}));
|
|
77
101
|
}
|
|
102
|
+
}
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
|
|
105
|
+
const identities: SandboxWorkspaceIdentity[] = workspace.mode === 'task-bound'
|
|
106
|
+
? [workspace, { mode: 'branch-only' }]
|
|
107
|
+
: [workspace];
|
|
108
|
+
const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
|
|
109
|
+
const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
|
|
110
|
+
base: config.controlBase, project: config.project, container, identity
|
|
111
|
+
}).root));
|
|
112
|
+
const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
|
|
113
|
+
base: config.workspaceViewBase, project: config.project, container, identity
|
|
114
|
+
}).root));
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
branch,
|
|
118
|
+
effectiveBranch,
|
|
119
|
+
engine,
|
|
120
|
+
matchedContainers,
|
|
121
|
+
existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
|
|
122
|
+
toolCandidates,
|
|
123
|
+
workspace,
|
|
124
|
+
controlRoots: [...new Set(controlRoots)],
|
|
125
|
+
workspaceViewRoots: [...new Set(workspaceViewRoots)]
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceIdentity): void {
|
|
130
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
131
|
+
if (!fs.existsSync(manifestPath)) return;
|
|
132
|
+
const manifest = readSandboxControlManifest(manifestPath);
|
|
133
|
+
const identityMatches = workspace.mode === 'task-bound'
|
|
134
|
+
? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
|
|
135
|
+
: manifest.mode === 'branch-only';
|
|
136
|
+
const branchOnlyFallback = workspace.mode === 'task-bound' && manifest.mode === 'branch-only';
|
|
137
|
+
if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
|
|
138
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertLegacyCandidateEvidence(
|
|
143
|
+
candidate: string,
|
|
144
|
+
base: string,
|
|
145
|
+
config: SandboxConfig,
|
|
146
|
+
effectiveBranch: string,
|
|
147
|
+
matchedContainers: readonly string[]
|
|
148
|
+
): void {
|
|
149
|
+
const relative = path.relative(path.join(base, config.project), candidate);
|
|
150
|
+
const [container, identity] = relative.split(path.sep);
|
|
151
|
+
const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
|
|
152
|
+
if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container)) return;
|
|
153
|
+
const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
|
|
154
|
+
if (!fs.existsSync(manifestPath)) {
|
|
155
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertRemoved(target: string, label: string): void {
|
|
160
|
+
if (fs.existsSync(target)) throw new Error(`${label} still exists after removal: ${target}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function removeEmptyManagedParent(base: string, directory: string): void {
|
|
164
|
+
const parent = path.dirname(directory);
|
|
165
|
+
if (path.resolve(parent) === path.resolve(base)) return;
|
|
166
|
+
assertManagedPath(base, parent);
|
|
167
|
+
try {
|
|
168
|
+
fs.rmdirSync(parent);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
assertRemoved(parent, 'Empty sandbox container directory');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function removeExactSandboxContainer(
|
|
177
|
+
engine: string,
|
|
178
|
+
manifest: ReturnType<typeof readSandboxControlManifest>,
|
|
179
|
+
timeoutMs: number
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
182
|
+
const remaining = (): number => Math.max(1, deadlineAt - Date.now());
|
|
183
|
+
const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
|
|
184
|
+
const before = await inspect();
|
|
185
|
+
if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
|
|
186
|
+
if (before.state === 'absent') return;
|
|
187
|
+
if (before.running && !runOkEngine(engine, 'docker', ['stop', manifest.containerIdentity.id], { timeout: remaining() })) {
|
|
188
|
+
const afterStop = await inspect();
|
|
189
|
+
if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
|
|
190
|
+
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
94
191
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
192
|
+
if (afterStop.state === 'found' && afterStop.running) {
|
|
193
|
+
throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const afterStop = await inspect();
|
|
197
|
+
if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
198
|
+
if (afterStop.state === 'found' && !runOkEngine(engine, 'docker', ['rm', manifest.containerIdentity.id], { timeout: remaining() })) {
|
|
199
|
+
const afterRemove = await inspect();
|
|
200
|
+
if (afterRemove.state !== 'absent') {
|
|
201
|
+
throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function removeProjectControlRoots(config: SandboxConfig, engine: string): Promise<Set<string>> {
|
|
207
|
+
const containers = new Set<string>();
|
|
208
|
+
const projectRoot = path.join(config.controlBase, config.project);
|
|
209
|
+
if (!fs.existsSync(projectRoot)) return containers;
|
|
210
|
+
assertManagedPath(config.controlBase, projectRoot);
|
|
211
|
+
const projectStat = fs.lstatSync(projectRoot);
|
|
212
|
+
if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
|
|
213
|
+
for (const container of fs.readdirSync(projectRoot, { withFileTypes: true })) {
|
|
214
|
+
if (!container.isDirectory() || container.isSymbolicLink()) continue;
|
|
215
|
+
const containerRoot = path.join(projectRoot, container.name);
|
|
216
|
+
for (const identity of fs.readdirSync(containerRoot, { withFileTypes: true })) {
|
|
217
|
+
if (!identity.isDirectory() || identity.isSymbolicLink()) continue;
|
|
218
|
+
const root = path.join(containerRoot, identity.name);
|
|
219
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
220
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
|
|
221
|
+
const manifest = readSandboxControlManifest(manifestPath);
|
|
222
|
+
containers.add(manifest.container);
|
|
223
|
+
await removeSandboxControlRoot(root, {
|
|
224
|
+
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
225
|
+
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return containers;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function inspectionBlockers(inspections: readonly WorktreeInspection[]): WorktreeInspection[] {
|
|
233
|
+
return inspections.filter((inspection) => inspection.status !== 'clean');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function blockerMessage(blockers: readonly WorktreeInspection[]): string {
|
|
237
|
+
return blockers.map((blocker) => {
|
|
238
|
+
if (blocker.status === 'failed') return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
|
|
239
|
+
return `${formatWorktreeSnapshot(blocker.snapshot)}\n Action: commit, stash, clean, or remove this sandbox interactively.`;
|
|
240
|
+
}).join('\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cleanPermits(inspections: readonly WorktreeInspection[]): Map<string, WorktreeRemovalPermit> {
|
|
244
|
+
const permits = new Map<string, WorktreeRemovalPermit>();
|
|
245
|
+
for (const inspection of inspections) {
|
|
246
|
+
if (inspection.status === 'clean') permits.set(inspection.snapshot.worktree, createCleanPermit(inspection.snapshot));
|
|
247
|
+
}
|
|
248
|
+
return permits;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function authorizeWorktrees(
|
|
252
|
+
worktrees: readonly string[],
|
|
253
|
+
{ allowDirtyDiscard, assumeYes }: { allowDirtyDiscard: boolean; assumeYes: boolean },
|
|
254
|
+
{
|
|
255
|
+
interactive = Boolean(process.stdin.isTTY),
|
|
256
|
+
confirm = p.confirm,
|
|
257
|
+
isCancel = p.isCancel
|
|
258
|
+
}: PromptDependencies & { interactive?: boolean } = {}
|
|
259
|
+
): Promise<Map<string, WorktreeRemovalPermit>> {
|
|
260
|
+
const inspections = inspectWorktrees(worktrees);
|
|
261
|
+
const failures = inspections.filter((inspection) => inspection.status === 'failed');
|
|
262
|
+
if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
|
|
263
|
+
const permits = cleanPermits(inspections);
|
|
264
|
+
for (const inspection of inspections) {
|
|
265
|
+
if (inspection.status !== 'dirty') continue;
|
|
266
|
+
if (!allowDirtyDiscard || assumeYes || !interactive) {
|
|
267
|
+
throw new Error(`Refusing to remove dirty worktree(s):\n${blockerMessage([inspection])}`);
|
|
268
|
+
}
|
|
269
|
+
p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
|
|
270
|
+
const confirmed = await confirm({
|
|
271
|
+
message: 'Discard these exact uncommitted changes?',
|
|
272
|
+
initialValue: false
|
|
273
|
+
});
|
|
274
|
+
if (isCancel(confirmed) || !confirmed) throw new Error('Dirty worktree removal cancelled; nothing was deleted');
|
|
275
|
+
permits.set(inspection.snapshot.worktree, createDiscardPermit(inspection.snapshot));
|
|
276
|
+
}
|
|
277
|
+
return permits;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function rmOne(
|
|
281
|
+
config: SandboxConfig,
|
|
282
|
+
tools: SandboxTool[],
|
|
283
|
+
branch: string,
|
|
284
|
+
options: RmOneOptions = {}
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
const target = options.target ?? resolveRmTarget(config, tools, branch);
|
|
287
|
+
const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
|
|
288
|
+
const { workspace, controlRoots, workspaceViewRoots } = target;
|
|
289
|
+
const confirm = options.prompt?.confirm ?? p.confirm;
|
|
290
|
+
const isCancel = options.prompt?.isCancel ?? p.isCancel;
|
|
291
|
+
|
|
292
|
+
if (!options.quiet) {
|
|
293
|
+
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const permits = options.permits
|
|
297
|
+
? new Map(options.permits)
|
|
298
|
+
: await authorizeWorktrees(existingWorktrees, {
|
|
299
|
+
allowDirtyDiscard: options.allowDirtyDiscard ?? true,
|
|
300
|
+
assumeYes: Boolean(options.assumeYes)
|
|
301
|
+
}, options.prompt);
|
|
302
|
+
for (const worktree of existingWorktrees) {
|
|
303
|
+
const permit = permits.get(path.resolve(worktree));
|
|
304
|
+
if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
305
|
+
verifyWorktreePermit(permit);
|
|
98
306
|
}
|
|
99
307
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
308
|
+
const shouldRemoveWorktree = existingWorktrees.length === 0
|
|
309
|
+
? false
|
|
310
|
+
: options.assumeYes
|
|
103
311
|
? true
|
|
104
|
-
: await
|
|
312
|
+
: await confirm({
|
|
105
313
|
message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
|
|
106
314
|
initialValue: true
|
|
107
315
|
});
|
|
316
|
+
if (isCancel(shouldRemoveWorktree)) {
|
|
317
|
+
p.outro('Cancelled');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
108
320
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
321
|
+
const coordinatedContainers = new Set<string>();
|
|
322
|
+
for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
323
|
+
assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
|
|
324
|
+
assertControlRootMatchesTarget(root, effectiveBranch, workspace);
|
|
325
|
+
const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
|
|
326
|
+
coordinatedContainers.add(manifest.container);
|
|
327
|
+
await removeSandboxControlRoot(root, {
|
|
328
|
+
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
329
|
+
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
|
|
334
|
+
if (uncoordinatedContainers.length > 0) {
|
|
335
|
+
const spinner = p.spinner();
|
|
336
|
+
spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
|
|
337
|
+
for (const name of uncoordinatedContainers) {
|
|
338
|
+
if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
|
|
339
|
+
if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
|
|
340
|
+
}
|
|
341
|
+
const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
342
|
+
const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
|
|
343
|
+
if (leftovers.length > 0) throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
|
|
344
|
+
spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
|
|
345
|
+
} else {
|
|
346
|
+
p.log.warn(`No sandbox container found for '${branch}'`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const [roots, base, label] of [
|
|
350
|
+
[workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
|
|
351
|
+
[controlRoots, path.join(config.controlBase, config.project), 'Control channel']
|
|
352
|
+
] as const) {
|
|
353
|
+
for (const directory of roots.filter((candidate) => fs.existsSync(candidate))) {
|
|
354
|
+
assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
|
|
355
|
+
? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
|
|
356
|
+
removeManagedDir(base, directory);
|
|
357
|
+
assertRemoved(directory, label);
|
|
358
|
+
removeEmptyManagedParent(base, directory);
|
|
359
|
+
if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
|
|
112
360
|
}
|
|
361
|
+
}
|
|
113
362
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
363
|
+
if (shouldRemoveWorktree) {
|
|
364
|
+
for (const worktree of existingWorktrees) {
|
|
365
|
+
const permit = permits.get(path.resolve(worktree));
|
|
366
|
+
if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
367
|
+
removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree, permit, {
|
|
368
|
+
allowRegisteredPathFallback: engine === ENGINES.WSL2
|
|
369
|
+
});
|
|
370
|
+
assertRemoved(worktree, 'Worktree');
|
|
371
|
+
const registered = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
|
|
372
|
+
.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9)));
|
|
373
|
+
if (registered.includes(path.resolve(worktree))) throw new Error(`Worktree is still registered after removal: ${worktree}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const shouldDeleteBranch = options.assumeYes
|
|
377
|
+
? true
|
|
378
|
+
: await confirm({
|
|
379
|
+
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
380
|
+
initialValue: true
|
|
381
|
+
});
|
|
118
382
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
|
|
127
|
-
if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
|
|
128
|
-
p.log.warn(`Local branch '${effectiveBranch}' was not deleted`);
|
|
129
|
-
}
|
|
383
|
+
if (!isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
|
|
384
|
+
if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
|
|
385
|
+
throw new Error(`Local branch '${effectiveBranch}' was not deleted`);
|
|
386
|
+
}
|
|
387
|
+
if (runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
|
|
388
|
+
throw new Error(`Local branch '${effectiveBranch}' still exists after removal`);
|
|
130
389
|
}
|
|
131
390
|
}
|
|
132
391
|
}
|
|
@@ -134,12 +393,14 @@ async function rmOne(
|
|
|
134
393
|
for (const { tool, candidates } of toolCandidates) {
|
|
135
394
|
for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
|
|
136
395
|
removeManagedDir(tool.sandboxBase, dir);
|
|
396
|
+
assertRemoved(dir, `${tool.name} state`);
|
|
137
397
|
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
138
398
|
}
|
|
139
399
|
}
|
|
140
400
|
|
|
141
401
|
for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
|
|
142
402
|
removeManagedDir(config.shellConfigBase, dir);
|
|
403
|
+
assertRemoved(dir, 'Shell config');
|
|
143
404
|
p.log.success(`Shell config removed: ${dir}`);
|
|
144
405
|
}
|
|
145
406
|
|
|
@@ -147,12 +408,13 @@ async function rmOne(
|
|
|
147
408
|
if (fs.existsSync(shareBranch)) {
|
|
148
409
|
const shouldRemoveShare = options.assumeYes
|
|
149
410
|
? true
|
|
150
|
-
: await
|
|
411
|
+
: await confirm({
|
|
151
412
|
message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
|
|
152
413
|
initialValue: true
|
|
153
414
|
});
|
|
154
|
-
if (!
|
|
415
|
+
if (!isCancel(shouldRemoveShare) && shouldRemoveShare) {
|
|
155
416
|
removeManagedDir(config.shareBase, shareBranch);
|
|
417
|
+
assertRemoved(shareBranch, 'Share dir');
|
|
156
418
|
p.log.success(`Share dir removed: ${shareBranch}`);
|
|
157
419
|
}
|
|
158
420
|
}
|
|
@@ -162,11 +424,33 @@ async function rmOne(
|
|
|
162
424
|
}
|
|
163
425
|
}
|
|
164
426
|
|
|
165
|
-
async function rmPurge(
|
|
427
|
+
async function rmPurge(
|
|
428
|
+
config: SandboxConfig,
|
|
429
|
+
tools: SandboxTool[],
|
|
430
|
+
prompt: PromptDependencies = {}
|
|
431
|
+
): Promise<void> {
|
|
166
432
|
const engine = detectEngine(config);
|
|
433
|
+
const confirm = prompt.confirm ?? p.confirm;
|
|
434
|
+
const isCancel = prompt.isCancel ?? p.isCancel;
|
|
167
435
|
p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
|
|
168
436
|
|
|
169
|
-
const
|
|
437
|
+
const worktrees = fs.existsSync(config.worktreeBase)
|
|
438
|
+
? fs.readdirSync(config.worktreeBase)
|
|
439
|
+
.map((entry) => path.join(config.worktreeBase, entry))
|
|
440
|
+
.filter((entry) => {
|
|
441
|
+
try { return fs.statSync(entry).isDirectory(); } catch { return false; }
|
|
442
|
+
})
|
|
443
|
+
: [];
|
|
444
|
+
const inspections = inspectWorktrees(worktrees);
|
|
445
|
+
const blockers = inspectionBlockers(inspections);
|
|
446
|
+
if (blockers.length > 0) {
|
|
447
|
+
throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
|
|
448
|
+
}
|
|
449
|
+
const permits = cleanPermits(inspections);
|
|
450
|
+
|
|
451
|
+
const coordinatedContainers = await removeProjectControlRoots(config, engine);
|
|
452
|
+
|
|
453
|
+
const containers = runEngine(engine, 'docker', [
|
|
170
454
|
'ps',
|
|
171
455
|
'-a',
|
|
172
456
|
'--filter',
|
|
@@ -174,62 +458,79 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
|
|
|
174
458
|
'--format',
|
|
175
459
|
'{{.Names}}'
|
|
176
460
|
]);
|
|
177
|
-
|
|
461
|
+
const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
|
|
462
|
+
if (uncoordinatedContainers.length > 0) {
|
|
178
463
|
const spinner = p.spinner();
|
|
179
464
|
spinner.start('Stopping project sandbox containers...');
|
|
180
|
-
for (const name of
|
|
181
|
-
|
|
182
|
-
|
|
465
|
+
for (const name of uncoordinatedContainers) {
|
|
466
|
+
if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
|
|
467
|
+
if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
|
|
183
468
|
}
|
|
469
|
+
const remaining = runEngine(engine, 'docker', [
|
|
470
|
+
'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
|
|
471
|
+
]);
|
|
472
|
+
const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
|
|
473
|
+
if (leftovers.length > 0) throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
|
|
184
474
|
spinner.stop(pc.green('Project sandbox containers removed'));
|
|
185
475
|
} else {
|
|
186
476
|
p.log.warn('No project sandbox containers found');
|
|
187
477
|
}
|
|
188
478
|
|
|
189
|
-
if (
|
|
190
|
-
const shouldRemoveWorktrees = await
|
|
479
|
+
if (worktrees.length > 0) {
|
|
480
|
+
const shouldRemoveWorktrees = await confirm({
|
|
191
481
|
message: `Remove all worktrees in ${config.worktreeBase}?`,
|
|
192
482
|
initialValue: true
|
|
193
483
|
});
|
|
194
484
|
|
|
195
|
-
if (!
|
|
196
|
-
for (const
|
|
197
|
-
const
|
|
198
|
-
|
|
485
|
+
if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
|
|
486
|
+
for (const dir of worktrees) {
|
|
487
|
+
const permit = permits.get(path.resolve(dir));
|
|
488
|
+
if (!permit) throw new Error(`Missing worktree removal permit: ${dir}`);
|
|
489
|
+
removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
|
|
490
|
+
allowRegisteredPathFallback: engine === ENGINES.WSL2
|
|
491
|
+
});
|
|
492
|
+
assertRemoved(dir, 'Worktree');
|
|
199
493
|
}
|
|
200
494
|
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
495
|
+
const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
|
|
496
|
+
.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
|
|
497
|
+
const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
|
|
498
|
+
if (leftovers.length > 0) throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
|
|
201
499
|
}
|
|
202
500
|
}
|
|
203
501
|
|
|
204
502
|
for (const dir of projectToolDirs(config, tools)) {
|
|
205
503
|
if (fs.existsSync(dir)) {
|
|
206
504
|
removeManagedDir(path.dirname(dir), dir);
|
|
505
|
+
assertRemoved(dir, 'Tool state');
|
|
207
506
|
p.log.success(`Removed tool state: ${dir}`);
|
|
208
507
|
}
|
|
209
508
|
}
|
|
210
509
|
|
|
211
510
|
if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
|
|
212
|
-
const shouldRemoveShellConfigs = await
|
|
511
|
+
const shouldRemoveShellConfigs = await confirm({
|
|
213
512
|
message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
|
|
214
513
|
initialValue: true
|
|
215
514
|
});
|
|
216
515
|
|
|
217
|
-
if (!
|
|
516
|
+
if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
|
|
218
517
|
for (const entry of fs.readdirSync(config.shellConfigBase)) {
|
|
219
518
|
const dir = path.join(config.shellConfigBase, entry);
|
|
220
519
|
removeManagedDir(config.shellConfigBase, dir);
|
|
520
|
+
assertRemoved(dir, 'Shell config');
|
|
221
521
|
}
|
|
222
522
|
p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
|
|
223
523
|
}
|
|
224
524
|
}
|
|
225
525
|
|
|
226
526
|
if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
|
|
227
|
-
const shouldRemoveAllShares = await
|
|
527
|
+
const shouldRemoveAllShares = await confirm({
|
|
228
528
|
message: `Remove all share dirs for project (${config.shareBase})?`,
|
|
229
529
|
initialValue: true
|
|
230
530
|
});
|
|
231
|
-
if (!
|
|
531
|
+
if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
|
|
232
532
|
removeManagedDir(path.dirname(config.shareBase), config.shareBase);
|
|
533
|
+
assertRemoved(config.shareBase, 'Project share dir');
|
|
233
534
|
p.log.success(`Project share dirs removed: ${config.shareBase}`);
|
|
234
535
|
}
|
|
235
536
|
}
|
|
@@ -241,15 +542,16 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
|
|
|
241
542
|
const projectDir = path.join(base, config.project);
|
|
242
543
|
if (fs.existsSync(projectDir)) {
|
|
243
544
|
removeManagedDir(base, projectDir);
|
|
545
|
+
assertRemoved(projectDir, `Project ${label}`);
|
|
244
546
|
p.log.success(`Removed project ${label}: ${projectDir}`);
|
|
245
547
|
}
|
|
246
548
|
}
|
|
247
549
|
|
|
248
|
-
const shouldRemoveImage = await
|
|
550
|
+
const shouldRemoveImage = await confirm({
|
|
249
551
|
message: `Remove image ${config.imageName}?`,
|
|
250
552
|
initialValue: false
|
|
251
553
|
});
|
|
252
|
-
if (!
|
|
554
|
+
if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
|
|
253
555
|
runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
|
|
254
556
|
}
|
|
255
557
|
|
|
@@ -263,11 +565,11 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
|
|
|
263
565
|
}
|
|
264
566
|
|
|
265
567
|
const name = engineDisplayName(engine);
|
|
266
|
-
const shouldStopVm = await
|
|
568
|
+
const shouldStopVm = await confirm({
|
|
267
569
|
message: `Stop ${name} VM?`,
|
|
268
570
|
initialValue: false
|
|
269
571
|
});
|
|
270
|
-
if (!
|
|
572
|
+
if (!isCancel(shouldStopVm) && shouldStopVm) {
|
|
271
573
|
stopManagedVm(config);
|
|
272
574
|
}
|
|
273
575
|
}
|
|
@@ -293,35 +595,41 @@ async function rmUnbound(
|
|
|
293
595
|
return;
|
|
294
596
|
}
|
|
295
597
|
|
|
598
|
+
const targets = removable.map((row) => resolveRmTarget(config, tools, row.branch));
|
|
599
|
+
const inspections = inspectWorktrees(targets.flatMap((target) => target.existingWorktrees));
|
|
600
|
+
const blockers = inspectionBlockers(inspections);
|
|
601
|
+
const permits = cleanPermits(inspections);
|
|
602
|
+
|
|
296
603
|
for (const row of removable) {
|
|
297
604
|
p.log.message(`${row.name} ${row.branch}`);
|
|
298
605
|
}
|
|
606
|
+
if (blockers.length > 0) p.log.error(blockerMessage(blockers));
|
|
299
607
|
|
|
300
608
|
if (options.dryRun) {
|
|
301
|
-
p.outro(`Dry run: ${removable.length} sandbox(es)
|
|
609
|
+
p.outro(`Dry run: ${removable.length} sandbox(es) inspected, nothing deleted`);
|
|
302
610
|
return;
|
|
303
611
|
}
|
|
304
612
|
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
initialValue: false
|
|
314
|
-
});
|
|
315
|
-
if (p.isCancel(confirmed) || !confirmed) {
|
|
316
|
-
p.outro('Cancelled');
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
613
|
+
if (blockers.length > 0) {
|
|
614
|
+
throw new Error(`Refusing batch removal because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!options.assumeYes && !process.stdin.isTTY) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
|
|
620
|
+
);
|
|
319
621
|
}
|
|
320
622
|
|
|
321
623
|
const failures: { branch: string; message: string }[] = [];
|
|
322
|
-
for (const row of removable) {
|
|
624
|
+
for (const [index, row] of removable.entries()) {
|
|
323
625
|
try {
|
|
324
|
-
await rmOne(config, tools, row.branch, {
|
|
626
|
+
await rmOne(config, tools, row.branch, {
|
|
627
|
+
assumeYes: options.assumeYes,
|
|
628
|
+
quiet: true,
|
|
629
|
+
target: targets[index],
|
|
630
|
+
permits,
|
|
631
|
+
allowDirtyDiscard: false
|
|
632
|
+
});
|
|
325
633
|
} catch (error) {
|
|
326
634
|
failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
|
|
327
635
|
}
|
|
@@ -346,6 +654,7 @@ export async function rm(args: string[]): Promise<void> {
|
|
|
346
654
|
strict: true,
|
|
347
655
|
options: {
|
|
348
656
|
all: { type: 'boolean' },
|
|
657
|
+
unbound: { type: 'boolean' },
|
|
349
658
|
purge: { type: 'boolean' },
|
|
350
659
|
'dry-run': { type: 'boolean' },
|
|
351
660
|
yes: { type: 'boolean', short: 'y' },
|
|
@@ -353,24 +662,28 @@ export async function rm(args: string[]): Promise<void> {
|
|
|
353
662
|
}
|
|
354
663
|
});
|
|
355
664
|
|
|
665
|
+
if (values.all) {
|
|
666
|
+
throw new Error('CLI_FLAG_REMOVED: --all was removed; use --unbound');
|
|
667
|
+
}
|
|
668
|
+
|
|
356
669
|
if (values.help) {
|
|
357
670
|
process.stdout.write(`${USAGE}\n`);
|
|
358
671
|
return;
|
|
359
672
|
}
|
|
360
673
|
|
|
361
|
-
if (values.
|
|
362
|
-
throw new Error('--
|
|
674
|
+
if (values.unbound && values.purge) {
|
|
675
|
+
throw new Error('--unbound and --purge are mutually exclusive');
|
|
363
676
|
}
|
|
364
677
|
|
|
365
|
-
if ((values['dry-run'] || values.yes) && !values.
|
|
366
|
-
throw new Error('--dry-run and --yes only apply to --
|
|
678
|
+
if ((values['dry-run'] || values.yes) && !values.unbound) {
|
|
679
|
+
throw new Error('--dry-run and --yes only apply to --unbound');
|
|
367
680
|
}
|
|
368
681
|
|
|
369
|
-
if ((values.
|
|
370
|
-
throw new Error(`${values.
|
|
682
|
+
if ((values.unbound || values.purge) && positionals.length > 0) {
|
|
683
|
+
throw new Error(`${values.unbound ? '--unbound' : '--purge'} does not take a branch argument`);
|
|
371
684
|
}
|
|
372
685
|
|
|
373
|
-
if (!values.
|
|
686
|
+
if (!values.unbound && !values.purge && positionals.length !== 1) {
|
|
374
687
|
throw new Error(USAGE);
|
|
375
688
|
}
|
|
376
689
|
|
|
@@ -382,7 +695,7 @@ export async function rm(args: string[]): Promise<void> {
|
|
|
382
695
|
return;
|
|
383
696
|
}
|
|
384
697
|
|
|
385
|
-
if (values.
|
|
698
|
+
if (values.unbound) {
|
|
386
699
|
await rmUnbound(config, tools, {
|
|
387
700
|
dryRun: Boolean(values['dry-run']),
|
|
388
701
|
assumeYes: Boolean(values.yes)
|
|
@@ -393,3 +706,5 @@ export async function rm(args: string[]): Promise<void> {
|
|
|
393
706
|
const target = resolveSandboxTarget(positionals[0] ?? '', config.repoRoot);
|
|
394
707
|
await rmOne(config, tools, target.branch);
|
|
395
708
|
}
|
|
709
|
+
|
|
710
|
+
export { authorizeWorktrees, rmOne, rmPurge };
|