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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user