feat(jobs): surface background jobs with progress, logs and controls

Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:42:22 -04:00
co-authored by Claude Opus 5
parent aead8eaef4
commit 01bc5f2094
48 changed files with 6656 additions and 2271 deletions
+795
View File
@@ -0,0 +1,795 @@
// Package jobs provides a central registry for long-running background
// work — library scans, search index builds, and anything else that runs
// while the user is doing something else. Producers report progress
// through a Handle; the registry coalesces those updates into a single
// JobsChanged event so the frontend can render one indicator, one job
// list, and one log viewer regardless of which subsystem is working.
package jobs
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// Kind identifies the subsystem that owns a job. The frontend uses it
// to pick an icon and to route "view details" to the right panel.
type Kind string
// Job kinds.
const (
KindLibraryScan Kind = "library-scan"
KindIndexBuild Kind = "index-build"
)
// State is the lifecycle position of a job.
type State string
// Job states. Queued, Running, Paused and Pausing are live; Complete,
// Cancelled and Error are terminal.
const (
StateQueued State = "queued"
StateRunning State = "running"
StatePausing State = "pausing"
StatePaused State = "paused"
StateCancelling State = "cancelling"
StateComplete State = "complete"
StateCancelled State = "cancelled"
StateError State = "error"
)
// IsTerminal reports whether the state means the job will not progress
// further without being started again from scratch.
func (s State) IsTerminal() bool {
return s == StateComplete || s == StateCancelled || s == StateError
}
// Level is the severity of a job log entry.
type Level string
// Log levels.
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
)
// maxLogEntries bounds the per-job log ring buffer. Scans can emit a
// warning per unreadable file, so the buffer is a tail, not an archive.
const maxLogEntries = 500
// emitInterval is how often a dirty registry is flushed to the frontend.
// Progress tickers run at 300ms, so this keeps re-render cost bounded
// without making the UI feel laggy.
const emitInterval = 250 * time.Millisecond
// finishedRetention is how long terminal jobs stay in the registry so
// the user can read their logs after the fact.
const finishedRetention = 30 * time.Minute
// maxFinished caps how many terminal jobs are retained regardless of age.
const maxFinished = 25
// Caps describes which controls a job supports. The frontend renders
// buttons from these rather than switching on Kind, so a job that gains
// pause support later needs no frontend change.
type Caps struct {
Pausable bool `json:"pausable"`
Cancellable bool `json:"cancellable"`
}
// Stage is one named sub-step of a multi-stage job, such as an index
// build tier. Jobs with a single linear phase leave Stages empty.
type Stage struct {
Name string `json:"name"`
State string `json:"state"` // pending, running, complete, error, skipped
Current int64 `json:"current"`
Total int64 `json:"total"`
Error string `json:"error,omitempty"`
}
// Stat is a display-only key/value pair shown in the job detail panel
// (e.g. "Added" / "1,204").
type Stat struct {
Label string `json:"label"`
Value string `json:"value"`
}
// LogEntry is one line of a job's output log.
type LogEntry struct {
Time int64 `json:"time"` // unix milliseconds
Level Level `json:"level"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}
// Job is the frontend-facing snapshot of a single background job.
type Job struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
State State `json:"state"`
Phase string `json:"phase,omitempty"`
// Current/Total drive the progress bar. Total == 0 means the job
// is indeterminate and the frontend should render a spinner.
Current int64 `json:"current"`
Total int64 `json:"total"`
Caps Caps `json:"caps"`
Stages []Stage `json:"stages"`
Stats []Stat `json:"stats"`
Error string `json:"error,omitempty"`
StartedAt int64 `json:"startedAt"` // unix milliseconds
UpdatedAt int64 `json:"updatedAt"`
EndedAt int64 `json:"endedAt,omitempty"`
LogCount int `json:"logCount"`
WarnCount int `json:"warnCount"`
ErrorCount int `json:"errorCount"`
}
// Controls holds the callbacks the registry invokes when the user asks
// for a job to be paused, resumed, or cancelled. All three are optional;
// a nil callback means the corresponding capability is unavailable.
//
// Callbacks are invoked on a dedicated goroutine, so implementations may
// block (StopBuild waits for its build goroutine to exit, for instance)
// without stalling the Wails call dispatcher.
type Controls struct {
Pause func()
Resume func()
Cancel func()
}
// Spec describes a job at registration time.
type Spec struct {
ID string
Kind Kind
Title string
Subtitle string
Total int64
State State
Caps Caps
Controls Controls
// Durable marks a job whose paused state should survive an app
// restart. On the next launch the owning subsystem adopts it back
// into the registry as paused instead of silently resuming.
Durable bool
}
// Handle is a producer's write side of a registered job. Every mutator
// marks the registry dirty; the emitter coalesces those into one event.
type Handle struct {
reg *Registry
id string
mu sync.Mutex
job Job
controls Controls
durable bool
log []LogEntry
}
// Registry owns every known job and pushes coalesced snapshots to the
// frontend. It is safe for concurrent use.
type Registry struct {
logger *slog.Logger
store *Store
mu sync.RWMutex
ctx context.Context
jobs map[string]*Handle
order []string
dirty atomic.Bool
}
// NewRegistry creates a registry. Pass a nil store to disable
// pause-across-restart persistence (tests do this).
func NewRegistry(logger *slog.Logger, store *Store) *Registry {
return &Registry{
logger: logger,
store: store,
jobs: make(map[string]*Handle),
}
}
// SetContext injects the Wails runtime context and starts the coalescing
// emitter. Until it is called, updates are recorded but not pushed.
func (r *Registry) SetContext(ctx context.Context) {
r.mu.Lock()
r.ctx = ctx
r.mu.Unlock()
go r.emitLoop(ctx)
}
// emitLoop flushes the registry to the frontend whenever it is dirty.
func (r *Registry) emitLoop(ctx context.Context) {
ticker := time.NewTicker(emitInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if r.dirty.Swap(false) {
r.emit()
}
}
}
}
// emit pushes a full snapshot to the frontend. A full snapshot (rather
// than a delta) means a component that mounts mid-scan is correct from
// the first event it receives.
func (r *Registry) emit() {
r.mu.RLock()
ctx := r.ctx
r.mu.RUnlock()
if ctx == nil {
return
}
runtime.EventsEmit(ctx, events.JobsChanged, r.Snapshot())
}
// touch marks the registry dirty so the next emitter tick publishes it.
func (r *Registry) touch() {
r.dirty.Store(true)
}
// flush publishes immediately. Used for state transitions, where a
// quarter-second of lag would make a button press feel unresponsive.
func (r *Registry) flush() {
r.dirty.Store(false)
r.emit()
}
// Start registers a job and returns its handle. Re-registering an
// existing ID reuses the handle and its log, which is what happens when
// a queued scan is popped off the queue and actually begins.
func (r *Registry) Start(spec Spec) *Handle {
now := nowMillis()
state := spec.State
if state == "" {
state = StateRunning
}
r.mu.Lock()
h, existing := r.jobs[spec.ID]
if !existing {
h = &Handle{reg: r, id: spec.ID}
r.jobs[spec.ID] = h
r.order = append(r.order, spec.ID)
}
r.mu.Unlock()
h.mu.Lock()
if !existing {
h.job = Job{
ID: spec.ID,
StartedAt: now,
Stages: []Stage{},
Stats: []Stat{},
}
}
h.job.Kind = spec.Kind
h.job.Title = spec.Title
h.job.Subtitle = spec.Subtitle
h.job.State = state
h.job.Caps = spec.Caps
h.job.Total = spec.Total
h.job.UpdatedAt = now
h.job.EndedAt = 0
h.job.Error = ""
h.controls = spec.Controls
h.durable = spec.Durable
h.mu.Unlock()
r.persistPause(spec.ID, state)
r.pruneFinished()
r.flush()
return h
}
// Get returns the handle for an ID, or nil when unknown.
func (r *Registry) Get(id string) *Handle {
r.mu.RLock()
defer r.mu.RUnlock()
return r.jobs[id]
}
// Snapshot returns every known job, oldest registration first.
func (r *Registry) Snapshot() []Job {
r.mu.RLock()
out := make([]Job, 0, len(r.order))
handles := make([]*Handle, 0, len(r.order))
for _, id := range r.order {
if h, ok := r.jobs[id]; ok {
handles = append(handles, h)
}
}
r.mu.RUnlock()
for _, h := range handles {
out = append(out, h.Snapshot())
}
return out
}
// Logs returns the retained log tail for a job, oldest entry first.
func (r *Registry) Logs(id string) []LogEntry {
h := r.Get(id)
if h == nil {
return []LogEntry{}
}
h.mu.Lock()
defer h.mu.Unlock()
out := make([]LogEntry, len(h.log))
copy(out, h.log)
return out
}
// HasActive reports whether any job is in a non-terminal state.
func (r *Registry) HasActive() bool {
for _, j := range r.Snapshot() {
if !j.State.IsTerminal() {
return true
}
}
return false
}
// Pause asks the owning subsystem to pause a job. The job moves to
// "pausing" immediately for UI feedback; the producer confirms the
// transition to "paused" when it actually stops.
func (r *Registry) Pause(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
pause := h.controls.Pause
pausable := h.job.Caps.Pausable
live := !h.job.State.IsTerminal()
h.mu.Unlock()
if pause == nil || !pausable || !live {
return
}
h.SetState(StatePausing)
h.Logf(LevelInfo, "Pause requested")
go pause()
}
// Resume asks the owning subsystem to continue a paused job.
func (r *Registry) Resume(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
resume := h.controls.Resume
paused := h.job.State == StatePaused || h.job.State == StatePausing
h.mu.Unlock()
if resume == nil || !paused {
return
}
h.Logf(LevelInfo, "Resume requested")
go resume()
}
// Cancel asks the owning subsystem to abandon a job.
func (r *Registry) Cancel(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
cancel := h.controls.Cancel
cancellable := h.job.Caps.Cancellable
live := !h.job.State.IsTerminal()
h.mu.Unlock()
if cancel == nil || !cancellable || !live {
return
}
h.SetState(StateCancelling)
h.Logf(LevelInfo, "Cancel requested")
go cancel()
}
// Remove drops a job from the registry entirely, discarding its log.
func (r *Registry) Remove(id string) {
r.mu.Lock()
delete(r.jobs, id)
for i, existing := range r.order {
if existing == id {
r.order = append(r.order[:i], r.order[i+1:]...)
break
}
}
r.mu.Unlock()
if r.store != nil {
r.store.ClearPaused(id)
}
r.flush()
}
// ClearFinished drops every terminal job. Bound to the "clear" action
// in the jobs panel.
func (r *Registry) ClearFinished() {
r.mu.Lock()
kept := r.order[:0]
for _, id := range r.order {
h, ok := r.jobs[id]
if !ok {
continue
}
h.mu.Lock()
terminal := h.job.State.IsTerminal()
h.mu.Unlock()
if terminal {
delete(r.jobs, id)
continue
}
kept = append(kept, id)
}
r.order = kept
r.mu.Unlock()
r.flush()
}
// pruneFinished evicts terminal jobs that are older than the retention
// window, and trims the oldest when too many have accumulated.
func (r *Registry) pruneFinished() {
cutoff := nowMillis() - finishedRetention.Milliseconds()
r.mu.Lock()
defer r.mu.Unlock()
var finished []string
for _, id := range r.order {
h, ok := r.jobs[id]
if !ok {
continue
}
h.mu.Lock()
terminal := h.job.State.IsTerminal()
ended := h.job.EndedAt
h.mu.Unlock()
if terminal && ended > 0 && ended < cutoff {
delete(r.jobs, id)
continue
}
if terminal {
finished = append(finished, id)
}
}
// Trim the oldest terminal jobs beyond the cap.
if excess := len(finished) - maxFinished; excess > 0 {
for _, id := range finished[:excess] {
delete(r.jobs, id)
}
}
kept := r.order[:0]
for _, id := range r.order {
if _, ok := r.jobs[id]; ok {
kept = append(kept, id)
}
}
r.order = kept
}
// persistPause writes or clears the durable pause record for a job so a
// paused job comes back paused after a restart instead of silently
// resuming (or silently never running again).
func (r *Registry) persistPause(id string, state State) {
if r.store == nil {
return
}
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
durable := h.durable
job := h.job
h.mu.Unlock()
if !durable {
return
}
if state != StatePaused {
r.store.ClearPaused(id)
return
}
r.store.SetPaused(Persisted{
ID: job.ID,
Kind: job.Kind,
Title: job.Title,
Subtitle: job.Subtitle,
})
}
// PausedEntries returns the jobs of the given kind that were paused when
// the app last shut down. Subsystems call this during startup and adopt
// each entry back into the registry with its controls attached.
func (r *Registry) PausedEntries(kind Kind) []Persisted {
if r.store == nil {
return nil
}
return r.store.PausedEntries(kind)
}
// IsPersistentlyPaused reports whether the given job ID was paused when
// the app last shut down. Subsystems check this before auto-starting
// work at launch, so a paused job stays paused.
func (r *Registry) IsPersistentlyPaused(id string) bool {
if r.store == nil {
return false
}
return r.store.IsPaused(id)
}
// ---------------------------------------------------------------------------
// Handle
// ---------------------------------------------------------------------------
// Snapshot returns a copy of the job's current state.
func (h *Handle) Snapshot() Job {
h.mu.Lock()
defer h.mu.Unlock()
job := h.job
job.Stages = make([]Stage, len(h.job.Stages))
copy(job.Stages, h.job.Stages)
job.Stats = make([]Stat, len(h.job.Stats))
copy(job.Stats, h.job.Stats)
return job
}
// State returns the job's current state.
func (h *Handle) State() State {
h.mu.Lock()
defer h.mu.Unlock()
return h.job.State
}
// SetState moves the job to a new state. Terminal states stamp EndedAt.
// Transitions flush immediately so controls feel responsive.
func (h *Handle) SetState(state State) {
h.mu.Lock()
if h.job.State == state {
h.mu.Unlock()
return
}
h.job.State = state
h.job.UpdatedAt = nowMillis()
if state.IsTerminal() {
h.job.EndedAt = h.job.UpdatedAt
} else {
h.job.EndedAt = 0
}
h.mu.Unlock()
h.reg.persistPause(h.id, state)
h.reg.flush()
}
// SetPhase records the human-readable phase label ("Scanning files").
func (h *Handle) SetPhase(phase string) {
h.mu.Lock()
changed := h.job.Phase != phase
h.job.Phase = phase
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
if changed {
h.reg.flush()
return
}
h.reg.touch()
}
// SetProgress updates the progress numerator and denominator. Pass a
// total of zero to render the job as indeterminate.
func (h *Handle) SetProgress(current, total int64) {
h.mu.Lock()
h.job.Current = current
h.job.Total = total
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetSubtitle updates the secondary line shown under the job title.
func (h *Handle) SetSubtitle(subtitle string) {
h.mu.Lock()
h.job.Subtitle = subtitle
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetStats replaces the job's display statistics.
func (h *Handle) SetStats(stats []Stat) {
h.mu.Lock()
h.job.Stats = stats
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetStages replaces the job's stage list. Used by multi-tier jobs such
// as the index build.
func (h *Handle) SetStages(stages []Stage) {
h.mu.Lock()
h.job.Stages = stages
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetCaps updates which controls the job currently supports.
func (h *Handle) SetCaps(caps Caps) {
h.mu.Lock()
h.job.Caps = caps
h.mu.Unlock()
h.reg.touch()
}
// Logf appends a line to the job's log ring buffer.
func (h *Handle) Logf(level Level, message string) {
h.logEntry(level, message, "")
}
// LogDetail appends a log line carrying a secondary detail string, such
// as the file path a warning refers to.
func (h *Handle) LogDetail(level Level, message, detail string) {
h.logEntry(level, message, detail)
}
func (h *Handle) logEntry(level Level, message, detail string) {
h.mu.Lock()
if len(h.log) >= maxLogEntries {
// Drop the oldest entry. Log volume is low enough (phase
// transitions and per-file warnings) that the copy is cheaper
// than maintaining an explicit ring index.
h.log = append(h.log[:0], h.log[1:]...)
}
h.log = append(h.log, LogEntry{
Time: nowMillis(),
Level: level,
Message: message,
Detail: detail,
})
h.job.LogCount++
switch level {
case LevelWarn:
h.job.WarnCount++
case LevelError:
h.job.ErrorCount++
case LevelInfo:
}
h.mu.Unlock()
h.reg.touch()
}
// Complete marks the job finished successfully.
func (h *Handle) Complete() {
h.SetPhase("")
h.SetState(StateComplete)
}
// Cancelled marks the job as abandoned at the user's request.
func (h *Handle) Cancelled() {
h.SetPhase("")
h.SetState(StateCancelled)
}
// Fail marks the job as errored and records the message.
func (h *Handle) Fail(err error) {
h.mu.Lock()
h.job.Error = err.Error()
h.mu.Unlock()
h.Logf(LevelError, err.Error())
h.SetState(StateError)
}
func nowMillis() int64 {
return time.Now().UnixMilli()
}
+326
View File
@@ -0,0 +1,326 @@
package jobs
import (
"errors"
"log/slog"
"sync"
"testing"
)
var errBoom = errors.New("boom")
func testRegistry() *Registry {
return NewRegistry(slog.New(slog.DiscardHandler), nil)
}
func TestStartRegistersJob(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Title: "Scanning Music",
Caps: Caps{Pausable: true, Cancellable: true},
})
if h == nil {
t.Fatal("expected a handle")
}
snap := r.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 job, got %d", len(snap))
}
if snap[0].State != StateRunning {
t.Errorf("expected default state running, got %q", snap[0].State)
}
if snap[0].Title != "Scanning Music" {
t.Errorf("unexpected title %q", snap[0].Title)
}
}
func TestStartReusesHandleAndLog(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan, State: StateQueued})
h.Logf(LevelInfo, "queued")
// A queued scan being popped off the queue re-registers the same ID.
again := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
if again != h {
t.Fatal("expected the same handle to be reused")
}
if got := len(r.Logs("scan:1")); got != 1 {
t.Errorf("expected the log to survive re-registration, got %d entries", got)
}
if again.State() != StateRunning {
t.Errorf("expected state running after restart, got %q", again.State())
}
if len(r.Snapshot()) != 1 {
t.Error("re-registering should not duplicate the job")
}
}
func TestLogRingIsBounded(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
for range maxLogEntries + 50 {
h.Logf(LevelWarn, "warning")
}
logs := r.Logs("scan:1")
if len(logs) != maxLogEntries {
t.Errorf("expected log capped at %d, got %d", maxLogEntries, len(logs))
}
// LogCount keeps counting past the ring so the UI can show that
// entries were dropped.
if got := h.Snapshot().LogCount; got != maxLogEntries+50 {
t.Errorf("expected LogCount %d, got %d", maxLogEntries+50, got)
}
if got := h.Snapshot().WarnCount; got != maxLogEntries+50 {
t.Errorf("expected WarnCount %d, got %d", maxLogEntries+50, got)
}
}
func TestTerminalStatesStampEndedAt(t *testing.T) {
t.Parallel()
tests := []struct {
name string
apply func(*Handle)
want State
}{
{"complete", func(h *Handle) { h.Complete() }, StateComplete},
{"cancelled", func(h *Handle) { h.Cancelled() }, StateCancelled},
{"failed", func(h *Handle) { h.Fail(errBoom) }, StateError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
tt.apply(h)
snap := h.Snapshot()
if snap.State != tt.want {
t.Errorf("expected state %q, got %q", tt.want, snap.State)
}
if !snap.State.IsTerminal() {
t.Error("expected a terminal state")
}
if snap.EndedAt == 0 {
t.Error("expected EndedAt to be stamped")
}
})
}
}
func TestFailRecordsErrorMessage(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "index:build", Kind: KindIndexBuild})
h.Fail(errBoom)
if got := h.Snapshot().Error; got != "boom" {
t.Errorf("expected error %q, got %q", "boom", got)
}
if got := h.Snapshot().ErrorCount; got != 1 {
t.Errorf("expected the failure to be logged, got ErrorCount %d", got)
}
}
func TestPauseInvokesControlAndMarksPausing(t *testing.T) {
t.Parallel()
r := testRegistry()
var wg sync.WaitGroup
wg.Add(1)
r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: true},
Controls: Controls{Pause: wg.Done},
})
r.Pause("scan:1")
wg.Wait()
// The producer confirms StatePaused; the registry only promises
// the intermediate "pausing" state.
if got := r.Get("scan:1").State(); got != StatePausing {
t.Errorf("expected state pausing, got %q", got)
}
}
func TestControlsRespectCapabilities(t *testing.T) {
t.Parallel()
r := testRegistry()
called := false
r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: false, Cancellable: false},
Controls: Controls{Pause: func() { called = true }, Cancel: func() { called = true }},
})
r.Pause("scan:1")
r.Cancel("scan:1")
if called {
t.Error("controls must not fire for a job that declares no capability")
}
if got := r.Get("scan:1").State(); got != StateRunning {
t.Errorf("expected state to be unchanged, got %q", got)
}
}
func TestControlsIgnoreTerminalJobs(t *testing.T) {
t.Parallel()
r := testRegistry()
called := false
h := r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: true, Cancellable: true},
Controls: Controls{Pause: func() { called = true }, Cancel: func() { called = true }},
})
h.Complete()
r.Pause("scan:1")
r.Cancel("scan:1")
if called {
t.Error("controls must not fire for a finished job")
}
}
func TestControlsOnUnknownJobAreNoOps(t *testing.T) {
t.Parallel()
r := testRegistry()
// Stale IDs arrive from a frontend holding an old snapshot.
r.Pause("scan:404")
r.Resume("scan:404")
r.Cancel("scan:404")
if got := len(r.Logs("scan:404")); got != 0 {
t.Errorf("expected no logs for an unknown job, got %d", got)
}
}
func TestClearFinishedKeepsActiveJobs(t *testing.T) {
t.Parallel()
r := testRegistry()
done := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
done.Complete()
r.Start(Spec{ID: "scan:2", Kind: KindLibraryScan})
r.ClearFinished()
snap := r.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 job to survive, got %d", len(snap))
}
if snap[0].ID != "scan:2" {
t.Errorf("expected the active job to survive, kept %q", snap[0].ID)
}
}
func TestHasActive(t *testing.T) {
t.Parallel()
r := testRegistry()
if r.HasActive() {
t.Error("an empty registry has no active jobs")
}
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
if !r.HasActive() {
t.Error("expected the running job to count as active")
}
h.Complete()
if r.HasActive() {
t.Error("a completed job is not active")
}
}
func TestSnapshotIsADeepCopy(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "index:build", Kind: KindIndexBuild})
h.SetStages([]Stage{{Name: "Catalog Import", State: "running"}})
snap := h.Snapshot()
snap.Stages[0].State = "mutated"
if got := h.Snapshot().Stages[0].State; got != "running" {
t.Errorf("mutating a snapshot leaked into the job: %q", got)
}
}
func TestConcurrentUpdatesAreSafe(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func(n int) {
defer wg.Done()
for range 100 {
h.SetProgress(int64(n), 100)
h.Logf(LevelWarn, "concurrent")
_ = r.Snapshot()
}
}(i)
}
wg.Wait()
if got := h.Snapshot().WarnCount; got != 800 {
t.Errorf("expected 800 warnings, got %d", got)
}
}
+50
View File
@@ -0,0 +1,50 @@
package jobs
// Service is the Wails-bound facade over the registry. It deliberately
// exposes only the read and control surface — producers get a *Handle
// through the registry instead, so the frontend cannot invent jobs.
type Service struct {
reg *Registry
}
// NewService wraps a registry for frontend binding.
func NewService(reg *Registry) *Service {
return &Service{reg: reg}
}
// GetJobs returns every known job — active first by registration order,
// including recently finished ones so the panel can show outcomes.
func (s *Service) GetJobs() []Job {
return s.reg.Snapshot()
}
// GetJobLog returns the retained log tail for one job.
func (s *Service) GetJobLog(id string) []LogEntry {
return s.reg.Logs(id)
}
// PauseJob asks the owning subsystem to pause a job. Returns
// immediately; the job reports "paused" once it actually stops.
func (s *Service) PauseJob(id string) {
s.reg.Pause(id)
}
// ResumeJob continues a paused job.
func (s *Service) ResumeJob(id string) {
s.reg.Resume(id)
}
// CancelJob abandons a job.
func (s *Service) CancelJob(id string) {
s.reg.Cancel(id)
}
// DismissJob removes a single finished job from the list.
func (s *Service) DismissJob(id string) {
s.reg.Remove(id)
}
// ClearFinishedJobs removes every terminal job from the list.
func (s *Service) ClearFinishedJobs() {
s.reg.ClearFinished()
}
+123
View File
@@ -0,0 +1,123 @@
package jobs
import (
"log/slog"
"time"
"yellowjacket/backend/database"
)
// Persisted is a job whose paused state outlived the process that
// created it. The owning subsystem adopts these back into the registry
// during startup, re-attaching the controls needed to resume.
type Persisted struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
}
// Store persists durable job state to the job_state table.
type Store struct {
db *database.DB
logger *slog.Logger
}
// NewStore creates a job state store backed by the application database.
func NewStore(db *database.DB, logger *slog.Logger) *Store {
return &Store{db: db, logger: logger}
}
// SetPaused records that a job is paused.
func (s *Store) SetPaused(p Persisted) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
`INSERT OR REPLACE INTO job_state`+
` (id, kind, title, subtitle, paused_at)`+
` VALUES (?, ?, ?, ?, ?)`,
p.ID, string(p.Kind), p.Title, p.Subtitle,
time.Now().UTC().Format(time.RFC3339),
); err != nil {
s.logger.Warn("jobs: could not persist paused job",
"id", p.ID, "err", err)
}
}
// ClearPaused removes a job's durable pause record.
func (s *Store) ClearPaused(id string) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
"DELETE FROM job_state WHERE id = ?", id,
); err != nil {
s.logger.Warn("jobs: could not clear paused job",
"id", id, "err", err)
}
}
// PausedEntries returns every persisted paused job of the given kind.
func (s *Store) PausedEntries(kind Kind) []Persisted {
if s == nil || s.db == nil {
return nil
}
rows, err := s.db.QueryContext(
`SELECT id, kind, title, subtitle FROM job_state`+
` WHERE kind = ? ORDER BY paused_at`,
string(kind),
)
if err != nil {
s.logger.Warn("jobs: could not read paused jobs",
"kind", kind, "err", err)
return nil
}
defer func() { _ = rows.Close() }()
var out []Persisted
for rows.Next() {
var (
p Persisted
kindText string
)
if scanErr := rows.Scan(
&p.ID, &kindText, &p.Title, &p.Subtitle,
); scanErr != nil {
s.logger.Warn("jobs: could not scan paused job", "err", scanErr)
continue
}
p.Kind = Kind(kindText)
out = append(out, p)
}
return out
}
// IsPaused reports whether the given job ID has a durable pause record.
// Subsystems check this before auto-starting work at launch.
func (s *Store) IsPaused(id string) bool {
if s == nil || s.db == nil {
return false
}
rows, err := s.db.QueryContext(
"SELECT 1 FROM job_state WHERE id = ?", id,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}