T1.1: Create src/domain; move Job/RunRecord/Config/JobsFile/StartInTrayArgument

Extracts the five domain types out of src/core/model.go into a new
src/domain package (job.go, record.go, config.go). The unexported
nextDue field is promoted to NextDue so it is accessible from core.
All references across src/core, src/gui, and cmd/gosentry are updated
to use domain.TypeName. src/core/model.go is reduced to a bare package
declaration. Windows and Linux cross-compilation both pass; all tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-18 21:18:57 +03:00
parent 462752f995
commit 80c76a0cba
17 changed files with 164 additions and 143 deletions
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
const autostartDesktopFileName = "gosentry.desktop"
@@ -43,7 +45,7 @@ Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
@@ -75,7 +77,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
}
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
+4 -2
View File
@@ -6,6 +6,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
const autostartName = "GoSentry"
@@ -69,7 +71,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != StartInTrayArgument {
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
}
return true, "Autostart is configured"
@@ -101,7 +103,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
+6 -4
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"syscall"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestParseRegistryRunValue(t *testing.T) {
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
@@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
-67
View File
@@ -1,68 +1 @@
package core
import "time"
// 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"`
}
// Job is the user-visible scheduled command.
//
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state.
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"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// nextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting
// must not persist it.
nextDue time.Time
}
// 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"`
}
+11 -9
View File
@@ -13,12 +13,14 @@ import (
"strings"
"time"
"unicode"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
started := time.Now()
// Commands can hang forever if a script waits for input or a child process
// stalls. A fixed timeout is a conservative first guardrail for a desktop
@@ -57,7 +59,7 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
job.Output = output
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
record := RunRecord{
record := domain.RunRecord{
Time: job.LastRun,
JobID: job.ID,
JobName: job.Name,
@@ -70,7 +72,7 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
// Keep a small in-memory history for the currently running GUI. Full command
// output is persisted to files, so retaining every past record in RAM would
// only duplicate data and make long sessions grow without bound.
job.Logs = append([]RunRecord{record}, job.Logs...)
job.Logs = append([]domain.RunRecord{record}, job.Logs...)
if len(job.Logs) > 50 {
job.Logs = job.Logs[:50]
}
@@ -128,7 +130,7 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
return nil
}
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
@@ -171,7 +173,7 @@ func sanitizeFileName(name string) string {
return result
}
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
configureHiddenWindow(command)
@@ -188,7 +190,7 @@ func startJobOnly(invocation commandInvocation, job Job, started time.Time) (str
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job Job, pid int) string {
func startOnlyOutput(job domain.Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
@@ -204,7 +206,7 @@ func startOnlyOutput(job Job, pid int) string {
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
@@ -259,7 +261,7 @@ func parseExitCodes(value string) []int {
return result
}
func successExitCodesText(job Job) string {
func successExitCodesText(job domain.Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
@@ -273,7 +275,7 @@ type commandInvocation struct {
hideWindow bool
}
func jobInvocation(ctx context.Context, job Job) commandInvocation {
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
+14 -12
View File
@@ -8,11 +8,13 @@ import (
"strings"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
@@ -61,7 +63,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
}
func TestRunJobRecordFields(t *testing.T) {
job := Job{
job := domain.Job{
ID: 55,
Name: "Record Fields Test",
Command: echoCommand("record field check"),
@@ -145,7 +147,7 @@ func TestSanitizeFileName(t *testing.T) {
func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 42,
Name: "Hello Test",
Command: echoCommand("hello from test"),
@@ -180,7 +182,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
}
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 43,
Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
@@ -209,7 +211,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err)
}
job := Job{
job := domain.Job{
ID: 44,
Name: "Unquoted Windows Command",
Command: scriptPath,
@@ -230,7 +232,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
}
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 45,
Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`,
@@ -251,7 +253,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
job := domain.Job{
ID: 46,
Name: "Accepted Exit Code",
Command: command,
@@ -275,7 +277,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
job := domain.Job{
ID: 47,
Name: "Rejected Exit Code",
Command: command,
@@ -301,7 +303,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7"
}
job := Job{
job := domain.Job{
ID: 48,
Name: "Start Only",
Command: command,
@@ -322,7 +324,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
}
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{
job := domain.Job{
ID: 49,
Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command",
@@ -357,7 +359,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{
invocation := jobInvocation(context.Background(), domain.Job{
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process",
})
@@ -371,7 +373,7 @@ func TestShellCommandHidesWindow(t *testing.T) {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
+13 -12
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"github.com/robfig/cron/v3"
)
@@ -18,8 +19,8 @@ var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month
// still in one desktop process.
type Scheduler struct {
store *Store
jobs *[]Job
onChange func(RunRecord)
jobs *[]domain.Job
onChange func(domain.RunRecord)
mu sync.Mutex
ctx context.Context
@@ -27,7 +28,7 @@ type Scheduler struct {
paused bool
}
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
func NewScheduler(store *Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
store: store,
@@ -125,7 +126,7 @@ func (s *Scheduler) tick(now time.Time) {
if !s.paused {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
if !job.Enabled || job.NextDue.IsZero() || now.Before(job.NextDue) {
continue
}
// Run only one due job per tick for now. That avoids overlapping shell
@@ -150,7 +151,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
job.LastState = "Running"
job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{}
job.NextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs)
go func() {
@@ -161,7 +162,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
current.LastRun = record.Time
current.LastState = record.State
current.Output = record.Output
current.Logs = append([]RunRecord{record}, current.Logs...)
current.Logs = append([]domain.RunRecord{record}, current.Logs...)
if len(current.Logs) > 50 {
current.Logs = current.Logs[:50]
}
@@ -178,7 +179,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
return true
}
func (s *Scheduler) findJobByIDLocked(id int) *Job {
func (s *Scheduler) findJobByIDLocked(id int) *domain.Job {
for index := range *s.jobs {
if (*s.jobs)[index].ID == id {
return &(*s.jobs)[index]
@@ -187,7 +188,7 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job {
return nil
}
func runningOutput(job Job, trigger string, started time.Time) string {
func runningOutput(job domain.Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
@@ -216,15 +217,15 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
func (s *Scheduler) prepareNextRun(job *domain.Job, from time.Time) {
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
job.NextDue = time.Time{}
return
}
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
job.NextDue = next
job.NextRun = job.NextDue.Format("2006-01-02 15:04:05")
}
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
+9 -7
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
@@ -30,7 +32,7 @@ func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
}
func TestPrepareNextRunSetsDisplayString(t *testing.T) {
jobs := []Job{{Schedule: "*/5 * * * *", Enabled: true}}
jobs := []domain.Job{{Schedule: "*/5 * * * *", Enabled: true}}
s := &Scheduler{jobs: &jobs}
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
@@ -41,13 +43,13 @@ func TestPrepareNextRunSetsDisplayString(t *testing.T) {
t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want)
}
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !jobs[0].nextDue.Equal(wantDue) {
t.Errorf("nextDue: got %v, want %v", jobs[0].nextDue, wantDue)
if !jobs[0].NextDue.Equal(wantDue) {
t.Errorf("NextDue: got %v, want %v", jobs[0].NextDue, wantDue)
}
}
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
jobs := []Job{{Schedule: "not-a-cron", Enabled: true}}
jobs := []domain.Job{{Schedule: "not-a-cron", Enabled: true}}
s := &Scheduler{jobs: &jobs}
s.prepareNextRun(&jobs[0], time.Now())
@@ -55,8 +57,8 @@ func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
if jobs[0].NextRun != "Invalid schedule" {
t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun)
}
if !jobs[0].nextDue.IsZero() {
t.Errorf("nextDue should be zero for invalid schedule, got %v", jobs[0].nextDue)
if !jobs[0].NextDue.IsZero() {
t.Errorf("NextDue should be zero for invalid schedule, got %v", jobs[0].NextDue)
}
}
@@ -85,7 +87,7 @@ func TestNextRunTimeSupportsCron(t *testing.T) {
func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{
job := domain.Job{
Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
+15 -14
View File
@@ -7,15 +7,16 @@ import (
"runtime"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
Paths Paths
Config Config
Config domain.Config
}
func OpenStore() (*Store, []Job, error) {
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
return nil, nil, err
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
return writeYAML(s.Paths.ConfigPath, s.Config)
}
func (s *Store) SaveJobs(jobs []Job) error {
func (s *Store) SaveJobs(jobs []domain.Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
}
func loadOrCreateConfig(paths Paths) (Config, error) {
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory.
config := Config{
config := domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return Config{}, err
return domain.Config{}, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return Config{}, err
return domain.Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
return config, nil
}
func loadOrCreateJobs(path string) ([]Job, error) {
func loadOrCreateJobs(path string) ([]domain.Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// The first run creates harmless sample jobs so a new user can immediately
// see scheduled and manual execution without inventing a command.
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file JobsFile
var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
return file.Jobs, nil
}
func normalizeJobs(jobs []Job) {
func normalizeJobs(jobs []domain.Job) {
next := 1
for index := range jobs {
job := &jobs[index]
@@ -222,8 +223,8 @@ func writeYAML(path string, value any) error {
return os.WriteFile(path, data, 0o644)
}
func defaultJobs() []Job {
return []Job{
func defaultJobs() []domain.Job {
return []domain.Job{
{
ID: 1,
Name: "Hello scheduler",
+8 -7
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
@@ -12,7 +13,7 @@ func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml")
original := []Job{
original := []domain.Job{
{
ID: 7,
Name: "Backup data",
@@ -26,7 +27,7 @@ func TestJobsRoundTrip(t *testing.T) {
},
}
if err := writeYAML(path, JobsFile{Jobs: original}); err != nil {
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
@@ -86,7 +87,7 @@ func TestConfigRoundTrip(t *testing.T) {
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := Config{
want := domain.Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
@@ -128,7 +129,7 @@ func TestConfigRoundTrip(t *testing.T) {
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []Job{
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
@@ -174,7 +175,7 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
}
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
jobs := []domain.Job{
{
ID: 1,
Name: "Clean job",
@@ -185,13 +186,13 @@ func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
Logs: []domain.RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
+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"`
}
+30
View File
@@ -0,0 +1,30 @@
package domain
import "time"
// Job is the user-visible scheduled command.
//
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state.
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"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// NextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting
// must not persist it.
NextDue time.Time `yaml:"-"`
}
+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"`
}
+4 -3
View File
@@ -14,6 +14,7 @@ import (
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
@@ -39,8 +40,8 @@ const singleInstanceShowCommand = "show"
// The GUI package aliases core types to keep widget callbacks short. The actual
// durable model still lives in src/core, so GUI code does not define a second
// copy of the scheduler data.
type job = core.Job
type event = core.RunRecord
type job = domain.Job
type event = domain.RunRecord
func Run(startInTray bool) {
started := time.Now()
@@ -501,7 +502,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
jobLogs,
)
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
scheduler = core.NewScheduler(store, &jobs, func(record domain.RunRecord) {
// Scheduled runs happen on the scheduler goroutine. The callback updates
// the shared in-memory event list so History reflects background activity.
events = append(events, record)