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