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
+2 -2
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"os" "os"
"gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/gui" "gitea.mixdep.ru/mix/gosentry/src/gui"
) )
@@ -11,7 +11,7 @@ func main() {
// The executable entry point intentionally delegates all startup work to the // The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific // GUI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic. // 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 { func hasArgument(argument string) bool {
+1 -1
View File
@@ -244,7 +244,7 @@ Track progress here. Mark tasks complete as they land and pass review.
- [x] T0.2 — Add characterization tests - [x] T0.2 — Add characterization tests
### Phase 1 — Split flat `core` package ### 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.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
- [ ] T1.3 — Create `src/runner`; move runner logic - [ ] T1.3 — Create `src/runner`; move runner logic
- [ ] T1.4 — Create `src/scheduler`; move scheduler - [ ] T1.4 — Create `src/scheduler`; move scheduler
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
const autostartDesktopFileName = "gosentry.desktop" const autostartDesktopFileName = "gosentry.desktop"
@@ -43,7 +45,7 @@ Exec=%s %s
%s %s
Terminal=false Terminal=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath)) `, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
} }
@@ -75,7 +77,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil { if readErr != nil {
return false, "Autostart desktop entry is missing" return false, "Autostart desktop entry is missing"
} }
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) { if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable" return false, "Autostart desktop entry points to another executable"
} }
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestLinuxAutostartStartsInTray(t *testing.T) { func TestLinuxAutostartStartsInTray(t *testing.T) {
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Fatalf("read desktop entry: %v", err) t.Fatalf("read desktop entry: %v", err)
} }
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) { if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data) t.Fatalf("desktop entry does not start in tray: %s", data)
} }
+4 -2
View File
@@ -6,6 +6,8 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
const autostartName = "GoSentry" const autostartName = "GoSentry"
@@ -69,7 +71,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) { if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable" 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 false, "Autostart shortcut does not start in tray"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
@@ -101,7 +103,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(), command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath, "GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath, "GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument, "GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory, "GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath, "GOSENTRY_ICON_PATH="+iconPath,
) )
+6 -4
View File
@@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"syscall" "syscall"
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestParseRegistryRunValue(t *testing.T) { func TestParseRegistryRunValue(t *testing.T) {
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
if !sameWindowsPath(actual, targetPath) { if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
} }
if arguments != StartInTrayArgument { if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, 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) { if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
} }
if arguments != StartInTrayArgument { if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
} }
} }
-67
View File
@@ -1,68 +1 @@
package core 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" "strings"
"time" "time"
"unicode" "unicode"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
const commandTimeout = 30 * time.Second const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * 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() started := time.Now()
// Commands can hang forever if a script waits for input or a child process // 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 // 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 job.Output = output
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now) logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
record := RunRecord{ record := domain.RunRecord{
Time: job.LastRun, Time: job.LastRun,
JobID: job.ID, JobID: job.ID,
JobName: job.Name, 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 // 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 // output is persisted to files, so retaining every past record in RAM would
// only duplicate data and make long sessions grow without bound. // 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 { if len(job.Logs) > 50 {
job.Logs = job.Logs[:50] job.Logs = job.Logs[:50]
} }
@@ -128,7 +130,7 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
return nil 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) == "" { if strings.TrimSpace(logsDir) == "" {
return "" return ""
} }
@@ -171,7 +173,7 @@ func sanitizeFileName(name string) string {
return result 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 command := invocation.command
if invocation.hideWindow { if invocation.hideWindow {
configureHiddenWindow(command) 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) 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 var builder strings.Builder
builder.WriteString("status:\n") builder.WriteString("status:\n")
if pid > 0 { if pid > 0 {
@@ -204,7 +206,7 @@ func startOnlyOutput(job Job, pid int) string {
return builder.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 { if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration) return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
} }
@@ -259,7 +261,7 @@ func parseExitCodes(value string) []int {
return result return result
} }
func successExitCodesText(job Job) string { func successExitCodesText(job domain.Job) string {
codes := parseExitCodes(job.SuccessExitCodes) codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes)) parts := make([]string, 0, len(codes))
for _, code := range codes { for _, code := range codes {
@@ -273,7 +275,7 @@ type commandInvocation struct {
hideWindow bool hideWindow bool
} }
func jobInvocation(ctx context.Context, job Job) commandInvocation { func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
command := strings.TrimSpace(job.Command) command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments) arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) { if len(arguments) > 0 || commandPathExists(command) {
+14 -12
View File
@@ -8,11 +8,13 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestRunJobLogFileAllHeaders(t *testing.T) { func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 99, ID: 99,
Name: "Log Header Test", Name: "Log Header Test",
Command: echoCommand("header test output"), Command: echoCommand("header test output"),
@@ -61,7 +63,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
} }
func TestRunJobRecordFields(t *testing.T) { func TestRunJobRecordFields(t *testing.T) {
job := Job{ job := domain.Job{
ID: 55, ID: 55,
Name: "Record Fields Test", Name: "Record Fields Test",
Command: echoCommand("record field check"), Command: echoCommand("record field check"),
@@ -145,7 +147,7 @@ func TestSanitizeFileName(t *testing.T) {
func TestRunJobWritesLogFile(t *testing.T) { func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 42, ID: 42,
Name: "Hello Test", Name: "Hello Test",
Command: echoCommand("hello from test"), Command: echoCommand("hello from test"),
@@ -180,7 +182,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
} }
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 43, ID: 43,
Name: "Quoted Windows Command", Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, 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 { if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
job := Job{ job := domain.Job{
ID: 44, ID: 44,
Name: "Unquoted Windows Command", Name: "Unquoted Windows Command",
Command: scriptPath, Command: scriptPath,
@@ -230,7 +232,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
} }
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 45, ID: 45,
Name: "Separate Arguments", Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`, Command: `C:\Windows\System32\cmd.exe`,
@@ -251,7 +253,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
} }
job := Job{ job := domain.Job{
ID: 46, ID: 46,
Name: "Accepted Exit Code", Name: "Accepted Exit Code",
Command: command, Command: command,
@@ -275,7 +277,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
} }
job := Job{ job := domain.Job{
ID: 47, ID: 47,
Name: "Rejected Exit Code", Name: "Rejected Exit Code",
Command: command, Command: command,
@@ -301,7 +303,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7" arguments = "/C\nexit /b 7"
} }
job := Job{ job := domain.Job{
ID: 48, ID: 48,
Name: "Start Only", Name: "Start Only",
Command: command, Command: command,
@@ -322,7 +324,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
} }
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{ job := domain.Job{
ID: 49, ID: 49,
Name: "Missing Start Only", Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command", Command: "definitely-missing-gosentry-command",
@@ -357,7 +359,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
t.Skip("Windows window visibility only") t.Skip("Windows window visibility only")
} }
invocation := jobInvocation(context.Background(), Job{ invocation := jobInvocation(context.Background(), domain.Job{
Command: `C:\Windows\System32\cmd.exe`, Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process", Arguments: "/C\necho visible direct process",
}) })
@@ -371,7 +373,7 @@ func TestShellCommandHidesWindow(t *testing.T) {
t.Skip("Windows window visibility only") 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 { if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window") t.Fatal("shell command should request hidden startup window")
} }
+13 -12
View File
@@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"github.com/robfig/cron/v3" "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. // still in one desktop process.
type Scheduler struct { type Scheduler struct {
store *Store store *Store
jobs *[]Job jobs *[]domain.Job
onChange func(RunRecord) onChange func(domain.RunRecord)
mu sync.Mutex mu sync.Mutex
ctx context.Context ctx context.Context
@@ -27,7 +28,7 @@ type Scheduler struct {
paused bool 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()) ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{ s := &Scheduler{
store: store, store: store,
@@ -125,7 +126,7 @@ func (s *Scheduler) tick(now time.Time) {
if !s.paused { if !s.paused {
for index := range *s.jobs { for index := range *s.jobs {
job := &(*s.jobs)[index] 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 continue
} }
// Run only one due job per tick for now. That avoids overlapping shell // 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.LastState = "Running"
job.NextRun = "Running" job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now()) job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{} job.NextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs) _ = s.store.SaveJobs(*s.jobs)
go func() { go func() {
@@ -161,7 +162,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
current.LastRun = record.Time current.LastRun = record.Time
current.LastState = record.State current.LastState = record.State
current.Output = record.Output current.Output = record.Output
current.Logs = append([]RunRecord{record}, current.Logs...) current.Logs = append([]domain.RunRecord{record}, current.Logs...)
if len(current.Logs) > 50 { if len(current.Logs) > 50 {
current.Logs = current.Logs[:50] current.Logs = current.Logs[:50]
} }
@@ -178,7 +179,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
return true return true
} }
func (s *Scheduler) findJobByIDLocked(id int) *Job { func (s *Scheduler) findJobByIDLocked(id int) *domain.Job {
for index := range *s.jobs { for index := range *s.jobs {
if (*s.jobs)[index].ID == id { if (*s.jobs)[index].ID == id {
return &(*s.jobs)[index] return &(*s.jobs)[index]
@@ -187,7 +188,7 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job {
return nil 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 var builder strings.Builder
builder.WriteString("status:\n") builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\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) _ = 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) next, ok := nextRunTime(job.Schedule, from)
if !ok { if !ok {
job.NextRun = "Invalid schedule" job.NextRun = "Invalid schedule"
job.nextDue = time.Time{} job.NextDue = time.Time{}
return return
} }
job.nextDue = next job.NextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05") job.NextRun = job.NextDue.Format("2006-01-02 15:04:05")
} }
func nextRunTime(schedule string, from time.Time) (time.Time, bool) { func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
+9 -7
View File
@@ -4,6 +4,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) { func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
@@ -30,7 +32,7 @@ func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
} }
func TestPrepareNextRunSetsDisplayString(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} s := &Scheduler{jobs: &jobs}
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC) 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) t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want)
} }
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC) wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !jobs[0].nextDue.Equal(wantDue) { if !jobs[0].NextDue.Equal(wantDue) {
t.Errorf("nextDue: got %v, want %v", jobs[0].nextDue, wantDue) t.Errorf("NextDue: got %v, want %v", jobs[0].NextDue, wantDue)
} }
} }
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) { 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 := &Scheduler{jobs: &jobs}
s.prepareNextRun(&jobs[0], time.Now()) s.prepareNextRun(&jobs[0], time.Now())
@@ -55,8 +57,8 @@ func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
if jobs[0].NextRun != "Invalid schedule" { if jobs[0].NextRun != "Invalid schedule" {
t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun) t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun)
} }
if !jobs[0].nextDue.IsZero() { if !jobs[0].NextDue.IsZero() {
t.Errorf("nextDue should be zero for invalid schedule, got %v", jobs[0].nextDue) 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) { func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local) started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{ job := domain.Job{
Name: "Backup", Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`, Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`, Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
+15 -14
View File
@@ -7,15 +7,16 @@ import (
"runtime" "runtime"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
type Store struct { type Store struct {
Paths Paths Paths Paths
Config Config Config domain.Config
} }
func OpenStore() (*Store, []Job, error) { func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths() paths, err := ResolvePaths()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
return writeYAML(s.Paths.ConfigPath, s.Config) 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 { if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err 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 // Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory. // executable, while logs are grouped under a dedicated subdirectory.
config := Config{ config := domain.Config{
JobsDir: ".", JobsDir: ".",
LogsDir: "logs", LogsDir: "logs",
MaxLogFiles: 100, MaxLogFiles: 100,
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return Config{}, err return domain.Config{}, err
} }
if err := yaml.Unmarshal(data, &config); err != nil { if err := yaml.Unmarshal(data, &config); err != nil {
return Config{}, err return domain.Config{}, err
} }
if strings.TrimSpace(config.JobsDir) == "" { if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root // Empty paths are treated as missing values rather than intentional root
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
return config, nil 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) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// The first run creates harmless sample jobs so a new user can immediately // The first run creates harmless sample jobs so a new user can immediately
// see scheduled and manual execution without inventing a command. // see scheduled and manual execution without inventing a command.
jobs := defaultJobs() jobs := defaultJobs()
normalizeJobs(jobs) normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs}) return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var file JobsFile var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil { if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err return nil, err
} }
return file.Jobs, nil return file.Jobs, nil
} }
func normalizeJobs(jobs []Job) { func normalizeJobs(jobs []domain.Job) {
next := 1 next := 1
for index := range jobs { for index := range jobs {
job := &jobs[index] job := &jobs[index]
@@ -222,8 +223,8 @@ func writeYAML(path string, value any) error {
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0o644)
} }
func defaultJobs() []Job { func defaultJobs() []domain.Job {
return []Job{ return []domain.Job{
{ {
ID: 1, ID: 1,
Name: "Hello scheduler", Name: "Hello scheduler",
+8 -7
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -12,7 +13,7 @@ func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml") path := filepath.Join(dir, "jobs.yaml")
original := []Job{ original := []domain.Job{
{ {
ID: 7, ID: 7,
Name: "Backup data", 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) t.Fatal(err)
} }
@@ -86,7 +87,7 @@ func TestConfigRoundTrip(t *testing.T) {
ConfigPath: filepath.Join(dir, ConfigFileName), ConfigPath: filepath.Join(dir, ConfigFileName),
} }
want := Config{ want := domain.Config{
JobsDir: "/custom/jobs", JobsDir: "/custom/jobs",
LogsDir: "/custom/logs", LogsDir: "/custom/logs",
MaxLogFiles: 50, MaxLogFiles: 50,
@@ -128,7 +129,7 @@ func TestConfigRoundTrip(t *testing.T) {
} }
func TestNormalizeJobsFillsDefaults(t *testing.T) { func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []Job{ jobs := []domain.Job{
{Enabled: true}, {Enabled: true},
{Enabled: false}, {Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true}, {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) { func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{ jobs := []domain.Job{
{ {
ID: 1, ID: 1,
Name: "Clean job", Name: "Clean job",
@@ -185,13 +186,13 @@ func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
NextRun: "2026-06-14 12:00:10", NextRun: "2026-06-14 12:00:10",
LastState: "OK", LastState: "OK",
Output: "stdout: ok", Output: "stdout: ok",
Logs: []RunRecord{ Logs: []domain.RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"}, {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 { if err != nil {
t.Fatal(err) 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/assets"
"gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "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 // 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 // durable model still lives in src/core, so GUI code does not define a second
// copy of the scheduler data. // copy of the scheduler data.
type job = core.Job type job = domain.Job
type event = core.RunRecord type event = domain.RunRecord
func Run(startInTray bool) { func Run(startInTray bool) {
started := time.Now() started := time.Now()
@@ -501,7 +502,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
jobLogs, 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 // Scheduled runs happen on the scheduler goroutine. The callback updates
// the shared in-memory event list so History reflects background activity. // the shared in-memory event list so History reflects background activity.
events = append(events, record) events = append(events, record)