docs(11): create phase plan for per-library scan pipeline

This commit is contained in:
2026-03-09 15:44:07 -04:00
parent 025da10d98
commit 68463f3549
4 changed files with 790 additions and 1 deletions
+5 -1
View File
@@ -76,7 +76,11 @@ Plans:
2. Only one library scans at a time — requesting a second scan while one is running either queues it or is rejected with clear feedback
3. Scan progress UI identifies which library is currently being scanned (library name visible in progress indicator)
4. Existing cancel and pause/resume controls work correctly for per-library scans — cancelling one library's scan doesn't affect others
**Plans:** TBD
**Plans:** 3 plans
Plans:
- [ ] 11-01-PLAN.md — Backend scan queue coordinator, per-library scan methods, CreateAudioFile with library_id
- [ ] 11-02-PLAN.md — Frontend progress UI with library name, cancel scope modal, Scan All button
- [ ] 11-03-PLAN.md — App startup auto-scan wiring, legacy single-directory cleanup
### Phase 12: Library CRUD & Data Integrity
**Goal:** Users can add, rename, and remove libraries through the UI with correct data lifecycle management
@@ -0,0 +1,358 @@
---
phase: 11-per-library-scan-pipeline
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/library/scan_queue.go
- backend/library/library.go
- backend/library/scan_control.go
- backend/library/config.go
- backend/library/rescan.go
- backend/library/metrics.go
- backend/events/events.go
- frontend/src/events.ts
- backend/database/sql/queries/audio_files.sql
- backend/database/sql/sqlcgen/audio_files.sql.go
- backend/database/sql/sqlcgen/models.go
autonomous: true
requirements: [LSCAN-01, LSCAN-02, LSCAN-04]
must_haves:
truths:
- "ScanLibrary(id) scans only the directory associated with that library ID"
- "Only one library scans at a time — additional requests are silently queued"
- "Duplicate scan requests for the same library are silently ignored"
- "Cancel/pause/resume work per-library — cancelling one library starts the next queued"
- "Pausing freezes both the current scan AND the queue"
- "ScanAllLibraries queries all libraries and queues them sequentially"
artifacts:
- path: "backend/library/scan_queue.go"
provides: "Scan queue coordinator with sequential execution"
exports: ["ScanLibrary", "ScanAllLibraries", "CancelCurrentScan", "CancelAllScans"]
- path: "backend/library/library.go"
provides: "Updated Scan() accepting library ID and path"
- path: "backend/events/events.go"
provides: "Updated scan events with library identification"
- path: "backend/database/sql/queries/audio_files.sql"
provides: "CreateAudioFile with library_id parameter"
key_links:
- from: "backend/library/scan_queue.go"
to: "backend/library/library.go"
via: "scanQueue calls scanLibrary which calls internal scan pipeline"
pattern: "l\\.scanInternal"
- from: "backend/library/scan_queue.go"
to: "backend/database/sql/sqlcgen/libraries.sql.go"
via: "GetLibrary query to resolve library path from ID"
pattern: "Queries\\.GetLibrary"
- from: "backend/library/library.go"
to: "backend/database/sql/sqlcgen/audio_files.sql.go"
via: "CreateAudioFile now includes library_id"
pattern: "CreateAudioFileParams.*LibraryID"
---
<objective>
Refactor the scan pipeline from scanning a single hardcoded directory to scanning individual libraries by database ID, with a sequential scan queue coordinator.
Purpose: Enable per-library scanning (LSCAN-01), sequential coordination (LSCAN-02), and per-library cancel/pause scope (LSCAN-04) at the backend level.
Output: `ScanLibrary(id)` and `ScanAllLibraries()` Wails-bound methods, scan queue coordinator, updated events with library identification, `CreateAudioFile` with `library_id`.
</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/11-per-library-scan-pipeline/11-CONTEXT.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md
@.planning/phases/10-schema-migration/10-02-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From backend/library/library.go:
```go
type Library struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
rescanHooks RescanHooks
scanActive bool
scanCancel context.CancelFunc
scanPaused bool
scanPauseCh chan struct{}
}
func (l *Library) Scan() (*ScanMetrics, error)
func (l *Library) SetContext(ctx context.Context)
func (l *Library) CancelScan()
func (l *Library) PauseScan()
func (l *Library) ResumeScan()
func (l *Library) IsScanActive() bool
func (l *Library) IsScanPaused() bool
```
From backend/library/config.go:
```go
type Config struct {
DirectoryPath Directory `toml:"DirectoryPath"`
ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"`
}
```
From backend/library/metrics.go:
```go
type ScanProgress struct {
Phase string `json:"phase"`
Total int64 `json:"total"`
Processed int64 `json:"processed"`
Added int64 `json:"added"`
Skipped int64 `json:"skipped"`
Updated int64 `json:"updated"`
}
type ScanMetrics struct { ... Cancelled bool ... }
```
From backend/events/events.go:
```go
const (
LibraryScanStarted = "LibraryScanStarted"
LibraryScanProgress = "LibraryScanProgress"
LibraryScanComplete = "LibraryScanComplete"
LibraryScanCancelled = "LibraryScanCancelled"
LibraryScanPaused = "LibraryScanPaused"
LibraryScanResumed = "LibraryScanResumed"
)
```
From backend/database/sql/sqlcgen/libraries.sql.go:
```go
func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error)
func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error)
```
From backend/database/sql/sqlcgen/audio_files.sql.go:
```go
type CreateAudioFileParams struct {
FilePath string
LengthMilliseconds int64
FileTypeID int64
RecordingID int64
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
Basename string
// NOTE: library_id NOT included — uses DEFAULT 0
}
func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error)
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add library_id to CreateAudioFile + update events and progress types</name>
<files>
backend/database/sql/queries/audio_files.sql
backend/database/sql/sqlcgen/audio_files.sql.go
backend/database/sql/sqlcgen/models.go
backend/events/events.go
frontend/src/events.ts
backend/library/metrics.go
</files>
<action>
1. **Update CreateAudioFile SQL query** in `backend/database/sql/queries/audio_files.sql`:
- Add `library_id` to the INSERT column list and VALUES: `INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
- This adds the `library_id` parameter so scans can associate files with their library.
2. **Run `sqlc generate`** to regenerate Go code:
```bash
sqlc generate
```
This will update `CreateAudioFileParams` to include `LibraryID int64`.
3. **Add new event constants** to `backend/events/events.go` — add a "Scan queue events" group:
```go
// Scan queue events.
const (
LibraryScanQueued = "LibraryScanQueued"
LibraryScanQueueDrained = "LibraryScanQueueDrained"
)
```
4. **Regenerate TypeScript events** via `go generate ./backend/events/...` (uses the genevents tool).
5. **Add library identification fields** to `ScanProgress` and `ScanMetrics` in `backend/library/metrics.go`:
- Add to `ScanProgress`: `LibraryID int64 \`json:"libraryId"\`` and `LibraryName string \`json:"libraryName"\``
- Add to `ScanProgress`: `QueuedCount int \`json:"queuedCount"\`` (number of libraries still queued after this one)
- Add to `ScanMetrics`: `LibraryID int64 \`json:"libraryId"\`` and `LibraryName string \`json:"libraryName"\``
6. **Fix compilation** — update the `CreateAudioFile` call in `library.go` `saveAudioFile()` method to include `LibraryID` field. The library ID will be threaded through as a parameter to `Scan`/`scanInternal` (done in Task 2), so for now add the field but use a placeholder `0` value that Task 2 will replace. Actually — since Task 2 immediately follows and both are in the same plan, add `libraryID int64` as a field on the `Library` struct (or better: pass it through the scan methods). For the compilation fix, add `LibraryID: 0` to the CreateAudioFileParams in saveAudioFile — Task 2 will thread the real value.
Verify the generated code compiles: `go build ./backend/...`
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && sqlc generate && go generate ./backend/events/... && go build ./backend/...</automated>
</verify>
<done>CreateAudioFileParams includes LibraryID field. ScanProgress and ScanMetrics include library identification fields. New scan queue events exist in both Go and TypeScript. Code compiles.</done>
</task>
<task type="auto">
<name>Task 2: Create scan queue coordinator and refactor Library for per-library scanning</name>
<files>
backend/library/scan_queue.go
backend/library/library.go
backend/library/scan_control.go
backend/library/config.go
backend/library/rescan.go
</files>
<action>
**Create `backend/library/scan_queue.go`** — the scan queue coordinator. This is the core of Phase 11.
Design:
- The `Library` struct gains scan queue fields (protected by `mu`):
- `scanQueue []scanQueueEntry` — FIFO queue of library IDs to scan
- `currentScanLibraryID int64` — the library currently being scanned (0 if none)
- `currentScanLibraryName string` — for event payloads
- `scanQueueEntry` struct: `libraryID int64`, `libraryName string`, `libraryPath string`
**Wails-bound methods** (exported, on `*Library`):
1. `ScanLibrary(id int64) error`:
- Query `l.db.Queries.GetLibrary(l.ctx, id)` to get library name and path
- If library not found, return error
- Acquire `l.mu`:
- If this library ID is already `currentScanLibraryID` or already in `scanQueue`, return nil (silent dedup per CONTEXT.md)
- If no scan is active (`!l.scanActive`), set `currentScanLibraryID = id` and start scanning in a goroutine
- If a scan is active, append to `scanQueue` and emit `LibraryScanQueued` event with library name and queue length
- Release `l.mu`
- Return nil
2. `ScanAllLibraries() error`:
- Query `l.db.Queries.GetAllLibraries(l.ctx)` to get all libraries
- For each library, call `ScanLibrary(lib.ID)` (reuses dedup logic)
- Return nil
3. `CancelCurrentScan()` — cancels only the current library's scan (replaces old `CancelScan`):
- Cancel the scan context (existing `l.scanCancel()` call)
- The scan completion handler (`drainQueue`) will automatically start the next queued library
4. `CancelAllScans()` — cancels current and clears queue:
- Acquire `l.mu`, clear `l.scanQueue`, release `l.mu`
- Then cancel the current scan context
5. `GetScanQueueLength() int` — returns length of scan queue (for UI)
**Internal scan orchestration:**
- `startScan(entry scanQueueEntry)` — goroutine entry point:
- Calls `l.scanInternal(entry.libraryID, entry.libraryName, entry.libraryPath)`
- On completion, calls `l.drainQueue()`
- `drainQueue()` — called after each scan completes:
- Acquire `l.mu`
- If `scanQueue` is not empty, pop first entry, set as `currentScanLibraryID`, release lock, call `startScan` in new goroutine
- If `scanQueue` is empty, set `currentScanLibraryID = 0`, `scanActive = false`, emit `LibraryScanQueueDrained`, release lock
**Refactor `Library.Scan()` → `scanInternal()`:**
- Rename current `Scan()` to `scanInternal(libraryID int64, libraryName string, libraryPath string)` (unexported)
- Remove the `l.conf.DirectoryPath` dependency — use the `libraryPath` parameter instead
- Replace `l.db.Queries.GetAllAudioFiles(l.ctx)` with `l.db.Queries.GetAudioFilesByLibrary(l.ctx, libraryID)` in Phase 1 (load existing)
- Pass `libraryID` through to `saveAudioFile` so `CreateAudioFileParams.LibraryID` is set correctly
- Update all `ScanProgress` emissions to include `LibraryID`, `LibraryName`, and `QueuedCount` (read queue length under lock)
- Update `ScanMetrics` to include `LibraryID` and `LibraryName` before emitting `LibraryScanComplete`/`LibraryScanCancelled`
- The `workerCount` should use `resolveScanWorkerCount(ScanConcurrencyAuto, libraryPath)` — no longer from config (each library path may be on different storage)
**Keep backward-compatible `Scan()` method** — public method that scans using the legacy `l.conf.DirectoryPath` for `handleConfigUpdate`. Mark it deprecated. It should:
- Look up or create a library for `l.conf.DirectoryPath` using `GetLibraryByPath`
- Call `ScanLibrary(lib.ID)`
**Update `scan_control.go`:**
- Rename `CancelScan()` to an internal helper `cancelCurrentScan()` (unexported)
- Keep `PauseScan()` and `ResumeScan()` as-is — they operate on the current scan which is correct
- `IsScanActive()` unchanged
- Add `QueuedLibraryNames() []string` — returns names of queued libraries (for UI display)
**Update `config.go`:**
- The `Config` struct keeps `DirectoryPath` and `ScanConcurrency` for backward compatibility, but `DirectoryPath` is now unused for normal scanning (libraries come from DB). `ScanConcurrency` is still useful as a global default.
**Update `rescan.go`:**
- `FullRescan()` needs updating — it should accept a library ID. For now, keep it working with `l.conf.DirectoryPath` (it's used from the config page). Phase 12 will add per-library rescan.
**Thread `libraryID` through the scan pipeline:**
- Add `libraryID int64` field to `scanWork` struct (or pass it via closure)
- In `saveAudioFile`, use `LibraryID: libraryID` in `CreateAudioFileParams`
- In the `commitBatch` → `saveAudioFile` call chain, thread the library ID through. Simplest: add `libraryID int64` as a parameter to `commitBatch` and `saveAudioFile` and `updateAudioFileMetadata`.
**Linting notes:**
- All exported methods need doc comments ending with period (godot)
- No stuttering (revive) — method names don't repeat "Library"
- Sentinel errors as package vars (err113)
- Blank line after early returns (nlreturn)
- Keep lines under 100 chars (golines)
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/library/...</automated>
</verify>
<done>
- `ScanLibrary(id)` scans a specific library's directory, associating files with that library_id
- `ScanAllLibraries()` queues all libraries for sequential scanning
- Scan queue coordinator ensures only one scan runs at a time, with silent dedup
- Cancel: `CancelCurrentScan()` cancels current and starts next; `CancelAllScans()` cancels current and clears queue
- Pause freezes current scan AND queue (existing behavior — drainQueue is only called on scan completion, which doesn't happen while paused)
- All scan events include library name and queue count
- `go build ./...` passes
</done>
</task>
</tasks>
<verification>
```bash
# Build passes
go build ./...
# Vet passes
go vet ./backend/library/...
# Generated code is up to date
sqlc generate && go generate ./backend/events/...
# Existing tests still pass (scan_test.go uses the old Scan() path)
go test ./backend/library/... -count=1 -timeout 60s
# Events synced
diff <(grep -oP '"[A-Z][a-zA-Z]+"' backend/events/events.go | sort) <(grep -oP '"[A-Z][a-zA-Z]+"' frontend/src/events.ts | sort)
```
</verification>
<success_criteria>
- ScanLibrary(id) resolves library path from DB and scans only that directory
- CreateAudioFile includes library_id — new files are associated with their library
- Only one scan runs at a time — queue coordinates sequential execution
- Duplicate requests are silently ignored
- CancelCurrentScan stops current library, next queued starts automatically
- CancelAllScans stops current and clears queue
- Pause freezes scan AND queue
- All scan events include library name and queue count
- go build ./... passes, go test ./backend/library/... passes
</success_criteria>
<output>
After completion, create `.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md`
</output>
@@ -0,0 +1,245 @@
---
phase: 11-per-library-scan-pipeline
plan: 02
type: execute
wave: 2
depends_on: ["11-01"]
files_modified:
- frontend/src/components/config-page/config-page.ts
- frontend/src/components/library-manager/library-manager.ts
- frontend/wailsjs/go/library/Library.d.ts
- frontend/wailsjs/go/library/Library.js
autonomous: true
requirements: [LSCAN-03, LSCAN-04]
must_haves:
truths:
- "Progress UI shows which library is currently being scanned by name"
- "Progress UI shows queue count when libraries are queued"
- "Cancel during queued multi-scan shows modal with 'Cancel This Library' and 'Cancel All Scanning' choices"
- "Cancelling one library automatically starts scanning the next queued library"
- "Scan All Libraries button exists and triggers ScanAllLibraries binding"
artifacts:
- path: "frontend/src/components/config-page/config-page.ts"
provides: "Updated cancel dialog with scope choice, progress with library name"
- path: "frontend/src/components/library-manager/library-manager.ts"
provides: "Scan All Libraries button, per-library progress display"
- path: "frontend/wailsjs/go/library/Library.d.ts"
provides: "TypeScript declarations for ScanLibrary, ScanAllLibraries, CancelCurrentScan, CancelAllScans"
key_links:
- from: "frontend/src/components/config-page/config-page.ts"
to: "@go/library/Library"
via: "Wails binding calls for CancelCurrentScan, CancelAllScans"
pattern: "CancelCurrentScan|CancelAllScans"
- from: "frontend/src/components/library-manager/library-manager.ts"
to: "@go/library/Library"
via: "Wails binding calls for ScanAllLibraries"
pattern: "ScanAllLibraries"
---
<objective>
Update the frontend scan UI to display per-library progress (library name + queue count), add a "Scan All Libraries" button, and implement the cancel scope modal dialog for queued scans.
Purpose: Fulfill LSCAN-03 (progress identifies which library) and LSCAN-04 frontend (cancel/pause work per-library with clear scope).
Output: Updated config-page with library-aware cancel dialog, library-manager with Scan All button, Wails binding stubs.
</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/phases/11-per-library-scan-pipeline/11-CONTEXT.md
@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plan 01 -->
Updated ScanProgress payload (from backend/library/metrics.go after Plan 01):
```typescript
interface ScanProgress {
phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails';
total: number;
processed: number;
added: number;
skipped: number;
updated: number;
libraryId: number; // NEW — which library is scanning
libraryName: string; // NEW — display name
queuedCount: number; // NEW — libraries still queued
}
```
New Wails-bound methods (from Plan 01):
```typescript
// These will need stubs in Library.d.ts and Library.js
export function ScanLibrary(id: number): Promise<void>;
export function ScanAllLibraries(): Promise<void>;
export function CancelCurrentScan(): Promise<void>;
export function CancelAllScans(): Promise<void>;
export function GetScanQueueLength(): Promise<number>;
```
New events (from Plan 01):
```typescript
LibraryScanQueued: "LibraryScanQueued",
LibraryScanQueueDrained: "LibraryScanQueueDrained",
```
Existing cancel dialog pattern from config-page.ts:
- Modal overlay with stopPropagation
- Three button choices
- handleCancelKeep / handleCancelDiscard / handleCancelDialogDismiss
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add Wails binding stubs and update progress/cancel UI in config-page</name>
<files>
frontend/wailsjs/go/library/Library.d.ts
frontend/wailsjs/go/library/Library.js
frontend/src/components/config-page/config-page.ts
</files>
<action>
1. **Add Wails binding stubs** to `frontend/wailsjs/go/library/Library.d.ts`:
```typescript
export function ScanLibrary(id: number): Promise<void>;
export function ScanAllLibraries(): Promise<void>;
export function CancelCurrentScan(): Promise<void>;
export function CancelAllScans(): Promise<void>;
export function GetScanQueueLength(): Promise<number>;
export function QueuedLibraryNames(): Promise<string[]>;
```
And corresponding runtime implementations in `Library.js`:
```javascript
export function ScanLibrary(id) { return window['go']['library']['Library']['ScanLibrary'](id); }
export function ScanAllLibraries() { return window['go']['library']['Library']['ScanAllLibraries'](); }
export function CancelCurrentScan() { return window['go']['library']['Library']['CancelCurrentScan'](); }
export function CancelAllScans() { return window['go']['library']['Library']['CancelAllScans'](); }
export function GetScanQueueLength() { return window['go']['library']['Library']['GetScanQueueLength'](); }
export function QueuedLibraryNames() { return window['go']['library']['Library']['QueuedLibraryNames'](); }
```
2. **Update config-page.ts ScanProgress interface** to include the new fields:
- Add `libraryId: number`, `libraryName: string`, `queuedCount: number` to the `ScanProgress` interface
3. **Update imports** — replace `CancelScan` import with `CancelCurrentScan, CancelAllScans` from `@go/library/Library`
4. **Update progress display** (`renderScanProgress` method or equivalent):
- When `scanProgress.libraryName` is non-empty, show "Scanning: [Library Name]" as the progress label instead of just "Scanning"
- When `scanProgress.queuedCount > 0`, add a line below: "[N] libraries queued" in tertiary text color
- Format: `Scanning: My Music (245/1200 files)` with `2 libraries queued` below
5. **Update cancel dialog** — replace the current three-option dialog with the per-library-aware version per CONTEXT.md:
- Add `@state() private scanQueuedCount = 0;` to track queue state
- Update `handleScanProgress` to also save `queuedCount`
- **When `queuedCount > 0`** (multi-scan in progress): show modal dialog with TWO buttons:
- "Cancel This Library" — calls `CancelCurrentScan()` (stops current, next starts)
- "Cancel All Scanning" — calls `CancelAllScans()` (stops everything)
- No default — user must pick (per CONTEXT.md: "no default, user must pick")
- **When `queuedCount === 0`** (single scan): keep existing cancel behavior but call `CancelCurrentScan()` instead of `CancelScan()`. Can use the existing Keep/Discard/Continue dialog pattern.
- Update `handleCancelKeep` → call `CancelCurrentScan()` instead of `CancelScan()`
- Update `handleCancelDiscard` → call `CancelCurrentScan()` instead of `CancelScan()`
6. **Handle new events** in `connectedCallback`:
- Listen for `LibraryScanQueued` — update `scanQueuedCount` from event payload
- Listen for `LibraryScanQueueDrained` — set `scanQueuedCount = 0`, reset scan state
7. **Update scan buttons section** — when not scanning, show "Scan All Libraries" as an additional button alongside Soft Scan and Full Rescan. It calls `ScanAllLibraries()`.
**Styling notes:**
- Use existing design tokens (`--yj-text-primary`, `--yj-text-tertiary`, `--yj-accent`)
- Queue count text: `.progress-detail` style (smaller, tertiary color)
- Library name in progress: bold, primary text color
- Cancel modal buttons: "Cancel This Library" gets `btn-warning`, "Cancel All Scanning" gets `btn-danger`
- Keep `.cancel-dialog` CSS class pattern from Phase 9
**TypeScript strictness:**
- `override` keyword on lifecycle methods
- `import type` for type-only imports
- Private event handlers as arrow functions
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket/frontend && npx tsc --noEmit</automated>
</verify>
<done>
- ScanProgress interface includes libraryId, libraryName, queuedCount
- Progress UI shows "Scanning: [Library Name]" and queue count
- Cancel dialog shows scope choice when multiple scans queued
- CancelCurrentScan/CancelAllScans called instead of CancelScan
- Scan All Libraries button exists in scan actions
- TypeScript compiles cleanly
</done>
</task>
<task type="auto">
<name>Task 2: Update library-manager component for per-library scan display</name>
<files>
frontend/src/components/library-manager/library-manager.ts
</files>
<action>
1. **Update ScanProgress interface** in library-manager.ts to match the new fields: add `libraryId: number`, `libraryName: string`, `queuedCount: number`.
2. **Update progress rendering** in `renderScanProgress()`:
- Show library name: "Scanning: [Library Name]" as the progress label
- Show queued count when > 0: "[N] libraries queued" in tertiary text
3. **Update imports** — add `ScanAllLibraries` import from `@go/library/Library`
4. **Add "Scan All Libraries" button** to the scan actions section:
- Place it alongside existing "Soft Scan" and "Full Rescan" buttons
- Style: `btn-primary` class, disabled when scanning
- Handler: `private handleScanAll = async (): Promise<void> => { await ScanAllLibraries(); }`
- Label: "Scan All Libraries" (or "Scanning..." when active)
5. **Listen for LibraryScanQueued and LibraryScanQueueDrained events**:
- In `connectedCallback`, add event subscriptions
- In `disconnectedCallback`, clean up subscriptions
- These events update scanning state for the UI
6. **Update handleScanComplete** to handle per-library scan completion:
- The `LibraryScanComplete` event now includes `libraryName` in the metrics
- If queue is still draining, don't reset scanning state (wait for `LibraryScanQueueDrained`)
- Only fully reset `scanning = false` on `LibraryScanQueueDrained` or when `queuedCount === 0` in the complete event
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket/frontend && npx tsc --noEmit</automated>
</verify>
<done>
- Library-manager shows library name in scan progress
- "Scan All Libraries" button exists and calls ScanAllLibraries
- Scan state properly tracks queue draining (doesn't reset early)
- TypeScript compiles cleanly
</done>
</task>
</tasks>
<verification>
```bash
# TypeScript compiles
cd frontend && npx tsc --noEmit
# Full project builds (backend + frontend)
cd .. && go build ./...
```
</verification>
<success_criteria>
- Progress bar shows "Scanning: [Library Name] (N/M files)" during scan
- Queue count visible when libraries are queued
- Cancel modal offers "Cancel This Library" / "Cancel All Scanning" during queued scans
- "Scan All Libraries" button exists in both config-page and library-manager
- TypeScript compiles cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/11-per-library-scan-pipeline/11-02-SUMMARY.md`
</output>
@@ -0,0 +1,182 @@
---
phase: 11-per-library-scan-pipeline
plan: 03
type: execute
wave: 2
depends_on: ["11-01"]
files_modified:
- backend/app.go
- backend/library/library.go
autonomous: true
requirements: [LSCAN-01, LSCAN-02]
must_haves:
truths:
- "App auto-scans all libraries on launch using ScanAllLibraries"
- "Legacy LibraryConfigChanged event handler is removed or updated for multi-library"
- "Library struct no longer requires Config.DirectoryPath to function"
artifacts:
- path: "backend/app.go"
provides: "Updated OnDomReady or OnStartup to trigger ScanAllLibraries on launch"
- path: "backend/library/library.go"
provides: "Updated NewLibrary constructor — Config no longer required"
key_links:
- from: "backend/app.go"
to: "backend/library/scan_queue.go"
via: "ScanAllLibraries call on startup"
pattern: "library\\.ScanAllLibraries"
---
<objective>
Wire the per-library scan pipeline into app startup and clean up legacy single-directory scanning paths.
Purpose: Ensure auto-scan on launch uses `ScanAllLibraries()` (same codepath as the UI button per CONTEXT.md), and remove/update legacy `LibraryConfigChanged` handler that assumed a single directory.
Output: Updated app.go startup wiring, cleaned-up Library constructor.
</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/phases/11-per-library-scan-pipeline/11-CONTEXT.md
@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 -->
From backend/library/scan_queue.go (created in Plan 01):
```go
func (l *Library) ScanLibrary(id int64) error
func (l *Library) ScanAllLibraries() error
func (l *Library) CancelCurrentScan()
func (l *Library) CancelAllScans()
```
From backend/app.go (current):
```go
func (yj *YellowJacketApp) OnStartup(ctx context.Context)
// Currently: yj.library.SetContext(ctx)
// Currently: library is created with appConfig.Library (Config with DirectoryPath)
func NewYellowJacketApp(...) {
lib, err := library.NewLibrary(
yjApp.appContext,
yjApp.logger,
yjApp.appConfig.Library, // Config with DirectoryPath
yjApp.database,
)
}
```
From backend/library/library.go (current event handler):
```go
func (l *Library) registerEventHandlers() {
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
// Parses DirectoryPath from event data, calls l.handleConfigUpdate
})
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire auto-scan on startup and clean up legacy single-directory code</name>
<files>
backend/app.go
backend/library/library.go
</files>
<action>
1. **Update `NewLibrary` constructor** in `backend/library/library.go`:
- Make `*Config` parameter optional/removable. The Library no longer needs a pre-configured DirectoryPath because scan paths come from the database.
- Keep the `*Config` parameter for backward compatibility but don't require `DirectoryPath` to be set.
- Update validation: if `conf` is nil, create a default config with empty DirectoryPath (already handled).
2. **Update `registerEventHandlers`** in `backend/library/library.go`:
- Remove the `LibraryConfigChanged` event handler entirely. This handler assumed a single-directory model where changing the config triggers a scan. In the multi-library model:
- Libraries are added/removed through the library CRUD API (Phase 12)
- Scanning is triggered explicitly via `ScanLibrary()` or `ScanAllLibraries()`
- The `LibraryConfigChanged` event and `handleConfigUpdate` method can be deleted or marked deprecated
- Delete `handleConfigUpdate` method
- Delete `errLibraryDirNotConfigured` sentinel error (no longer needed)
3. **Update `NewYellowJacketApp` in `backend/app.go`**:
- Change the `library.NewLibrary(...)` call. The Config parameter is less important now since DirectoryPath is ignored. Pass `yjApp.appConfig.Library` as before (it still has ScanConcurrency which is useful as a default).
4. **Add auto-scan on startup** in `backend/app.go`:
- In `OnDomReady` (or via a goroutine started in `OnStartup` that waits for DOM ready), trigger auto-scan.
- Best approach: In `OnDomReady`, after the startup error check, launch a goroutine:
```go
go func() {
if err := yj.library.ScanAllLibraries(); err != nil {
yj.logger.Error("auto-scan failed", "err", err)
}
}()
```
- This uses the same `ScanAllLibraries()` codepath as the UI button (per CONTEXT.md: "Auto-scan on launch should use the same ScanAllLibraries() codepath as the UI button — single implementation").
- It runs in a goroutine so it doesn't block the DOM ready callback.
- Only run if there are libraries in the DB: check `l.db.Queries.CountLibraries(l.ctx)` first (or let ScanAllLibraries handle the empty case gracefully by returning immediately when GetAllLibraries returns an empty slice).
5. **Clean up legacy `Scan()` method**:
- In Plan 01, the old `Scan()` was kept as backward-compatible wrapper. Now review: since we're removing `handleConfigUpdate` which was the only caller of the legacy `Scan()` via `l.handleConfigUpdate → l.Scan()`, we can either:
- Keep `Scan()` for tests (it's used in `scan_test.go`)
- Update it to call `scanInternal` with the library from `l.conf.DirectoryPath` if set, or return early if not set
- Keep `FullRescan()` — it's still called from the config-page UI. It should work with the first/default library. Update it to look up the default library from DB rather than using `l.conf.DirectoryPath`.
6. **Update `FullRescan()`** in `backend/library/rescan.go`:
- Instead of using `l.conf.DirectoryPath`, look up the first library from DB: `libs, err := l.db.Queries.GetAllLibraries(l.ctx)` and use `libs[0]`.
- If no libraries exist, return an error.
- Call `scanInternal(lib.ID, lib.Name, lib.Path)` instead of `l.Scan()`.
- Per-library FullRescan will be added in Phase 12 — for now this rescans the first/only library.
**Linting requirements:**
- Doc comments ending with period
- Blank line after early returns
- Lines under 100 chars
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/... && go test ./backend/library/... -count=1 -timeout 60s</automated>
</verify>
<done>
- Auto-scan on startup calls ScanAllLibraries (same codepath as UI button)
- Legacy LibraryConfigChanged handler removed
- Legacy handleConfigUpdate removed
- FullRescan uses library from DB instead of config DirectoryPath
- go build passes, go vet passes, existing tests pass
</done>
</task>
</tasks>
<verification>
```bash
# Full build
go build ./...
# Vet
go vet ./backend/...
# Tests pass (including scan_test.go)
go test ./backend/library/... -count=1 -timeout 60s
# No references to removed handler
grep -rn "LibraryConfigChanged" backend/library/ | grep -v "_test.go"
# Should return no hits (only events.go constant definition, not handler registration)
```
</verification>
<success_criteria>
- App auto-scans all libraries on launch via ScanAllLibraries
- LibraryConfigChanged handler removed from library package
- handleConfigUpdate removed
- FullRescan works with DB-sourced library (not config DirectoryPath)
- All tests pass, build passes
</success_criteria>
<output>
After completion, create `.planning/phases/11-per-library-scan-pipeline/11-03-SUMMARY.md`
</output>