@fitlab-ai/agent-infra 0.9.15 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
- package/dist/lib/fs/durable-write.js +2 -0
- package/dist/lib/internal/cli-route-inventory.js +1 -1
- package/dist/lib/internal/manual-validation.js +17 -8
- package/dist/lib/internal/task-artifact.js +1 -1
- package/dist/lib/internal/task-lifecycle.js +2 -2
- package/dist/lib/internal/task-operation-registry.js +0 -1
- package/dist/lib/internal/task-review.js +4 -4
- package/dist/lib/platform/issue-comments.js +16 -10
- package/dist/lib/platform/issues.js +1 -1
- package/dist/lib/platform/pr-change-report.js +5 -4
- package/dist/lib/platform/pr-summary.js +64 -32
- package/dist/lib/platform/pull-requests.js +3 -3
- package/dist/lib/platform/task-identities.js +2 -9
- package/dist/lib/platform/verification-sync.js +6 -2
- package/dist/lib/pr-review/evidence-grading.js +4 -8
- package/dist/lib/sandbox/commands/rm.js +21 -2
- package/dist/lib/sandbox/control/server.js +29 -9
- package/dist/lib/sandbox/control/state.js +7 -1
- package/dist/lib/sandbox/control/workflow-executor.js +4 -14
- package/dist/lib/sandbox/index.js +1 -2
- package/dist/lib/sandbox/removal.js +116 -1011
- package/dist/lib/sandbox/workspace-identity.js +17 -1
- package/dist/lib/task/artifact-command.js +6 -28
- package/dist/lib/task/artifact-operations.js +16 -230
- package/dist/lib/task/artifact-recovery.js +430 -0
- package/dist/lib/task/artifact-repair-intent.js +73 -39
- package/dist/lib/task/commit-operation.js +31 -4
- package/dist/lib/task/control-authority.js +38 -7
- package/dist/lib/task/control-recovery.js +14 -2
- package/dist/lib/task/create-service.js +1 -8
- package/dist/lib/task/delegation-receipts.js +64 -3
- package/dist/lib/task/events.js +164 -22
- package/dist/lib/task/human-override.js +5 -8
- package/dist/lib/task/intermediate-cleanup.js +66 -8
- package/dist/lib/task/lifecycle-recovery.js +549 -0
- package/dist/lib/task/lifecycle.js +12 -3
- package/dist/lib/task/local-artifact-finalization.js +148 -123
- package/dist/lib/task/manual-validation-receipt.js +5 -1
- package/dist/lib/task/orchestration.js +52 -2
- package/dist/lib/task/pr-delivery-fact.js +12 -56
- package/dist/lib/task/recovery-warning.js +19 -0
- package/dist/lib/task/review-command.js +1 -0
- package/dist/lib/task/review-finalization.js +104 -77
- package/dist/lib/task/short-id.js +15 -7
- package/dist/lib/task/task-execution-lock.js +1 -1
- package/dist/lib/task/verification-engine.js +3 -14
- package/dist/lib/task/workflow-command.js +0 -1
- package/dist/lib/version.js +1 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
- package/lib/fs/durable-write.ts +1 -0
- package/lib/internal/cli-route-inventory.ts +1 -1
- package/lib/internal/manual-validation.ts +17 -8
- package/lib/internal/task-artifact.ts +1 -1
- package/lib/internal/task-lifecycle.ts +4 -3
- package/lib/internal/task-operation-registry.ts +0 -1
- package/lib/internal/task-review.ts +4 -4
- package/lib/platform/issue-comments.ts +18 -10
- package/lib/platform/issues.ts +1 -1
- package/lib/platform/pr-change-report.ts +7 -5
- package/lib/platform/pr-summary.ts +64 -32
- package/lib/platform/pull-requests.ts +3 -3
- package/lib/platform/task-identities.ts +3 -14
- package/lib/platform/verification-sync.ts +6 -2
- package/lib/pr-review/evidence-grading.ts +4 -9
- package/lib/sandbox/commands/rm.ts +34 -2
- package/lib/sandbox/control/protocol.ts +5 -0
- package/lib/sandbox/control/server.ts +30 -10
- package/lib/sandbox/control/state.ts +9 -1
- package/lib/sandbox/control/workflow-executor.ts +4 -14
- package/lib/sandbox/index.ts +1 -2
- package/lib/sandbox/removal.ts +126 -1128
- package/lib/sandbox/workspace-identity.ts +19 -3
- package/lib/task/artifact-command.ts +8 -27
- package/lib/task/artifact-operations.ts +21 -251
- package/lib/task/artifact-recovery.ts +519 -0
- package/lib/task/artifact-repair-intent.ts +99 -51
- package/lib/task/commit-operation.ts +35 -4
- package/lib/task/control-authority.ts +47 -14
- package/lib/task/control-recovery.ts +13 -1
- package/lib/task/create-service.ts +1 -10
- package/lib/task/delegation-receipts.ts +73 -2
- package/lib/task/events.ts +187 -23
- package/lib/task/human-override.ts +5 -8
- package/lib/task/intermediate-cleanup.ts +50 -12
- package/lib/task/lifecycle-recovery.ts +691 -0
- package/lib/task/lifecycle.ts +13 -4
- package/lib/task/local-artifact-finalization.ts +176 -132
- package/lib/task/manual-validation-receipt.ts +5 -1
- package/lib/task/orchestration.ts +65 -0
- package/lib/task/pr-delivery-fact.ts +15 -62
- package/lib/task/recovery-warning.ts +21 -0
- package/lib/task/review-command.ts +1 -0
- package/lib/task/review-finalization.ts +106 -87
- package/lib/task/short-id.ts +27 -5
- package/lib/task/task-execution-lock.ts +1 -1
- package/lib/task/verification-engine.ts +3 -17
- package/lib/task/verification-types.ts +0 -2
- package/lib/task/workflow-command.ts +0 -1
- package/lib/version.ts +1 -15
- package/package.json +1 -1
- package/templates/.agents/rules/create-issue.en.md +1 -1
- package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
- package/templates/.agents/rules/issue-sync.en.md +4 -2
- package/templates/.agents/rules/issue-sync.zh-CN.md +4 -3
- package/templates/.agents/rules/local-artifact-repair.en.md +46 -39
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +43 -36
- package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/block-task/config/verify.json +1 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +7 -7
- package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/create-pr/config/verify.json +1 -1
- package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
- package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/create-task/config/verify.json +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
- package/templates/.agents/skills/import-issue/config/verify.json +2 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/restore-task/config/verify.json +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
- package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
- package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
- package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/templates/task.en.md +1 -1
- package/templates/.agents/templates/task.zh-CN.md +1 -1
- package/templates/.claude/commands/import-issue.en.md +1 -1
- package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
- package/templates/.claude/commands/restore-task.en.md +1 -1
- package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
|
@@ -16,10 +16,10 @@ import { commandForSandboxAuthority } from './engines/authority.js';
|
|
|
16
16
|
import { acquireSandboxResourceLock } from './control/native-file-lock.js';
|
|
17
17
|
import { toolConfigDirCandidates, toolProjectDirCandidates } from './tools.js';
|
|
18
18
|
import { getProcessStartTime } from '../server/process-state.js';
|
|
19
|
+
import { releaseStaleShortIdRegistry } from '../task/short-id.js';
|
|
19
20
|
import { fetchSandboxRows } from './commands/list-running.js';
|
|
20
21
|
import { formatIntermediateCleanupReport, cleanupIntermediateUnderRemovalCoordinator, protectIntermediateCleanupReport, mergeIntermediateCleanupReports, scanIntermediateCleanup } from '../task/intermediate-cleanup.js';
|
|
21
22
|
import { createSandboxControlBindingEvidence } from './task-cleanup.js';
|
|
22
|
-
import { withRepositoryMutationLock, withTaskExecutionLock } from '../task/task-execution-lock.js';
|
|
23
23
|
import { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectRecoveredWorktree, inspectWorktrees, verifyWorktreePermit } from './worktree-safety.js';
|
|
24
24
|
function projectToolDirs(config, tools) {
|
|
25
25
|
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
@@ -547,78 +547,6 @@ export function sandboxManagedPathKey(candidate, platform = process.platform, re
|
|
|
547
547
|
}
|
|
548
548
|
return platform === 'win32' ? identity.toLowerCase() : identity;
|
|
549
549
|
}
|
|
550
|
-
function projectSandboxControlRoots(config) {
|
|
551
|
-
const projectRoot = path.join(config.controlBase, config.project);
|
|
552
|
-
let containers;
|
|
553
|
-
try {
|
|
554
|
-
containers = fs.readdirSync(projectRoot, { withFileTypes: true });
|
|
555
|
-
}
|
|
556
|
-
catch (error) {
|
|
557
|
-
if (isMissingPathError(error))
|
|
558
|
-
return [];
|
|
559
|
-
throw error;
|
|
560
|
-
}
|
|
561
|
-
return containers
|
|
562
|
-
.filter((container) => container.isDirectory())
|
|
563
|
-
.flatMap((container) => {
|
|
564
|
-
const containerRoot = path.join(projectRoot, container.name);
|
|
565
|
-
const identities = fs.readdirSync(containerRoot, { withFileTypes: true });
|
|
566
|
-
return identities
|
|
567
|
-
.filter((identity) => identity.isDirectory())
|
|
568
|
-
.map((identity) => path.join(containerRoot, identity.name));
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
function projectSandboxControlBindingEvidence(config, controlRoots = projectSandboxControlRoots(config), removalJournals = listSandboxRemovalJournals({ project: config.project })) {
|
|
572
|
-
return createSandboxControlBindingEvidence(config.repoRoot, controlRoots, removalJournals);
|
|
573
|
-
}
|
|
574
|
-
// Reasons that mean "intentionally preserved", not "cleanup is unsafe". A task
|
|
575
|
-
// that is still active keeps its auxiliary intents by design, so preserving
|
|
576
|
-
// them must not make its sandbox unremovable. Every other protected reason
|
|
577
|
-
// signals tampering or an unreadable path and still blocks, as does any
|
|
578
|
-
// failure, so unknown reasons stay fail-closed.
|
|
579
|
-
const REMOVAL_PRESERVED_REASONS = new Set([
|
|
580
|
-
'TASK_STATE_PROTECTED',
|
|
581
|
-
'TASK_NOT_FOUND',
|
|
582
|
-
'LFAI_STATE_PROTECTED',
|
|
583
|
-
'LFAI_RETRY_OR_WARNING_OPEN',
|
|
584
|
-
'FINALIZATION_RECEIPT_MISSING',
|
|
585
|
-
'FINALIZATION_RECEIPT_PENDING',
|
|
586
|
-
'AUXILIARY_ROOT_NOT_EMPTY'
|
|
587
|
-
]);
|
|
588
|
-
function assertIntermediateCleanupPreflight(report) {
|
|
589
|
-
const blockers = report.items.filter((candidate) => candidate.disposition === 'failed'
|
|
590
|
-
|| (candidate.disposition === 'protected' && !REMOVAL_PRESERVED_REASONS.has(candidate.reason)));
|
|
591
|
-
if (blockers.length === 0)
|
|
592
|
-
return;
|
|
593
|
-
throw new Error([
|
|
594
|
-
'SANDBOX_AUXILIARY_PREFLIGHT_FAILED:',
|
|
595
|
-
...blockers.map((candidate) => (`${candidate.kind} ${candidate.path} (${candidate.taskId ?? 'unbound'}; ${candidate.reason})`))
|
|
596
|
-
].join('\n'));
|
|
597
|
-
}
|
|
598
|
-
function recoveryContexts(config, target, worktrees) {
|
|
599
|
-
const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
|
|
600
|
-
const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
|
|
601
|
-
if (existingCandidates.length !== 1)
|
|
602
|
-
return new Map();
|
|
603
|
-
const contexts = new Map();
|
|
604
|
-
for (const worktree of worktrees) {
|
|
605
|
-
const resolvedWorktree = path.resolve(worktree);
|
|
606
|
-
if (resolvedWorktree !== existingCandidates[0])
|
|
607
|
-
continue;
|
|
608
|
-
for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
609
|
-
assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
|
|
610
|
-
assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
|
|
611
|
-
}
|
|
612
|
-
contexts.set(resolvedWorktree, {
|
|
613
|
-
repoRoot: config.repoRoot,
|
|
614
|
-
worktreeBase: config.worktreeBase,
|
|
615
|
-
branch: target.effectiveBranch,
|
|
616
|
-
identitySource: target.workspace.mode,
|
|
617
|
-
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
return contexts;
|
|
621
|
-
}
|
|
622
550
|
function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
|
|
623
551
|
assertValidBranchName(cleanupTarget.branch);
|
|
624
552
|
const engine = detectEngine(config);
|
|
@@ -632,12 +560,7 @@ function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
|
|
|
632
560
|
const shellCandidates = shellConfigDirCandidates(config, branch);
|
|
633
561
|
const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
634
562
|
const matchedContainers = options.discoveredContainers
|
|
635
|
-
? options.discoveredContainers
|
|
636
|
-
if (!existing.includes(container)) {
|
|
637
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`);
|
|
638
|
-
}
|
|
639
|
-
return container;
|
|
640
|
-
})
|
|
563
|
+
? [...options.discoveredContainers]
|
|
641
564
|
: containerNameCandidates(config, branch).filter((name) => existing.includes(name));
|
|
642
565
|
const workspace = cleanupTarget.workspace;
|
|
643
566
|
const identities = [workspace];
|
|
@@ -671,172 +594,18 @@ function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
|
|
|
671
594
|
workspaceViewRoots: [...new Set(workspaceViewRoots)]
|
|
672
595
|
};
|
|
673
596
|
}
|
|
674
|
-
function inspectNamedContainerIdentity(target, container) {
|
|
675
|
-
const raw = runSafeEngine(target.engine, 'docker', [
|
|
676
|
-
'inspect', '--format', '{{json .}}', container
|
|
677
|
-
]).trim();
|
|
678
|
-
let parsed;
|
|
679
|
-
try {
|
|
680
|
-
parsed = JSON.parse(raw);
|
|
681
|
-
}
|
|
682
|
-
catch {
|
|
683
|
-
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
|
|
684
|
-
}
|
|
685
|
-
const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
|
|
686
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
687
|
-
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
|
|
688
|
-
}
|
|
689
|
-
const candidate = value;
|
|
690
|
-
if (typeof candidate.Id !== 'string' || !candidate.Id
|
|
691
|
-
|| !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
|
|
692
|
-
|| Array.isArray(candidate.Config.Labels)
|
|
693
|
-
|| Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
|
|
694
|
-
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
|
|
695
|
-
}
|
|
696
|
-
return { id: candidate.Id, labels: candidate.Config.Labels };
|
|
697
|
-
}
|
|
698
|
-
function assertMatchedContainerIdentities(config, target) {
|
|
699
|
-
const current = new Map();
|
|
700
|
-
for (const container of target.matchedContainers) {
|
|
701
|
-
const observation = inspectNamedContainerIdentity(target, container);
|
|
702
|
-
const stringLabels = observation.labels;
|
|
703
|
-
const branch = stringLabels[sandboxBranchLabel(config)];
|
|
704
|
-
if (branch !== target.branch) {
|
|
705
|
-
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
|
|
706
|
-
+ `but this request is ${JSON.stringify(target.branch)}`);
|
|
707
|
-
}
|
|
708
|
-
const identity = parseSandboxWorkspaceIdentity(stringLabels, {
|
|
709
|
-
mode: sandboxWorkspaceModeLabel(config),
|
|
710
|
-
taskId: sandboxTaskIdLabel(config)
|
|
711
|
-
});
|
|
712
|
-
if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
|
|
713
|
-
const existingDescription = identity.mode === 'task-bound'
|
|
714
|
-
? `task-bound:${identity.taskId}`
|
|
715
|
-
: identity.mode;
|
|
716
|
-
const requestedDescription = target.workspace.mode === 'task-bound'
|
|
717
|
-
? `task-bound:${target.workspace.taskId}`
|
|
718
|
-
: target.workspace.mode;
|
|
719
|
-
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
|
|
720
|
-
+ `but this request is ${requestedDescription}`);
|
|
721
|
-
}
|
|
722
|
-
current.set(container, observation);
|
|
723
|
-
}
|
|
724
|
-
return current;
|
|
725
|
-
}
|
|
726
|
-
function controlRootContainer(config, root) {
|
|
727
|
-
const relative = path.relative(path.join(config.controlBase, config.project), root);
|
|
728
|
-
const [container, identity, ...extra] = relative.split(path.sep);
|
|
729
|
-
if (!container || !identity || extra.length > 0) {
|
|
730
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
|
|
731
|
-
}
|
|
732
|
-
return container;
|
|
733
|
-
}
|
|
734
|
-
function assertControlRootMatchesTarget(root, effectiveBranch, workspace) {
|
|
735
|
-
const manifestPath = path.join(root, 'manifest.json');
|
|
736
|
-
if (!fs.existsSync(manifestPath)) {
|
|
737
|
-
if (workspace.mode === 'task-bound') {
|
|
738
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
|
|
739
|
-
}
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
const manifest = readSandboxControlManifest(manifestPath);
|
|
743
|
-
const identityMatches = workspace.mode === 'task-bound'
|
|
744
|
-
? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
|
|
745
|
-
: manifest.mode === 'branch-only';
|
|
746
|
-
if (manifest.branch !== effectiveBranch || !identityMatches) {
|
|
747
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
function preflightRmTarget(config, target) {
|
|
751
|
-
const current = assertMatchedContainerIdentities(config, target);
|
|
752
|
-
const manifests = [];
|
|
753
|
-
for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
754
|
-
assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
|
|
755
|
-
assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
|
|
756
|
-
const manifestPath = path.join(root, 'manifest.json');
|
|
757
|
-
if (!fs.existsSync(manifestPath))
|
|
758
|
-
continue;
|
|
759
|
-
const manifest = readSandboxControlManifest(manifestPath);
|
|
760
|
-
const rootContainer = controlRootContainer(config, root);
|
|
761
|
-
if (manifest.container !== rootContainer) {
|
|
762
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
763
|
-
}
|
|
764
|
-
const observed = current.get(manifest.container);
|
|
765
|
-
if (observed && (observed.id !== manifest.containerIdentity.id
|
|
766
|
-
|| Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
|
|
767
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
768
|
-
}
|
|
769
|
-
manifests.push(manifest);
|
|
770
|
-
}
|
|
771
|
-
if (target.workspace.mode !== 'task-bound')
|
|
772
|
-
return;
|
|
773
|
-
const matched = new Set(target.matchedContainers);
|
|
774
|
-
const manifestContainers = manifests.map((manifest) => manifest.container);
|
|
775
|
-
if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
|
|
776
|
-
throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
|
|
777
|
-
}
|
|
778
|
-
for (const container of matched) {
|
|
779
|
-
const matches = manifests.filter((manifest) => manifest.container === container);
|
|
780
|
-
if (matches.length !== 1) {
|
|
781
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
if (matched.size === 0 && manifests.length > 0) {
|
|
785
|
-
const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
|
|
786
|
-
if (manifests.some((manifest) => !supported.has(manifest.container))) {
|
|
787
|
-
throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
function assertCleanupGroupPathsDoNotOverlap(groups, protectedTargets = []) {
|
|
792
|
-
const owners = new Map();
|
|
793
|
-
const addOwner = (branch, candidates) => {
|
|
794
|
-
for (const candidate of candidates) {
|
|
795
|
-
const key = sandboxManagedPathKey(candidate);
|
|
796
|
-
const existingBranch = owners.get(key);
|
|
797
|
-
if (existingBranch && existingBranch !== branch) {
|
|
798
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
|
|
799
|
-
+ `${JSON.stringify(existingBranch)} and ${JSON.stringify(branch)}`);
|
|
800
|
-
}
|
|
801
|
-
owners.set(key, branch);
|
|
802
|
-
}
|
|
803
|
-
};
|
|
804
|
-
for (const group of groups) {
|
|
805
|
-
addOwner(group.cleanupTarget.branch, group.target.managedPathCandidates ?? []);
|
|
806
|
-
}
|
|
807
|
-
for (const target of protectedTargets) {
|
|
808
|
-
addOwner(target.branch, target.managedPathCandidates);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
function assertWorktreeBranchesMatchGroups(groups, inspections) {
|
|
812
|
-
const byPath = new Map(inspections.flatMap((inspection) => inspection.status === 'failed'
|
|
813
|
-
? []
|
|
814
|
-
: [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch]]));
|
|
815
|
-
for (const group of groups) {
|
|
816
|
-
for (const worktree of group.target.existingWorktrees) {
|
|
817
|
-
const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
|
|
818
|
-
if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
|
|
819
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
|
|
820
|
-
+ `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
function assertLegacyCandidateEvidence(candidate, base, config, effectiveBranch, matchedContainers) {
|
|
826
|
-
const relative = path.relative(path.join(base, config.project), candidate);
|
|
827
|
-
const [container, identity] = relative.split(path.sep);
|
|
828
|
-
const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
|
|
829
|
-
if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container))
|
|
830
|
-
return;
|
|
831
|
-
const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
|
|
832
|
-
if (!fs.existsSync(manifestPath)) {
|
|
833
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
597
|
function assertRemoved(target, label) {
|
|
837
598
|
if (fs.existsSync(target))
|
|
838
599
|
throw new Error(`${label} still exists after removal: ${target}`);
|
|
839
600
|
}
|
|
601
|
+
function sandboxContainersRemoved(engine, containers) {
|
|
602
|
+
if (containers.length === 0)
|
|
603
|
+
return true;
|
|
604
|
+
const remaining = new Set(runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}'])
|
|
605
|
+
.split('\n')
|
|
606
|
+
.filter(Boolean));
|
|
607
|
+
return containers.every((container) => !remaining.has(container));
|
|
608
|
+
}
|
|
840
609
|
function removeEmptyManagedParent(base, directory) {
|
|
841
610
|
const parent = path.dirname(directory);
|
|
842
611
|
if (path.resolve(parent) === path.resolve(base))
|
|
@@ -852,97 +621,6 @@ function removeEmptyManagedParent(base, directory) {
|
|
|
852
621
|
}
|
|
853
622
|
assertRemoved(parent, 'Empty sandbox container directory');
|
|
854
623
|
}
|
|
855
|
-
async function removeExactSandboxContainer(engine, manifest, timeoutMs) {
|
|
856
|
-
const deadlineAt = Date.now() + timeoutMs;
|
|
857
|
-
const remaining = () => Math.max(1, deadlineAt - Date.now());
|
|
858
|
-
const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
|
|
859
|
-
const runAuthorityCommand = (args) => {
|
|
860
|
-
const command = manifest.authorityEvidence
|
|
861
|
-
? commandForSandboxAuthority(manifest.authorityEvidence, 'docker', args)
|
|
862
|
-
: null;
|
|
863
|
-
return command ? runOk(command.cmd, command.args, { timeout: remaining() })
|
|
864
|
-
: runOkEngine(engine, 'docker', args, { timeout: remaining() });
|
|
865
|
-
};
|
|
866
|
-
const before = await inspect();
|
|
867
|
-
if (before.state === 'unknown')
|
|
868
|
-
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
|
|
869
|
-
if (before.state === 'absent')
|
|
870
|
-
return;
|
|
871
|
-
if (before.running && !runAuthorityCommand(['stop', '--timeout', '1', manifest.containerIdentity.id])) {
|
|
872
|
-
const afterStop = await inspect();
|
|
873
|
-
if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
|
|
874
|
-
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
875
|
-
}
|
|
876
|
-
if (afterStop.state === 'found' && afterStop.running) {
|
|
877
|
-
throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
const afterStop = await inspect();
|
|
881
|
-
if (afterStop.state === 'unknown')
|
|
882
|
-
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
883
|
-
if (afterStop.state === 'found' && !runAuthorityCommand(['rm', manifest.containerIdentity.id])) {
|
|
884
|
-
const afterRemove = await inspect();
|
|
885
|
-
if (afterRemove.state !== 'absent') {
|
|
886
|
-
throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
async function removeProjectControlRoots(config, engine, options = {}) {
|
|
891
|
-
const containers = new Set();
|
|
892
|
-
const projectRoot = path.join(config.controlBase, config.project);
|
|
893
|
-
if (!fs.existsSync(projectRoot))
|
|
894
|
-
return containers;
|
|
895
|
-
assertManagedPath(config.controlBase, projectRoot);
|
|
896
|
-
const projectStat = fs.lstatSync(projectRoot);
|
|
897
|
-
if (!projectStat.isDirectory() || projectStat.isSymbolicLink())
|
|
898
|
-
throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
|
|
899
|
-
for (const root of projectSandboxControlRoots(config)) {
|
|
900
|
-
const manifestPath = path.join(root, 'manifest.json');
|
|
901
|
-
if (!fs.existsSync(manifestPath))
|
|
902
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
|
|
903
|
-
const manifest = readSandboxControlManifest(manifestPath);
|
|
904
|
-
containers.add(manifest.container);
|
|
905
|
-
await removeSandboxControlRoot(root, {
|
|
906
|
-
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
907
|
-
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
|
|
908
|
-
...(options.retainRemovalJournal ? { retainRemovalJournal: true } : {})
|
|
909
|
-
});
|
|
910
|
-
}
|
|
911
|
-
return containers;
|
|
912
|
-
}
|
|
913
|
-
function completePurgeRemovalJournals(config) {
|
|
914
|
-
const projectRoot = path.resolve(config.controlBase, config.project);
|
|
915
|
-
const projectJournals = listSandboxRemovalJournals({ project: config.project }).filter((journal) => {
|
|
916
|
-
const relativeRoot = path.relative(projectRoot, path.resolve(journal.target.controlRoot));
|
|
917
|
-
return relativeRoot !== '' && relativeRoot !== '..'
|
|
918
|
-
&& !relativeRoot.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeRoot);
|
|
919
|
-
});
|
|
920
|
-
if (projectJournals.some((journal) => !isDefaultSandboxRemovalJournal(journal))) {
|
|
921
|
-
throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
|
|
922
|
-
}
|
|
923
|
-
for (const journal of projectJournals) {
|
|
924
|
-
const resourceLock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
|
|
925
|
-
lockDomain: journal.lockDomain
|
|
926
|
-
});
|
|
927
|
-
try {
|
|
928
|
-
let current = journal;
|
|
929
|
-
const currentProcessStartTime = getProcessStartTime(process.pid);
|
|
930
|
-
const ownedByCurrentProcess = currentProcessStartTime !== null
|
|
931
|
-
&& current.owner.pid === process.pid
|
|
932
|
-
&& current.owner.startTime === currentProcessStartTime;
|
|
933
|
-
if (!ownedByCurrentProcess) {
|
|
934
|
-
current = claimSandboxRemovalJournal(current, { resourceLock });
|
|
935
|
-
}
|
|
936
|
-
advanceSandboxRemovalJournalToPhase(current, 'completed', resourceLock);
|
|
937
|
-
}
|
|
938
|
-
finally {
|
|
939
|
-
resourceLock.release();
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
function inspectionBlockers(inspections) {
|
|
944
|
-
return inspections.filter((inspection) => inspection.status !== 'clean');
|
|
945
|
-
}
|
|
946
624
|
function blockerMessage(blockers) {
|
|
947
625
|
return blockers.map((blocker) => {
|
|
948
626
|
if (blocker.status === 'failed')
|
|
@@ -989,729 +667,156 @@ async function authorizeWorktrees(worktrees, { allowDirtyDiscard, assumeYes }, {
|
|
|
989
667
|
}
|
|
990
668
|
async function runRmOneUnderRepositoryLock(config, tools, branch, options = {}) {
|
|
991
669
|
const target = options.target ?? resolveRmTarget(config, tools, options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot));
|
|
992
|
-
|
|
993
|
-
const execute = () => rmOneCore(config, tools, branch, { ...options, target });
|
|
994
|
-
return taskId
|
|
995
|
-
? withTaskExecutionLock(config.repoRoot, taskId, 'sandbox-removal', execute)
|
|
996
|
-
: execute();
|
|
670
|
+
return rmOneCore(config, tools, branch, { ...options, target });
|
|
997
671
|
}
|
|
998
672
|
async function rmOne(config, tools, branch, options = {}) {
|
|
999
|
-
await
|
|
673
|
+
await runRmOneUnderRepositoryLock(config, tools, branch, options);
|
|
1000
674
|
}
|
|
1001
|
-
async function
|
|
1002
|
-
const target = options.target ?? resolveRmTarget(config, tools, options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot));
|
|
1003
|
-
const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
|
|
1004
|
-
const { workspace, controlRoots, workspaceViewRoots } = target;
|
|
1005
|
-
preflightRmTarget(config, target);
|
|
1006
|
-
const auxiliaryTaskId = workspace.mode === 'task-bound' ? workspace.taskId : null;
|
|
1007
|
-
if (auxiliaryTaskId) {
|
|
1008
|
-
const targetJournals = listSandboxRemovalJournals({
|
|
1009
|
-
branch: effectiveBranch,
|
|
1010
|
-
project: config.project,
|
|
1011
|
-
targetDigest: removalTargetDigest(config, target)
|
|
1012
|
-
});
|
|
1013
|
-
assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
|
|
1014
|
-
preflight: true,
|
|
1015
|
-
taskIds: [auxiliaryTaskId],
|
|
1016
|
-
controlBindingEvidence: projectSandboxControlBindingEvidence(config, controlRoots, targetJournals)
|
|
1017
|
-
}));
|
|
1018
|
-
}
|
|
675
|
+
async function removeUncheckedSandbox(config, target, options) {
|
|
1019
676
|
const confirm = options.prompt?.confirm ?? p.confirm;
|
|
1020
677
|
const isCancel = options.prompt?.isCancel ?? p.isCancel;
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
project: config.project,
|
|
1028
|
-
targetDigest
|
|
1029
|
-
});
|
|
1030
|
-
const persistedTarget = existingJournals[0]?.target;
|
|
1031
|
-
for (const journal of existingJournals) {
|
|
1032
|
-
if (!persistedTarget)
|
|
1033
|
-
break;
|
|
1034
|
-
assertRemovalSelectionMatches(persistedTarget, journal.target);
|
|
1035
|
-
}
|
|
1036
|
-
const recovery = options.permits
|
|
1037
|
-
? new Map()
|
|
1038
|
-
: recoveryContexts(config, target, existingWorktrees);
|
|
1039
|
-
const journalPermits = options.permits ? new Map()
|
|
1040
|
-
: permitsFromJournal(existingJournals, target, targetDigest);
|
|
1041
|
-
const permits = options.permits
|
|
1042
|
-
? new Map(options.permits)
|
|
1043
|
-
: journalPermits.size > 0
|
|
1044
|
-
? journalPermits
|
|
1045
|
-
: await authorizeWorktrees(existingWorktrees, {
|
|
1046
|
-
allowDirtyDiscard: options.allowDirtyDiscard ?? true,
|
|
1047
|
-
assumeYes: Boolean(options.assumeYes)
|
|
1048
|
-
}, {
|
|
1049
|
-
...options.prompt,
|
|
1050
|
-
interactive: options.interactive ?? Boolean(process.stdin.isTTY),
|
|
1051
|
-
recovery
|
|
1052
|
-
});
|
|
1053
|
-
for (const worktree of existingWorktrees) {
|
|
1054
|
-
const permit = permits.get(path.resolve(worktree));
|
|
1055
|
-
if (!permit)
|
|
1056
|
-
throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
1057
|
-
const worktreeTombstone = removalTombstonePath(targetDigest, 'worktree', worktree);
|
|
1058
|
-
const workspacePhasePending = existingJournals.some((journal) => (sandboxRemovalPhaseIndex(journal.phase) < sandboxRemovalPhaseIndex('workspace-removed')));
|
|
1059
|
-
if (fs.existsSync(worktree)
|
|
1060
|
-
|| (workspacePhasePending
|
|
1061
|
-
&& !isOwnedRemovalTombstone(config.worktreeBase, worktreeTombstone, {
|
|
1062
|
-
version: 1,
|
|
1063
|
-
targetDigest,
|
|
1064
|
-
permitDigest: removalPermitDigest(permits),
|
|
1065
|
-
kind: 'worktree',
|
|
1066
|
-
source: worktree
|
|
1067
|
-
}))) {
|
|
1068
|
-
verifyWorktreePermit(permit);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
const recoveredRemovalChoice = persistedTarget;
|
|
1072
|
-
const shouldRemoveWorktree = recoveredRemovalChoice
|
|
1073
|
-
? recoveredRemovalChoice.removeWorktree
|
|
1074
|
-
: existingWorktrees.length === 0
|
|
1075
|
-
? false
|
|
1076
|
-
: options.assumeYes
|
|
1077
|
-
? true
|
|
1078
|
-
: await confirm({
|
|
1079
|
-
message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
|
|
1080
|
-
initialValue: true
|
|
1081
|
-
});
|
|
678
|
+
const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates, controlRoots, workspaceViewRoots } = target;
|
|
679
|
+
if (!options.quiet)
|
|
680
|
+
p.intro(pc.cyan(`Removing sandbox for ${target.branch}`));
|
|
681
|
+
const shouldRemoveWorktree = existingWorktrees.length > 0 && !options.assumeYes
|
|
682
|
+
? await confirm({ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`, initialValue: true })
|
|
683
|
+
: existingWorktrees.length > 0;
|
|
1082
684
|
if (isCancel(shouldRemoveWorktree)) {
|
|
1083
|
-
|
|
685
|
+
if (!options.quiet)
|
|
686
|
+
p.outro('Cancelled');
|
|
1084
687
|
return null;
|
|
1085
688
|
}
|
|
1086
|
-
const shouldDeleteBranch =
|
|
1087
|
-
?
|
|
1088
|
-
: shouldRemoveWorktree && existingWorktrees.length > 0
|
|
1089
|
-
? options.assumeYes
|
|
1090
|
-
? true
|
|
1091
|
-
: await confirm({
|
|
1092
|
-
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
1093
|
-
initialValue: true
|
|
1094
|
-
})
|
|
1095
|
-
: false;
|
|
689
|
+
const shouldDeleteBranch = Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0 && !options.assumeYes
|
|
690
|
+
? await confirm({ message: `Also delete local branch '${effectiveBranch}'?`, initialValue: true })
|
|
691
|
+
: Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0;
|
|
1096
692
|
if (isCancel(shouldDeleteBranch)) {
|
|
1097
|
-
|
|
693
|
+
if (!options.quiet)
|
|
694
|
+
p.outro('Cancelled');
|
|
1098
695
|
return null;
|
|
1099
696
|
}
|
|
1100
697
|
const sharePath = path.resolve(shareBranchDir(config, effectiveBranch));
|
|
1101
|
-
const shouldRemoveShare =
|
|
1102
|
-
?
|
|
1103
|
-
: fs.existsSync(sharePath)
|
|
1104
|
-
? options.assumeYes
|
|
1105
|
-
? true
|
|
1106
|
-
: await confirm({
|
|
1107
|
-
message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`,
|
|
1108
|
-
initialValue: true
|
|
1109
|
-
})
|
|
1110
|
-
: false;
|
|
698
|
+
const shouldRemoveShare = fs.existsSync(sharePath) && !options.assumeYes
|
|
699
|
+
? await confirm({ message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`, initialValue: true })
|
|
700
|
+
: fs.existsSync(sharePath);
|
|
1111
701
|
if (isCancel(shouldRemoveShare)) {
|
|
1112
|
-
p.outro('Cancelled');
|
|
1113
|
-
return null;
|
|
1114
|
-
}
|
|
1115
|
-
const committedTarget = persistedTarget
|
|
1116
|
-
? {
|
|
1117
|
-
...persistedTarget,
|
|
1118
|
-
permitDigest: removalPermitDigest(permits),
|
|
1119
|
-
permits: removalPermitCommits(permits)
|
|
1120
|
-
}
|
|
1121
|
-
: removalTargetCommit(config, target, permits, Boolean(shouldRemoveWorktree), Boolean(shouldDeleteBranch), Boolean(shouldRemoveShare));
|
|
1122
|
-
if (persistedTarget)
|
|
1123
|
-
assertRemovalSelectionMatches(persistedTarget, committedTarget);
|
|
1124
|
-
const coordinatedManifests = controlRoots
|
|
1125
|
-
.filter((candidate) => fs.existsSync(candidate))
|
|
1126
|
-
.flatMap((root) => {
|
|
1127
|
-
const manifestPath = path.join(root, 'manifest.json');
|
|
1128
|
-
return fs.existsSync(manifestPath)
|
|
1129
|
-
? [[root, readSandboxControlManifest(manifestPath), fs.readFileSync(manifestPath, 'utf8')]]
|
|
1130
|
-
: [];
|
|
1131
|
-
});
|
|
1132
|
-
const resourceLocks = new Map();
|
|
1133
|
-
const recoveredContainerNames = new Set();
|
|
1134
|
-
try {
|
|
1135
|
-
for (const [root, manifest] of [...coordinatedManifests].sort(([left], [right]) => left.localeCompare(right))) {
|
|
1136
|
-
resourceLocks.set(root, acquireSandboxResourceLock(`${manifest.engine}:${manifest.containerIdentity.id}`, { lockDomain: manifest.authorityEvidence.lockDomain }));
|
|
1137
|
-
}
|
|
1138
|
-
for (const journal of existingJournals.filter((candidate) => !fs.existsSync(candidate.target.controlRoot))) {
|
|
1139
|
-
if (!target.controlRoots.some((root) => path.resolve(root) === path.resolve(journal.target.controlRoot))) {
|
|
1140
|
-
throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
|
|
1141
|
-
}
|
|
1142
|
-
assertRemovalSelectionMatches(committedTarget, journal.target);
|
|
1143
|
-
if (journal.target.permitDigest !== removalPermitDigest(permits)) {
|
|
1144
|
-
throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
|
|
1145
|
-
}
|
|
1146
|
-
const lock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
|
|
1147
|
-
lockDomain: journal.lockDomain
|
|
1148
|
-
});
|
|
1149
|
-
resourceLocks.set(journal.target.controlRoot, lock);
|
|
1150
|
-
const current = listSandboxRemovalJournals({
|
|
1151
|
-
branch: journal.target.branch,
|
|
1152
|
-
project: journal.target.project,
|
|
1153
|
-
targetDigest: journal.target.targetDigest
|
|
1154
|
-
}).find((candidate) => candidate.carrierIdentityDigest === journal.carrierIdentityDigest);
|
|
1155
|
-
if (!current || current.revision !== journal.revision || current.handoffId !== journal.handoffId) {
|
|
1156
|
-
throw new Error('SANDBOX_CONTROL_REMOVAL_JOURNAL_REVISION_MISMATCH');
|
|
1157
|
-
}
|
|
1158
|
-
const claimed = claimSandboxRemovalJournal(current, { resourceLock: lock });
|
|
1159
|
-
if (!['carrier-finalizing', 'carrier-removed', 'workspace-finalizing', 'workspace-removed',
|
|
1160
|
-
'branch-finalizing', 'branch-removed', 'tool-finalizing', 'tool-removed',
|
|
1161
|
-
'shell-finalizing', 'shell-removed', 'share-finalizing', 'share-removed', 'completed']
|
|
1162
|
-
.includes(claimed.phase)) {
|
|
1163
|
-
throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
|
|
1164
|
-
}
|
|
1165
|
-
const recovered = claimed.phase === 'carrier-finalizing'
|
|
1166
|
-
? advanceSandboxRemovalJournalPhase(claimed, 'carrier-removed', { resourceLock: lock })
|
|
1167
|
-
: claimed;
|
|
1168
|
-
recoveredContainerNames.add(controlRootContainer(config, journal.target.controlRoot));
|
|
1169
|
-
}
|
|
1170
|
-
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1171
|
-
preflightRmTarget(config, target);
|
|
1172
|
-
const coordinatedContainers = new Set(recoveredContainerNames);
|
|
1173
|
-
for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
1174
|
-
assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
|
|
1175
|
-
assertControlRootMatchesTarget(root, effectiveBranch, workspace);
|
|
1176
|
-
const manifestPath = path.join(root, 'manifest.json');
|
|
1177
|
-
if (!fs.existsSync(manifestPath))
|
|
1178
|
-
continue;
|
|
1179
|
-
const currentRaw = fs.readFileSync(manifestPath, 'utf8');
|
|
1180
|
-
const manifest = readSandboxControlManifest(manifestPath);
|
|
1181
|
-
const initial = coordinatedManifests.find(([candidate]) => candidate === root);
|
|
1182
|
-
if (!initial || currentRaw !== initial[2])
|
|
1183
|
-
throw new Error('SANDBOX_CONTROL_MANIFEST_CHANGED');
|
|
1184
|
-
coordinatedContainers.add(manifest.container);
|
|
1185
|
-
await removeSandboxControlRoot(root, {
|
|
1186
|
-
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
1187
|
-
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
|
|
1188
|
-
resourceLock: resourceLocks.get(root),
|
|
1189
|
-
retainRemovalJournal: true,
|
|
1190
|
-
removalTarget: { ...committedTarget, controlRoot: path.resolve(root) }
|
|
1191
|
-
});
|
|
1192
|
-
removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
|
|
1193
|
-
}
|
|
1194
|
-
if ([...recoveredContainerNames].some((name) => matchedContainers.includes(name))) {
|
|
1195
|
-
throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
|
|
1196
|
-
}
|
|
1197
|
-
const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
|
|
1198
|
-
if (uncoordinatedContainers.length > 0) {
|
|
1199
|
-
if (workspace.mode === 'task-bound') {
|
|
1200
|
-
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
|
|
1201
|
-
}
|
|
1202
|
-
const spinner = p.spinner();
|
|
1203
|
-
spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
|
|
1204
|
-
for (const name of uncoordinatedContainers) {
|
|
1205
|
-
if (!runOkEngine(engine, 'docker', ['stop', name]))
|
|
1206
|
-
throw new Error(`Failed to stop sandbox container: ${name}`);
|
|
1207
|
-
if (!runOkEngine(engine, 'docker', ['rm', name]))
|
|
1208
|
-
throw new Error(`Failed to remove sandbox container: ${name}`);
|
|
1209
|
-
}
|
|
1210
|
-
const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
1211
|
-
const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
|
|
1212
|
-
if (leftovers.length > 0)
|
|
1213
|
-
throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
|
|
1214
|
-
spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
|
|
1215
|
-
}
|
|
1216
|
-
else {
|
|
1217
|
-
p.log.warn(`No sandbox container found for '${branch}'`);
|
|
1218
|
-
}
|
|
1219
|
-
const runWorkspaceCleanup = prepareRemovalAction(config.project, target, targetDigest, 'workspace-finalizing', 'workspace-removed', resourceLocks);
|
|
1220
|
-
if (runWorkspaceCleanup.run) {
|
|
1221
|
-
for (const [roots, base, label] of [
|
|
1222
|
-
[workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
|
|
1223
|
-
[controlRoots, path.join(config.controlBase, config.project), 'Control channel']
|
|
1224
|
-
]) {
|
|
1225
|
-
for (const directory of roots.filter((candidate) => shouldStageManagedRemoval(candidate, removalTombstonePath(targetDigest, 'workspace', candidate), runWorkspaceCleanup.recovering))) {
|
|
1226
|
-
assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
|
|
1227
|
-
? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
|
|
1228
|
-
stageManagedRemoval(base, directory, removalTombstonePath(targetDigest, 'workspace', directory), runWorkspaceCleanup.recovering, {
|
|
1229
|
-
version: 1,
|
|
1230
|
-
targetDigest,
|
|
1231
|
-
permitDigest: committedTarget.permitDigest,
|
|
1232
|
-
kind: 'workspace',
|
|
1233
|
-
source: directory
|
|
1234
|
-
});
|
|
1235
|
-
if (!options.quiet)
|
|
1236
|
-
p.log.success(`${label} removed: ${directory}`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
if (shouldRemoveWorktree) {
|
|
1240
|
-
for (const worktree of existingWorktrees) {
|
|
1241
|
-
const permit = permits.get(path.resolve(worktree));
|
|
1242
|
-
if (!permit)
|
|
1243
|
-
throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
1244
|
-
stageWorktreeRemoval(config, worktree, permit, removalTombstonePath(targetDigest, 'worktree', worktree), runWorkspaceCleanup.recovering, {
|
|
1245
|
-
version: 1,
|
|
1246
|
-
targetDigest,
|
|
1247
|
-
permitDigest: committedTarget.permitDigest,
|
|
1248
|
-
kind: 'worktree',
|
|
1249
|
-
source: worktree
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
else {
|
|
1255
|
-
assertRemovalPathsAbsent([
|
|
1256
|
-
...removalActionPaths(target, config, 'workspace-removed'),
|
|
1257
|
-
...(shouldRemoveWorktree ? existingWorktrees : [])
|
|
1258
|
-
]);
|
|
1259
|
-
}
|
|
1260
|
-
advanceRemovalJournals(config.project, target, targetDigest, 'workspace-removed', resourceLocks);
|
|
1261
|
-
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1262
|
-
for (const [roots, base] of [
|
|
1263
|
-
[workspaceViewRoots, path.join(config.workspaceViewBase, config.project)],
|
|
1264
|
-
[controlRoots, path.join(config.controlBase, config.project)]
|
|
1265
|
-
]) {
|
|
1266
|
-
for (const directory of roots) {
|
|
1267
|
-
if (!fs.existsSync(directory))
|
|
1268
|
-
removeEmptyManagedParent(base, directory);
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
const runBranchCleanup = prepareRemovalAction(config.project, target, targetDigest, 'branch-finalizing', 'branch-removed', resourceLocks);
|
|
1272
|
-
if (runBranchCleanup.run) {
|
|
1273
|
-
if (shouldRemoveWorktree && shouldDeleteBranch) {
|
|
1274
|
-
stageBranchRemoval(config, effectiveBranch, targetDigest, permits, runBranchCleanup.recovering);
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
else if (shouldRemoveWorktree && shouldDeleteBranch
|
|
1278
|
-
&& runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
|
|
1279
|
-
throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${effectiveBranch}`);
|
|
1280
|
-
}
|
|
1281
|
-
advanceRemovalJournals(config.project, target, targetDigest, 'branch-removed', resourceLocks);
|
|
1282
|
-
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1283
|
-
const directoryStages = [
|
|
1284
|
-
{ kind: 'tool', entries: toolCandidates.flatMap(({ tool, candidates }) => candidates.map((dir) => ({ dir, base: tool.sandboxBase, label: tool.name + ' state' }))) },
|
|
1285
|
-
{ kind: 'shell', entries: shellConfigDirCandidates(config, effectiveBranch)
|
|
1286
|
-
.map((dir) => ({ dir, base: config.shellConfigBase, label: 'Shell config' })) },
|
|
1287
|
-
{ kind: 'share', entries: shouldRemoveShare
|
|
1288
|
-
? [{ dir: sharePath, base: config.shareBase, label: 'Share dir' }] : [] }
|
|
1289
|
-
];
|
|
1290
|
-
for (const { kind, entries } of directoryStages) {
|
|
1291
|
-
const completedPhase = `${kind}-removed`;
|
|
1292
|
-
const action = prepareRemovalAction(config.project, target, targetDigest, `${kind}-finalizing`, completedPhase, resourceLocks);
|
|
1293
|
-
if (action.run) {
|
|
1294
|
-
for (const { dir, base, label } of entries) {
|
|
1295
|
-
const tombstone = removalTombstonePath(targetDigest, kind, dir);
|
|
1296
|
-
if (!shouldStageManagedRemoval(dir, tombstone, action.recovering))
|
|
1297
|
-
continue;
|
|
1298
|
-
stageManagedRemoval(base, dir, tombstone, action.recovering, {
|
|
1299
|
-
version: 1, targetDigest, permitDigest: committedTarget.permitDigest, kind, source: dir
|
|
1300
|
-
});
|
|
1301
|
-
p.log.success(`${label} removed: ${dir}`);
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
else if (kind !== 'share' || shouldRemoveShare) {
|
|
1305
|
-
assertRemovalPathsAbsent(removalActionPaths(target, config, completedPhase));
|
|
1306
|
-
}
|
|
1307
|
-
advanceRemovalJournals(config.project, target, targetDigest, completedPhase, resourceLocks);
|
|
1308
|
-
cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
|
|
1309
|
-
}
|
|
1310
|
-
advanceRemovalJournals(config.project, target, targetDigest, 'completed', resourceLocks);
|
|
1311
|
-
let auxiliaryReport = null;
|
|
1312
|
-
if (auxiliaryTaskId) {
|
|
1313
|
-
const finalControlBindingEvidence = projectSandboxControlBindingEvidence(config, controlRoots, listSandboxRemovalJournals({ branch: effectiveBranch, project: config.project, targetDigest }));
|
|
1314
|
-
const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
|
|
1315
|
-
taskIds: [auxiliaryTaskId],
|
|
1316
|
-
controlBindingEvidence: finalControlBindingEvidence,
|
|
1317
|
-
lockedTaskIds: new Set([auxiliaryTaskId])
|
|
1318
|
-
});
|
|
1319
|
-
auxiliaryReport = report;
|
|
1320
|
-
if (!options.quiet) {
|
|
1321
|
-
for (const line of formatIntermediateCleanupReport(report))
|
|
1322
|
-
p.log.message(line);
|
|
1323
|
-
}
|
|
1324
|
-
if (!report.remaining.some((item) => item.taskId === auxiliaryTaskId)) {
|
|
1325
|
-
for (const journal of listSandboxRemovalJournals({
|
|
1326
|
-
branch: effectiveBranch,
|
|
1327
|
-
project: config.project,
|
|
1328
|
-
targetDigest
|
|
1329
|
-
}))
|
|
1330
|
-
clearSandboxRemovalJournalRecord(journal);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
else {
|
|
1334
|
-
for (const journal of listSandboxRemovalJournals({
|
|
1335
|
-
branch: effectiveBranch,
|
|
1336
|
-
project: config.project,
|
|
1337
|
-
targetDigest
|
|
1338
|
-
}))
|
|
1339
|
-
clearSandboxRemovalJournalRecord(journal);
|
|
1340
|
-
}
|
|
1341
702
|
if (!options.quiet)
|
|
1342
|
-
p.outro(
|
|
1343
|
-
return
|
|
1344
|
-
}
|
|
1345
|
-
finally {
|
|
1346
|
-
for (const lock of [...resourceLocks.values()].reverse())
|
|
1347
|
-
lock.release();
|
|
703
|
+
p.outro('Cancelled');
|
|
704
|
+
return null;
|
|
1348
705
|
}
|
|
706
|
+
for (const container of matchedContainers)
|
|
707
|
+
runSafeEngine(engine, 'docker', ['rm', '-f', container]);
|
|
708
|
+
for (const root of controlRoots) {
|
|
709
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
710
|
+
removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
|
|
711
|
+
}
|
|
712
|
+
for (const root of workspaceViewRoots) {
|
|
713
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
714
|
+
removeEmptyManagedParent(path.join(config.workspaceViewBase, config.project), root);
|
|
715
|
+
}
|
|
716
|
+
for (const candidate of toolCandidates.flatMap(({ candidates }) => candidates))
|
|
717
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
718
|
+
for (const shell of shellConfigDirCandidates(config, effectiveBranch))
|
|
719
|
+
fs.rmSync(shell, { recursive: true, force: true });
|
|
720
|
+
if (shouldRemoveShare)
|
|
721
|
+
fs.rmSync(sharePath, { recursive: true, force: true });
|
|
722
|
+
if (shouldRemoveWorktree) {
|
|
723
|
+
for (const worktree of existingWorktrees)
|
|
724
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
725
|
+
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
726
|
+
}
|
|
727
|
+
if (shouldDeleteBranch)
|
|
728
|
+
runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
|
|
729
|
+
if (!sandboxContainersRemoved(engine, matchedContainers)) {
|
|
730
|
+
throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${matchedContainers.join(', ')}`);
|
|
731
|
+
}
|
|
732
|
+
if (target.workspace.mode === 'task-bound') {
|
|
733
|
+
releaseStaleShortIdRegistry(config.repoRoot, target.workspace.taskId);
|
|
734
|
+
}
|
|
735
|
+
if (!options.quiet)
|
|
736
|
+
p.outro('Sandbox removed');
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
async function rmOneCore(config, tools, branch, options = {}) {
|
|
740
|
+
const target = options.target ?? resolveRmTarget(config, tools, options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot));
|
|
741
|
+
return removeUncheckedSandbox(config, target, options);
|
|
1349
742
|
}
|
|
1350
743
|
async function rmPurge(config, tools, prompt = {}) {
|
|
1351
|
-
return
|
|
744
|
+
return rmPurgeCore(config, tools, prompt);
|
|
1352
745
|
}
|
|
1353
746
|
async function rmPurgeCore(config, tools, prompt = {}) {
|
|
1354
747
|
const engine = detectEngine(config);
|
|
1355
748
|
const confirm = prompt.confirm ?? p.confirm;
|
|
1356
749
|
const isCancel = prompt.isCancel ?? p.isCancel;
|
|
1357
750
|
p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
|
|
751
|
+
const containers = runEngine(engine, 'docker', [
|
|
752
|
+
'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
|
|
753
|
+
]).split('\n').filter(Boolean);
|
|
754
|
+
for (const name of containers)
|
|
755
|
+
runSafeEngine(engine, 'docker', ['rm', '-f', name]);
|
|
1358
756
|
const worktrees = fs.existsSync(config.worktreeBase)
|
|
1359
|
-
? fs.readdirSync(config.worktreeBase)
|
|
1360
|
-
.map((entry) => path.join(config.worktreeBase, entry))
|
|
1361
|
-
.filter((entry) => {
|
|
1362
|
-
try {
|
|
1363
|
-
return fs.statSync(entry).isDirectory();
|
|
1364
|
-
}
|
|
1365
|
-
catch {
|
|
1366
|
-
return false;
|
|
1367
|
-
}
|
|
1368
|
-
})
|
|
757
|
+
? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
|
|
1369
758
|
: [];
|
|
1370
|
-
const inspections = inspectWorktrees(worktrees);
|
|
1371
|
-
const blockers = inspectionBlockers(inspections);
|
|
1372
|
-
if (blockers.length > 0) {
|
|
1373
|
-
throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
|
|
1374
|
-
}
|
|
1375
|
-
const permits = cleanPermits(inspections);
|
|
1376
|
-
assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
|
|
1377
|
-
preflight: true,
|
|
1378
|
-
controlBindingEvidence: projectSandboxControlBindingEvidence(config)
|
|
1379
|
-
}));
|
|
1380
|
-
const coordinatedContainers = await removeProjectControlRoots(config, engine, {
|
|
1381
|
-
retainRemovalJournal: true
|
|
1382
|
-
});
|
|
1383
|
-
const containers = runEngine(engine, 'docker', [
|
|
1384
|
-
'ps',
|
|
1385
|
-
'-a',
|
|
1386
|
-
'--filter',
|
|
1387
|
-
`label=${sandboxLabel(config)}`,
|
|
1388
|
-
'--format',
|
|
1389
|
-
'{{.Names}}'
|
|
1390
|
-
]);
|
|
1391
|
-
const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
|
|
1392
|
-
if (uncoordinatedContainers.length > 0) {
|
|
1393
|
-
const spinner = p.spinner();
|
|
1394
|
-
spinner.start('Stopping project sandbox containers...');
|
|
1395
|
-
for (const name of uncoordinatedContainers) {
|
|
1396
|
-
if (!runOkEngine(engine, 'docker', ['stop', name]))
|
|
1397
|
-
throw new Error(`Failed to stop sandbox container: ${name}`);
|
|
1398
|
-
if (!runOkEngine(engine, 'docker', ['rm', name]))
|
|
1399
|
-
throw new Error(`Failed to remove sandbox container: ${name}`);
|
|
1400
|
-
}
|
|
1401
|
-
const remaining = runEngine(engine, 'docker', [
|
|
1402
|
-
'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
|
|
1403
|
-
]);
|
|
1404
|
-
const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
|
|
1405
|
-
if (leftovers.length > 0)
|
|
1406
|
-
throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
|
|
1407
|
-
spinner.stop(pc.green('Project sandbox containers removed'));
|
|
1408
|
-
}
|
|
1409
|
-
else {
|
|
1410
|
-
p.log.warn('No project sandbox containers found');
|
|
1411
|
-
}
|
|
1412
759
|
if (worktrees.length > 0) {
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
|
|
1418
|
-
for (const dir of worktrees) {
|
|
1419
|
-
const permit = permits.get(path.resolve(dir));
|
|
1420
|
-
if (!permit)
|
|
1421
|
-
throw new Error(`Missing worktree removal permit: ${dir}`);
|
|
1422
|
-
removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
|
|
1423
|
-
allowRegisteredPathFallback: engine === ENGINES.WSL2
|
|
1424
|
-
});
|
|
1425
|
-
assertRemoved(dir, 'Worktree');
|
|
1426
|
-
}
|
|
760
|
+
const selected = await confirm({ message: `Remove all worktrees in ${config.worktreeBase}?`, initialValue: true });
|
|
761
|
+
if (!isCancel(selected) && selected) {
|
|
762
|
+
for (const worktree of worktrees)
|
|
763
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
1427
764
|
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
1428
|
-
const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
|
|
1429
|
-
.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
|
|
1430
|
-
const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
|
|
1431
|
-
if (leftovers.length > 0)
|
|
1432
|
-
throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
|
|
1433
765
|
}
|
|
1434
766
|
}
|
|
1435
|
-
for (const dir of projectToolDirs(config, tools))
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
767
|
+
for (const dir of projectToolDirs(config, tools))
|
|
768
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
769
|
+
if (fs.existsSync(config.shellConfigBase)) {
|
|
770
|
+
const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
|
|
771
|
+
if (!isCancel(selected) && selected)
|
|
772
|
+
fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
|
|
1441
773
|
}
|
|
1442
|
-
if (fs.existsSync(config.
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
});
|
|
1447
|
-
if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
|
|
1448
|
-
for (const entry of fs.readdirSync(config.shellConfigBase)) {
|
|
1449
|
-
const dir = path.join(config.shellConfigBase, entry);
|
|
1450
|
-
removeManagedDir(config.shellConfigBase, dir);
|
|
1451
|
-
assertRemoved(dir, 'Shell config');
|
|
1452
|
-
}
|
|
1453
|
-
p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
|
|
1454
|
-
}
|
|
774
|
+
if (fs.existsSync(config.shareBase)) {
|
|
775
|
+
const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
|
|
776
|
+
if (!isCancel(selected) && selected)
|
|
777
|
+
fs.rmSync(config.shareBase, { recursive: true, force: true });
|
|
1455
778
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
message: `Remove all share dirs for project (${config.shareBase})?`,
|
|
1459
|
-
initialValue: true
|
|
1460
|
-
});
|
|
1461
|
-
if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
|
|
1462
|
-
removeManagedDir(path.dirname(config.shareBase), config.shareBase);
|
|
1463
|
-
assertRemoved(config.shareBase, 'Project share dir');
|
|
1464
|
-
p.log.success(`Project share dirs removed: ${config.shareBase}`);
|
|
1465
|
-
}
|
|
779
|
+
for (const base of [config.workspaceViewBase, config.controlBase]) {
|
|
780
|
+
fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
|
|
1466
781
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
[config.controlBase, 'control channels']
|
|
1470
|
-
]) {
|
|
1471
|
-
const projectDir = path.join(base, config.project);
|
|
1472
|
-
if (fs.existsSync(projectDir)) {
|
|
1473
|
-
removeManagedDir(base, projectDir);
|
|
1474
|
-
assertRemoved(projectDir, `Project ${label}`);
|
|
1475
|
-
p.log.success(`Removed project ${label}: ${projectDir}`);
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
const shouldRemoveImage = await confirm({
|
|
1479
|
-
message: `Remove image ${config.imageName}?`,
|
|
1480
|
-
initialValue: false
|
|
1481
|
-
});
|
|
1482
|
-
if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
|
|
782
|
+
const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
|
|
783
|
+
if (!isCancel(removeImage) && removeImage)
|
|
1483
784
|
runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
|
|
1484
|
-
}
|
|
1485
|
-
pruneSandboxDanglingImages(config, engine);
|
|
1486
|
-
const cleanupPurgeAuxiliary = () => {
|
|
1487
|
-
const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
|
|
1488
|
-
controlBindingEvidence: projectSandboxControlBindingEvidence(config)
|
|
1489
|
-
});
|
|
1490
|
-
for (const line of formatIntermediateCleanupReport(report))
|
|
1491
|
-
p.log.message(line);
|
|
1492
|
-
if (!report.remaining.some((item) => item.taskId !== null)) {
|
|
1493
|
-
for (const journal of listSandboxRemovalJournals({ project: config.project })) {
|
|
1494
|
-
if (journal.phase === 'completed')
|
|
1495
|
-
clearSandboxRemovalJournalRecord(journal);
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
};
|
|
1499
|
-
if (isManagedEngine(engine)) {
|
|
1500
|
-
if (engine === ENGINES.WSL2) {
|
|
1501
|
-
completePurgeRemovalJournals(config);
|
|
1502
|
-
cleanupPurgeAuxiliary();
|
|
1503
|
-
p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
|
|
1504
|
-
p.outro(pc.green('All project sandboxes removed'));
|
|
1505
|
-
return;
|
|
1506
|
-
}
|
|
1507
|
-
const name = engineDisplayName(engine);
|
|
1508
|
-
const shouldStopVm = await confirm({
|
|
1509
|
-
message: `Stop ${name} VM?`,
|
|
1510
|
-
initialValue: false
|
|
1511
|
-
});
|
|
1512
|
-
if (!isCancel(shouldStopVm) && shouldStopVm) {
|
|
1513
|
-
stopManagedVm(config);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
completePurgeRemovalJournals(config);
|
|
1517
|
-
cleanupPurgeAuxiliary();
|
|
1518
785
|
p.outro(pc.green('All project sandboxes removed'));
|
|
1519
786
|
}
|
|
1520
787
|
async function rmUnbound(config, tools, options) {
|
|
1521
|
-
return
|
|
788
|
+
return rmUnboundCore(config, tools, options);
|
|
1522
789
|
}
|
|
1523
790
|
async function rmUnboundCore(config, tools, options) {
|
|
1524
791
|
const engine = detectEngine(config);
|
|
1525
|
-
const
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
|
|
1529
|
-
const intermediatePreview = scanIntermediateCleanup(config.repoRoot, { controlBindingEvidence });
|
|
1530
|
-
const candidates = [];
|
|
1531
|
-
const protectedCandidates = [];
|
|
1532
|
-
for (const row of rows) {
|
|
1533
|
-
if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
|
|
1534
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
|
|
1535
|
-
}
|
|
1536
|
-
if (row.workspaceMode === 'task-bound') {
|
|
1537
|
-
if (!row.taskId) {
|
|
1538
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
|
|
1539
|
-
}
|
|
1540
|
-
try {
|
|
1541
|
-
const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
|
|
1542
|
-
if (cleanupTarget.branch !== row.branch
|
|
1543
|
-
|| cleanupTarget.workspace.mode !== 'task-bound'
|
|
1544
|
-
|| cleanupTarget.workspace.taskId !== row.taskId) {
|
|
1545
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
|
|
1546
|
-
}
|
|
1547
|
-
candidates.push({ row, cleanupTarget });
|
|
1548
|
-
}
|
|
1549
|
-
catch (error) {
|
|
1550
|
-
if (!isMissingTaskRecordError(error))
|
|
1551
|
-
throw error;
|
|
1552
|
-
protectedCandidates.push({
|
|
1553
|
-
row,
|
|
1554
|
-
branch: row.branch,
|
|
1555
|
-
identity: `task-bound:${row.taskId}`,
|
|
1556
|
-
reason: 'TASK_NOT_FOUND'
|
|
1557
|
-
});
|
|
1558
|
-
}
|
|
1559
|
-
continue;
|
|
1560
|
-
}
|
|
1561
|
-
candidates.push({
|
|
1562
|
-
row,
|
|
1563
|
-
cleanupTarget: {
|
|
1564
|
-
requestedRef: row.branch,
|
|
1565
|
-
branch: row.branch,
|
|
1566
|
-
workspace: { mode: 'branch-only' },
|
|
1567
|
-
taskState: 'branch-only'
|
|
1568
|
-
}
|
|
1569
|
-
});
|
|
1570
|
-
}
|
|
1571
|
-
const branchIdentities = new Map();
|
|
1572
|
-
const groupedCandidates = new Map();
|
|
1573
|
-
for (const candidate of protectedCandidates) {
|
|
1574
|
-
const existingIdentity = branchIdentities.get(candidate.branch);
|
|
1575
|
-
if (existingIdentity && existingIdentity !== candidate.identity) {
|
|
1576
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.branch}' has conflicting workspace identities`);
|
|
1577
|
-
}
|
|
1578
|
-
branchIdentities.set(candidate.branch, candidate.identity);
|
|
1579
|
-
}
|
|
1580
|
-
for (const candidate of candidates) {
|
|
1581
|
-
const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
|
|
1582
|
-
? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
|
|
1583
|
-
: 'branch-only';
|
|
1584
|
-
const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
|
|
1585
|
-
if (existingIdentity && existingIdentity !== identity) {
|
|
1586
|
-
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`);
|
|
1587
|
-
}
|
|
1588
|
-
branchIdentities.set(candidate.cleanupTarget.branch, identity);
|
|
1589
|
-
const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
|
|
1590
|
-
const group = groupedCandidates.get(groupKey) ?? [];
|
|
1591
|
-
group.push(candidate);
|
|
1592
|
-
groupedCandidates.set(groupKey, group);
|
|
1593
|
-
}
|
|
1594
|
-
const groups = [...groupedCandidates.values()].map((groupCandidates) => {
|
|
1595
|
-
const cleanupTarget = groupCandidates[0].cleanupTarget;
|
|
1596
|
-
const target = resolveRmTarget(config, tools, cleanupTarget, {
|
|
1597
|
-
discoveredContainers: groupCandidates.map(({ row }) => row.name)
|
|
1598
|
-
});
|
|
1599
|
-
preflightRmTarget(config, target);
|
|
1600
|
-
return { candidates: groupCandidates, cleanupTarget, target };
|
|
1601
|
-
});
|
|
1602
|
-
const protectedTargets = protectedCandidates.map(({ row, branch }) => {
|
|
1603
|
-
const target = resolveRmTarget(config, tools, {
|
|
1604
|
-
requestedRef: branch,
|
|
1605
|
-
branch,
|
|
1606
|
-
workspace: { mode: 'task-bound', taskId: row.taskId },
|
|
1607
|
-
taskState: 'completed'
|
|
1608
|
-
}, { discoveredContainers: [row.name] });
|
|
1609
|
-
return { branch, managedPathCandidates: target.managedPathCandidates ?? [] };
|
|
1610
|
-
});
|
|
1611
|
-
assertCleanupGroupPathsDoNotOverlap(groups, protectedTargets);
|
|
1612
|
-
const removableGroups = groups.filter(({ cleanupTarget }) => cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only');
|
|
1613
|
-
const rowTaskIds = new Set(groups.flatMap(({ cleanupTarget }) => (cleanupTarget.workspace.mode === 'task-bound' ? [cleanupTarget.workspace.taskId] : [])));
|
|
1614
|
-
const cleanupOnlyTaskIds = new Set(intermediatePreview.items
|
|
1615
|
-
.filter((candidate) => candidate.disposition === 'planned' && candidate.taskId !== null && !rowTaskIds.has(candidate.taskId))
|
|
1616
|
-
.map((candidate) => candidate.taskId));
|
|
1617
|
-
const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
|
|
1618
|
-
for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) => cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only')) {
|
|
1619
|
-
for (const { row } of groupCandidates) {
|
|
1620
|
-
p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
for (const { row, reason } of protectedCandidates) {
|
|
1624
|
-
p.log.message(`Skipped protected sandbox ${row.name} (${reason})`);
|
|
1625
|
-
}
|
|
1626
|
-
for (const line of formatIntermediateCleanupReport(intermediatePreview))
|
|
1627
|
-
p.log.message(line);
|
|
1628
|
-
const hasAuxiliaryWork = intermediatePreview.items.some((candidate) => candidate.disposition === 'planned');
|
|
1629
|
-
if (removableGroups.length === 0 && !hasAuxiliaryWork) {
|
|
1630
|
-
p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
|
|
1634
|
-
assertWorktreeBranchesMatchGroups(removableGroups, inspections);
|
|
1635
|
-
const blockers = inspectionBlockers(inspections);
|
|
1636
|
-
const permits = cleanPermits(inspections);
|
|
1637
|
-
for (const { row } of removable) {
|
|
1638
|
-
p.log.message(`${row.name} ${row.branch}`);
|
|
1639
|
-
}
|
|
1640
|
-
if (blockers.length > 0)
|
|
1641
|
-
p.log.error(blockerMessage(blockers));
|
|
792
|
+
const listed = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config), { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) });
|
|
793
|
+
const rows = [...listed.running, ...listed.nonRunning];
|
|
794
|
+
p.intro(pc.cyan(`Removing sandboxes for ${config.project}`));
|
|
1642
795
|
if (options.dryRun) {
|
|
1643
|
-
p.outro(`Dry run: ${
|
|
796
|
+
p.outro(`Dry run: ${rows.length} sandbox(es) found, nothing deleted`);
|
|
1644
797
|
return;
|
|
1645
798
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
throw new Error('Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.');
|
|
1651
|
-
}
|
|
1652
|
-
const failures = [];
|
|
1653
|
-
const actualReports = [];
|
|
1654
|
-
let failedRows = 0;
|
|
1655
|
-
for (const group of removableGroups) {
|
|
1656
|
-
try {
|
|
1657
|
-
const report = await runRmOneUnderRepositoryLock(config, tools, group.cleanupTarget.branch, {
|
|
1658
|
-
assumeYes: options.assumeYes,
|
|
1659
|
-
quiet: true,
|
|
1660
|
-
target: group.target,
|
|
1661
|
-
cleanupTarget: group.cleanupTarget,
|
|
1662
|
-
permits,
|
|
1663
|
-
allowDirtyDiscard: false
|
|
1664
|
-
});
|
|
1665
|
-
if (report)
|
|
1666
|
-
actualReports.push(report);
|
|
1667
|
-
}
|
|
1668
|
-
catch (error) {
|
|
1669
|
-
failedRows += group.candidates.length;
|
|
1670
|
-
failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
|
|
1671
|
-
const taskId = group.cleanupTarget.workspace.mode === 'task-bound'
|
|
1672
|
-
? group.cleanupTarget.workspace.taskId
|
|
1673
|
-
: null;
|
|
1674
|
-
if (taskId) {
|
|
1675
|
-
const failedReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
|
|
1676
|
-
dryRun: true,
|
|
1677
|
-
taskIds: [taskId],
|
|
1678
|
-
controlBindingEvidence
|
|
1679
|
-
});
|
|
1680
|
-
actualReports.push(protectIntermediateCleanupReport(failedReport, 'SANDBOX_ROW_REMOVAL_FAILED'));
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
const auxiliaryTaskIds = [...cleanupOnlyTaskIds].sort();
|
|
1685
|
-
const finalControlBindingEvidence = projectSandboxControlBindingEvidence(config);
|
|
1686
|
-
const cleanupOnlyReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
|
|
1687
|
-
taskIds: auxiliaryTaskIds,
|
|
1688
|
-
controlBindingEvidence: finalControlBindingEvidence
|
|
1689
|
-
});
|
|
1690
|
-
actualReports.push(cleanupOnlyReport);
|
|
1691
|
-
const intermediateReport = mergeIntermediateCleanupReports(actualReports);
|
|
1692
|
-
for (const line of formatIntermediateCleanupReport(intermediateReport))
|
|
1693
|
-
p.log.message(line);
|
|
1694
|
-
if (cleanupOnlyReport.remaining.every((item) => item.taskId === null)) {
|
|
1695
|
-
for (const taskId of auxiliaryTaskIds) {
|
|
1696
|
-
let branch;
|
|
1697
|
-
try {
|
|
1698
|
-
branch = resolveSandboxCleanupTarget(taskId, config.repoRoot, { allowProtected: true }).branch;
|
|
1699
|
-
}
|
|
1700
|
-
catch {
|
|
1701
|
-
continue;
|
|
1702
|
-
}
|
|
1703
|
-
for (const journal of listSandboxRemovalJournals({ project: config.project, branch })) {
|
|
1704
|
-
clearSandboxRemovalJournalRecord(journal);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
if (failures.length > 0) {
|
|
1709
|
-
for (const failure of failures) {
|
|
1710
|
-
p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
|
|
799
|
+
for (const row of rows) {
|
|
800
|
+
if (!row.branch) {
|
|
801
|
+
runSafeEngine(engine, 'docker', ['rm', '-f', row.name]);
|
|
802
|
+
continue;
|
|
1711
803
|
}
|
|
1712
|
-
|
|
804
|
+
const cleanupTarget = {
|
|
805
|
+
requestedRef: row.branch,
|
|
806
|
+
branch: row.branch,
|
|
807
|
+
workspace: row.workspaceMode === 'task-bound' && row.taskId
|
|
808
|
+
? { mode: 'task-bound', taskId: row.taskId }
|
|
809
|
+
: { mode: 'branch-only' },
|
|
810
|
+
taskState: 'branch-only'
|
|
811
|
+
};
|
|
812
|
+
const target = resolveRmTarget(config, tools, cleanupTarget, { discoveredContainers: [row.name] });
|
|
813
|
+
await rmOne(config, tools, cleanupTarget.branch, {
|
|
814
|
+
assumeYes: options.assumeYes,
|
|
815
|
+
target,
|
|
816
|
+
cleanupTarget
|
|
817
|
+
});
|
|
1713
818
|
}
|
|
1714
|
-
p.outro(pc.green(`Removed ${
|
|
819
|
+
p.outro(pc.green(`Removed ${rows.length} sandbox(es)`));
|
|
1715
820
|
}
|
|
1716
821
|
export { authorizeWorktrees, rmOne, rmPurge, rmUnbound };
|
|
1717
822
|
//# sourceMappingURL=removal.js.map
|