--- phase: 09-scan-cancellation-keyboard-shortcuts plan: 01 type: execute wave: 1 depends_on: [] files_modified: - backend/events/events.go - frontend/src/events.ts - backend/library/library.go - backend/library/scan_control.go - backend/library/metrics.go autonomous: true requirements: - SCAN-01 - SCAN-02 - SCAN-03 must_haves: truths: - "CancelScan() cancels the scan context and workers stop at their next checkpoint" - "PauseScan() blocks workers via a channel; ResumeScan() unblocks them" - "Cancelled scans skip orphan cleanup to avoid deleting unvisited files" - "Batch commits use l.ctx (app context), not the cancellable scanCtx, so in-flight transactions complete" - "ScanMetrics.Cancelled is true when a scan was cancelled" artifacts: - path: "backend/library/scan_control.go" provides: "CancelScan, PauseScan, ResumeScan, IsScanActive, IsScanPaused methods" exports: ["CancelScan", "PauseScan", "ResumeScan", "IsScanActive", "IsScanPaused"] - path: "backend/events/events.go" provides: "New scan control events" contains: "LibraryScanCancelled" - path: "backend/library/metrics.go" provides: "Cancelled field on ScanMetrics" contains: "Cancelled" key_links: - from: "backend/library/scan_control.go" to: "backend/library/library.go" via: "scanCancel context.CancelFunc and scanPauseCh channel on Library struct" pattern: "l\\.scanCancel|l\\.scanPauseCh" - from: "backend/library/library.go" to: "backend/events/events.go" via: "EventsEmit for scan lifecycle events" pattern: "events\\.LibraryScan" --- Add scan cancellation and pause/resume to the Go backend. Thread a per-scan cancellable context through the existing scan pipeline, add pause/resume via a blocking channel, and expose Wails-bound methods for frontend control. Purpose: Backend foundation for SCAN-01/02/03 — frontend buttons wire to these methods in Plan 03. Output: scan_control.go with CancelScan/PauseScan/ResumeScan, modified Scan() method, new events, updated metrics. @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md @backend/library/library.go @backend/library/metrics.go @backend/events/events.go type Library struct { mu sync.Mutex ctx context.Context logger *slog.Logger conf *Config db *database.DB rescanHooks RescanHooks } type ScanMetrics struct { mu sync.Mutex // ... existing timing and count fields ... Added int64 `json:"added"` Updated int64 `json:"updated"` Skipped int64 `json:"skipped"` Removed int64 `json:"removed"` Warnings []ScanWarning `json:"warnings"` } const ( LibraryScanStarted = "LibraryScanStarted" LibraryScanProgress = "LibraryScanProgress" LibraryScanComplete = "LibraryScanComplete" ) func (l *Library) Scan() (*ScanMetrics, error) Task 1: Add scan control events and metrics fields backend/events/events.go, frontend/src/events.ts, backend/library/metrics.go 1. In `backend/events/events.go`, add a new const block for scan control events: ```go // Scan control events. const ( LibraryScanCancelled = "LibraryScanCancelled" LibraryScanPaused = "LibraryScanPaused" LibraryScanResumed = "LibraryScanResumed" ) ``` Place it after the existing Library events block (line 48). 2. Run `go generate ./backend/events/...` to regenerate `frontend/src/events.ts`. 3. In `backend/library/metrics.go`, add a `Cancelled` field to `ScanMetrics`: ```go Cancelled bool `json:"cancelled"` ``` Place it after the `Removed int64` field (line 51), before the `Warnings` field. cd backend && go build ./... && go generate ./events/... && grep -q "LibraryScanCancelled" events/events.go && grep -q "LibraryScanCancelled" ../frontend/src/events.ts && grep -q "Cancelled" library/metrics.go Three new scan control events exist in events.go and are synced to frontend/src/events.ts. ScanMetrics has a Cancelled bool field. Task 2: Add scan control fields to Library struct and create scan_control.go backend/library/library.go, backend/library/scan_control.go 1. In `backend/library/library.go`, add scan control fields to the `Library` struct (after `rescanHooks` at line 86): ```go // Scan control fields — protected by mu. scanActive bool scanCancel context.CancelFunc scanPaused bool scanPauseCh chan struct{} ``` 2. Create `backend/library/scan_control.go` with these Wails-bound methods: ```go package library import ( "github.com/wailsapp/wails/v2/pkg/runtime" "yellowjacket/backend/events" ) // CancelScan cancels an in-progress scan. Returns immediately; // scan goroutines stop at their next checkpoint. func (l *Library) CancelScan() { l.mu.Lock() cancel := l.scanCancel l.mu.Unlock() if cancel != nil { cancel() } } // PauseScan pauses an in-progress scan. Workers block at their // next pause checkpoint until ResumeScan is called. func (l *Library) PauseScan() { l.mu.Lock() defer l.mu.Unlock() if !l.scanActive || l.scanPaused { return } l.scanPaused = true l.scanPauseCh = make(chan struct{}) runtime.EventsEmit(l.ctx, events.LibraryScanPaused) } // ResumeScan unblocks a paused scan. func (l *Library) ResumeScan() { l.mu.Lock() defer l.mu.Unlock() if !l.scanPaused { return } l.scanPaused = false close(l.scanPauseCh) // unblocks all waiting workers runtime.EventsEmit(l.ctx, events.LibraryScanResumed) } // IsScanActive returns whether a scan is currently running. func (l *Library) IsScanActive() bool { l.mu.Lock() defer l.mu.Unlock() return l.scanActive } // IsScanPaused returns whether the scan is currently paused. func (l *Library) IsScanPaused() bool { l.mu.Lock() defer l.mu.Unlock() return l.scanPaused } // waitIfPaused blocks the calling goroutine if the scan is paused. // Returns ctx.Err() if the context is cancelled while waiting. func (l *Library) waitIfPaused(ctx context.Context) error { l.mu.Lock() ch := l.scanPauseCh paused := l.scanPaused l.mu.Unlock() if !paused || ch == nil { return nil } select { case <-ch: // closed = unpaused return nil case <-ctx.Done(): return ctx.Err() } } ``` Note: `waitIfPaused` takes a `context.Context` parameter (the scan-specific context), not `l.ctx`. Add `"context"` to the import block. 3. Modify `Scan()` in `backend/library/library.go`: a. At the top of Scan() (after `metrics := newScanMetrics()`, line 176), create a cancellable scan context: ```go scanCtx, scanCancel := context.WithCancel(l.ctx) defer scanCancel() l.mu.Lock() l.scanCancel = scanCancel l.scanActive = true l.scanPaused = false l.scanPauseCh = nil l.mu.Unlock() defer func() { l.mu.Lock() l.scanCancel = nil l.scanActive = false // If still paused, unpause so no dangling channel if l.scanPaused { l.scanPaused = false if l.scanPauseCh != nil { close(l.scanPauseCh) } } l.scanPauseCh = nil l.mu.Unlock() }() ``` b. Replace ALL occurrences of `<-l.ctx.Done()` inside Scan() with `<-scanCtx.Done()`, and `l.ctx.Err()` with `scanCtx.Err()` (the walk goroutine send-to-workChan selects and the walk error return, and the worker pool send-to-resultChan select). There are 3 occurrences: line ~297, ~324, ~496. c. In the worker pool loop (Phase 3, around line 474), add a pause checkpoint before processing each file. Add at the start of the `g.Go(func() error {` closure body: ```go if err := l.waitIfPaused(scanCtx); err != nil { return err } ``` d. **CRITICAL — Batch commits use l.ctx, NOT scanCtx:** The `commitBatch` method and all DB operations within it should continue to use `l.ctx` (the app context), NOT the scan-specific `scanCtx`. This is already the case since `commitBatch` accesses `l.ctx` internally. DO NOT change `commitBatch` to use `scanCtx`. This ensures in-flight transactions always complete even when the scan is cancelled. e. **CRITICAL — Skip orphan cleanup on cancelled scan:** Before the orphan cleanup phase (Phase 5, around line 549), add a check: ```go // Skip orphan cleanup if the scan was cancelled — existingPaths // still contains unvisited files that would be incorrectly deleted. cancelled := scanCtx.Err() != nil if cancelled { metrics.Cancelled = true l.logger.Info("scan cancelled, skipping orphan cleanup") } else { // ... existing orphan cleanup code ... } ``` Wrap the existing orphan cleanup code (existingPaths.Range through metrics.OrphanCleanup = ...) inside the `else` block. f. Also skip the "Phase 6: post-scan variant generation" if cancelled (wrap in same `if !cancelled` check or separate check). g. When the scan was cancelled, emit `LibraryScanCancelled` instead of (or in addition to) `LibraryScanComplete`. Update the finalize section: ```go if cancelled { runtime.EventsEmit(l.ctx, events.LibraryScanCancelled, metrics) } else { runtime.EventsEmit(l.ctx, events.LibraryScanComplete, metrics) } ``` cd backend && go build ./... && go vet ./library/... Library struct has scan control fields. scan_control.go provides CancelScan/PauseScan/ResumeScan/IsScanActive/IsScanPaused. Scan() uses per-scan context, workers check for pause, orphan cleanup is skipped on cancel, and appropriate events are emitted. ```bash cd backend && go build ./... && go vet ./library/... && go vet ./events/... ``` All backend code compiles. No vet errors. New scan control methods are exported and Wails-bindable. - `go build ./...` passes with no errors - `CancelScan`, `PauseScan`, `ResumeScan`, `IsScanActive`, `IsScanPaused` are exported methods on `*Library` - `waitIfPaused` is an unexported helper that blocks on pause channel - Scan() creates a per-scan context and uses it for worker cancellation - Orphan cleanup and variant generation are skipped when scan is cancelled - `LibraryScanCancelled`, `LibraryScanPaused`, `LibraryScanResumed` events exist and are synced to TypeScript - `ScanMetrics.Cancelled` bool field exists After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md`