Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .cursor/plans/delete_mission_enhancement_fccda2ee.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
name: Delete Mission Enhancement
overview: Add delete functionality for individual missions (with container cleanup) and a "Delete All Missions" button that wipes ~/.haflow/missions/*.
todos:
- id: backend-docker
content: Add removeByMissionId() to docker.ts
status: completed
- id: backend-store
content: Add deleteAllMissions() to mission-store.ts
status: completed
- id: backend-routes
content: Update DELETE endpoint and add DELETE /api/missions
status: completed
- id: frontend-api
content: Add deleteMission and deleteAllMissions to API client
status: completed
- id: frontend-detail
content: Add delete button with confirmation to MissionDetail
status: completed
- id: frontend-app
content: Add mutations and Delete All Missions button to App.tsx
status: completed
---

# Delete Mission Enhancement

## Overview

Add two delete features: (a) delete individual missions with their associated Docker containers, and (b) delete all missions to reset ~/.haflow/missions/*.

## Implementation

### Phase 1: Backend - Enhanced Delete Endpoints

**File**: [`packages/backend/src/services/docker.ts`](packages/backend/src/services/docker.ts)

- Add `removeByMissionId(missionId: string)` function to find and remove containers with label `haflow.mission_id={missionId}`

**File**: [`packages/backend/src/routes/missions.ts`](packages/backend/src/routes/missions.ts)

- Modify `DELETE /api/missions/:missionId` to also cleanup associated containers before deleting mission directory
- Add `DELETE /api/missions` endpoint to delete ALL missions (wipes ~/.haflow/missions/*)

**File**: [`packages/backend/src/services/mission-store.ts`](packages/backend/src/services/mission-store.ts)

- Add `deleteAllMissions()` function that removes everything in missions directory

### Phase 2: Frontend - API Client

**File**: [`packages/frontend/src/api/client.ts`](packages/frontend/src/api/client.ts)

- Add `deleteMission(missionId: string)` method
- Add `deleteAllMissions()` method

### Phase 3: Frontend - Delete Mission Button

**File**: [`packages/frontend/src/components/MissionDetail.tsx`](packages/frontend/src/components/MissionDetail.tsx)

- Add delete button in the header area (near mission title/status)
- Add confirmation dialog before deletion
- Pass `onDelete` callback prop

**File**: [`packages/frontend/src/App.tsx`](packages/frontend/src/App.tsx)

- Add `deleteMissionMutation` using TanStack Query
- Handle `onDelete` - clear selection after successful delete
- Add `deleteAllMissionsMutation`
- Add "Delete All Missions" button near existing "Cleanup Containers" button with confirmation dialog

## Key Code Changes

### Docker - Find containers by mission:

```typescript
async function removeByMissionId(missionId: string): Promise<number> {
const { stdout } = await execAsync(
`docker ps -aq --filter="label=${LABEL_PREFIX}.mission_id=${missionId}"`
);
const ids = stdout.trim().split('\n').filter(Boolean);
for (const id of ids) await remove(id);
return ids.length;
}
```

### Delete mission route enhancement:

```typescript
// Delete mission AND its containers
await dockerProvider.removeByMissionId(missionId);
await missionStore.deleteMission(missionId);
```

### Delete all missions:

```typescript
async function deleteAllMissions(): Promise<void> {
const { rm, readdir } = await import('fs/promises');
const dir = missionsDir();
const entries = await readdir(dir);
for (const entry of entries) {
await rm(join(dir, entry), { recursive: true, force: true });
}
}
```
76 changes: 75 additions & 1 deletion packages/backend/src/routes/missions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync } from 'fs';
import { CreateMissionRequestSchema, SaveArtifactRequestSchema } from '@haflow/shared';
import { missionStore } from '../services/mission-store.js';
import { missionEngine } from '../services/mission-engine.js';
import { dockerProvider } from '../services/docker.js';
import { getWorkflows } from '../services/workflow.js';
import { sendSuccess, sendError } from '../utils/response.js';
import { config, execAsync, getProjectGitStatus, getFileDiff } from '../utils/config.js';
Expand All @@ -22,6 +23,61 @@ workflowRoutes.get('/', async (_req, res, next) => {
}
});

// GET /api/workflows/templates - Alias for listing workflows
workflowRoutes.get('/templates', async (_req, res, next) => {
try {
const workflows = getWorkflows();
sendSuccess(res, workflows);
} catch (err) {
next(err);
}
});

// POST /api/workflows/execute - Validate and execute a workflow
workflowRoutes.post('/execute', async (req, res, next) => {
try {
const { workflowId, workflow } = req.body;

// Must provide either workflowId or workflow
if (!workflowId && !workflow) {
return sendError(res, 'workflowId or workflow required', 400);
}

let resolvedWorkflow;

if (workflowId) {
// Look up template workflow by ID
const workflows = getWorkflows();
resolvedWorkflow = workflows.find(w => w.workflow_id === workflowId);
if (!resolvedWorkflow) {
return sendError(res, `Workflow not found: ${workflowId}`, 404);
}
} else {
// Use provided dynamic workflow
resolvedWorkflow = workflow;
}

// Validate workflow has at least one step
if (!resolvedWorkflow.steps || resolvedWorkflow.steps.length === 0) {
return sendError(res, 'Workflow must have at least one step', 400);
}

// Validate agent steps have agent type
for (const step of resolvedWorkflow.steps) {
if (step.type === 'agent' && !step.agent) {
return sendError(res, `Step "${step.name}" is type "agent" but missing agent type`, 400);
}
}

sendSuccess(res, {
workflow_id: resolvedWorkflow.workflow_id,
steps_count: resolvedWorkflow.steps.length,
});
} catch (err) {
next(err);
}
});

// GET /api/missions - List all missions
missionRoutes.get('/', async (_req, res, next) => {
try {
Expand Down Expand Up @@ -121,7 +177,21 @@ missionRoutes.post('/:missionId/mark-completed', async (req, res, next) => {
}
});

// DELETE /api/missions/:missionId - Delete mission
// DELETE /api/missions - Delete ALL missions
missionRoutes.delete('/', async (_req, res, next) => {
try {
// First cleanup all haflow containers
await dockerProvider.cleanupOrphaned();

// Then delete all mission directories
const deletedCount = await missionStore.deleteAllMissions();
sendSuccess(res, { deleted: deletedCount, message: `Deleted ${deletedCount} mission(s)` });
} catch (err) {
next(err);
}
});

// DELETE /api/missions/:missionId - Delete mission and its containers
missionRoutes.delete('/:missionId', async (req, res, next) => {
try {
const { missionId } = req.params;
Expand All @@ -131,6 +201,10 @@ missionRoutes.delete('/:missionId', async (req, res, next) => {
return sendError(res, `Mission not found: ${missionId}`, 404);
}

// First cleanup containers associated with this mission
await dockerProvider.removeByMissionId(missionId);

// Then delete the mission directory
await missionStore.deleteMission(missionId);
sendSuccess(res, null);
} catch (err) {
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/services/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ async function cleanupOrphaned(): Promise<void> {
}
}

async function removeByMissionId(missionId: string): Promise<number> {
try {
const { stdout } = await execAsync(
`docker ps -aq --filter="label=${LABEL_PREFIX}.mission_id=${missionId}"`
);
const ids = stdout.trim().split('\n').filter(Boolean);
for (const id of ids) {
await remove(id);
}
return ids.length;
} catch {
// Ignore errors - containers may not exist
return 0;
}
}

// COMPLETE marker for Ralph loop detection
const COMPLETE_MARKER = '<promise>COMPLETE</promise>';

Expand Down Expand Up @@ -485,5 +501,6 @@ export const dockerProvider: SandboxProvider = {
remove,
isAvailable,
cleanupOrphaned,
removeByMissionId,
startClaudeStreaming,
};
16 changes: 16 additions & 0 deletions packages/backend/src/services/mission-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ async function deleteMission(missionId: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}

async function deleteAllMissions(): Promise<number> {
const dir = missionsDir();
if (!existsSync(dir)) {
return 0;
}
const entries = await readdir(dir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory());

const { rm } = await import('fs/promises');
for (const entry of dirs) {
await rm(join(dir, entry.name), { recursive: true, force: true });
}
return dirs.length;
}

// --- Update ---
async function updateMeta(missionId: string, updates: Partial<MissionMeta>): Promise<void> {
const meta = await getMeta(missionId);
Expand Down Expand Up @@ -286,6 +301,7 @@ export const missionStore = {
getDetail,
listMissions,
deleteMission,
deleteAllMissions,
updateMeta,
loadArtifacts,
getArtifact,
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/services/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface SandboxProvider {
*/
cleanupOrphaned(): Promise<void>;

/**
* Remove all containers associated with a specific mission
* Returns the number of containers removed
*/
removeByMissionId(missionId: string): Promise<number>;

/**
* Start Claude sandbox with streaming output
* Returns an async generator that yields StreamEvents
Expand Down
Loading
Loading