refactor: split source files that exceeded the ~300-line ceiling
Mechanical moves only — operations, store, history_view, and settings_view are now split along their existing seams so every file stays within the 250+20% guideline. Document the new layout in ARCHITECTURE.md and close the ROADMAP item. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,11 +2,8 @@ package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
@@ -115,138 +112,6 @@ func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||
return s.PrepareSaveJobs(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.DefaultConfig()
|
||||
|
||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
return config, writeJSON(paths.ConfigPath, config)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(paths.ConfigPath)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||
// The fallbacks restore a value in either case.
|
||||
config.JobsFile = ""
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
|
||||
// A config written before the setting named a file carries jobs_dir instead
|
||||
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||
// used, then drop the old key so the file is rewritten in the current shape.
|
||||
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||
}
|
||||
config.JobsDir = ""
|
||||
if strings.TrimSpace(config.JobsFile) == "" {
|
||||
// Empty paths are treated as missing values rather than intentional root
|
||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||
config.JobsFile = JobsFileName
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
config.LogsDir = "logs"
|
||||
}
|
||||
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||
// backfilling it here would make that choice impossible to persist. A config
|
||||
// written before either field existed already carries 0 from json.Unmarshal
|
||||
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||
// pick up 100 / 30 without an explicit backfill.
|
||||
if config.ExecutionMode == "" {
|
||||
config.ExecutionMode = domain.ExecutionModeParallel
|
||||
}
|
||||
if config.OverlapPolicy == "" {
|
||||
config.OverlapPolicy = domain.OverlapPolicySkip
|
||||
}
|
||||
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
|
||||
// value ("no timeout"), not a missing one, so backfilling it here would make
|
||||
// the setting impossible to persist. Negative values are rejected by
|
||||
// app.validateConfig before they can be saved.
|
||||
if config.Theme == "" {
|
||||
config.Theme = domain.ThemeGoSentry
|
||||
}
|
||||
if config.Theme == "default" {
|
||||
config.Theme = domain.ThemeSystem
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||
// reports whether the file was there: a missing file is not an error but the
|
||||
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||
// needs when the user points the application at a different jobs file.
|
||||
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var file domain.JobsFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
normalizeJobs(file.Jobs)
|
||||
return file.Jobs, true, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
jobs, found, err := LoadJobsFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return jobs, nil
|
||||
}
|
||||
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||
// execution without inventing a command. The failure sample stays disabled
|
||||
// so it does not spam notifications; Run now still works for testing.
|
||||
jobs = defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
next := 1
|
||||
seen := make(map[int]bool, len(jobs))
|
||||
for index := range jobs {
|
||||
job := &jobs[index]
|
||||
if job.ID <= 0 || seen[job.ID] {
|
||||
// IDs are assigned only when absent or already claimed by an earlier job
|
||||
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||
// because History and future log associations use them to identify jobs.
|
||||
job.ID = next
|
||||
}
|
||||
seen[job.ID] = true
|
||||
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)
|
||||
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||
// part of Job. It is reconstructed each time the app starts via
|
||||
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveConfiguredPath turns a file or directory path from the config into the
|
||||
// absolute path the application will actually use. It is exported so callers
|
||||
// outside storage — the settings tab, which opens the configured logs folder —
|
||||
@@ -328,55 +193,3 @@ func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Name: "Failure notification test",
|
||||
Folder: "Examples",
|
||||
Schedule: "@every 1m",
|
||||
Command: failCommand(),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func failCommand() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "exit /b 1"
|
||||
}
|
||||
return "exit 1"
|
||||
}
|
||||
|
||||
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, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
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.DefaultConfig()
|
||||
|
||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
return config, writeJSON(paths.ConfigPath, config)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(paths.ConfigPath)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||
// The fallbacks restore a value in either case.
|
||||
config.JobsFile = ""
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
|
||||
// A config written before the setting named a file carries jobs_dir instead
|
||||
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||
// used, then drop the old key so the file is rewritten in the current shape.
|
||||
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||
}
|
||||
config.JobsDir = ""
|
||||
if strings.TrimSpace(config.JobsFile) == "" {
|
||||
// Empty paths are treated as missing values rather than intentional root
|
||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||
config.JobsFile = JobsFileName
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
config.LogsDir = "logs"
|
||||
}
|
||||
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||
// backfilling it here would make that choice impossible to persist. A config
|
||||
// written before either field existed already carries 0 from json.Unmarshal
|
||||
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||
// pick up 100 / 30 without an explicit backfill.
|
||||
if config.ExecutionMode == "" {
|
||||
config.ExecutionMode = domain.ExecutionModeParallel
|
||||
}
|
||||
if config.OverlapPolicy == "" {
|
||||
config.OverlapPolicy = domain.OverlapPolicySkip
|
||||
}
|
||||
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
|
||||
// value ("no timeout"), not a missing one, so backfilling it here would make
|
||||
// the setting impossible to persist. Negative values are rejected by
|
||||
// app.validateConfig before they can be saved.
|
||||
if config.Theme == "" {
|
||||
config.Theme = domain.ThemeGoSentry
|
||||
}
|
||||
if config.Theme == "default" {
|
||||
config.Theme = domain.ThemeSystem
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||
// reports whether the file was there: a missing file is not an error but the
|
||||
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||
// needs when the user points the application at a different jobs file.
|
||||
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var file domain.JobsFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
normalizeJobs(file.Jobs)
|
||||
return file.Jobs, true, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
jobs, found, err := LoadJobsFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return jobs, nil
|
||||
}
|
||||
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||
// execution without inventing a command. The failure sample stays disabled
|
||||
// so it does not spam notifications; Run now still works for testing.
|
||||
jobs = defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
next := 1
|
||||
seen := make(map[int]bool, len(jobs))
|
||||
for index := range jobs {
|
||||
job := &jobs[index]
|
||||
if job.ID <= 0 || seen[job.ID] {
|
||||
// IDs are assigned only when absent or already claimed by an earlier job
|
||||
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||
// because History and future log associations use them to identify jobs.
|
||||
job.ID = next
|
||||
}
|
||||
seen[job.ID] = true
|
||||
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)
|
||||
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||
// part of Job. It is reconstructed each time the app starts via
|
||||
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Name: "Failure notification test",
|
||||
Folder: "Examples",
|
||||
Schedule: "@every 1m",
|
||||
Command: failCommand(),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func failCommand() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "exit /b 1"
|
||||
}
|
||||
return "exit 1"
|
||||
}
|
||||
|
||||
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, "'", "'\\''") + "'"
|
||||
}
|
||||
Reference in New Issue
Block a user