T1.5: Create src/storage; move store/paths logic

Move store.go, paths.go, and store_test.go from src/core into the new
src/storage package. Update src/scheduler and src/gui to import storage
instead of core for Store/Paths/OpenStore. Empty the moved files in core
to preserve the package declaration for the remaining core symbols.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-18 22:15:40 +03:00
parent ad0e45a7dd
commit 06edbfff77
9 changed files with 527 additions and 523 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ Track progress here. Mark tasks complete as they land and pass review.
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow` - [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
- [x] T1.3 — Create `src/runner`; move runner logic - [x] T1.3 — Create `src/runner`; move runner logic
- [x] T1.4 — Create `src/scheduler`; move scheduler - [x] T1.4 — Create `src/scheduler`; move scheduler
- [ ] T1.5 — Create `src/storage`; move store/paths - [x] T1.5 — Create `src/storage`; move store/paths
- [ ] T1.6 — Create `src/platform/autostart`; move autostart logic - [ ] T1.6 — Create `src/platform/autostart`; move autostart logic
- [ ] T1.7 — Create `src/platform/desktop`; move desktop integration - [ ] T1.7 — Create `src/platform/desktop`; move desktop integration
- [ ] T1.8 — Delete empty `src/core`; build + test both platforms - [ ] T1.8 — Delete empty `src/core`; build + test both platforms
-53
View File
@@ -1,54 +1 @@
package core package core
import (
"os"
"path/filepath"
)
const (
// The config file stays beside the executable so the portable build behaves
// predictably: moving the program folder moves its settings with it.
ConfigFileName = "gosentry.yaml"
// Older builds were named PySentry. Keep the old config name readable during
// the rename window so portable installations can start once and rewrite the
// settings to gosentry.yaml without manual file copying.
LegacyConfigFileName = "pysentry.yaml"
// Jobs are kept in a separate YAML file because the user can choose a
// different jobs directory, while application settings remain local to the
// installed/copied program.
JobsFileName = "jobs.yaml"
)
// Paths contains both the physical program location and the resolved runtime
// storage locations. Keeping resolved paths in one struct prevents the GUI and
// scheduler from interpreting relative directories differently.
type Paths struct {
ExecutablePath string
AppDir string
ConfigPath string
JobsDir string
JobsPath string
LogsDir string
DesktopIcon string
}
func ResolvePaths() (Paths, error) {
// os.Executable is used instead of the current working directory because GUI
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
// In those cases the working directory can be surprising, but the executable
// path is stable and matches the "portable app folder" storage model.
executable, err := os.Executable()
if err != nil {
return Paths{}, err
}
appDir := filepath.Dir(executable)
configPath := filepath.Join(appDir, ConfigFileName)
return Paths{
ExecutablePath: executable,
AppDir: appDir,
ConfigPath: configPath,
JobsDir: appDir,
JobsPath: filepath.Join(appDir, JobsFileName),
}, nil
}
-260
View File
@@ -1,261 +1 @@
package core package core
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
Paths Paths
Config domain.Config
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
return nil, nil, err
}
store := &Store{Paths: paths}
config, err := loadOrCreateConfig(paths)
if err != nil {
return nil, nil, err
}
store.Config = config
store.applyConfigPaths()
// Save the config after loading so missing defaults are written back. This
// rewrites old or hand-edited files into the current clean schema without
// forcing the user to delete them manually.
if err := store.SaveConfig(); err != nil {
return nil, nil, err
}
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
if err != nil {
return nil, nil, err
}
normalizeJobs(jobs)
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
// only durable job definitions remain, because runtime fields are tagged
// yaml:"-" in the model.
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
func (s *Store) SaveConfig() error {
s.applyConfigPaths()
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.ConfigPath, s.Config)
}
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, domain.JobsFile{Jobs: jobs})
}
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 := domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
}
configPath := paths.ConfigPath
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
// The rename from PySentry to GoSentry changed the preferred config
// filename. Read the old file once if it is still present so portable
// installs continue to start without a manual migration step. The
// caller later saves the loaded config back through SaveConfig, which
// naturally rewrites it under gosentry.yaml.
configPath = legacyPath
} else {
return config, writeYAML(paths.ConfigPath, config)
}
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
return config, writeYAML(paths.ConfigPath, config)
}
data, err := os.ReadFile(configPath)
if err != nil {
return domain.Config{}, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return domain.Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root
// directories. This avoids accidentally writing jobs to unexpected places.
config.JobsDir = "."
}
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
return config, nil
}
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, domain.JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
return file.Jobs, nil
}
func normalizeJobs(jobs []domain.Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
// IDs are assigned only when absent. Existing IDs stay stable because
// History and future log associations use them to identify jobs.
job.ID = next
}
if job.ID >= next {
next = job.ID + 1
}
if strings.TrimSpace(job.Name) == "" {
job.Name = "Untitled job"
}
if strings.TrimSpace(job.Schedule) == "" {
job.Schedule = "@every 1m"
}
if strings.TrimSpace(job.Command) == "" {
// An empty command would fail in a confusing way. A safe echo command
// gives the user something observable and harmless instead.
job.Command = echoCommand("GoSentry job ran")
}
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
// Runtime fields are reconstructed each time the app starts. Persisted run
// records live in log files, not in jobs.yaml, to keep the jobs file easy
// to review and edit by hand.
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir)
}
func resolveConfiguredDir(appDir string, dir string) string {
if filepath.IsAbs(dir) {
return dir
}
// Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts,
// Explorer launches, and terminal launches consistent.
return filepath.Clean(filepath.Join(appDir, dir))
}
func (s *Store) applyConfigPaths() {
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
}
func writeYAML(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := yaml.Marshal(value)
if err != nil {
return err
}
// WriteFile replaces the full file instead of patching it in place. For small
// YAML files this is simpler and prevents stale keys from older versions from
// lingering after the schema changes.
return os.WriteFile(path, data, 0o644)
}
func defaultJobs() []domain.Job {
return []domain.Job{
{
ID: 1,
Name: "Hello scheduler",
Folder: "Examples",
Schedule: "@every 1m",
Command: echoCommand("GoSentry test job: scheduler is alive"),
Enabled: true,
},
{
ID: 2,
Name: "Write timestamp",
Folder: "Examples",
Schedule: "*/1 * * * *",
Command: echoCommand("GoSentry test job: timestamp command ran"),
Enabled: true,
},
{
ID: 3,
Name: "Paused sample",
Schedule: "@every 1m",
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
},
}
}
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
}
// POSIX shells need quotes for messages with spaces. Single quotes inside the
// message are escaped using the standard close-quote/backslash/reopen pattern.
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
-204
View File
@@ -1,205 +1 @@
package core package core
import (
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml")
original := []domain.Job{
{
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
SuccessExitCodes: "0,1",
StartOnly: true,
Enabled: true,
},
}
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
g, w := got[0], original[0]
if g.ID != w.ID {
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
}
if g.Name != w.Name {
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
}
if g.Folder != w.Folder {
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
}
if g.Schedule != w.Schedule {
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
}
if g.Command != w.Command {
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
}
if g.Arguments != w.Arguments {
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
}
if g.SuccessExitCodes != w.SuccessExitCodes {
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
}
if g.StartOnly != w.StartOnly {
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
}
if g.Enabled != w.Enabled {
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
}
// Runtime fields must not survive the save→load round-trip.
if g.LastRun != "" {
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
}
if g.LastState != "" {
t.Errorf("LastState should be empty after load, got %q", g.LastState)
}
if g.Logs != nil {
t.Errorf("Logs should be nil after load, got %v", g.Logs)
}
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != want.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
}
if got.LogsDir != want.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
}
if got.MaxLogFiles != want.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
}
if got.MaxLogAgeDays != want.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
}
if got.StartOnLogin != want.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
}
if got.KeepRunningInTray != want.KeepRunningInTray {
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
}
if got.NotifyOnFailure != want.NotifyOnFailure {
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
}
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
if jobs[0].ID != 1 {
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
}
if jobs[0].Name != "Untitled job" {
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
}
if jobs[0].Schedule != "@every 1m" {
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
}
if jobs[0].SuccessExitCodes != "0" {
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
}
if jobs[0].LastState != "Ready" {
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
}
if jobs[0].NextRun != "After start" {
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
}
// Disabled job is marked Paused.
if jobs[1].LastState != "Paused" {
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
}
if jobs[1].NextRun != "Paused" {
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
}
// Pre-set fields survive normalization unchanged.
if jobs[2].ID != 5 {
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
}
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []domain.Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []domain.RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
+3 -2
View File
@@ -17,6 +17,7 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/runner"
"gitea.mixdep.ru/mix/gosentry/src/scheduler" "gitea.mixdep.ru/mix/gosentry/src/scheduler"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@@ -162,7 +163,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
} }
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
store, jobs, err := core.OpenStore() store, jobs, err := storage.OpenStore()
if err != nil { if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {} return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
} }
@@ -904,7 +905,7 @@ func logFileName(path string) string {
return path return path
} }
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { func settingsView(w fyne.Window, store *storage.Store, jobs *[]job) fyne.CanvasObject {
startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
+3 -3
View File
@@ -7,8 +7,8 @@ import (
"sync" "sync"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/runner"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
@@ -20,7 +20,7 @@ var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month
// this keeps the early architecture simple while storage and scheduling are // this keeps the early architecture simple while storage and scheduling are
// still in one desktop process. // still in one desktop process.
type Scheduler struct { type Scheduler struct {
store *core.Store store *storage.Store
jobs *[]domain.Job jobs *[]domain.Job
onChange func(domain.RunRecord) onChange func(domain.RunRecord)
@@ -30,7 +30,7 @@ type Scheduler struct {
paused bool paused bool
} }
func NewScheduler(store *core.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler { func NewScheduler(store *storage.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,
+54
View File
@@ -0,0 +1,54 @@
package storage
import (
"os"
"path/filepath"
)
const (
// The config file stays beside the executable so the portable build behaves
// predictably: moving the program folder moves its settings with it.
ConfigFileName = "gosentry.yaml"
// Older builds were named PySentry. Keep the old config name readable during
// the rename window so portable installations can start once and rewrite the
// settings to gosentry.yaml without manual file copying.
LegacyConfigFileName = "pysentry.yaml"
// Jobs are kept in a separate YAML file because the user can choose a
// different jobs directory, while application settings remain local to the
// installed/copied program.
JobsFileName = "jobs.yaml"
)
// Paths contains both the physical program location and the resolved runtime
// storage locations. Keeping resolved paths in one struct prevents the GUI and
// scheduler from interpreting relative directories differently.
type Paths struct {
ExecutablePath string
AppDir string
ConfigPath string
JobsDir string
JobsPath string
LogsDir string
DesktopIcon string
}
func ResolvePaths() (Paths, error) {
// os.Executable is used instead of the current working directory because GUI
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
// In those cases the working directory can be surprising, but the executable
// path is stable and matches the "portable app folder" storage model.
executable, err := os.Executable()
if err != nil {
return Paths{}, err
}
appDir := filepath.Dir(executable)
configPath := filepath.Join(appDir, ConfigFileName)
return Paths{
ExecutablePath: executable,
AppDir: appDir,
ConfigPath: configPath,
JobsDir: appDir,
JobsPath: filepath.Join(appDir, JobsFileName),
}, nil
}
+261
View File
@@ -0,0 +1,261 @@
package storage
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
Paths Paths
Config domain.Config
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
return nil, nil, err
}
store := &Store{Paths: paths}
config, err := loadOrCreateConfig(paths)
if err != nil {
return nil, nil, err
}
store.Config = config
store.applyConfigPaths()
// Save the config after loading so missing defaults are written back. This
// rewrites old or hand-edited files into the current clean schema without
// forcing the user to delete them manually.
if err := store.SaveConfig(); err != nil {
return nil, nil, err
}
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
if err != nil {
return nil, nil, err
}
normalizeJobs(jobs)
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
// only durable job definitions remain, because runtime fields are tagged
// yaml:"-" in the model.
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
func (s *Store) SaveConfig() error {
s.applyConfigPaths()
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.ConfigPath, s.Config)
}
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, domain.JobsFile{Jobs: jobs})
}
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 := domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
}
configPath := paths.ConfigPath
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
// The rename from PySentry to GoSentry changed the preferred config
// filename. Read the old file once if it is still present so portable
// installs continue to start without a manual migration step. The
// caller later saves the loaded config back through SaveConfig, which
// naturally rewrites it under gosentry.yaml.
configPath = legacyPath
} else {
return config, writeYAML(paths.ConfigPath, config)
}
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
return config, writeYAML(paths.ConfigPath, config)
}
data, err := os.ReadFile(configPath)
if err != nil {
return domain.Config{}, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return domain.Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root
// directories. This avoids accidentally writing jobs to unexpected places.
config.JobsDir = "."
}
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
return config, nil
}
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, domain.JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
return file.Jobs, nil
}
func normalizeJobs(jobs []domain.Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
// IDs are assigned only when absent. Existing IDs stay stable because
// History and future log associations use them to identify jobs.
job.ID = next
}
if job.ID >= next {
next = job.ID + 1
}
if strings.TrimSpace(job.Name) == "" {
job.Name = "Untitled job"
}
if strings.TrimSpace(job.Schedule) == "" {
job.Schedule = "@every 1m"
}
if strings.TrimSpace(job.Command) == "" {
// An empty command would fail in a confusing way. A safe echo command
// gives the user something observable and harmless instead.
job.Command = echoCommand("GoSentry job ran")
}
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
// Runtime fields are reconstructed each time the app starts. Persisted run
// records live in log files, not in jobs.yaml, to keep the jobs file easy
// to review and edit by hand.
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir)
}
func resolveConfiguredDir(appDir string, dir string) string {
if filepath.IsAbs(dir) {
return dir
}
// Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts,
// Explorer launches, and terminal launches consistent.
return filepath.Clean(filepath.Join(appDir, dir))
}
func (s *Store) applyConfigPaths() {
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
}
func writeYAML(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := yaml.Marshal(value)
if err != nil {
return err
}
// WriteFile replaces the full file instead of patching it in place. For small
// YAML files this is simpler and prevents stale keys from older versions from
// lingering after the schema changes.
return os.WriteFile(path, data, 0o644)
}
func defaultJobs() []domain.Job {
return []domain.Job{
{
ID: 1,
Name: "Hello scheduler",
Folder: "Examples",
Schedule: "@every 1m",
Command: echoCommand("GoSentry test job: scheduler is alive"),
Enabled: true,
},
{
ID: 2,
Name: "Write timestamp",
Folder: "Examples",
Schedule: "*/1 * * * *",
Command: echoCommand("GoSentry test job: timestamp command ran"),
Enabled: true,
},
{
ID: 3,
Name: "Paused sample",
Schedule: "@every 1m",
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
},
}
}
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
}
// POSIX shells need quotes for messages with spaces. Single quotes inside the
// message are escaped using the standard close-quote/backslash/reopen pattern.
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
+205
View File
@@ -0,0 +1,205 @@
package storage
import (
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml")
original := []domain.Job{
{
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
SuccessExitCodes: "0,1",
StartOnly: true,
Enabled: true,
},
}
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
g, w := got[0], original[0]
if g.ID != w.ID {
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
}
if g.Name != w.Name {
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
}
if g.Folder != w.Folder {
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
}
if g.Schedule != w.Schedule {
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
}
if g.Command != w.Command {
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
}
if g.Arguments != w.Arguments {
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
}
if g.SuccessExitCodes != w.SuccessExitCodes {
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
}
if g.StartOnly != w.StartOnly {
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
}
if g.Enabled != w.Enabled {
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
}
// Runtime fields must not survive the save→load round-trip.
if g.LastRun != "" {
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
}
if g.LastState != "" {
t.Errorf("LastState should be empty after load, got %q", g.LastState)
}
if g.Logs != nil {
t.Errorf("Logs should be nil after load, got %v", g.Logs)
}
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != want.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
}
if got.LogsDir != want.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
}
if got.MaxLogFiles != want.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
}
if got.MaxLogAgeDays != want.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
}
if got.StartOnLogin != want.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
}
if got.KeepRunningInTray != want.KeepRunningInTray {
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
}
if got.NotifyOnFailure != want.NotifyOnFailure {
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
}
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
if jobs[0].ID != 1 {
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
}
if jobs[0].Name != "Untitled job" {
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
}
if jobs[0].Schedule != "@every 1m" {
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
}
if jobs[0].SuccessExitCodes != "0" {
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
}
if jobs[0].LastState != "Ready" {
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
}
if jobs[0].NextRun != "After start" {
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
}
// Disabled job is marked Paused.
if jobs[1].LastState != "Paused" {
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
}
if jobs[1].NextRun != "Paused" {
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
}
// Pre-set fields survive normalization unchanged.
if jobs[2].ID != 5 {
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
}
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []domain.Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []domain.RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}