diff --git a/cmd/gosentry/main.go b/cmd/gosentry/main.go index ab6b23d..ac013e4 100644 --- a/cmd/gosentry/main.go +++ b/cmd/gosentry/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "gitea.mixdep.ru/mix/gosentry/src/core" + "gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/gui" ) @@ -11,7 +11,7 @@ func main() { // The executable entry point intentionally delegates all startup work to the // GUI package. Keeping main small makes it easier to add platform-specific // packaging later without mixing window setup, storage, and scheduler logic. - gui.Run(hasArgument(core.StartInTrayArgument)) + gui.Run(hasArgument(domain.StartInTrayArgument)) } func hasArgument(argument string) bool { diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index a78de8a..7ca1a98 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -244,7 +244,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T0.2 — Add characterization tests ### Phase 1 — Split flat `core` package -- [ ] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc +- [x] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc - [ ] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow` - [ ] T1.3 — Create `src/runner`; move runner logic - [ ] T1.4 — Create `src/scheduler`; move scheduler diff --git a/src/core/autostart_linux.go b/src/core/autostart_linux.go index a657b7e..670a6d3 100644 --- a/src/core/autostart_linux.go +++ b/src/core/autostart_linux.go @@ -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" } diff --git a/src/core/autostart_linux_test.go b/src/core/autostart_linux_test.go index a0c3234..52f1a66 100644 --- a/src/core/autostart_linux_test.go +++ b/src/core/autostart_linux_test.go @@ -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) } diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go index dbf3633..274b22f 100644 --- a/src/core/autostart_windows.go +++ b/src/core/autostart_windows.go @@ -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, ) diff --git a/src/core/autostart_windows_test.go b/src/core/autostart_windows_test.go index 99bd0fa..827e22d 100644 --- a/src/core/autostart_windows_test.go +++ b/src/core/autostart_windows_test.go @@ -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) } } diff --git a/src/core/model.go b/src/core/model.go index 2c023de..9a8bc95 100644 --- a/src/core/model.go +++ b/src/core/model.go @@ -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"` -} diff --git a/src/core/runner.go b/src/core/runner.go index e70fd4a..6e88f33 100644 --- a/src/core/runner.go +++ b/src/core/runner.go @@ -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) { diff --git a/src/core/runner_test.go b/src/core/runner_test.go index 3ab66ca..5d22bdb 100644 --- a/src/core/runner_test.go +++ b/src/core/runner_test.go @@ -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") } diff --git a/src/core/scheduler.go b/src/core/scheduler.go index 004bec4..b34ccec 100644 --- a/src/core/scheduler.go +++ b/src/core/scheduler.go @@ -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) { diff --git a/src/core/scheduler_test.go b/src/core/scheduler_test.go index 3076a9d..11eab80 100644 --- a/src/core/scheduler_test.go +++ b/src/core/scheduler_test.go @@ -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`, diff --git a/src/core/store.go b/src/core/store.go index ce347f9..e8bfce3 100644 --- a/src/core/store.go +++ b/src/core/store.go @@ -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", diff --git a/src/core/store_test.go b/src/core/store_test.go index 2b98b6e..e7e39fa 100644 --- a/src/core/store_test.go +++ b/src/core/store_test.go @@ -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) } diff --git a/src/domain/config.go b/src/domain/config.go new file mode 100644 index 0000000..3bfb3b1 --- /dev/null +++ b/src/domain/config.go @@ -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"` +} diff --git a/src/domain/job.go b/src/domain/job.go new file mode 100644 index 0000000..b8294d8 --- /dev/null +++ b/src/domain/job.go @@ -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:"-"` +} diff --git a/src/domain/record.go b/src/domain/record.go new file mode 100644 index 0000000..4ec8b03 --- /dev/null +++ b/src/domain/record.go @@ -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"` +} diff --git a/src/gui/app.go b/src/gui/app.go index c86ccf9..8bd1adc 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -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)