Document design choices and standardize Dockerfile
This commit is contained in:
@@ -2,6 +2,9 @@ package core
|
||||
|
||||
import "time"
|
||||
|
||||
// Config is stored in pysentry.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"`
|
||||
@@ -11,10 +14,17 @@ type Config struct {
|
||||
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 PySentry 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"`
|
||||
@@ -28,9 +38,15 @@ type Job struct {
|
||||
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"`
|
||||
|
||||
+13
-1
@@ -6,10 +6,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// The config file stays beside the executable so the portable build behaves
|
||||
// predictably: moving the program folder moves its settings with it.
|
||||
ConfigFileName = "pysentry.yaml"
|
||||
JobsFileName = "jobs.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 {
|
||||
AppDir string
|
||||
ConfigPath string
|
||||
@@ -19,6 +27,10 @@ type Paths struct {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -19,9 +19,17 @@ const commandTimeout = 30 * time.Second
|
||||
|
||||
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||
started := time.Now()
|
||||
// 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
|
||||
// scheduler; later it can become a per-job setting without changing the
|
||||
// runner contract.
|
||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
// The command is executed through the platform shell so users can type the
|
||||
// same command they would test manually in cmd.exe or sh. This is less strict
|
||||
// than argv-based execution, but it is the expected behavior for a cron-like
|
||||
// tool that supports redirection, environment expansion, and shell builtins.
|
||||
command := shellCommand(runCtx, job.Command)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -59,6 +67,9 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
|
||||
LogFile: logFile,
|
||||
Output: output,
|
||||
}
|
||||
// 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
|
||||
// only duplicate data and make long sessions grow without bound.
|
||||
job.Logs = append([]RunRecord{record}, job.Logs...)
|
||||
if len(job.Logs) > 50 {
|
||||
job.Logs = job.Logs[:50]
|
||||
@@ -82,6 +93,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||
var logs []logFile
|
||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
for _, entry := range entries {
|
||||
// Only PySentry run logs are managed here. Directories and non-.log files
|
||||
// are intentionally ignored so the user can keep notes or other artifacts
|
||||
// in the same folder without the cleanup policy deleting them.
|
||||
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
||||
continue
|
||||
}
|
||||
@@ -91,6 +105,8 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||
continue
|
||||
}
|
||||
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
|
||||
// Cleanup is best-effort: failing to delete one file should not block
|
||||
// the scheduler from running future jobs.
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
@@ -101,6 +117,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(logs, func(i int, j int) bool {
|
||||
// Newest files are kept first, then everything after maxFiles is removed.
|
||||
// This matches the user's expectation that the most recent failures and
|
||||
// command output remain available for investigation.
|
||||
return logs[i].modTime.After(logs[j].modTime)
|
||||
})
|
||||
for _, old := range logs[maxFiles:] {
|
||||
@@ -116,6 +135,9 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s
|
||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||
return ""
|
||||
}
|
||||
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||
// by run time. The job name is included for human scanning, but sanitized to
|
||||
// avoid characters that are invalid on Windows or awkward on shells.
|
||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||
path := filepath.Join(logsDir, fileName)
|
||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n",
|
||||
@@ -151,8 +173,12 @@ func sanitizeFileName(name string) string {
|
||||
|
||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||
if runtime.GOOS == "windows" {
|
||||
// cmd.exe /C preserves Windows users' expectations for commands such as
|
||||
// "dir", "copy", variable expansion, and .bat/.cmd wrappers.
|
||||
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
||||
}
|
||||
// sh -c is the portable baseline for Linux builds. It keeps the runner small
|
||||
// and avoids a hard dependency on a larger shell such as bash.
|
||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
||||
}
|
||||
|
||||
@@ -160,6 +186,8 @@ func formatOutput(stdout string, stderr string) string {
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
stderr = strings.TrimSpace(stderr)
|
||||
if stdout == "" {
|
||||
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||
// GUI: the user can tell that the command ran but produced no stream data.
|
||||
stdout = "<empty>"
|
||||
}
|
||||
if stderr == "" {
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||
// It receives a pointer to the jobs slice because the GUI edits the same slice;
|
||||
// this keeps the early architecture simple while storage and scheduling are
|
||||
// still in one desktop process.
|
||||
type Scheduler struct {
|
||||
store *Store
|
||||
jobs *[]Job
|
||||
@@ -36,6 +40,10 @@ func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Schedule
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
// A one-second ticker is accurate enough for cron-style desktop automation
|
||||
// and avoids the complexity of maintaining one timer per job. Five-field cron
|
||||
// expressions have minute precision, while @every values may be shorter for
|
||||
// testing and lightweight local tasks.
|
||||
ticker := time.NewTicker(time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
@@ -60,6 +68,8 @@ func (s *Scheduler) SetPaused(paused bool) {
|
||||
|
||||
s.paused = paused
|
||||
now := time.Now()
|
||||
// Pause state is reflected into each job's display string so the list view is
|
||||
// understandable even before the next scheduler tick.
|
||||
for index := range *s.jobs {
|
||||
job := &(*s.jobs)[index]
|
||||
if !job.Enabled {
|
||||
@@ -83,6 +93,9 @@ func (s *Scheduler) RunNow(index int) RunRecord {
|
||||
return RunRecord{}
|
||||
}
|
||||
job := &(*s.jobs)[index]
|
||||
// Manual runs share the same runner and log writer as scheduled runs. The
|
||||
// Trigger field is the only difference, which keeps History comparable and
|
||||
// prevents "Run now" from becoming a separate behavior path.
|
||||
record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir)
|
||||
s.prepareNextRun(job, time.Now())
|
||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
@@ -120,6 +133,10 @@ func (s *Scheduler) tick(now time.Time) {
|
||||
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
|
||||
continue
|
||||
}
|
||||
// Run only one due job per tick for now. That avoids overlapping shell
|
||||
// commands in the GUI process and keeps the first version predictable;
|
||||
// a future worker pool can add concurrency once cancellation and status
|
||||
// reporting are more explicit.
|
||||
record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir)
|
||||
s.prepareNextRun(job, time.Now())
|
||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
@@ -166,12 +183,17 @@ func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if strings.HasPrefix(schedule, "@every ") {
|
||||
// @every is kept alongside cron because it is convenient for quick tests
|
||||
// and for simple intervals that are awkward to express as five fields.
|
||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||
if err != nil || interval <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return from.Add(interval), true
|
||||
}
|
||||
// Standard five-field cron keeps PySentry compatible with the mental model
|
||||
// users already know from Unix cron, while robfig/cron handles edge cases
|
||||
// such as ranges, steps, and day-of-week names.
|
||||
parsed, err := cronParser.Parse(schedule)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
|
||||
@@ -28,6 +28,9 @@ func OpenStore() (*Store, []Job, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -37,6 +40,9 @@ func OpenStore() (*Store, []Job, error) {
|
||||
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
|
||||
}
|
||||
@@ -59,6 +65,8 @@ func (s *Store) SaveJobs(jobs []Job) error {
|
||||
}
|
||||
|
||||
func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||
// executable, while logs are grouped under a dedicated subdirectory.
|
||||
config := Config{
|
||||
JobsDir: ".",
|
||||
LogsDir: "logs",
|
||||
@@ -80,6 +88,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
return 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) == "" {
|
||||
@@ -96,6 +106,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
|
||||
func loadOrCreateJobs(path string) ([]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, JobsFile{Jobs: jobs})
|
||||
@@ -117,6 +129,8 @@ func normalizeJobs(jobs []Job) {
|
||||
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 {
|
||||
@@ -129,6 +143,8 @@ func normalizeJobs(jobs []Job) {
|
||||
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("PySentry job ran")
|
||||
}
|
||||
if job.LastRun == "" {
|
||||
@@ -144,6 +160,9 @@ func normalizeJobs(jobs []Job) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -156,6 +175,9 @@ 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))
|
||||
}
|
||||
|
||||
@@ -173,6 +195,9 @@ func writeYAML(path string, value any) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -208,5 +233,7 @@ 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