Document design choices and standardize Dockerfile

This commit is contained in:
mixeme
2026-06-15 00:05:12 +03:00
parent 59718e21b4
commit 47e2ba7272
14 changed files with 292 additions and 24 deletions
+16
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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 == "" {
+22
View File
@@ -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
+27
View File
@@ -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, "'", "'\\''") + "'"
}
+51
View File
@@ -23,10 +23,15 @@ const appID = "io.github.pysentry.desktop"
const allFolders = "All"
const noFolder = "No folder"
// The GUI package aliases core types to keep widget callbacks short. The actual
// durable model still lives in src/core, so GUI code does not define a second
// copy of the scheduler data.
type job = core.Job
type event = core.RunRecord
func Run() {
// A stable app ID lets Fyne persist desktop preferences consistently across
// launches and gives tray/window integration a predictable identity.
a := app.NewWithID(appID)
a.SetIcon(loadAppIcon())
@@ -44,6 +49,8 @@ func loadAppIcon() fyne.Resource {
func configureSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(desktop.App)
if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently
// keeps the same binary usable on platforms or sessions without a tray.
return
}
@@ -59,6 +66,9 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
)
desk.SetSystemTrayMenu(menu)
w.SetCloseIntercept(func() {
// Closing hides the window instead of quitting because scheduler tools are
// expected to keep working in the background. The explicit Quit tray item
// remains the way to stop the process.
w.Hide()
})
}
@@ -70,6 +80,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
}
events := collectActivity(jobs)
// The GUI keeps the loaded jobs slice in memory and persists changes after
// each edit/run. This keeps the first version responsive and easy to reason
// about; a database would be unnecessary overhead for one YAML file.
nextJobID := nextID(jobs)
selected := 0
selectedFolder := allFolders
@@ -86,6 +99,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
commandOutput := widget.NewTextGrid()
commandOutput.SetText(jobs[selected].Output)
commandOutputScroll := container.NewScroll(commandOutput)
// Command output can contain long lines and preserved whitespace. TextGrid is
// used instead of Label so stdout/stderr remains readable and does not vanish
// against the theme when it is placed inside a scroll container.
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
history := newHistoryView(&events)
jobLogs := widget.NewList(
@@ -103,6 +119,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
// A folder filter can temporarily leave no selectable rows. Clearing
// the details panel avoids showing stale information for a hidden job.
title.SetText("No job selected")
folder.SetText("")
schedule.SetText("")
@@ -125,6 +143,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
commandOutput.SetText(current.Output)
}
refresh := func() {
// Several callbacks mutate jobs, filters, and event history. A single
// refresh closure keeps the different widgets synchronized after each
// mutation without introducing a heavier state-management layer.
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
jobLogs.Refresh()
@@ -148,6 +169,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
current := jobs[filteredJobs[id]]
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
status.SetText(statusText(current))
},
@@ -169,6 +192,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
if len(filteredJobs) == 0 {
// The "No folder" filter is intentionally allowed to be empty. It is a
// real filter choice, not an error state, so the selection is cleared.
selected = -1
updateDetails(-1)
return
@@ -186,6 +211,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
jobs = append(jobs, saved)
selected = len(jobs) - 1
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
// UI events are kept in memory for the current session. They explain
// user actions in History, while command output remains in log files.
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
events = append([]event{created}, events...)
_ = store.SaveJobs(jobs)
@@ -229,6 +256,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
return
}
if schedulerPaused {
// The global pause is treated as an emergency stop for all execution,
// including manual "Run now", so the user has one reliable switch.
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return
}
@@ -262,6 +291,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
stopAllButton.SetIcon(theme.MediaStopIcon())
for index := range jobs {
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
// The scheduler will calculate the exact next run when it is
// resumed; this interim text prevents a stale paused timestamp.
jobs[index].NextRun = "Waiting for scheduler"
}
}
@@ -307,6 +338,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
return
}
deleted := jobs[selected]
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
@@ -358,6 +391,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
)
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
// Scheduled runs happen on the scheduler goroutine. The callback updates
// the shared in-memory event list so History reflects background activity.
events = append([]event{record}, events...)
refresh()
})
@@ -381,6 +416,8 @@ func statusText(j job) string {
}
func newEvent(jobID int, jobName string, state string, detail string) event {
// UI events use a short time because they are session-local activity markers.
// Command runs use full timestamps from core.RunJob and have log files.
return event{
Time: time.Now().Format("15:04:05"),
JobID: jobID,
@@ -405,6 +442,9 @@ func eventText(e event) string {
func collectActivity(jobs []job) []event {
var events []event
for _, current := range jobs {
// At startup this is usually empty because jobs.yaml does not persist
// runtime logs. The function still centralizes the merge for future
// history loading from log metadata.
events = append(events, current.Logs...)
}
return events
@@ -437,6 +477,8 @@ func filteredJobIndexes(jobs []job, folder string) []int {
}
func folderOptions(jobs []job) []string {
// "All" and "No folder" are always present so the filter UI is stable even
// before the user creates folders.
options := []string{allFolders, noFolder}
seen := map[string]bool{allFolders: true, noFolder: true}
for _, current := range jobs {
@@ -505,6 +547,8 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
return
}
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
// These three fields are the minimum executable job definition.
// Folder is optional because ungrouped jobs are a supported workflow.
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
return
}
@@ -594,10 +638,15 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
settingsStatus.SetText("Save failed: " + err.Error())
return
}
// When the jobs directory changes, save the currently loaded jobs to the
// newly resolved path immediately. That makes the setting visible on disk
// without requiring a restart or a separate migration command.
if err := store.SaveJobs(*jobs); err != nil {
settingsStatus.SetText("Jobs save failed: " + err.Error())
return
}
// Cleanup runs on settings save so a user who tightens retention limits
// sees the new policy take effect right away.
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
return
@@ -631,6 +680,8 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
}
target.SetText(uri.Path())
}, w)
// The default folder picker can be cramped on Windows. A larger size makes
// long paths readable and avoids forcing the user to resize it every time.
folderDialog.Resize(fyne.NewSize(900, 640))
folderDialog.Show()
}