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:
mixeme
2026-08-07 22:22:08 +03:00
parent 6a03ea4a20
commit 0e6b3bcecf
14 changed files with 1024 additions and 963 deletions
-187
View File
@@ -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, "'", "'\\''") + "'"
}
+72
View File
@@ -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
}
+134
View File
@@ -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, "'", "'\\''") + "'"
}