diff --git a/cronr/README.md b/cronr/README.md new file mode 100644 index 0000000..002c5c6 --- /dev/null +++ b/cronr/README.md @@ -0,0 +1,54 @@ +# cronr — agent scheduling via systemd user timers + +Lets an agent create recurring or one-shot jobs that **survive session +exit and reboot** — what agy's in-memory `schedule` tool and Claude's +in-session wakeups cannot do. No daemon: systemd runs the jobs, cronr +just manages namespaced `cronr-` units. Closes the +`CronCreate/List/Delete` gap from +[`doc/tool-parity.md`](../doc/tool-parity.md) §3.1. + +## Usage + +``` +cronr add --schedule "OnCalendar spec" --cmd "shell command" +cronr add --at "YYYY-MM-DD HH:MM" --cmd "shell command" # one-shot +cronr list # schedule, next run, last result per job +cronr run # run the job right now (timer untouched) +cronr logs [--lines N] # the job's journal +cronr rm # disable and delete +``` + +Examples: + +```bash +cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"' +cronr add backup-ping --schedule "Mon *-*-* 09:00" --cmd 'notifyr send --msg "weekly backup check"' +cronr add once --at "2026-08-10 03:00" --cmd 'systemctl --user restart helmd' +``` + +## How it works + +`add` writes three files and prints all of them, so the result is +fully verifiable: + +- `~/.local/share/cronr/.sh` — the command, verbatim (shell + quoting never meets systemd's ExecStart parsing) +- `~/.config/systemd/user/cronr-.service` — oneshot, with + `~/.local/bin` on PATH so agent tools (notifyr, svgc, agy…) resolve +- `~/.config/systemd/user/cronr-.timer` — `OnCalendar=…`, + `Persistent=true` for recurring jobs (missed runs fire on next boot) + +Schedules are validated by `systemd-analyze calendar` before anything +is written — you get systemd's own error text plus the computed next +elapse. `list`/`rm` only ever see `cronr-*` units, so other services +are untouchable by construction. + +`cronr run ` starts the service immediately — handy for testing +a job before trusting the schedule. + +## Build & test + +```bash +go test ./... +go build -o build/cronr . +``` diff --git a/cronr/go.mod b/cronr/go.mod new file mode 100644 index 0000000..eb3a4ca --- /dev/null +++ b/cronr/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/cronr + +go 1.24 diff --git a/cronr/main.go b/cronr/main.go new file mode 100644 index 0000000..78fe331 --- /dev/null +++ b/cronr/main.go @@ -0,0 +1,300 @@ +// cronr schedules recurring or one-shot jobs as systemd user timers, +// so an agent can set up work that survives session exit and reboot. +// No daemon of its own — systemd does the running. See +// doc/tool-parity.md §3.1. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit" +) + +var version = "dev" + +const usage = `cronr - agent-friendly scheduling via systemd user timers + +Usage: + cronr add --schedule "OnCalendar spec" --cmd "shell command" + cronr add --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot + cronr list all cronr jobs: schedule, next run, last result + cronr run run the job now (does not touch the timer) + cronr logs [--lines N] journal for the job + cronr rm disable and delete the job + cronr version + +Schedule examples (systemd OnCalendar; validated with systemd-analyze): + "*-*-* 07:00" every morning at 07:00 + "Mon *-*-* 09:00" mondays 09:00 + "*:0/15" every 15 minutes + +The job's command is stored as a script in ~/.local/share/cronr/ and +runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy) +work as in a login shell. Everything cronr creates is namespaced +cronr- — it never touches other units. + +Example: + cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"' +` + +func main() { + if len(os.Args) < 2 { + fmt.Print(usage) + os.Exit(2) + } + switch os.Args[1] { + case "add": + cmdAdd(os.Args[2:]) + case "list": + cmdList() + case "run": + requireName(os.Args[2:], "run") + sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service") + fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2]) + case "logs": + cmdLogs(os.Args[2:]) + case "rm": + cmdRm(os.Args[2:]) + case "version", "--version", "-v": + fmt.Println("cronr", version) + case "help", "--help", "-h": + fmt.Print(usage) + default: + die("unknown command %q — run 'cronr help'", os.Args[1]) + } +} + +func unitDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "systemd", "user") +} + +func scriptDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "cronr") +} + +func cmdAdd(args []string) { + fs := flag.NewFlagSet("add", flag.ExitOnError) + schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs") + at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`) + cmd := fs.String("cmd", "", "shell command the job runs (required)") + pos := parseInterspersed(fs, args) + if len(pos) != 1 { + die("add needs exactly one job name") + } + name := pos[0] + if !unit.ValidName(name) { + die("invalid job name %q (letters, digits, - and _)", name) + } + if *cmd == "" { + die("--cmd is required") + } + if (*schedule == "") == (*at == "") { + die("give exactly one of --schedule (recurring) or --at (one-shot)") + } + spec := *schedule + oneshot := false + if *at != "" { + var err error + spec, err = unit.AtToCalendar(*at) + if err != nil { + die("%v", err) + } + oneshot = true + } + // systemd itself is the authority on calendar specs + if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil { + die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out))) + } + + svcName, tmrName := unit.UnitNames(name) + if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil { + die("job %s already exists (cronr rm %s first)", name, name) + } + scriptPath := filepath.Join(scriptDir(), name+".sh") + if err := os.MkdirAll(scriptDir(), 0o755); err != nil { + die("%v", err) + } + if err := os.MkdirAll(unitDir(), 0o755); err != nil { + die("%v", err) + } + writes := []struct{ path, content string }{ + {scriptPath, unit.Script(*cmd)}, + {filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)}, + {filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)}, + } + for _, w := range writes { + mode := os.FileMode(0o644) + if strings.HasSuffix(w.path, ".sh") { + mode = 0o755 + } + if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil { + die("%v", err) + } + } + sh("systemctl", "--user", "daemon-reload") + sh("systemctl", "--user", "enable", "--now", tmrName) + + fmt.Printf("job %s created and enabled\n\n", name) + for _, w := range writes { + fmt.Printf("--- %s ---\n%s\n", w.path, w.content) + } + fmt.Print(nextRun(spec)) +} + +func cmdList() { + matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer")) + if len(matches) == 0 { + fmt.Println("no cronr jobs") + return + } + sort.Strings(matches) + fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT") + for _, m := range matches { + name := unit.JobName(m) + if name == "" { + continue + } + spec := "" + if data, err := os.ReadFile(m); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "OnCalendar=") { + spec = strings.TrimPrefix(line, "OnCalendar=") + } + } + } + next := strings.TrimPrefix(nextRun(spec), "next run: ") + svcName, _ := unit.UnitNames(name) + last := lastResult(svcName) + fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last) + } +} + +// nextRun asks systemd-analyze when the spec fires next. +func nextRun(spec string) string { + out, err := exec.Command("systemd-analyze", "calendar", spec).Output() + if err != nil { + return "next run: ?\n" + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Next elapse:") { + return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n" + } + } + return "next run: never (already elapsed?)\n" +} + +func lastResult(svcName string) string { + out, err := exec.Command("systemctl", "--user", "show", svcName, + "-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output() + if err != nil { + return "?" + } + status, when := "?", "" + for _, line := range strings.Split(string(out), "\n") { + if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok { + status = v + } + if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok { + when = v + } + } + if when == "" { + return "never ran" + } + if status == "0" { + return "ok (" + when + ")" + } + return "exit " + status + " (" + when + ")" +} + +func cmdLogs(args []string) { + fs := flag.NewFlagSet("logs", flag.ExitOnError) + lines := fs.Int("lines", 50, "number of journal lines") + pos := parseInterspersed(fs, args) + if len(pos) != 1 { + die("logs needs exactly one job name") + } + svcName, _ := unit.UnitNames(pos[0]) + c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager") + c.Stdout, c.Stderr = os.Stdout, os.Stderr + c.Run() +} + +func cmdRm(args []string) { + requireName(args, "rm") + name := args[0] + if !unit.ValidName(name) { + die("invalid job name %q", name) + } + svcName, tmrName := unit.UnitNames(name) + if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil { + die("no such job %s", name) + } + sh("systemctl", "--user", "disable", "--now", tmrName) + for _, p := range []string{ + filepath.Join(unitDir(), tmrName), + filepath.Join(unitDir(), svcName), + filepath.Join(scriptDir(), name+".sh"), + } { + os.Remove(p) + } + sh("systemctl", "--user", "daemon-reload") + fmt.Printf("job %s removed\n", name) +} + +func requireName(args []string, cmd string) { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + die("%s needs a job name", cmd) + } +} + +// sh runs a command, dying with its output on failure — every call +// here is a systemctl whose failure should stop the operation. +func sh(name string, args ...string) { + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } +} + +// parseInterspersed lets flags appear before or after positional args. +func parseInterspersed(fs *flag.FlagSet, args []string) []string { + var flags, pos []string + for i := 0; i < len(args); i++ { + a := args[i] + if len(a) > 1 && a[0] == '-' { + flags = append(flags, a) + name := strings.TrimLeft(a, "-") + if eq := strings.Index(name, "="); eq >= 0 { + continue + } + if f := fs.Lookup(name); f != nil { + if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() { + continue + } + } + if i+1 < len(args) { + i++ + flags = append(flags, args[i]) + } + } else { + pos = append(pos, a) + } + } + fs.Parse(flags) + return pos +} + +func die(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cronr/unit/unit.go b/cronr/unit/unit.go new file mode 100644 index 0000000..0691641 --- /dev/null +++ b/cronr/unit/unit.go @@ -0,0 +1,96 @@ +// Package unit generates the systemd user units cronr manages. Pure +// text generation — systemd does the scheduling, cronr owns no daemon. +package unit + +import ( + "fmt" + "regexp" + "strings" +) + +// Prefix namespaces everything cronr creates so list/rm can never +// touch units it does not own. +const Prefix = "cronr-" + +var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// ValidName reports whether a job name is safe for unit/file names. +func ValidName(name string) bool { + return len(name) <= 64 && nameRe.MatchString(name) +} + +// Script wraps the job command in an executable shell script — the +// unit ExecStart points here, so arbitrary quoting in the command +// never meets systemd's ExecStart parsing. +func Script(cmd string) string { + return "#!/bin/sh\n# generated by cronr - the job's command lives here so systemd\n# unit quoting never mangles it\n" + cmd + "\n" +} + +// Service renders the .service unit. PATH gets ~/.local/bin first so +// jobs can call agent tools (notifyr, svgc, agy, ...) like a login +// shell would. +func Service(name, scriptPath string) string { + return fmt.Sprintf(`[Unit] +Description=cronr job %s + +[Service] +Type=oneshot +Environment=PATH=%%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/bin/sh %s +`, name, scriptPath) +} + +// Timer renders the .timer unit. Recurring jobs get Persistent=true +// (a missed run fires at next boot/login); one-shots do not. +func Timer(name, calendarSpec string, oneshot bool) string { + persistent := "true" + if oneshot { + persistent = "false" + } + return fmt.Sprintf(`[Unit] +Description=cronr timer for %s + +[Timer] +OnCalendar=%s +Persistent=%s + +[Install] +WantedBy=timers.target +`, name, calendarSpec, persistent) +} + +// UnitNames returns the service and timer unit names for a job. +func UnitNames(name string) (service, timer string) { + return Prefix + name + ".service", Prefix + name + ".timer" +} + +// JobName extracts the job name from a cronr unit filename, or "" +// if the filename is not cronr's. +func JobName(unitFile string) string { + base := unitFile + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] + } + if !strings.HasPrefix(base, Prefix) { + return "" + } + base = strings.TrimPrefix(base, Prefix) + for _, suffix := range []string{".timer", ".service"} { + if strings.HasSuffix(base, suffix) { + return strings.TrimSuffix(base, suffix) + } + } + return "" +} + +// AtToCalendar converts "YYYY-MM-DD HH:MM" (or with seconds) to a +// systemd calendar spec for one-shot jobs. systemd accepts the format +// as-is; this just validates the shape early with a helpful error. +var atRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$`) + +func AtToCalendar(at string) (string, error) { + if !atRe.MatchString(at) { + return "", fmt.Errorf("--at must be \"YYYY-MM-DD HH:MM[:SS]\", got %q", at) + } + return at, nil +} diff --git a/cronr/unit/unit_test.go b/cronr/unit/unit_test.go new file mode 100644 index 0000000..d199f20 --- /dev/null +++ b/cronr/unit/unit_test.go @@ -0,0 +1,84 @@ +package unit + +import ( + "strings" + "testing" +) + +func TestValidName(t *testing.T) { + for _, ok := range []string{"nightly-ci-check", "a", "Job_2", "x1-y2"} { + if !ValidName(ok) { + t.Errorf("%q should be valid", ok) + } + } + for _, bad := range []string{"", "-leading", "has space", "slash/y", "ä", strings.Repeat("x", 65), "dot.name"} { + if ValidName(bad) { + t.Errorf("%q should be invalid", bad) + } + } +} + +func TestScriptKeepsCommandVerbatim(t *testing.T) { + cmd := `agy -p "check CI, say 'hi' & notify" | tee /tmp/x` + s := Script(cmd) + if !strings.HasPrefix(s, "#!/bin/sh\n") { + t.Error("missing shebang") + } + if !strings.Contains(s, cmd) { + t.Error("command was mangled") + } +} + +func TestServiceUnit(t *testing.T) { + s := Service("nightly", "/home/x/.local/share/cronr/nightly.sh") + for _, want := range []string{ + "Type=oneshot", + "ExecStart=/bin/sh /home/x/.local/share/cronr/nightly.sh", + "Environment=PATH=%h/.local/bin", + } { + if !strings.Contains(s, want) { + t.Errorf("service missing %q:\n%s", want, s) + } + } +} + +func TestTimerUnit(t *testing.T) { + rec := Timer("nightly", "*-*-* 07:00", false) + if !strings.Contains(rec, "OnCalendar=*-*-* 07:00") || !strings.Contains(rec, "Persistent=true") { + t.Errorf("recurring timer wrong:\n%s", rec) + } + once := Timer("boot", "2026-08-06 03:00", true) + if !strings.Contains(once, "Persistent=false") { + t.Errorf("one-shot timer should not be persistent:\n%s", once) + } + if !strings.Contains(once, "WantedBy=timers.target") { + t.Error("timer missing install section") + } +} + +func TestUnitAndJobNames(t *testing.T) { + svc, tmr := UnitNames("nightly") + if svc != "cronr-nightly.service" || tmr != "cronr-nightly.timer" { + t.Errorf("unit names: %s %s", svc, tmr) + } + if JobName("/home/x/.config/systemd/user/cronr-nightly.timer") != "nightly" { + t.Error("JobName failed on full path") + } + if JobName("helmd.service") != "" { + t.Error("foreign unit must not map to a job") + } +} + +func TestAtToCalendar(t *testing.T) { + if _, err := AtToCalendar("2026-08-06 03:00"); err != nil { + t.Errorf("valid --at rejected: %v", err) + } + if _, err := AtToCalendar("2026-08-06 03:00:30"); err != nil { + t.Errorf("valid --at with seconds rejected: %v", err) + } + for _, bad := range []string{"imorgon", "03:00", "2026-8-6 03:00", "2026-08-06T03:00"} { + if _, err := AtToCalendar(bad); err == nil { + t.Errorf("bad --at %q accepted", bad) + } + } +}