refactor: split jobs_view, drop YAML migration, update docs (T6.1-T6.4)

T6.1: split jobs_view.go into three files — jobs_view_helpers.go (pure
helpers) and jobs_view_details.go (detailsPanel struct with widget
creation, update, clear, and container methods) — bringing jobs_view.go
from 459 to ~200 lines.

T6.2: remove stale YAML upgrade note from README; drop *.yaml from
.dockerignore.

T6.3: delete YAML shadow structs (yamlConfig/yamlJob/yamlJobsFile),
importYAMLConfig/importYAMLJobs, legacy path constants, and all
YAML-import tests; run go mod tidy to remove go.yaml.in/yaml/v4.

T6.4: refresh ARCHITECTURE.md — JSON storage references, new Key Domain
Concepts section (per-job overlap policy, run-time statistics + log
seeding, persisted pause flag, jobs_view split).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-24 22:42:42 +03:00
parent d09b6e182c
commit 13f2779e1f
12 changed files with 302 additions and 407 deletions
-4
View File
@@ -14,10 +14,6 @@ const (
// installed/copied program.
JobsFileName = "jobs.json"
// Legacy YAML file names used by builds before the JSON migration. These are
// read once on first start (P1.4) and then replaced by the JSON equivalents.
legacyYAMLConfigFileName = "gosentry.yaml"
legacyYAMLJobsFileName = "jobs.yaml"
)
// Paths contains both the physical program location and the resolved runtime
+11 -109
View File
@@ -9,7 +9,6 @@ import (
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
@@ -17,41 +16,6 @@ type Store struct {
Config domain.Config
}
// yamlConfig and yamlJob / yamlJobsFile mirror the durable domain types using the
// yaml tags that pre-JSON-migration files carried. They exist only so the
// one-time import can parse a legacy gosentry.yaml / jobs.yaml; the domain types
// themselves stay JSON-only. Field layout must stay identical to the matching
// domain struct so the value conversions in importYAMLConfig / importYAMLJobs
// remain valid.
type yamlConfig 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,omitempty"`
KeepRunningInTray bool `yaml:"keep_running_in_tray,omitempty"`
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
Paused bool `yaml:"paused,omitempty"`
}
type yamlJob 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"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
OverlapPolicy string `yaml:"overlap_policy,omitempty"`
}
type yamlJobsFile struct {
Jobs []yamlJob `yaml:"jobs"`
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
@@ -117,26 +81,15 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
}
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
// No JSON config yet. Import a pre-migration gosentry.yaml once if it is
// present; otherwise write the defaults so later starts read a normal JSON
// file. The caller's SaveConfig rewrites whatever is loaded as gosentry.json.
legacyPath := filepath.Join(paths.AppDir, legacyYAMLConfigFileName)
imported, ok, err := importYAMLConfig(legacyPath, config)
if err != nil {
return domain.Config{}, err
}
if !ok {
return config, writeJSON(paths.ConfigPath, config)
}
config = imported
} else {
data, err := os.ReadFile(paths.ConfigPath)
if err != nil {
return domain.Config{}, err
}
if err := json.Unmarshal(data, &config); err != nil {
return domain.Config{}, err
}
return config, writeJSON(paths.ConfigPath, config)
}
data, err := os.ReadFile(paths.ConfigPath)
if err != nil {
return domain.Config{}, err
}
if err := json.Unmarshal(data, &config); err != nil {
return domain.Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
@@ -164,19 +117,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
func loadOrCreateJobs(path string) ([]domain.Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No JSON jobs file yet. Import a pre-migration jobs.yaml once if present;
// otherwise seed harmless sample jobs so a new user can immediately see
// scheduled and manual execution without inventing a command. Imported jobs
// are returned unsaved here — the caller's SaveJobs rewrites them as
// jobs.json after normalization.
legacyPath := filepath.Join(filepath.Dir(path), legacyYAMLJobsFileName)
imported, ok, err := importYAMLJobs(legacyPath)
if err != nil {
return nil, err
}
if ok {
return imported, nil
}
// Seed harmless sample jobs so a new user can immediately see scheduled
// and manual execution without inventing a command.
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
@@ -193,46 +135,6 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
return file.Jobs, nil
}
// importYAMLConfig reads a pre-migration gosentry.yaml into the current Config
// shape. It returns ok=false when the file is absent so the caller falls back to
// writing fresh defaults. The supplied base seeds the shadow struct so keys that
// the YAML omits keep their default value instead of becoming zero.
func importYAMLConfig(path string, base domain.Config) (domain.Config, bool, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return domain.Config{}, false, nil
}
if err != nil {
return domain.Config{}, false, err
}
shadow := yamlConfig(base)
if err := yaml.Unmarshal(data, &shadow); err != nil {
return domain.Config{}, false, err
}
return domain.Config(shadow), true, nil
}
// importYAMLJobs reads a pre-migration jobs.yaml into durable domain jobs. It
// returns ok=false when the file is absent so the caller can seed default jobs.
func importYAMLJobs(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 yamlJobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, false, err
}
jobs := make([]domain.Job, len(file.Jobs))
for i := range file.Jobs {
jobs[i] = domain.Job(file.Jobs[i])
}
return jobs, true, nil
}
func normalizeJobs(jobs []domain.Job) {
next := 1
for index := range jobs {
-89
View File
@@ -8,17 +8,8 @@ import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
func writeYAML(path string, value any) error {
data, err := yaml.Marshal(value)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.json")
@@ -154,50 +145,6 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
}
}
// TestLoadOrCreateConfigMigratesFromLegacy verifies that when gosentry.json is
// absent but gosentry.yaml exists the config is read from the legacy YAML file.
// This lets installs that pre-date the JSON migration start without manual steps.
func TestLoadOrCreateConfigMigratesFromLegacy(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName), // gosentry.json — not created
}
legacy := yamlConfig{
JobsDir: "/legacy/jobs",
LogsDir: "/legacy/logs",
MaxLogFiles: 77,
MaxLogAgeDays: 13,
StartOnLogin: true,
}
if err := writeYAML(filepath.Join(dir, legacyYAMLConfigFileName), legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != legacy.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, legacy.JobsDir)
}
if got.LogsDir != legacy.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, legacy.LogsDir)
}
if got.MaxLogFiles != legacy.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, legacy.MaxLogFiles)
}
if got.MaxLogAgeDays != legacy.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, legacy.MaxLogAgeDays)
}
if got.StartOnLogin != legacy.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, legacy.StartOnLogin)
}
}
// TestLoadOrCreateConfigCreatesDefaultsOnFirstRun verifies that the first run
// (no config files present) writes gosentry.json and returns sensible defaults.
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir()
paths := Paths{
@@ -252,39 +199,3 @@ func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
}
}
}
// TestLoadOrCreateJobsMigratesFromLegacy verifies that when jobs.json is absent
// but jobs.yaml exists the jobs are read from the legacy YAML file.
func TestLoadOrCreateJobsMigratesFromLegacy(t *testing.T) {
dir := t.TempDir()
jsonPath := filepath.Join(dir, JobsFileName) // jobs.json — not created
legacy := yamlJobsFile{
Jobs: []yamlJob{
{ID: 10, Name: "Legacy job", Schedule: "@every 5m", Command: "echo legacy", Enabled: true},
},
}
if err := writeYAML(filepath.Join(dir, legacyYAMLJobsFileName), legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(jsonPath)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
if got[0].ID != 10 {
t.Errorf("ID: got %d, want 10", got[0].ID)
}
if got[0].Name != "Legacy job" {
t.Errorf("Name: got %q, want 'Legacy job'", got[0].Name)
}
if got[0].Schedule != "@every 5m" {
t.Errorf("Schedule: got %q, want '@every 5m'", got[0].Schedule)
}
if !got[0].Enabled {
t.Errorf("Enabled: got false, want true")
}
}