Stabilize packaging and scheduler storage

This commit is contained in:
mixeme
2026-06-14 23:23:14 +03:00
parent 4c11bb4f06
commit 414be2dfe9
19 changed files with 440 additions and 84 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
bin
dist
logs
pysentry.yaml
jobs.yaml
*.exe
+5
View File
@@ -1,7 +1,12 @@
# ---> Python # ---> Python
# ---> Go # ---> Go
bin/ bin/
dist/
cmd/pysentry/*.syso
*.exe *.exe
pysentry.yaml
jobs.yaml
logs/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
+22
View File
@@ -0,0 +1,22 @@
FROM golang:1.22-bookworm
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
gcc \
libgl1-mesa-dev \
xorg-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=1
ENV GOOS=linux
ENV GOARCH=amd64
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry
+156 -20
View File
@@ -1,37 +1,173 @@
# PySentry # PySentry
PySentry is a cross-platform desktop scheduler inspired by cron. PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
The project is starting with the GUI shell first, then the scheduling core. ## Features
- Native desktop GUI built with Fyne.
- Job storage in one clean YAML file.
- App settings in a separate YAML file.
- `@every` schedules and standard 5-field cron expressions.
- Manual and scheduled command runs.
- Per-run `.log` files with stdout/stderr.
- Log cleanup by maximum file count and maximum age.
- Global pause/resume for all job execution.
- Windows tray support.
## Requirements ## Requirements
- Go 1.22 or newer Common:
- A C compiler for Fyne builds on Windows, for example MSYS2/MinGW-w64
## Run - Go 1.22 or newer.
```powershell Windows:
go mod tidy
go run ./cmd/pysentry - MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
Linux:
- A C compiler.
- Fyne native build dependencies, including OpenGL/X11 development packages.
On Debian/Ubuntu, the Linux dependencies are typically:
```bash
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
``` ```
If Go is installed but not available in `PATH`, use the full path: ## Build
Windows:
```powershell ```powershell
.\scripts\build-windows.ps1
```
The binary is written to:
```text
dist\windows\pysentry.exe
```
Linux:
```bash
chmod +x ./scripts/build-linux.sh
./scripts/build-linux.sh
```
The binary is written to:
```text
dist/linux/pysentry
```
Linux using Docker:
```powershell
.\scripts\build-linux-docker.ps1
```
The binary is copied to:
```text
dist\linux\pysentry
```
## Run From Source
Windows:
```powershell
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
$env:CGO_ENABLED = '1'
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry & 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
``` ```
## Current shape Linux:
```bash
CGO_ENABLED=1 go run ./cmd/pysentry
```
## Storage
PySentry creates its runtime files next to the executable by default.
`pysentry.yaml` stores application settings:
```yaml
jobs_dir: .
logs_dir: logs
max_log_files: 100
max_log_age_days: 30
keep_running_in_tray: true
notify_on_failure: true
```
`jobs.yaml` stores only job definitions:
```yaml
jobs:
- id: 1
name: Hello scheduler
folder: Examples
schedule: '@every 10s'
command: echo PySentry test job: scheduler is alive
enabled: true
```
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
```text
20260614-224306_Hello_scheduler.log
```
## Schedules
Fast interval schedules:
```text
@every 10s
@every 5m
@every 1h30m
```
Standard 5-field cron schedules:
```text
*/5 * * * * every five minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30
```
## Using The App
1. Start PySentry.
2. Use `New job` to create a command.
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
4. Use `Run now` for a manual test run.
5. Use `Pause` to disable one job.
6. Use `Pause all` as a global stop switch.
7. Open `History` to see whether a run was `Manual`, `Schedule`, or `UI`.
8. Open `Settings` to change `jobs_dir`, `logs_dir`, and log cleanup limits. Use `Browse` to choose directories.
Changing `jobs_dir` saves the current job list to the new directory.
## Project Layout
- `cmd/pysentry` starts the desktop app. - `cmd/pysentry` starts the desktop app.
- `internal/app` contains the first Fyne-based interface prototype. - `internal/app` contains the GUI.
- `internal/core` contains YAML storage, command execution, and the first scheduler loop. - `internal/core` contains YAML storage, command execution, scheduling, and log cleanup.
- Jobs can be created, edited, paused/resumed, run manually, and persisted to YAML. - `assets` contains app icons.
- Settings are stored in `pysentry.yaml` next to the executable. - `scripts` contains build helpers.
- Jobs are stored in one `jobs.yaml` file. The job directory is configured by `jobs_dir` and defaults to the executable directory.
- Command output is also written to per-run `.log` files in `logs_dir`. Log filenames include the run timestamp and job name. ## Dependencies
- Log cleanup is controlled by `max_log_files` and `max_log_age_days`.
- The current scheduler supports `@every` schedules such as `@every 10s` and `@every 1m`. PySentry keeps the direct dependency list intentionally small:
- Run history records include a `trigger` value such as `Manual`, `Schedule`, or `UI`.
- Cron expression parsing is planned for the next phase. - `fyne.io/fyne/v2` for the native GUI.
- `github.com/robfig/cron/v3` for cron schedule parsing.
- `gopkg.in/yaml.v3` for YAML settings and jobs.
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 996 KiB

+1
View File
@@ -4,6 +4,7 @@ go 1.22
require ( require (
fyne.io/fyne/v2 v2.5.3 fyne.io/fyne/v2 v2.5.3
github.com/robfig/cron/v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
+2
View File
@@ -243,6 +243,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+39 -20
View File
@@ -1,9 +1,8 @@
package app package app
import ( import (
_ "embed"
"fmt" "fmt"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +26,9 @@ const noFolder = "No folder"
type job = core.Job type job = core.Job
type event = core.RunRecord type event = core.RunRecord
//go:embed assets/pysentry-icon.png
var iconBytes []byte
func Run() { func Run() {
a := app.NewWithID(appID) a := app.NewWithID(appID)
a.SetIcon(loadAppIcon()) a.SetIcon(loadAppIcon())
@@ -39,19 +41,7 @@ func Run() {
} }
func loadAppIcon() fyne.Resource { func loadAppIcon() fyne.Resource {
candidates := []string{} return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
if executable, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "pysentry-icon.png"))
}
if workingDir, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(workingDir, "assets", "pysentry-icon.png"))
}
for _, path := range candidates {
if resource, err := fyne.LoadResourceFromPath(path); err == nil {
return resource
}
}
return theme.ComputerIcon()
} }
func configureSystemTray(a fyne.App, w fyne.Window) { func configureSystemTray(a fyne.App, w fyne.Window) {
@@ -379,7 +369,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))), container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(store)), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
) )
tabs.SetTabLocation(container.TabLocationTop) tabs.SetTabLocation(container.TabLocationTop)
@@ -557,14 +547,22 @@ func newHistoryView(events *[]event) *fyne.Container {
return container.NewPadded(list) return container.NewPadded(list)
} }
func settingsView(store *core.Store) fyne.CanvasObject { func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil) runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray) minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure) notifications.SetChecked(store.Config.NotifyOnFailure)
jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir)
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir)
})
logsDir := widget.NewEntry() logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir) logsDir.SetText(store.Config.LogsDir)
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
})
maxLogFiles := widget.NewEntry() maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry() maxLogAgeDays := widget.NewEntry()
@@ -583,6 +581,15 @@ func settingsView(store *core.Store) fyne.CanvasObject {
return return
} }
store.Config.LogsDir = strings.TrimSpace(logsDir.Text) store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
store.Config.MaxLogFiles = files store.Config.MaxLogFiles = files
store.Config.MaxLogAgeDays = days store.Config.MaxLogAgeDays = days
store.Config.KeepRunningInTray = minimizeToTray.Checked store.Config.KeepRunningInTray = minimizeToTray.Checked
@@ -591,6 +598,10 @@ func settingsView(store *core.Store) fyne.CanvasObject {
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return return
} }
if err := store.SaveJobs(*jobs); err != nil {
settingsStatus.SetText("Jobs save failed: " + err.Error())
return
}
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
settingsStatus.SetText("Saved, cleanup failed: " + err.Error()) settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
return return
@@ -606,9 +617,8 @@ func settingsView(store *core.Store) fyne.CanvasObject {
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
detailRow("Jobs YAML", widget.NewLabel(store.Paths.JobsPath)), detailRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
detailRow("Jobs directory", widget.NewLabel(store.Paths.JobsDir)), detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
detailRow("Logs directory", logsDir),
detailRow("Max log files", maxLogFiles), detailRow("Max log files", maxLogFiles),
detailRow("Max log age days", maxLogAgeDays), detailRow("Max log age days", maxLogAgeDays),
saveSettings, saveSettings,
@@ -618,3 +628,12 @@ func settingsView(store *core.Store) fyne.CanvasObject {
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."), widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
)) ))
} }
func chooseFolder(w fyne.Window, target *widget.Entry) {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

+5 -5
View File
@@ -22,11 +22,11 @@ type Job struct {
Schedule string `yaml:"schedule"` Schedule string `yaml:"schedule"`
Command string `yaml:"command"` Command string `yaml:"command"`
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
LastRun string `yaml:"last_run,omitempty"` LastRun string `yaml:"-"`
NextRun string `yaml:"next_run,omitempty"` NextRun string `yaml:"-"`
LastState string `yaml:"last_state,omitempty"` LastState string `yaml:"-"`
Logs []RunRecord `yaml:"activity,omitempty"` Logs []RunRecord `yaml:"-"`
Output string `yaml:"last_output,omitempty"` Output string `yaml:"-"`
nextDue time.Time nextDue time.Time
} }
+19 -8
View File
@@ -5,8 +5,12 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/robfig/cron/v3"
) )
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
type Scheduler struct { type Scheduler struct {
store *Store store *Store
jobs *[]Job jobs *[]Job
@@ -146,24 +150,31 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
} }
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) { func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
interval, ok := parseEvery(job.Schedule) next, ok := nextRunTime(job.Schedule, from)
if !ok { if !ok {
job.NextRun = "Unsupported schedule" job.NextRun = "Invalid schedule"
job.nextDue = time.Time{} job.nextDue = time.Time{}
return return
} }
job.nextDue = from.Add(interval) job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05") job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
} }
func parseEvery(schedule string) (time.Duration, bool) { func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule) schedule = strings.TrimSpace(schedule)
if !strings.HasPrefix(schedule, "@every ") { if schedule == "" {
return 0, false return time.Time{}, false
} }
if strings.HasPrefix(schedule, "@every ") {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every "))) interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 { if err != nil || interval <= 0 {
return 0, false return time.Time{}, false
} }
return interval, true return from.Add(interval), true
}
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
} }
+29
View File
@@ -0,0 +1,29 @@
package core
import (
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
+42 -13
View File
@@ -36,6 +36,10 @@ func OpenStore() (*Store, []Job, error) {
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
normalizeJobs(jobs)
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil return store, jobs, nil
} }
@@ -93,6 +97,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
func loadOrCreateJobs(path string) ([]Job, error) { func loadOrCreateJobs(path string) ([]Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
jobs := defaultJobs() jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs}) return jobs, writeYAML(path, JobsFile{Jobs: jobs})
} }
@@ -107,6 +112,42 @@ func loadOrCreateJobs(path string) ([]Job, error) {
return file.Jobs, nil return file.Jobs, nil
} }
func normalizeJobs(jobs []Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
job.ID = next
}
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) == "" {
job.Command = echoCommand("PySentry job ran")
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string { func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir) return resolveConfiguredDir(appDir, jobsDir)
} }
@@ -144,22 +185,14 @@ func defaultJobs() []Job {
Schedule: "@every 10s", Schedule: "@every 10s",
Command: echoCommand("PySentry test job: scheduler is alive"), Command: echoCommand("PySentry test job: scheduler is alive"),
Enabled: true, Enabled: true,
LastRun: "Never",
NextRun: "After start",
LastState: "Ready",
Output: "No command output captured yet.",
}, },
{ {
ID: 2, ID: 2,
Name: "Write timestamp", Name: "Write timestamp",
Folder: "Examples", Folder: "Examples",
Schedule: "@every 15s", Schedule: "*/1 * * * *",
Command: echoCommand("PySentry test job: timestamp command ran"), Command: echoCommand("PySentry test job: timestamp command ran"),
Enabled: true, Enabled: true,
LastRun: "Never",
NextRun: "After start",
LastState: "Ready",
Output: "No command output captured yet.",
}, },
{ {
ID: 3, ID: 3,
@@ -167,10 +200,6 @@ func defaultJobs() []Job {
Schedule: "@every 1m", Schedule: "@every 1m",
Command: echoCommand("This paused sample should not run until enabled"), Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false, Enabled: false,
LastRun: "Never",
NextRun: "Paused",
LastState: "Paused",
Output: "No command output captured yet.",
}, },
} }
} }
+38
View File
@@ -0,0 +1,38 @@
package core
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "packaging/windows/pysentry.ico"
+13
View File
@@ -0,0 +1,13 @@
param(
[string]$Output = "dist\linux\pysentry"
)
$ErrorActionPreference = "Stop"
docker build -f Dockerfile.linux -t pysentry-linux-builder .
$containerId = docker create pysentry-linux-builder
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
docker cp "${containerId}:/out/pysentry" $Output
docker rm $containerId | Out-Null
Write-Host "Built $Output"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
output="${1:-dist/linux/pysentry}"
mkdir -p "$(dirname "$output")"
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=amd64
go build -trimpath -ldflags "-s -w" -o "$output" ./cmd/pysentry
echo "Built $output"
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$Output = "dist\windows\pysentry.exe"
)
$ErrorActionPreference = "Stop"
$go = "${env:ProgramFiles}\Go\bin\go.exe"
if (-not (Test-Path $go)) {
$go = "go"
}
$msys2Bin = "C:\msys64\ucrt64\bin"
if (Test-Path $msys2Bin) {
$env:Path = "$msys2Bin;$env:Path"
}
$env:CGO_ENABLED = "1"
$env:GOOS = "windows"
$env:GOARCH = "amd64"
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
$windres = Get-Command windres.exe -ErrorAction SilentlyContinue
if ($windres) {
& $windres.Source -O coff -o .\cmd\pysentry\rsrc_windows_amd64.syso .\packaging\windows\pysentry.rc
}
& $go build -trimpath -ldflags "-s -w" -o $Output .\cmd\pysentry
Write-Host "Built $Output"