From 1455fe1251e3d26e9de4ef97c1681a28a9fa93f2 Mon Sep 17 00:00:00 2001 From: Chris Clapham Date: Sat, 9 May 2026 18:26:49 +1000 Subject: [PATCH] feat: add archived team purge workflow --- .gitignore | 1 + CONTRIBUTING.md | 31 +- README.md | 16 +- package.json | 2 +- src/index.ts | 40 ++- src/state.ts | 107 ++++++ src/system-prompt.ts | 5 + src/tools/shared.ts | 35 ++ src/tools/team-cleanup.ts | 424 ++++++++++++++++++++++- src/types.ts | 3 +- test/helpers.ts | 3 +- test/readme-social-preview.test.ts | 4 +- test/state.test.ts | 90 ++++- test/system-prompt.test.ts | 16 + test/tools/shared.test.ts | 50 ++- test/tools/team-lifecycle.test.ts | 533 +++++++++++++++++++++++++++++ 16 files changed, 1328 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index e38e9ea..a2c22a0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ logs # OpenCode local state .opencode/ .superpowers/ +.worktrees/ dogfood-output/ docs/superpowers/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eada952..e6b1fc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,17 +56,26 @@ All PRs require at least one approval from a code owner before merging. Direct p ``` src/ -├── index.ts # Plugin entry point -├── db.ts # SQLite connection + init -├── schema.ts # CREATE TABLE migrations -├── state.ts # In-memory registry + descendant tracker -├── messaging.ts # Message persistence + delivery helpers -├── recovery.ts # Crash recovery (stale members + undelivered messages) -├── hooks.ts # Event hook + sub-agent isolation -├── rate-limit.ts # Token bucket rate limiter -├── types.ts # Shared types + helper functions -├── util.ts # ID generation + name validation -└── tools/ # One file per tool (13 total) +├── index.ts # Plugin entry point and tool registration +├── client.ts # SDK wrapper that throws on API errors +├── config.ts # Global/project/env configuration loading +├── dashboard*.ts # Dashboard HTML, JS, and data endpoint +├── db.ts # SQLite connection + init +├── hooks.ts # Event hook + sub-agent isolation +├── log.ts # Plugin logging helpers +├── messaging.ts # Message persistence + delivery helpers +├── notify.ts # TUI notification helpers +├── progress.ts # Progress/stall tracking +├── rate-limit.ts # Token bucket rate limiter +├── recovery.ts # Crash recovery and orphan cleanup +├── result-parser.ts # Teammate result parsing helpers +├── schema.ts # CREATE TABLE migrations +├── state.ts # In-memory registry, descendant tracker, and purge approval state +├── system-prompt.ts # Lead/teammate prompt injection text +├── types.ts # Shared types + helper functions +├── util.ts # ID generation + name validation +├── watchdog.ts # Timeout and stall watchdog +└── tools/ # 14 team tools plus shared/merge helpers test/ ├── helpers.ts # Shared test utilities (setupDb, mockClient, etc.) diff --git a/README.md b/README.md index 94250fe..26a4b55 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble) [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble) -[![tests](https://img.shields.io/badge/tests-512%20passing-brightgreen.svg)]() +[![tests](https://img.shields.io/badge/tests-558%20passing-brightgreen.svg)]() [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]() [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]() [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) @@ -19,7 +19,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies. ```json { - "plugin": ["@hueyexe/opencode-ensemble@0.13.3"] + "plugin": ["@hueyexe/opencode-ensemble@0.14.0"] } ``` @@ -140,7 +140,7 @@ Add to your OpenCode config with a pinned version. Project-level or global. ```json { - "plugin": ["@hueyexe/opencode-ensemble@0.13.3"] + "plugin": ["@hueyexe/opencode-ensemble@0.14.0"] } ``` @@ -148,7 +148,7 @@ Add to your OpenCode config with a pinned version. Project-level or global. ```json { - "plugin": ["@hueyexe/opencode-ensemble@0.13.3"] + "plugin": ["@hueyexe/opencode-ensemble@0.14.0"] } ``` @@ -198,7 +198,7 @@ Build with `bun run build`, then restart OpenCode to pick up changes. 14 tools. The lead has all of them. Teammates get 6 (messaging + tasks). -**Team lifecycle** (lead only) +**Team lifecycle** (lead only, except archived-team purge may also be run from the main session) | Tool | What it does | |------|-------------| @@ -206,10 +206,12 @@ Build with `bun run build`, then restart OpenCode to pick up changes. | `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. | | `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. | | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. | -| `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. | +| `team_cleanup` | Remove the current team when done. Safety-net merges forgotten branches. With `purge`, previews archived-team deletion and returns exact approval labels plus a confirmation token. | | `team_status` | See all members, their status, and a task summary. | | `team_view` | Switch the TUI to a teammate's session. | +Archived-team purge is intentionally two-step. First call `team_cleanup` with `purge` to get a preview, exact approval and denial option labels, and `confirm_token`; no data is deleted. Stale archived worktree/workspace references and stale Ensemble-owned branches are counted in the preview and cleaned during confirmed purge. Arbitrary non-Ensemble branches still block purge for safety. The lead must then use the question tool with those exact options. Only after the user selects the exact approval option should it call `team_cleanup` again with the same `purge`, `confirm_purge: true`, and the preview token. + **Communication** (everyone) | Tool | What it does | @@ -410,7 +412,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s ```bash bun install bun run typecheck -bun test # 512 tests +bun test # 558 tests bun run build ``` diff --git a/package.json b/package.json index cc74de6..74aaf0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hueyexe/opencode-ensemble", - "version": "0.13.3", + "version": "0.14.0", "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution", "module": "src/index.ts", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 39e574f..9491ae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { mkdirSync } from "node:fs" import { createDb, getDbPath } from "./db" import { wrapThrowingClient } from "./client" import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery" -import { MemberRegistry, DescendantTracker } from "./state" +import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state" import { isWorktreeInstance } from "./util" import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks" import { notifyTeamEvent, notifyWorkingProgress } from "./notify" @@ -56,6 +56,7 @@ const plugin: Plugin = async (input) => { // Initialize in-memory state const registry = new MemberRegistry() const tracker = new DescendantTracker() + const purgeApprovals = new PendingPurgeApprovals() const nudgedMembers = new Set() const progressTracker = new ProgressTracker() const wakeLeadTimestamps = new Map() @@ -69,7 +70,7 @@ const plugin: Plugin = async (input) => { const rawClient = new OpencodeClient({ client: pluginTransport }) initLog(rawClient) const client = wrapThrowingClient(rawClient) - const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory, config } + const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config } // Recovery only runs for the main project instance — NOT for teammate worktree instances. // Worktree instances are created during session.create. Running recovery there makes HTTP @@ -306,6 +307,12 @@ const plugin: Plugin = async (input) => { } }, + "tool.execute.after": async (input, output) => { + if (input.tool === "question") { + purgeApprovals.recordQuestionAnswer(input.sessionID, output.output, input.args) + } + }, + // System prompt injection — keeps lead aware of team state, reminds teammates of role "experimental.chat.system.transform": async (input, output) => { if (!input.sessionID) return @@ -498,15 +505,38 @@ const plugin: Plugin = async (input) => { }), team_cleanup: tool({ - description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.", + description: "Clean up the current team, or purge archived teams after human approval. " + + "Omit purge for normal cleanup. Pass purge with archived team names, or ['*'] for all archived teams. " + + "First purge call returns a preview, exact approval and denial options, and confirmation token only. " + + "Archived worktree/workspace references and stale Ensemble-owned branches are shown in the preview and cleaned during confirmed purge. " + + "Use the question tool with those exact options, then call again with confirm_purge: true and confirm_token only if the user selected the exact approval option.", args: { force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"), acknowledge_uncommitted: tool.schema.boolean().default(false), + purge: tool.schema.array(tool.schema.string()).optional().describe("Archived team names to permanently delete, or ['*'] for all archived teams. Requires human approval."), + confirm_purge: tool.schema.boolean().default(false).describe("Set true only after the user explicitly selects the exact approval option from the purge preview via the question tool."), + confirm_token: tool.schema.string().optional().describe("Confirmation token from the purge preview. Valid only after the matching exact approval answer is selected in this session."), }, async execute(args, ctx) { - const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup) + const approvePurge = args.purge && args.purge.length > 0 && args.confirm_purge + ? async (preview: string) => { + await ctx.ask({ + permission: "team_cleanup.purge", + patterns: args.purge ?? [], + always: [], + metadata: { + title: "Purge archived teams", + preview, + }, + }) + } + : undefined + const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup, undefined, approvePurge) const blocked = result.includes("uncommitted") - ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" }) + const title = args.purge + ? result.startsWith("No archived teams") ? "No archived teams to purge" : result.startsWith("Purge preview") ? "Purge confirmation required" : "Archived teams purged" + : blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" + ctx.metadata({ title }) return result }, }), diff --git a/src/state.ts b/src/state.ts index d61856e..d10efb0 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto" + /** Info about a registered team member in the in-memory registry. */ export interface MemberEntry { teamId: string @@ -67,6 +69,40 @@ export class MemberRegistry { } const DEFAULT_MAX_DEPTH = 10 +const DEFAULT_PURGE_APPROVAL_TTL_MS = 10 * 60 * 1000 + +interface PendingPurgeApproval { + sessionId: string + purgeKey: string + approved: boolean + timeCreated: number +} + +function canonicalPurgeKey(purge: string[]): string { + if (purge.includes("*")) return JSON.stringify(["*"]) + return JSON.stringify([...new Set(purge)].sort()) +} + +function outputSelectedAnswer(output: string, label: string): boolean { + const quoted = JSON.stringify(label) + return output.includes(`=${quoted}`) || output.includes(`=[${quoted}]`) +} + +function optionLabels(args: unknown): string[] { + if (!args || typeof args !== "object") return [] + const questions = (args as { questions?: unknown }).questions + if (!Array.isArray(questions)) return [] + return questions.flatMap(question => { + if (!question || typeof question !== "object") return [] + const options = (question as { options?: unknown }).options + if (!Array.isArray(options)) return [] + return options.flatMap(option => { + if (!option || typeof option !== "object") return [] + const label = (option as { label?: unknown }).label + return typeof label === "string" ? [label] : [] + }) + }) +} /** * Tracks parent-child session relationships for sub-agent isolation. @@ -106,3 +142,74 @@ export class DescendantTracker { this.parents.delete(sessionId) } } + +/** Tracks pending archived-team purge confirmations between preview and execution. */ +export class PendingPurgeApprovals { + private pending = new Map() + private readonly ttlMs: number + private readonly now: () => number + private readonly createToken: () => string + + constructor(ttlMs = DEFAULT_PURGE_APPROVAL_TTL_MS, now: () => number = () => Date.now(), createToken: () => string = randomUUID) { + this.ttlMs = ttlMs + this.now = now + this.createToken = createToken + } + + /** Create a confirmation token for a purge preview. */ + create(sessionId: string, purge: string[]): string { + this.pruneExpired() + const token = this.createToken() + this.pending.set(token, { + sessionId, + purgeKey: canonicalPurgeKey(purge), + approved: false, + timeCreated: this.now(), + }) + return token + } + + /** Return the exact user-facing answer label that approves a confirmation token. */ + approvalLabel(token: string): string { + return `Approve purge ${token.slice(0, 8)}` + } + + /** Return the exact user-facing answer label that denies a confirmation token. */ + denialLabel(token: string): string { + return `Deny purge ${token.slice(0, 8)}` + } + + /** Record a question tool answer and approve only tokens whose exact approval label was selected. */ + recordQuestionAnswer(sessionId: string, output: string, args: unknown): void { + this.pruneExpired() + const labels = new Set(optionLabels(args)) + for (const [token, approval] of this.pending.entries()) { + const approvalLabel = this.approvalLabel(token) + const denialLabel = this.denialLabel(token) + const hasRequiredOptions = labels.has(approvalLabel) && labels.has(denialLabel) + if (approval.sessionId === sessionId && hasRequiredOptions && outputSelectedAnswer(output, approvalLabel)) { + approval.approved = true + } + } + } + + /** Consume a confirmation token once it is safe to execute the matching purge. */ + consume(sessionId: string, token: string, purge: string[]): void { + this.pruneExpired() + const approval = this.pending.get(token) + if (!approval || approval.sessionId !== sessionId || approval.purgeKey !== canonicalPurgeKey(purge)) { + throw new Error("Purge confirmation token is invalid or expired.") + } + if (!approval.approved) { + throw new Error("Purge was not approved by the user. Use the question tool with the exact approval option from the preview before confirming.") + } + this.pending.delete(token) + } + + private pruneExpired(): void { + const cutoff = this.now() - this.ttlMs + for (const [token, approval] of this.pending.entries()) { + if (approval.timeCreated < cutoff) this.pending.delete(token) + } + } +} diff --git a/src/system-prompt.ts b/src/system-prompt.ts index b043745..8d4d28c 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -152,6 +152,11 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.", + "To permanently delete archived teams, call team_cleanup with purge: [\"team-name\"] or purge: [\"*\"] for all archived teams.", + "The first purge call is preview-only and deletes nothing.", + "Use the question tool to ask the user for visible human approval before deleting archived team records or preserved Ensemble branches.", + "The question must include the exact approval and denial option labels shown in the preview.", + "Only if the user selects that exact approval option, call team_cleanup again with the same purge value, confirm_purge: true, and the confirm_token from the preview.", ) return lines.join("\n") diff --git a/src/tools/shared.ts b/src/tools/shared.ts index 8e4639a..a266e20 100644 --- a/src/tools/shared.ts +++ b/src/tools/shared.ts @@ -52,3 +52,38 @@ export function requireTeamMember( if (!teamInfo) throw new Error("This session is not in a team.") return teamInfo } + +/** Validate that a session can purge archived teams. Throws if not allowed. */ +export function requireCanPurgeArchivedTeams( + deps: Pick, + sessionId: string, +): void { + const activeMembers = deps.db.query( + `SELECT tm.session_id + FROM team_member tm + JOIN team t ON tm.team_id = t.id + WHERE t.status = 'active'` + ).all() as Array<{ session_id: string }> + + if (activeMembers.some(member => member.session_id === sessionId)) { + throw new Error("Team members cannot purge archived teams") + } + + const activeLeads = deps.db.query("SELECT lead_session_id FROM team WHERE status = 'active'") + .all() as Array<{ lead_session_id: string }> + + if (deps.tracker.getParent(sessionId)) { + throw new Error("Sub-agents cannot purge archived teams") + } + + if (activeLeads.some(team => team.lead_session_id === sessionId)) return + + const activeTeamSessions = new Set([ + ...activeMembers.map(member => member.session_id), + ...activeLeads.map(team => team.lead_session_id), + ]) + + if (deps.tracker.isDescendantOf(sessionId, activeTeamSessions)) { + throw new Error("Sub-agents cannot purge archived teams") + } +} diff --git a/src/tools/team-cleanup.ts b/src/tools/team-cleanup.ts index 2c4b9c3..e765c89 100644 --- a/src/tools/team-cleanup.ts +++ b/src/tools/team-cleanup.ts @@ -1,11 +1,394 @@ import type { ToolDeps } from "../types" -import { requireLead, checkWorktreeDirty } from "./shared" +import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared" import type { IsDirtyFn } from "./shared" import { spawnFailures } from "./team-spawn" import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper" import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper" import { log } from "../log" +type PurgeApprovalFn = (preview: string) => Promise +type ListBranchesFn = (teamName: string, cwd: string) => Promise +type BranchExistsFn = (branch: string, cwd: string) => Promise + +interface PurgeTarget { + id: string + name: string + time_updated: number +} + +interface PurgeStats extends PurgeTarget { + members: number + tasks: number + messages: number + branches: number + staleResources: number + staleBranches: number +} + +interface PurgeMemberResource { + team_id: string + team_name: string + member_name: string + worktree_dir: string | null + workspace_id: string | null + worktree_branch: string | null +} + +async function listPreservedBranches(teamName: string, cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" }) + const out = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exit = await proc.exited + if (exit !== 0) throw new Error(stderr.trim() || `git branch exited with code ${exit}`) + return out.split("\n").map(branch => branch.trim()).filter(Boolean) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes("not a git repository")) return [] + throw new Error(`Failed to list preserved branches for ${teamName}: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function branchExists(branch: string, cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" }) + const out = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exit = await proc.exited + if (exit !== 0) { + if (stderr.includes("not a git repository")) return false + throw new Error(stderr.trim() || `git branch exited with code ${exit}`) + } + return out.split("\n").map(item => item.trim()).includes(branch) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes("not a git repository")) return false + throw new Error(`Failed to check stale Ensemble branch ${branch}: ${message}`) + } +} + +function normalizeBranchName(branch: string): string { + return branch.trim().replace(/^\*\s*/, "") +} + +function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] { + if (purge.length === 0) throw new Error("Pass at least one archived team name to purge, or ['*'] for all archived teams.") + if (purge.includes("*") && purge.length !== 1) { + throw new Error("Wildcard purge cannot be combined with explicit team names. Use purge: ['*'] by itself.") + } + + if (purge.includes("*")) { + return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC") + .all() as PurgeTarget[] + } + + const uniqueNames = [...new Set(purge)] + const rows = uniqueNames.map(name => ({ + name, + team: deps.db.query("SELECT id, name, status, time_updated FROM team WHERE name = ?").get(name) as { id: string; name: string; status: string; time_updated: number } | null, + })) + + const missing = rows.filter(row => row.team === null).map(row => row.name) + if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`) + + const active = rows.filter(row => row.team?.status === "active").map(row => row.name) + if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`) + + return rows + .map(row => row.team!) + .sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name)) +} + +function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void { + const transaction = deps.db.transaction((teams: PurgeTarget[]) => { + teams.forEach(team => { + const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(team.id) as { status: string } | null + if (!row) throw new Error(`Team not found: ${team.name}`) + if (row.status === "active") throw new Error(`Cannot purge active team: ${team.name}`) + }) + validatePurgeResources(deps, teams) + teams.forEach(team => { + deps.db.run("DELETE FROM team WHERE id = ? AND status = 'archived'", [team.id]) + }) + }) + transaction(targets) + targets.forEach(team => { + deps.registry.unregisterTeam(team.id) + spawnFailures.delete(team.id) + }) +} + +function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] { + return targets.flatMap(target => deps.db.query( + `SELECT t.name as team_name, + tm.team_id, + tm.name as member_name, + tm.worktree_dir, + tm.workspace_id, + tm.worktree_branch + FROM team_member tm + JOIN team t ON tm.team_id = t.id + WHERE tm.team_id = ?` + ).all(target.id) as PurgeMemberResource[]) +} + +function preservedBranchPrefix(resource: PurgeMemberResource): string { + return `ensemble/preserved/${resource.team_name}/` +} + +function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] { + return [ + `ensemble-${resource.team_name}-${resource.member_name}`, + `opencode/ensemble-${resource.team_name}-${resource.member_name}`, + ] +} + +function isPreservedBranch(resource: PurgeMemberResource): boolean { + return resource.worktree_branch !== null && resource.worktree_branch.startsWith(preservedBranchPrefix(resource)) +} + +function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean { + return resource.worktree_branch !== null && staleEnsembleBranchNames(resource).includes(resource.worktree_branch) +} + +function validatePurgeResources(deps: ToolDeps, targets: PurgeTarget[]): void { + const resources = getPurgeMemberResources(deps, targets) + const nonPreserved = resources.filter(resource => + resource.worktree_branch !== null && !isPreservedBranch(resource) && !isStaleEnsembleBranch(resource) + ) + if (nonPreserved.length > 0) { + const details = nonPreserved.map(resource => `${resource.team_name}/${resource.member_name} (${resource.worktree_branch})`).join(", ") + throw new Error(`Cannot purge archived teams: ${details} has a non-preserved worktree branch or a branch outside its preserved namespace.`) + } + + const activeResourceRefs = resources.flatMap(resource => { + const worktreeRefs = resource.worktree_dir + ? deps.db.query( + `SELECT t.name as team_name, tm.name as member_name + FROM team_member tm + JOIN team t ON tm.team_id = t.id + WHERE t.status = 'active' AND tm.worktree_dir = ?` + ).all(resource.worktree_dir) as Array<{ team_name: string; member_name: string }> + : [] + const workspaceRefs = resource.workspace_id + ? deps.db.query( + `SELECT t.name as team_name, tm.name as member_name + FROM team_member tm + JOIN team t ON tm.team_id = t.id + WHERE t.status = 'active' AND tm.workspace_id = ?` + ).all(resource.workspace_id) as Array<{ team_name: string; member_name: string }> + : [] + return [ + ...worktreeRefs.map(ref => `${resource.team_name}/${resource.member_name} worktree is also referenced by active team ${ref.team_name}/${ref.member_name}`), + ...workspaceRefs.map(ref => `${resource.team_name}/${resource.member_name} workspace is also referenced by active team ${ref.team_name}/${ref.member_name}`), + ] + }) + if (activeResourceRefs.length > 0) { + throw new Error(`Cannot purge archived teams: ${[...new Set(activeResourceRefs)].join(", ")}.`) + } +} + +function collectStaleEnsembleBranches(deps: ToolDeps, targets: PurgeTarget[]): string[] { + return [...new Set( + getPurgeMemberResources(deps, targets) + .filter(isStaleEnsembleBranch) + .map(resource => resource.worktree_branch!) + )] +} + +function countStaleResourceRefs(deps: ToolDeps, target: PurgeTarget): number { + return getPurgeMemberResources(deps, [target]).reduce( + (count, resource) => count + (resource.worktree_dir ? 1 : 0) + (resource.workspace_id ? 1 : 0), + 0, + ) +} + +function countStaleBranchRefs(deps: ToolDeps, target: PurgeTarget): number { + return collectStaleEnsembleBranches(deps, [target]).length +} + +async function existingWorktreeDirs(deps: ToolDeps, resources: PurgeMemberResource[]): Promise> { + if (!resources.some(resource => resource.worktree_dir)) return new Set() + try { + const result = await deps.client.worktree.list() + return new Set((result.data ?? []).map(worktree => worktree.directory)) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Cannot purge archived teams: failed to list worktrees before stale resource cleanup: ${message}`) + } +} + +async function existingWorkspaceIds(deps: ToolDeps, resources: PurgeMemberResource[]): Promise> { + if (!resources.some(resource => resource.workspace_id)) return new Set() + try { + const result = await deps.client.workspace.list() + return new Set((result.data ?? []).map(workspace => workspace.id)) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Cannot purge archived teams: failed to list workspaces before stale resource cleanup: ${message}`) + } +} + +async function cleanupStalePurgeResources(deps: ToolDeps, targets: PurgeTarget[], isDirty: IsDirtyFn): Promise { + const resources = getPurgeMemberResources(deps, targets).filter(resource => resource.worktree_dir || resource.workspace_id) + if (resources.length === 0) return + + const worktreeDirs = await existingWorktreeDirs(deps, resources) + const workspaceIds = await existingWorkspaceIds(deps, resources) + + for (const resource of resources) { + if (resource.workspace_id) { + if (workspaceIds.has(resource.workspace_id)) { + try { + await deps.client.workspace.remove({ id: resource.workspace_id }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to remove stale workspace for ${resource.team_name}/${resource.member_name}: ${message}`) + } + } + deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name]) + } + + if (resource.worktree_dir) { + if (worktreeDirs.has(resource.worktree_dir)) { + let dirty: boolean + try { + dirty = await isDirty(resource.worktree_dir) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Cannot purge archived teams: failed to check archived worktree for uncommitted changes at ${resource.worktree_dir}: ${message}`) + } + if (dirty) { + throw new Error(`Cannot purge archived teams: ${resource.team_name}/${resource.member_name} has uncommitted changes in archived worktree ${resource.worktree_dir}.`) + } + try { + await deps.client.worktree.remove({ worktreeRemoveInput: { directory: resource.worktree_dir } }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to remove stale worktree for ${resource.team_name}/${resource.member_name}: ${message}`) + } + } + deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name]) + } + } +} + +async function deleteStaleEnsembleBranches( + branches: string[], + cwd: string, + delBranch: DeleteBranchFn, + exists: BranchExistsFn, +): Promise { + for (const branch of branches) { + if (!await exists(branch, cwd)) continue + const ok = await delBranch(branch, cwd) + if (!ok) throw new Error(`Failed to delete stale Ensemble branch: ${branch}`) + } +} + +function validatePurgeTargetsStillArchived(deps: ToolDeps, targets: PurgeTarget[]): void { + const missing: string[] = [] + const active: string[] = [] + targets.forEach(target => { + const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(target.id) as { status: string } | null + if (!row) missing.push(target.name) + else if (row.status === "active") active.push(target.name) + }) + + if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`) + if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`) +} + +async function collectPreservedBranches( + targets: PurgeTarget[], + cwd: string, + listBranches: ListBranchesFn, +): Promise> { + const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => { + const prefix = `ensemble/preserved/${target.name}/` + let listed: string[] + try { + listed = await listBranches(target.name, cwd) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes("not a git repository")) listed = [] + else throw new Error(`Failed to list preserved branches for ${target.name}: ${message}`) + } + const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix)) + return [target.id, [...new Set(branches)]] + })) + return new Map(entries) +} + +async function deletePreservedBranches( + branchesByTeam: Map, + cwd: string, + delBranch: DeleteBranchFn, +): Promise { + const branches = [...branchesByTeam.values()].flat() + for (const branch of branches) { + const ok = await delBranch(branch, cwd) + if (!ok) throw new Error(`Failed to delete preserved branch: ${branch}`) + } +} + +function formatCount(count: number, noun: string, plural = `${noun}s`): string { + return `${count} ${count === 1 ? noun : plural}` +} + +function buildPurgePreview(deps: ToolDeps, targets: PurgeTarget[], branchesByTeam: Map): string { + const rows = targets.map(target => { + const members = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(target.id) as { c: number }).c + const tasks = (deps.db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ?").get(target.id) as { c: number }).c + const messages = (deps.db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ?").get(target.id) as { c: number }).c + const branches = branchesByTeam.get(target.id)?.length ?? 0 + const staleResources = countStaleResourceRefs(deps, target) + const staleBranches = countStaleBranchRefs(deps, target) + return { ...target, members, tasks, messages, branches, staleResources, staleBranches } + }) satisfies PurgeStats[] + const totals = rows.reduce( + (acc, row) => ({ + members: acc.members + row.members, + tasks: acc.tasks + row.tasks, + messages: acc.messages + row.messages, + branches: acc.branches + row.branches, + staleResources: acc.staleResources + row.staleResources, + staleBranches: acc.staleBranches + row.staleBranches, + }), + { members: 0, tasks: 0, messages: 0, branches: 0, staleResources: 0, staleBranches: 0 } + ) + const details = rows.slice(0, 10).map(row => + `- ${row.name}: ${formatCount(row.members, "member")}, ${formatCount(row.tasks, "task")}, ${formatCount(row.messages, "message")}, ${formatCount(row.branches, "preserved branch", "preserved branches")}, ${formatCount(row.staleResources, "stale resource")}, ${formatCount(row.staleBranches, "stale branch", "stale branches")}` + ) + const hidden = rows.length > 10 ? [`...and ${rows.length - 10} more archived team${rows.length - 10 === 1 ? "" : "s"}`] : [] + + return [ + "Permanently delete archived teams?", + "This will delete archived team records and cascade-delete their members, tasks, and messages.", + "", + ...details, + ...hidden, + "", + `Total: ${formatCount(rows.length, "team")}, ${formatCount(totals.members, "member")}, ${formatCount(totals.tasks, "task")}, ${formatCount(totals.messages, "message")}, ${formatCount(totals.branches, "preserved branch", "preserved branches")}, ${formatCount(totals.staleResources, "stale resource")}, ${formatCount(totals.staleBranches, "stale branch", "stale branches")}`, + ].join("\n") +} + +function buildPurgeConfirmationInstructions(preview: string, confirmToken: string): string { + const approvalLabel = `Approve purge ${confirmToken.slice(0, 8)}` + const denialLabel = `Deny purge ${confirmToken.slice(0, 8)}` + return [ + "Purge preview only — no teams were deleted.", + "", + preview, + "", + "Use the question tool to ask the user whether to permanently delete these archived teams.", + `The approval option label must be exactly: ${approvalLabel}`, + `The denial option label must be exactly: ${denialLabel}`, + `Confirmation token: ${confirmToken}`, + `Only if the user selects "${approvalLabel}", call team_cleanup again with the same purge value, confirm_purge: true, and confirm_token set to this token.`, + ].join("\n") +} + /** * Execute the team_cleanup tool. Archives the team and cleans up resources. * Acts as a safety net: merges any remaining unmerged preserved branches @@ -13,14 +396,51 @@ import { log } from "../log" */ export async function executeTeamCleanup( deps: ToolDeps, - args: { force: boolean; acknowledge_uncommitted?: boolean }, + args: { force: boolean; acknowledge_uncommitted?: boolean; purge?: string[]; confirm_purge?: boolean; confirm_token?: string }, sessionId: string, isDirty: IsDirtyFn = checkWorktreeDirty, merge: MergeBranchFn = mergeBranch, delBranch: DeleteBranchFn = deleteBranch, mergeOnCleanup = true, overlapCheck: OverlapCheckFn = getOverlappingFiles, + _approvePurge?: PurgeApprovalFn, + _listBranches?: ListBranchesFn, + _branchExists?: BranchExistsFn, ): Promise { + if (args.purge && args.purge.length > 0) { + requireCanPurgeArchivedTeams(deps, sessionId) + const targets = resolvePurgeTargets(deps, args.purge) + if (targets.length === 0) return "No archived teams to purge." + + validatePurgeTargetsStillArchived(deps, targets) + validatePurgeResources(deps, targets) + const branchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches) + const preview = buildPurgePreview(deps, targets, branchesByTeam) + if (!args.confirm_purge) { + const confirmToken = deps.purgeApprovals.create(sessionId, targets.map(target => target.id)) + return buildPurgeConfirmationInstructions(preview, confirmToken) + } + + if (!args.confirm_token) { + throw new Error("A purge confirmation token is required. First call team_cleanup without confirm_purge, then use the question tool before confirming.") + } + deps.purgeApprovals.consume(sessionId, args.confirm_token, targets.map(target => target.id)) + + validatePurgeTargetsStillArchived(deps, targets) + validatePurgeResources(deps, targets) + await cleanupStalePurgeResources(deps, targets, isDirty) + + validatePurgeResources(deps, targets) + await deleteStaleEnsembleBranches(collectStaleEnsembleBranches(deps, targets), deps.directory, delBranch, _branchExists ?? branchExists) + const finalBranchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches) + await deletePreservedBranches(finalBranchesByTeam, deps.directory, delBranch) + + deleteArchivedTeams(deps, targets) + + const noun = targets.length === 1 ? "archived team" : "archived teams" + return `Permanently deleted ${targets.length} ${noun}: ${targets.map(target => target.name).join(", ")}.` + } + const teamInfo = requireLead(deps, sessionId) const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?") diff --git a/src/types.ts b/src/types.ts index 3474e92..5cce1ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import type { Database } from "bun:sqlite" -import type { MemberRegistry, DescendantTracker } from "./state" +import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state" import type { EnsembleConfig } from "./config" /** @@ -10,6 +10,7 @@ export interface ToolDeps { db: Database registry: MemberRegistry tracker: DescendantTracker + purgeApprovals: PendingPurgeApprovals /** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */ client: PluginClient /** The project root directory — used for reading AGENTS.md and other project files. */ diff --git a/test/helpers.ts b/test/helpers.ts index 85f0eb3..75a8f12 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite" import { applyMigrations } from "../src/schema" -import { MemberRegistry, DescendantTracker } from "../src/state" +import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "../src/state" import type { ToolDeps, PluginClient } from "../src/types" import { DEFAULT_CONFIG } from "../src/config" @@ -89,6 +89,7 @@ export function setupDeps(db?: Database): ToolDeps & { client: ReturnType { test("uses the bumped plugin version in install snippets", () => { const readme = readFileSync(readmePath, "utf8"); - expect(readme).toContain("@hueyexe/opencode-ensemble@0.13.3"); - expect(readme).not.toContain("@hueyexe/opencode-ensemble@0.13.2"); + expect(readme).toContain("@hueyexe/opencode-ensemble@0.14.0"); + expect(readme).not.toContain("@hueyexe/opencode-ensemble@0.13.3"); }); }); diff --git a/test/state.test.ts b/test/state.test.ts index a4ed50d..e598fa6 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach } from "bun:test" -import { MemberRegistry, DescendantTracker } from "../src/state" +import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "../src/state" describe("MemberRegistry", () => { let registry: MemberRegistry @@ -111,3 +111,91 @@ describe("DescendantTracker", () => { expect(tracker.getParent("child")).toBeUndefined() }) }) + +describe("PendingPurgeApprovals", () => { + function approvalQuestionArgs(label: string) { + return { + questions: [{ + question: "Delete archived team?", + header: "Confirm Purge", + options: [ + { label, description: "Delete the archived team." }, + { label: label.replace("Approve", "Deny"), description: "Keep the archived team." }, + ], + multiple: false, + }], + } + } + + test("allows consuming a matching token after the same session selects the exact approval answer", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + const label = approvals.approvalLabel(token) + + approvals.recordQuestionAnswer("lead-sess", `User has answered your questions: "Delete?"="${label}".`, approvalQuestionArgs(label)) + + expect(() => approvals.consume("lead-sess", token, ["old-team"])).not.toThrow() + }) + + test("rejects consuming a token before an exact approval answer", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + + expect(() => approvals.consume("lead-sess", token, ["old-team"])).toThrow("approved") + }) + + test("rejects a denial answer even after the question tool runs", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + + approvals.recordQuestionAnswer("lead-sess", 'User has answered your questions: "Delete?"="Deny purge".', approvalQuestionArgs(approvals.approvalLabel(token))) + + expect(() => approvals.consume("lead-sess", token, ["old-team"])).toThrow("approved") + }) + + test("rejects approval labels that appear in question text but not as the selected answer", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + const label = approvals.approvalLabel(token) + + approvals.recordQuestionAnswer("lead-sess", `User has answered your questions: "Select ${label} to delete."="Deny purge".`, approvalQuestionArgs(label)) + + expect(() => approvals.consume("lead-sess", token, ["old-team"])).toThrow("approved") + }) + + test("rejects approval answers from questions without the exact denial option", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + const label = approvals.approvalLabel(token) + const malformedQuestion = { + questions: [{ + question: "Delete archived team?", + header: "Confirm Purge", + options: [{ label, description: "Only approval is available." }], + multiple: false, + }], + } + + approvals.recordQuestionAnswer("lead-sess", `User has answered your questions: "Delete?"="${label}".`, malformedQuestion) + + expect(() => approvals.consume("lead-sess", token, ["old-team"])).toThrow("approved") + }) + + test("rejects tokens from a different session", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + const label = approvals.approvalLabel(token) + approvals.recordQuestionAnswer("lead-sess", `User has answered your questions: "Delete?"="${label}".`, approvalQuestionArgs(label)) + + expect(() => approvals.consume("other-sess", token, ["old-team"])).toThrow("confirmation token") + }) + + test("rejects tokens for a different purge value", () => { + const approvals = new PendingPurgeApprovals() + const token = approvals.create("lead-sess", ["old-team"]) + const label = approvals.approvalLabel(token) + approvals.recordQuestionAnswer("lead-sess", `User has answered your questions: "Delete?"="${label}".`, approvalQuestionArgs(label)) + + expect(() => approvals.consume("lead-sess", token, ["other-team"])).toThrow("confirmation token") + }) +}) diff --git a/test/system-prompt.test.ts b/test/system-prompt.test.ts index 9664861..7aca4e1 100644 --- a/test/system-prompt.test.ts +++ b/test/system-prompt.test.ts @@ -77,6 +77,22 @@ describe("buildLeadSystemPrompt", () => { expect(result).toMatch(/worktree contention/i) }) + test("documents archived team purge through team_cleanup", () => { + const db = setupDb() + insertTeam(db, "t5", "seq-team", "lead-sess") + + const result = buildLeadSystemPrompt(db, "t5") + + expect(result).toContain("team_cleanup") + expect(result).toContain("purge") + expect(result).toContain('["*"]') + expect(result).toContain("human approval") + expect(result).toContain("question tool") + expect(result).toContain("exact approval and denial option labels") + expect(result).toContain("confirm_purge: true") + expect(result).toContain("confirm_token") + }) + test("delivers pending messages inline in system prompt and marks them delivered", () => { const db = setupDb() insertTeam(db, "t6", "msg-team", "lead-sess") diff --git a/test/tools/shared.test.ts b/test/tools/shared.test.ts index 4260244..8afc704 100644 --- a/test/tools/shared.test.ts +++ b/test/tools/shared.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach } from "bun:test" import { setupDeps, insertTeam, insertMember } from "../helpers" -import { requireLead, requireTeamMember } from "../../src/tools/shared" +import { requireLead, requireTeamMember, requireCanPurgeArchivedTeams } from "../../src/tools/shared" describe("requireLead", () => { let deps: ReturnType @@ -41,3 +41,51 @@ describe("requireTeamMember", () => { expect(() => requireTeamMember(deps, "random-sess")).toThrow("not in a team") }) }) + +describe("requireCanPurgeArchivedTeams", () => { + let deps: ReturnType + + beforeEach(() => { + deps = setupDeps() + }) + + test("allows a main session that is not part of an active team", () => { + expect(() => requireCanPurgeArchivedTeams(deps, "main-sess")).not.toThrow() + }) + + test("allows the lead of an active team", () => { + insertTeam(deps.db, "t1", "my-team", "lead-sess") + + expect(() => requireCanPurgeArchivedTeams(deps, "lead-sess")).not.toThrow() + }) + + test("rejects an active team member", () => { + insertTeam(deps.db, "t1", "my-team", "lead-sess") + insertMember(deps.db, "t1", "alice", "sess-alice") + deps.registry.register("t1", "alice", "sess-alice") + + expect(() => requireCanPurgeArchivedTeams(deps, "sess-alice")).toThrow("Team members cannot purge archived teams") + }) + + test("rejects descendants of active team sessions", () => { + insertTeam(deps.db, "t1", "my-team", "lead-sess") + insertMember(deps.db, "t1", "alice", "sess-alice") + deps.registry.register("t1", "alice", "sess-alice") + deps.tracker.track("child-sess", "sess-alice") + + expect(() => requireCanPurgeArchivedTeams(deps, "child-sess")).toThrow("Sub-agents cannot purge archived teams") + }) + + test("rejects descendants of main sessions", () => { + deps.tracker.track("child-sess", "main-sess") + + expect(() => requireCanPurgeArchivedTeams(deps, "child-sess")).toThrow("Sub-agents cannot purge archived teams") + }) + + test("rejects descendants even if they lead their own active team", () => { + deps.tracker.track("child-sess", "main-sess") + insertTeam(deps.db, "t1", "child-team", "child-sess") + + expect(() => requireCanPurgeArchivedTeams(deps, "child-sess")).toThrow("Sub-agents cannot purge archived teams") + }) +}) diff --git a/test/tools/team-lifecycle.test.ts b/test/tools/team-lifecycle.test.ts index 3e98985..c14082d 100644 --- a/test/tools/team-lifecycle.test.ts +++ b/test/tools/team-lifecycle.test.ts @@ -7,9 +7,53 @@ import type { MergeBranchFn, DeleteBranchFn } from "../../src/tools/merge-helper /** Noop merge fn for tests that don't need real git. */ const noopMerge: MergeBranchFn = async () => ({ ok: true }) const noopDelete: DeleteBranchFn = async () => true +const noopListBranches = async () => [] /** Noop preserve fn for shutdown tests. */ const noopPreserve = async () => true +function insertTask(db: ReturnType["db"], teamId: string, id: string) { + db.run( + "INSERT INTO team_task (id, team_id, content, status, priority, time_created, time_updated) VALUES (?, ?, 'task', 'pending', 'medium', ?, ?)", + [id, teamId, Date.now(), Date.now()] + ) +} + +function insertMessage(db: ReturnType["db"], teamId: string, id: string) { + db.run( + "INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created) VALUES (?, ?, 'alice', 'lead', 'done', 1, ?)", + [id, teamId, Date.now()] + ) +} + +function extractConfirmToken(preview: string): string { + const match = preview.match(/Confirmation token: (\S+)/) + if (!match?.[1]) throw new Error("Expected purge preview to include a confirmation token") + return match[1] +} + +function purgeQuestionArgs(approvalLabel: string, denialLabel: string) { + return { + questions: [{ + question: "Delete archived teams?", + header: "Confirm Purge", + options: [ + { label: approvalLabel, description: "Delete the archived teams." }, + { label: denialLabel, description: "Keep the archived teams." }, + ], + multiple: false, + }], + } +} + +async function preparePurgeConfirmation(deps: ReturnType, purge: string[], sessionId = "main-sess"): Promise { + const preview = await executeTeamCleanup(deps, { force: false, purge }, sessionId, undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches) + const token = extractConfirmToken(preview) + const approvalLabel = deps.purgeApprovals.approvalLabel(token) + const denialLabel = deps.purgeApprovals.denialLabel(token) + deps.purgeApprovals.recordQuestionAnswer(sessionId, `User has answered your questions: "Delete?"="${approvalLabel}".`, purgeQuestionArgs(approvalLabel, denialLabel)) + return token +} + describe("team_shutdown", () => { let deps: ReturnType @@ -247,6 +291,14 @@ describe("team_cleanup", () => { expect(result).toContain("cleaned up") }) + test("treats empty purge array as normal cleanup", async () => { + const result = await executeTeamCleanup(deps, { force: false, purge: [] }, "lead-sess", undefined, noopMerge, noopDelete, false) + expect(result).toContain("cleaned up") + + const team = deps.db.query("SELECT status FROM team WHERE id = ?").get("t1") as Record + expect(team.status).toBe("archived") + }) + test("treats shutdown_requested members as inactive (cleanup succeeds without force)", async () => { insertMember(deps.db, "t1", "alice", "sess-alice", "shutdown_requested", "idle") deps.registry.register("t1", "alice", "sess-alice") @@ -444,4 +496,485 @@ describe("team_cleanup", () => { const removeCalls = deps.client.calls.filter(c => c.method === "worktree.remove") expect(removeCalls).toHaveLength(1) }) + + // --- Archived team purge tests --- + + test("purge first call previews without deleting even when approval callback is available", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + insertTask(deps.db, "old-1", "task-old-1") + insertMessage(deps.db, "old-1", "msg-old-1") + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Purge preview") + expect(result).toContain("Use the question tool") + expect(result).toContain("confirm_token") + expect(result).toContain("Approve purge") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("confirmed purge rejects without a confirmation token even when approval callback is available", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches)) + .rejects.toThrow("confirmation token") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("confirmed purge rejects if the question tool was not used after preview", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const preview = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches) + const token = extractConfirmToken(preview) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches)) + .rejects.toThrow("approved") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("confirmed purge rejects if the user selected the denial answer", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const preview = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches) + const token = extractConfirmToken(preview) + + deps.purgeApprovals.recordQuestionAnswer("main-sess", `User has answered your questions: "Delete?"="${deps.purgeApprovals.denialLabel(token)}".`, purgeQuestionArgs(deps.purgeApprovals.approvalLabel(token), deps.purgeApprovals.denialLabel(token))) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches)) + .rejects.toThrow("approved") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("confirmed purge deletes an archived team and cascades child records", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + insertTask(deps.db, "old-1", "task-old-1") + insertMessage(deps.db, "old-1", "msg-old-1") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Permanently deleted 1 archived team") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).toBeNull() + expect(deps.db.query("SELECT team_id FROM team_member WHERE team_id = 'old-1'").all()).toHaveLength(0) + expect(deps.db.query("SELECT team_id FROM team_task WHERE team_id = 'old-1'").all()).toHaveLength(0) + expect(deps.db.query("SELECT team_id FROM team_message WHERE team_id = 'old-1'").all()).toHaveLength(0) + }) + + test("purge wildcard deletes all archived teams and leaves active teams", async () => { + insertTeam(deps.db, "old-1", "old-one", "old-lead-1", "archived") + insertTeam(deps.db, "old-2", "old-two", "old-lead-2", "archived") + const token = await preparePurgeConfirmation(deps, ["*"], "lead-sess") + + const result = await executeTeamCleanup(deps, { force: false, purge: ["*"], confirm_purge: true, confirm_token: token }, "lead-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Permanently deleted 2 archived teams") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).toBeNull() + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-2'").get()).toBeNull() + expect(deps.db.query("SELECT id FROM team WHERE id = 't1'").get()).not.toBeNull() + }) + + test("purge wildcard rejects if archived targets changed after preview", async () => { + insertTeam(deps.db, "old-1", "old-one", "old-lead-1", "archived") + const token = await preparePurgeConfirmation(deps, ["*"], "lead-sess") + insertTeam(deps.db, "old-2", "old-two", "old-lead-2", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["*"], confirm_purge: true, confirm_token: token }, "lead-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches)) + .rejects.toThrow("confirmation token") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-2'").get()).not.toBeNull() + }) + + test("purge rejects wildcard mixed with explicit team names", async () => { + insertTeam(deps.db, "old-1", "old-one", "old-lead-1", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["*", "my-team"] }, "lead-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {})) + .rejects.toThrow("Wildcard purge cannot be combined") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + expect(deps.db.query("SELECT id FROM team WHERE id = 't1'").get()).not.toBeNull() + }) + + test("purge rejects active teams and leaves archived targets untouched", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team", "my-team"] }, "main-sess", undefined, noopMerge, noopDelete, false)) + .rejects.toThrow("Cannot purge active team") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + const active = deps.db.query("SELECT status FROM team WHERE id = 't1'").get() as { status: string } + expect(active.status).toBe("active") + }) + + test("purge rejects missing teams and leaves valid archived targets untouched", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team", "missing-team"] }, "main-sess", undefined, noopMerge, noopDelete, false)) + .rejects.toThrow("Team not found") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge rejects active team members", async () => { + insertMember(deps.db, "t1", "alice", "sess-alice", "ready", "idle") + deps.registry.register("t1", "alice", "sess-alice") + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "sess-alice", undefined, noopMerge, noopDelete, false)) + .rejects.toThrow("Team members cannot purge archived teams") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge without confirmation returns preview and leaves archived team intact", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Purge preview") + expect(result).toContain("Use the question tool") + expect(result).toContain("confirm_token") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge denial answer deletes nothing", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const preview = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches) + const token = extractConfirmToken(preview) + deps.purgeApprovals.recordQuestionAnswer("main-sess", `User has answered your questions: "Delete?"="${deps.purgeApprovals.denialLabel(token)}".`, purgeQuestionArgs(deps.purgeApprovals.approvalLabel(token), deps.purgeApprovals.denialLabel(token))) + + await expect(executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + undefined, + noopListBranches + )).rejects.toThrow("approved") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge preview includes destructive action details", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + insertTask(deps.db, "old-1", "task-old-1") + insertMessage(deps.db, "old-1", "msg-old-1") + + const preview = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches) + + expect(preview).toContain("Permanently delete archived teams") + expect(preview).toContain("old-team") + expect(preview).toContain("1 member") + expect(preview).toContain("1 task") + expect(preview).toContain("1 message") + }) + + test("purge preview pluralizes branch counts correctly", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + + const preview = await executeTeamCleanup( + deps, + { force: false, purge: ["old-team"] }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + undefined, + async () => ["ensemble/preserved/old-team/alice", "ensemble/preserved/old-team/bob"], + ) + + expect(preview).toContain("2 preserved branches") + expect(preview).toContain("0 stale branches") + expect(preview).not.toContain("branchs") + }) + + test("purge blocks if preserved branch listing fails", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await expect(executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + async () => {}, + async () => { throw new Error("git failed") } + )).rejects.toThrow("Failed to list preserved branches") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge treats non-git directories as having no preserved branches", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + const result = await executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + async () => {}, + async () => { throw new Error("fatal: not a git repository (or any of the parent directories): .git") } + ) + + expect(result).toContain("Permanently deleted 1 archived team") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).toBeNull() + }) + + test("purge revalidates active status after approval", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + deps.db.run("UPDATE team SET status = 'active' WHERE id = 'old-1'") + + await expect(executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + undefined, + noopListBranches + )).rejects.toThrow("Cannot purge active team") + + const team = deps.db.query("SELECT status FROM team WHERE id = 'old-1'").get() as { status: string } + expect(team.status).toBe("active") + }) + + test("purge cleans stale worktree resources added after approval", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + deps.db.run("UPDATE team_member SET worktree_dir = ? WHERE team_id = 'old-1' AND name = 'alice'", ["/tmp/wt-alice"]) + deps.client.worktree.list = async () => { + deps.client.calls.push({ method: "worktree.list", args: [] }) + return { data: [{ name: "ensemble-old-team-alice", branch: "ensemble-old-team-alice", directory: "/tmp/wt-alice" }] } + } + + const result = await executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + noopDelete, + false, + undefined, + undefined, + noopListBranches + ) + + expect(result).toContain("Permanently deleted 1 archived team") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).toBeNull() + expect(deps.client.calls.filter(c => c.method === "worktree.remove")).toHaveLength(1) + }) + + test("purge previews teams that still reference a stale worktree directory", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_dir = ? WHERE team_id = 'old-1' AND name = 'alice'", ["/tmp/wt-alice"]) + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Purge preview") + expect(result).toContain("1 stale resource") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge previews teams that still reference a stale workspace", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET workspace_id = ? WHERE team_id = 'old-1' AND name = 'alice'", ["ws-alice"]) + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Purge preview") + expect(result).toContain("1 stale resource") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge aborts before DB deletion if stale worktree removal fails", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_dir = ? WHERE team_id = 'old-1' AND name = 'alice'", ["/tmp/wt-alice"]) + deps.client.worktree.list = async () => ({ data: [{ name: "ensemble-old-team-alice", branch: "ensemble-old-team-alice", directory: "/tmp/wt-alice" }] }) + deps.client.worktree.remove = async () => { throw new Error("remove failed") } + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches)) + .rejects.toThrow("Failed to remove stale worktree") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge blocks existing stale worktrees with uncommitted changes", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_dir = ? WHERE team_id = 'old-1' AND name = 'alice'", ["/tmp/wt-alice"]) + deps.client.worktree.list = async () => ({ data: [{ name: "ensemble-old-team-alice", branch: "ensemble-old-team-alice", directory: "/tmp/wt-alice" }] }) + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, "main-sess", async () => true, noopMerge, noopDelete, false, undefined, undefined, noopListBranches)) + .rejects.toThrow("uncommitted changes") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + expect(deps.client.calls.filter(c => c.method === "worktree.remove")).toHaveLength(0) + }) + + test("purge blocks stale resources that are referenced by active teams", async () => { + insertMember(deps.db, "t1", "active-alice", "sess-active", "ready", "idle") + deps.db.run("UPDATE team_member SET worktree_dir = ?, workspace_id = ? WHERE team_id = 't1' AND name = 'active-alice'", ["/tmp/shared-wt", "ws-shared"]) + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_dir = ?, workspace_id = ? WHERE team_id = 'old-1' AND name = 'alice'", ["/tmp/shared-wt", "ws-shared"]) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, undefined, noopListBranches)) + .rejects.toThrow("referenced by active team") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge blocks non-preserved worktree branches", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = 'old-1' AND name = 'alice'", ["feature/alice"]) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {})) + .rejects.toThrow("non-preserved worktree branch") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge previews stale Ensemble-owned branch references", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = 'old-1' AND name = 'alice'", ["opencode/ensemble-old-team-alice"]) + + const result = await executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {}, noopListBranches) + + expect(result).toContain("Purge preview") + expect(result).toContain("1 stale branch") + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("confirmed purge deletes stale Ensemble-owned branch references", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = 'old-1' AND name = 'alice'", ["opencode/ensemble-old-team-alice"]) + const token = await preparePurgeConfirmation(deps, ["old-team"]) + const deleted: string[] = [] + + const result = await executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + async (branch) => { deleted.push(branch); return true }, + false, + undefined, + undefined, + noopListBranches, + async () => true, + ) + + expect(result).toContain("Permanently deleted 1 archived team") + expect(deleted).toEqual(["opencode/ensemble-old-team-alice"]) + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).toBeNull() + }) + + test("purge aborts before DB deletion if stale Ensemble-owned branch deletion fails", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = 'old-1' AND name = 'alice'", ["opencode/ensemble-old-team-alice"]) + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await expect(executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + async () => false, + false, + undefined, + undefined, + noopListBranches, + async () => true, + )).rejects.toThrow("Failed to delete stale Ensemble branch") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge blocks preserved branch references for a different team namespace", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + insertMember(deps.db, "old-1", "alice", "old-alice", "shutdown", "idle") + deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = 'old-1' AND name = 'alice'", ["ensemble/preserved/other-team/alice"]) + + await expect(executeTeamCleanup(deps, { force: false, purge: ["old-team"] }, "main-sess", undefined, noopMerge, noopDelete, false, undefined, async () => {})) + .rejects.toThrow("outside its preserved namespace") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) + + test("purge deletes only preserved branches for the target team", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const deleted: string[] = [] + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + async (branch) => { deleted.push(branch); return true }, + false, + undefined, + async () => {}, + async () => ["ensemble/preserved/old-team/alice", "feature/unrelated", "ensemble/preserved/other-team/bob"] + ) + + expect(deleted).toEqual(["ensemble/preserved/old-team/alice"]) + }) + + test("purge aborts before DB deletion if preserved branch deletion fails", async () => { + insertTeam(deps.db, "old-1", "old-team", "old-lead", "archived") + const token = await preparePurgeConfirmation(deps, ["old-team"]) + + await expect(executeTeamCleanup( + deps, + { force: false, purge: ["old-team"], confirm_purge: true, confirm_token: token }, + "main-sess", + undefined, + noopMerge, + async () => false, + false, + undefined, + async () => {}, + async () => ["ensemble/preserved/old-team/alice"] + )).rejects.toThrow("Failed to delete preserved branch") + + expect(deps.db.query("SELECT id FROM team WHERE id = 'old-1'").get()).not.toBeNull() + }) })