Files
yellowjacket/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-PLAN.md
T

338 lines
12 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Library struct (library.go:78-87) — add scan control fields here -->
type Library struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
rescanHooks RescanHooks
}
<!-- ScanMetrics (metrics.go:11-55) — add Cancelled bool field -->
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"`
}
<!-- Existing events (events.go:44-48) -->
const (
LibraryScanStarted = "LibraryScanStarted"
LibraryScanProgress = "LibraryScanProgress"
LibraryScanComplete = "LibraryScanComplete"
)
<!-- Scan() method signature (library.go:175) -->
func (l *Library) Scan() (*ScanMetrics, error)
<!-- Key scan pipeline locations that check l.ctx.Done() -->
<!-- library.go:297-298: case <-l.ctx.Done(): return l.ctx.Err() (walk, sending to workChan) -->
<!-- library.go:324-325: case <-l.ctx.Done(): return l.ctx.Err() (walk, new file) -->
<!-- library.go:496-497: case <-l.ctx.Done(): return l.ctx.Err() (worker, sending to resultChan) -->
<!-- commitBatch called at library.go:433 — uses l.ctx implicitly for DB ops -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add scan control events and metrics fields</name>
<files>backend/events/events.go, frontend/src/events.ts, backend/library/metrics.go</files>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>Three new scan control events exist in events.go and are synced to frontend/src/events.ts. ScanMetrics has a Cancelled bool field.</done>
</task>
<task type="auto">
<name>Task 2: Add scan control fields to Library struct and create scan_control.go</name>
<files>backend/library/library.go, backend/library/scan_control.go</files>
<action>
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)
}
```
</action>
<verify>
<automated>cd backend && go build ./... && go vet ./library/...</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<verification>
```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.
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md`
</output>