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
+25
View File
@@ -0,0 +1,25 @@
package domain
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
+18
View File
@@ -0,0 +1,18 @@
package domain
// Job is the user-visible scheduled command. It contains only durable
// configuration: every field is persisted to jobs.yaml. Transient execution
// state (last run, next run, command output, in-memory activity) lives in a
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
// configuration and never mixes in process-lifetime bookkeeping.
type Job struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
}
+15
View File
@@ -0,0 +1,15 @@
package domain
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
+49
View File
@@ -0,0 +1,49 @@
package domain
import "time"
// JobRuntime is the transient execution state for a Job. It is never written to
// jobs.yaml: it is rebuilt from scratch each time GoSentry starts and is held in
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate
// from Job is what lets the durable configuration file stay free of run records,
// status strings, and scheduling bookkeeping.
type JobRuntime struct {
LastRun string
NextRun string
LastState string
Output string
Logs []RunRecord
// NextDue is the next scheduled execution time, kept as time.Time for
// scheduler comparisons. NextRun above is its formatted display string and is
// the only form shown in the GUI.
NextDue time.Time
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
// job. Enabled jobs start "Ready" and wait for the scheduler to compute their
// first run; disabled jobs start "Paused".
func NewRuntime(job Job) *JobRuntime {
runtime := &JobRuntime{
LastRun: "Never",
Output: "No command output captured yet.",
}
if job.Enabled {
runtime.LastState = "Ready"
runtime.NextRun = "After start"
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
}
return runtime
}
// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is
// the convenience entry point used when a whole jobs file has just been loaded.
func NewRuntimes(jobs []Job) map[int]*JobRuntime {
runtimes := make(map[int]*JobRuntime, len(jobs))
for _, job := range jobs {
runtimes[job.ID] = NewRuntime(job)
}
return runtimes
}
+87
View File
@@ -0,0 +1,87 @@
package domain
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// cronParser accepts standard five-field cron expressions (minute, hour, day of
// month, month, day of week) plus descriptors such as "@daily". It is the single
// source of truth for what GoSentry considers a valid cron schedule.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// everyPrefix marks the "@every <duration>" form, which is kept alongside cron
// because it is convenient for quick tests and for simple intervals that are
// awkward to express as five fields.
const everyPrefix = "@every "
// Schedule is a parsed, validated job schedule. It supports two forms:
//
// - "@every <duration>" intervals (e.g. "@every 10s"), and
// - standard five-field cron expressions (e.g. "*/5 * * * *").
//
// Parsing once and reusing the value avoids re-validating and re-parsing the
// same string on every scheduler tick. A zero Schedule is invalid; its Next
// method returns the zero time.
type Schedule struct {
raw string
every time.Duration // > 0 when the schedule is an "@every" interval
cron cron.Schedule // non-nil when the schedule is a cron expression
}
// Parse validates spec and returns a reusable Schedule. It returns an error
// describing why the schedule is unusable, which callers can surface to the user.
func Parse(spec string) (Schedule, error) {
trimmed := strings.TrimSpace(spec)
if trimmed == "" {
return Schedule{}, fmt.Errorf("schedule is empty")
}
if strings.HasPrefix(trimmed, everyPrefix) {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(trimmed, everyPrefix)))
if err != nil {
return Schedule{}, fmt.Errorf("invalid %q duration: %w", strings.TrimSpace(everyPrefix), err)
}
if interval <= 0 {
return Schedule{}, fmt.Errorf("%q duration must be positive, got %s", strings.TrimSpace(everyPrefix), interval)
}
return Schedule{raw: trimmed, every: interval}, nil
}
// robfig/cron handles edge cases such as ranges, steps, and day-of-week names,
// keeping GoSentry compatible with the mental model users know from Unix cron.
parsed, err := cronParser.Parse(trimmed)
if err != nil {
return Schedule{}, fmt.Errorf("invalid cron expression: %w", err)
}
return Schedule{raw: trimmed, cron: parsed}, nil
}
// Validate reports whether spec is a usable schedule string. It is a convenience
// wrapper around Parse for callers (such as form validation) that only need the
// yes/no answer and the error message.
func Validate(spec string) error {
_, err := Parse(spec)
return err
}
// Next returns the next time the schedule fires strictly after from. For an
// "@every" interval this is from plus the interval; for a cron expression it is
// the cron library's next matching time. A zero (unparsed) Schedule returns the
// zero time.
func (s Schedule) Next(from time.Time) time.Time {
switch {
case s.every > 0:
return from.Add(s.every)
case s.cron != nil:
return s.cron.Next(from)
default:
return time.Time{}
}
}
// String returns the original, trimmed schedule specification.
func (s Schedule) String() string {
return s.raw
}
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"testing"
"time"
)
func TestParseRejectsInvalidSchedules(t *testing.T) {
cases := []struct {
spec string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every ", "@every with trailing space but no duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
{"* * * *", "too few cron fields"},
}
for _, tc := range cases {
if _, err := Parse(tc.spec); err == nil {
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
if err := Validate(tc.spec); err == nil {
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
}
}
func TestParseEveryInterval(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse("@every 10s")
if err != nil {
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse(" @every 90m ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronExpression(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("*/5 * * * *")
if err != nil {
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronDescriptor(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("@daily")
if err != nil {
t.Fatalf("Parse(@daily): unexpected error: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestValidateAcceptsValidSchedules(t *testing.T) {
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
if err := Validate(spec); err != nil {
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
}
}
}
func TestZeroScheduleNextIsZero(t *testing.T) {
var s Schedule
if got := s.Next(time.Now()); !got.IsZero() {
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
}
}
func TestStringReturnsTrimmedSpec(t *testing.T) {
s, err := Parse(" */5 * * * * ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.String(), "*/5 * * * *"; got != want {
t.Fatalf("String: got %q, want %q", got, want)
}
}