Repository navigation
feat(cli): cleanup watch capability — stale file housekeeping (#791) #794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| "@bradygaster/squad-cli": minor | ||
| --- | ||
|
|
||
| Add cleanup watch capability for stale file housekeeping (#791) | ||
|
|
||
| - New `cleanup` capability in the `housekeeping` phase | ||
| - Clears `.squad/.scratch/` (all ephemeral temp files) | ||
| - Archives orchestration-log and session-log entries older than 30 days | ||
| - Warns about stale decision inbox files (>7 days) | ||
| - Configurable: `everyNRounds` (default: 10), `maxAgeDays` (default: 30) | ||
| - 12 new tests |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| /** | ||
| * Cleanup capability — housekeeping for stale temp and log files. | ||
| * | ||
| * Runs in the 'housekeeping' phase. Clears the scratch directory, prunes | ||
| * old orchestration-log and session-log entries, and warns about stale | ||
| * decision inbox files. | ||
| */ | ||
|
|
||
| import path from 'node:path'; | ||
| import { rmSync } from 'node:fs'; | ||
| import { FSStorageProvider } from '@bradygaster/squad-sdk'; | ||
| import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; | ||
|
|
||
| const storage = new FSStorageProvider(); | ||
|
|
||
| /** Default: files older than this many days are pruned. */ | ||
| const DEFAULT_MAX_AGE_DAYS = 30; | ||
| /** Default: run cleanup every N rounds (not every round). */ | ||
| const DEFAULT_EVERY_N_ROUNDS = 10; | ||
| /** Stale inbox warning threshold (days). */ | ||
| const STALE_INBOX_DAYS = 7; | ||
|
|
||
| interface CleanupConfig { | ||
| /** Run cleanup every N rounds (default: 10). */ | ||
| everyNRounds?: number; | ||
| /** Max age in days for log/orchestration files before archival (default: 30). */ | ||
| maxAgeDays?: number; | ||
| } | ||
|
|
||
| function parseConfig(raw: Record<string, unknown>): CleanupConfig { | ||
| return { | ||
| everyNRounds: typeof raw.everyNRounds === 'number' && Number.isFinite(raw.everyNRounds) && raw.everyNRounds > 0 | ||
| ? raw.everyNRounds | ||
| : DEFAULT_EVERY_N_ROUNDS, | ||
| maxAgeDays: typeof raw.maxAgeDays === 'number' && Number.isFinite(raw.maxAgeDays) && raw.maxAgeDays > 0 | ||
| ? raw.maxAgeDays | ||
| : DEFAULT_MAX_AGE_DAYS, | ||
| }; | ||
| } | ||
|
|
||
| function isOlderThan(filename: string, days: number): boolean { | ||
| // Filenames may start with ISO timestamp: 2026-04-01T12-00-00Z-agent.md | ||
| const match = filename.match(/^(\d{4}-\d{2}-\d{2})/); | ||
| if (!match) return false; | ||
| const fileDate = new Date(match[1]!); | ||
| if (isNaN(fileDate.getTime())) return false; | ||
| const cutoffMs = Date.now() - (days * 86400000); | ||
| return fileDate.getTime() < cutoffMs; | ||
| } | ||
|
|
||
| function safeList(dir: string): string[] { | ||
| try { | ||
| if (!storage.existsSync(dir)) return []; | ||
| return storage.listSync?.(dir) ?? []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| function safeDelete(filePath: string): boolean { | ||
| try { | ||
| rmSync(filePath, { recursive: true, force: true }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export class CleanupCapability implements WatchCapability { | ||
| readonly name = 'cleanup'; | ||
| readonly description = 'Remove stale scratch files, prune old logs, warn about stale inbox'; | ||
| readonly configShape = 'object' as const; | ||
| readonly requires: string[] = []; | ||
| readonly phase = 'housekeeping' as const; | ||
|
|
||
| async preflight(context: WatchContext): Promise<PreflightResult> { | ||
| const squadDir = path.join(context.teamRoot, '.squad'); | ||
| if (!storage.existsSync(squadDir)) { | ||
| return { ok: false, reason: '.squad/ directory not found' }; | ||
| } | ||
| return { ok: true }; | ||
| } | ||
|
|
||
| async execute(context: WatchContext): Promise<CapabilityResult> { | ||
| const config = parseConfig(context.config); | ||
| const everyN = config.everyNRounds ?? DEFAULT_EVERY_N_ROUNDS; | ||
|
|
||
| // Only run on every Nth round (or round 1 for immediate feedback) | ||
| if (context.round > 1 && context.round % everyN !== 0) { | ||
| return { success: true, summary: `cleanup: skipped (runs every ${everyN} rounds)` }; | ||
| } | ||
|
|
||
| const squadDir = path.join(context.teamRoot, '.squad'); | ||
| const maxAge = config.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS; | ||
| const actions: string[] = []; | ||
|
|
||
| // 1. Clear .squad/.scratch/ — everything in here is ephemeral | ||
| const scratchDir = path.join(squadDir, '.scratch'); | ||
| const scratchFiles = safeList(scratchDir); | ||
| let scratchDeleted = 0; | ||
| for (const f of scratchFiles) { | ||
| if (safeDelete(path.join(scratchDir, f))) scratchDeleted++; | ||
| } | ||
| if (scratchDeleted > 0) { | ||
| actions.push(`scratch: ${scratchDeleted} files cleared`); | ||
| } | ||
|
Comment on lines
+97
to
+106
|
||
|
|
||
| // 2. Prune old orchestration-log entries (older than maxAge days) | ||
| const orchDir = path.join(squadDir, 'orchestration-log'); | ||
| const orchFiles = safeList(orchDir); | ||
| let orchPruned = 0; | ||
| for (const f of orchFiles) { | ||
| if (isOlderThan(f, maxAge)) { | ||
| if (safeDelete(path.join(orchDir, f))) orchPruned++; | ||
| } | ||
| } | ||
| if (orchPruned > 0) { | ||
| actions.push(`orchestration-log: ${orchPruned} entries pruned (>${maxAge}d)`); | ||
| } | ||
|
|
||
| // 3. Prune old session logs | ||
| const logDir = path.join(squadDir, 'log'); | ||
| const logFiles = safeList(logDir); | ||
| let logPruned = 0; | ||
| for (const f of logFiles) { | ||
| if (isOlderThan(f, maxAge)) { | ||
| if (safeDelete(path.join(logDir, f))) logPruned++; | ||
| } | ||
| } | ||
| if (logPruned > 0) { | ||
| actions.push(`log: ${logPruned} entries pruned (>${maxAge}d)`); | ||
| } | ||
|
|
||
| // 4. Warn about stale decision inbox files | ||
| const inboxDir = path.join(squadDir, 'decisions', 'inbox'); | ||
| const inboxFiles = safeList(inboxDir).filter(f => f.endsWith('.md')); | ||
| const staleInbox = inboxFiles.filter(f => isOlderThan(f, STALE_INBOX_DAYS)); | ||
| if (staleInbox.length > 0) { | ||
| actions.push(`⚠ ${staleInbox.length} stale inbox files (>${STALE_INBOX_DAYS}d)`); | ||
| } | ||
|
|
||
| if (actions.length === 0) { | ||
| return { success: true, summary: 'cleanup: nothing to do' }; | ||
| } | ||
|
|
||
| return { | ||
| success: true, | ||
| summary: `cleanup: ${actions.join('; ')}`, | ||
| data: { scratchDeleted, orchPruned, logPruned, staleInboxCount: staleInbox.length }, | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | ||
| import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import os from 'node:os'; | ||
|
|
||
| const TEST_ROOT = path.join(os.tmpdir(), `squad-cleanup-test-${Date.now()}`); | ||
| const SQUAD_DIR = path.join(TEST_ROOT, '.squad'); | ||
|
|
||
| // We test the CleanupCapability by importing it directly | ||
| // (vitest resolves workspace paths, but we import from relative source) | ||
| import { CleanupCapability } from '../packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.js'; | ||
| import type { WatchContext } from '../packages/squad-cli/src/cli/commands/watch/types.js'; | ||
|
|
||
| function makeContext(overrides: Partial<WatchContext> = {}): WatchContext { | ||
| return { | ||
| teamRoot: TEST_ROOT, | ||
| adapter: {} as WatchContext['adapter'], | ||
| round: 1, | ||
| roster: [], | ||
| config: {}, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function writeFile(relativePath: string, content: string = ''): void { | ||
| const full = path.join(TEST_ROOT, relativePath); | ||
| mkdirSync(path.dirname(full), { recursive: true }); | ||
| writeFileSync(full, content); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mkdirSync(SQUAD_DIR, { recursive: true }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| rmSync(TEST_ROOT, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('CleanupCapability', () => { | ||
| const cap = new CleanupCapability(); | ||
|
|
||
| it('has correct metadata', () => { | ||
| expect(cap.name).toBe('cleanup'); | ||
| expect(cap.phase).toBe('housekeeping'); | ||
| expect(cap.configShape).toBe('object'); | ||
| }); | ||
|
|
||
| it('preflight succeeds when .squad/ exists', async () => { | ||
| const result = await cap.preflight(makeContext()); | ||
| expect(result.ok).toBe(true); | ||
| }); | ||
|
|
||
| it('preflight fails when .squad/ is missing', async () => { | ||
| rmSync(SQUAD_DIR, { recursive: true, force: true }); | ||
| const result = await cap.preflight(makeContext()); | ||
| expect(result.ok).toBe(false); | ||
| }); | ||
|
|
||
| it('clears all files in .squad/.scratch/', async () => { | ||
| writeFile('.squad/.scratch/prompt-123.txt', 'hello'); | ||
| writeFile('.squad/.scratch/msg-456.tmp', 'world'); | ||
|
|
||
| const result = await cap.execute(makeContext()); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('scratch: 2 files cleared'); | ||
| expect(readdirSync(path.join(SQUAD_DIR, '.scratch'))).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('archives old orchestration-log entries', async () => { | ||
| // Old file (date in filename older than 30 days) | ||
| writeFile('.squad/orchestration-log/2025-01-01T00-00-00Z-agent.md', 'old'); | ||
| // Recent file (today) | ||
| const today = new Date().toISOString().slice(0, 10); | ||
| writeFile(`.squad/orchestration-log/${today}T12-00-00Z-agent.md`, 'recent'); | ||
|
|
||
| const result = await cap.execute(makeContext()); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('orchestration-log: 1 entries archived'); | ||
|
Check failure on line 78 in test/watch-cleanup.test.ts
|
||
| // Recent file should still exist | ||
| const remaining = readdirSync(path.join(SQUAD_DIR, 'orchestration-log')); | ||
| expect(remaining).toHaveLength(1); | ||
| expect(remaining[0]).toContain(today); | ||
| }); | ||
|
|
||
| it('archives old session log entries', async () => { | ||
| writeFile('.squad/log/2025-02-15T10-00-00Z-session.md', 'old session'); | ||
| const today = new Date().toISOString().slice(0, 10); | ||
| writeFile(`.squad/log/${today}T08-00-00Z-session.md`, 'fresh'); | ||
|
|
||
| const result = await cap.execute(makeContext()); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('log: 1 entries archived'); | ||
|
Check failure on line 92 in test/watch-cleanup.test.ts
|
||
| }); | ||
|
|
||
| it('warns about stale inbox files (>7 days)', async () => { | ||
| writeFile('.squad/decisions/inbox/2025-03-01-old-decision.md', 'stale'); | ||
| const today = new Date().toISOString().slice(0, 10); | ||
| writeFile(`.squad/decisions/inbox/${today}-fresh-decision.md`, 'fresh'); | ||
|
|
||
| const result = await cap.execute(makeContext()); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('stale inbox files'); | ||
| }); | ||
|
|
||
| it('skips cleanup on non-matching rounds', async () => { | ||
| writeFile('.squad/.scratch/should-survive.txt', 'data'); | ||
| // everyNRounds defaults to 10; round 5 should skip | ||
| const result = await cap.execute(makeContext({ round: 5, config: {} })); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('skipped'); | ||
| expect(existsSync(path.join(SQUAD_DIR, '.scratch', 'should-survive.txt'))).toBe(true); | ||
| }); | ||
|
|
||
| it('runs on round 10 (every 10th round)', async () => { | ||
| writeFile('.squad/.scratch/temp.txt', 'data'); | ||
| const result = await cap.execute(makeContext({ round: 10 })); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).not.toContain('skipped'); | ||
| }); | ||
|
|
||
| it('always runs on round 1', async () => { | ||
| writeFile('.squad/.scratch/temp.txt', 'data'); | ||
| const result = await cap.execute(makeContext({ round: 1 })); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toContain('scratch: 1 files cleared'); | ||
| }); | ||
|
|
||
| it('respects custom everyNRounds config', async () => { | ||
| writeFile('.squad/.scratch/temp.txt', 'data'); | ||
| // Custom config: every 3 rounds | ||
| const result = await cap.execute(makeContext({ round: 3, config: { everyNRounds: 3 } })); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).not.toContain('skipped'); | ||
| }); | ||
|
|
||
| it('reports nothing to do when all clean', async () => { | ||
| const result = await cap.execute(makeContext()); | ||
| expect(result.success).toBe(true); | ||
| expect(result.summary).toBe('cleanup: nothing to do'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The capability claims it “archives” old log/orchestration entries (and reports them as archived), but the implementation only unlinks the files (deleteSync). Either implement a real archive/move, or rename the user-facing strings/variable names to “deleted/removed” to avoid misleading housekeeping output.