Refactoring complete: v0.4.0 architectural milestone (#1)

## Summary

Completed Phase 5 refactoring and reached the target architecture.

**Architectural milestone achieved:**
- Service layer owns all state and is the sole writer
- UI is a thin Fyne view, all widget updates marshaled via `fyne.Do`
- Core engines are stateless and injectable
- Domain types are pure (no `yaml:"-"` fields)
- Full module builds and `go vet ./...` clean

## Changes

- Bump version: 0.3.6 → 0.4.0
- Update CHANGELOG with Phase 5 summary
- Add ROADMAP "Refactoring Follow-Ups" section

## Known follow-up work

1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag
2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines)

See ROADMAP.md for details.

---------

Co-authored-by: mixeme <mix.public@ya.ru>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-22 08:05:10 +03:00
parent d24211cab2
commit 01fd572a89
74 changed files with 5424 additions and 2712 deletions
+55
View File
@@ -0,0 +1,55 @@
package scheduler
import "time"
// Clock supplies the scheduler with the current time and a stream of ticks.
// Hiding both behind an interface lets tests drive the loop deterministically —
// firing ticks and controlling "now" — instead of waiting on the wall clock.
// Production uses RealClock.
type Clock interface {
// Now returns the current time. It is the value passed to the tick callback
// on each tick, so a fake can make due-evaluation deterministic.
Now() time.Time
// Ticks returns a channel that delivers a value on every scheduler tick. The
// scheduler reads it for the lifetime of the loop.
Ticks() <-chan time.Time
// Stop releases the resources backing Ticks. The scheduler calls it once when
// the loop exits.
Stop()
}
// RealClock is the production Clock: wall-clock time and a one-second ticker.
//
// A one-second cadence is accurate enough for cron-style desktop automation —
// five-field cron expressions have minute precision, while @every values may be
// shorter for testing and lightweight local tasks — and it keeps a single timer
// instead of one per job.
type RealClock struct {
ticker *time.Ticker
}
// NewRealClock returns a real clock. The underlying ticker is created lazily on
// the first Ticks call so a clock that is never started leaks nothing.
func NewRealClock() *RealClock {
return &RealClock{}
}
// Now returns the wall-clock time.
func (c *RealClock) Now() time.Time {
return time.Now()
}
// Ticks starts (once) and returns the one-second ticker channel.
func (c *RealClock) Ticks() <-chan time.Time {
if c.ticker == nil {
c.ticker = time.NewTicker(time.Second)
}
return c.ticker.C
}
// Stop halts the ticker if it was ever started.
func (c *RealClock) Stop() {
if c.ticker != nil {
c.ticker.Stop()
}
}
+56
View File
@@ -0,0 +1,56 @@
package scheduler
import (
"context"
"time"
)
// Scheduler is a thin timing loop. It owns no job or runtime state: on every
// clock tick it calls the injected tick function with the current time, and that
// function — the application service's RunDue — decides what, if anything, to
// run. Keeping all state and mutation in the service makes the service the sole
// writer (resolving the old shared-*[]Job data race) and reduces the scheduler
// to a loop that is trivially testable with a fake Clock.
type Scheduler struct {
clock Clock
tick func(now time.Time)
ctx context.Context
cancel context.CancelFunc
}
// NewScheduler builds a scheduler that calls tick on every Clock tick. The clock
// is injected so tests can drive the loop without the wall clock.
func NewScheduler(clock Clock, tick func(now time.Time)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
return &Scheduler{
clock: clock,
tick: tick,
ctx: ctx,
cancel: cancel,
}
}
// Start launches the loop on its own goroutine and returns immediately.
func (s *Scheduler) Start() {
go func() {
ticks := s.clock.Ticks()
defer s.clock.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticks:
// Pass the clock's notion of "now" rather than the tick value so a
// fake clock can control due-evaluation precisely.
s.tick(s.clock.Now())
}
}
}()
}
// Stop ends the loop. A tick already in progress finishes; no further ticks are
// delivered.
func (s *Scheduler) Stop() {
s.cancel()
}
+85
View File
@@ -0,0 +1,85 @@
package scheduler
import (
"sync"
"testing"
"time"
)
// fakeClock is a Clock whose ticks and "now" are driven by the test instead of
// the wall clock, so the scheduler loop can be exercised deterministically.
type fakeClock struct {
ticks chan time.Time
mu sync.Mutex
now time.Time
stopped bool
}
func newFakeClock(now time.Time) *fakeClock {
return &fakeClock{ticks: make(chan time.Time, 1), now: now}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *fakeClock) Stop() {
c.mu.Lock()
c.stopped = true
c.mu.Unlock()
}
func (c *fakeClock) isStopped() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.stopped
}
// fire advances the clock to t and delivers one tick.
func (c *fakeClock) fire(t time.Time) {
c.mu.Lock()
c.now = t
c.mu.Unlock()
c.ticks <- t
}
func TestSchedulerCallsTickWithClockNow(t *testing.T) {
clock := newFakeClock(time.Unix(0, 0))
got := make(chan time.Time, 1)
s := NewScheduler(clock, func(now time.Time) { got <- now })
s.Start()
defer s.Stop()
want := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
clock.fire(want)
select {
case now := <-got:
if !now.Equal(want) {
t.Errorf("tick now = %v, want %v", now, want)
}
case <-time.After(time.Second):
t.Fatal("scheduler did not call tick after a clock tick")
}
}
func TestSchedulerStopReleasesClock(t *testing.T) {
clock := newFakeClock(time.Now())
s := NewScheduler(clock, func(time.Time) {})
s.Start()
s.Stop()
// After Stop the loop exits and releases the clock via the deferred Stop.
deadline := time.Now().Add(time.Second)
for !clock.isStopped() {
if time.Now().After(deadline) {
t.Fatal("clock was not stopped after scheduler Stop")
}
time.Sleep(time.Millisecond)
}
}