diff --git a/src/cli/commands/clean-playbooks.ts b/src/cli/commands/clean-playbooks.ts new file mode 100644 index 00000000..941dfd67 --- /dev/null +++ b/src/cli/commands/clean-playbooks.ts @@ -0,0 +1,127 @@ +// Clean playbooks command +// Removes orphaned playbooks (playbooks for sessions that no longer exist) + +import * as fs from 'fs'; +import * as path from 'path'; +import { readSessions, getConfigDirectory } from '../services/storage'; +import { formatError, formatSuccess } from '../output/formatter'; + +interface CleanPlaybooksOptions { + json?: boolean; + dryRun?: boolean; +} + +/** + * Get the playbooks directory path + */ +function getPlaybooksDir(): string { + return path.join(getConfigDirectory(), 'playbooks'); +} + +/** + * Find orphaned playbook files (files for sessions that no longer exist) + */ +function findOrphanedPlaybooks(): Array<{ sessionId: string; filePath: string }> { + const playbooksDir = getPlaybooksDir(); + const orphaned: Array<{ sessionId: string; filePath: string }> = []; + + try { + if (!fs.existsSync(playbooksDir)) { + return orphaned; + } + + const sessions = readSessions(); + const sessionIds = new Set(sessions.map((s) => s.id)); + + const files = fs.readdirSync(playbooksDir); + + for (const file of files) { + if (!file.endsWith('.json')) continue; + + const sessionId = file.replace('.json', ''); + if (!sessionIds.has(sessionId)) { + orphaned.push({ + sessionId, + filePath: path.join(playbooksDir, file), + }); + } + } + + return orphaned; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return orphaned; + } + throw error; + } +} + +export function cleanPlaybooks(options: CleanPlaybooksOptions): void { + try { + const orphaned = findOrphanedPlaybooks(); + + if (orphaned.length === 0) { + if (options.json) { + console.log(JSON.stringify({ removed: [], count: 0 })); + } else { + console.log(formatSuccess('No orphaned playbooks found')); + } + return; + } + + if (options.dryRun) { + if (options.json) { + console.log( + JSON.stringify({ + dryRun: true, + wouldRemove: orphaned.map((o) => ({ + sessionId: o.sessionId, + filePath: o.filePath, + })), + count: orphaned.length, + }) + ); + } else { + console.log(`\nWould remove ${orphaned.length} orphaned playbook file(s):\n`); + for (const o of orphaned) { + console.log(` ${o.sessionId.slice(0, 8)} ${o.filePath}`); + } + console.log('\nRun without --dry-run to actually remove these files.'); + } + return; + } + + // Actually remove the files + const removed: string[] = []; + for (const o of orphaned) { + try { + fs.unlinkSync(o.filePath); + removed.push(o.sessionId); + } catch (error) { + console.error(`Failed to remove ${o.filePath}: ${error}`); + } + } + + if (options.json) { + console.log( + JSON.stringify({ + removed: removed.map((id) => id.slice(0, 8)), + count: removed.length, + }) + ); + } else { + console.log(formatSuccess(`Removed ${removed.length} orphaned playbook file(s)`)); + for (const id of removed) { + console.log(` ${id.slice(0, 8)}`); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.error(JSON.stringify({ error: message })); + } else { + console.error(formatError(`Failed to clean playbooks: ${message}`)); + } + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 972487ea..ba94ff7a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,7 @@ import { listPlaybooks } from './commands/list-playbooks'; import { showPlaybook } from './commands/show-playbook'; import { showAgent } from './commands/show-agent'; import { runPlaybook } from './commands/run-playbook'; +import { cleanPlaybooks } from './commands/clean-playbooks'; // Read version from package.json at runtime function getVersion(): string { @@ -81,4 +82,14 @@ program .option('--wait', 'Wait for agent to become available if busy') .action(runPlaybook); +// Clean command +const clean = program.command('clean').description('Clean up orphaned resources'); + +clean + .command('playbooks') + .description('Remove playbooks for deleted sessions') + .option('--dry-run', 'Show what would be removed without actually removing') + .option('--json', 'Output as JSON (for scripting)') + .action(cleanPlaybooks); + program.parse(); diff --git a/src/main/ipc/handlers/playbooks.ts b/src/main/ipc/handlers/playbooks.ts index 1ed855a7..22959380 100644 --- a/src/main/ipc/handlers/playbooks.ts +++ b/src/main/ipc/handlers/playbooks.ts @@ -216,6 +216,24 @@ export function registerPlaybooksHandlers(deps: PlaybooksHandlerDependencies): v }) ); + // Delete all playbooks for a session (used when session is deleted) + ipcMain.handle( + 'playbooks:deleteAll', + createIpcHandler(handlerOpts('deleteAll'), async (sessionId: string) => { + const filePath = getPlaybooksFilePath(app, sessionId); + try { + await fs.unlink(filePath); + logger.info(`Deleted all playbooks for session ${sessionId}`, LOG_CONTEXT); + } catch (error) { + // File doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + return {}; + }) + ); + // Export a playbook as a ZIP file ipcMain.handle( 'playbooks:export', diff --git a/src/main/preload.ts b/src/main/preload.ts index c29596eb..37dc0c0a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -907,6 +907,8 @@ contextBridge.exposeInMainWorld('maestro', { ) => ipcRenderer.invoke('playbooks:update', sessionId, playbookId, updates), delete: (sessionId: string, playbookId: string) => ipcRenderer.invoke('playbooks:delete', sessionId, playbookId), + deleteAll: (sessionId: string) => + ipcRenderer.invoke('playbooks:deleteAll', sessionId), export: (sessionId: string, playbookId: string, autoRunFolderPath: string) => ipcRenderer.invoke('playbooks:export', sessionId, playbookId, autoRunFolderPath), import: (sessionId: string, autoRunFolderPath: string) => @@ -1796,6 +1798,10 @@ export interface MaestroAPI { success: boolean; error?: string; }>; + deleteAll: (sessionId: string) => Promise<{ + success: boolean; + error?: string; + }>; }; debug: { createPackage: (options?: { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a10f5cbf..58960807 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3541,6 +3541,13 @@ export default function MaestroConsole() { console.error('Failed to kill terminal process:', error); } + // Delete associated playbooks + try { + await window.maestro.playbooks.deleteAll(id); + } catch (error) { + console.error('Failed to delete playbooks:', error); + } + const newSessions = sessions.filter(s => s.id !== id); setSessions(newSessions); // Flush immediately for critical operation (session deletion) diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 6f21de95..344d92d6 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -750,7 +750,7 @@ export function AgentSessionsBrowser({ - {/* Context Window with visual gauge */} + {/* Total Tokens */}