Compare commits
14 Commits
main
...
17c5e15b63
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c5e15b63 | |||
| 794ed8061c | |||
| 16d818d03d | |||
| 06edbfff77 | |||
| ad0e45a7dd | |||
| c29d581fb1 | |||
| f4fb16c0ed | |||
| 80c76a0cba | |||
| 462752f995 | |||
| ef6902d65c | |||
| 520a7ef98b | |||
| 0038975adc | |||
| f653b1e484 | |||
| 4c49104cce |
@@ -9,6 +9,7 @@ Project notes:
|
|||||||
- [Changelog](docs/CHANGELOG.md)
|
- [Changelog](docs/CHANGELOG.md)
|
||||||
- [Roadmap](docs/ROADMAP.md)
|
- [Roadmap](docs/ROADMAP.md)
|
||||||
- [Architecture](docs/ARCHITECTURE.md)
|
- [Architecture](docs/ARCHITECTURE.md)
|
||||||
|
- [Refactoring plan](docs/REFACTORING.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/core"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/gui"
|
"gitea.mixdep.ru/mix/gosentry/src/gui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ func main() {
|
|||||||
// The executable entry point intentionally delegates all startup work to the
|
// The executable entry point intentionally delegates all startup work to the
|
||||||
// GUI package. Keeping main small makes it easier to add platform-specific
|
// GUI package. Keeping main small makes it easier to add platform-specific
|
||||||
// packaging later without mixing window setup, storage, and scheduler logic.
|
// packaging later without mixing window setup, storage, and scheduler logic.
|
||||||
gui.Run(hasArgument(core.StartInTrayArgument))
|
gui.Run(hasArgument(domain.StartInTrayArgument))
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasArgument(argument string) bool {
|
func hasArgument(argument string) bool {
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# GoSentry Refactoring Plan
|
||||||
|
|
||||||
|
Status: proposed — not yet started.
|
||||||
|
Goal: make the codebase **solid**, **comprehensive**, and **human-readable / maintainable**
|
||||||
|
without changing observable behavior.
|
||||||
|
|
||||||
|
This document is the single source of truth for the refactor. It records the
|
||||||
|
target architecture, the rationale, and a sequence of small, independently
|
||||||
|
reviewable tasks. Each task lists the recommended agent model and effort level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why refactor
|
||||||
|
|
||||||
|
The application works and is well-commented, but its structure does not scale:
|
||||||
|
|
||||||
|
| # | Problem | Impact |
|
||||||
|
|---|---------|--------|
|
||||||
|
| 1 | `src/gui/app.go` is a 1,057-line monolith | Nothing can be found, reused, or tested in isolation |
|
||||||
|
| 2 | `src/core` is one flat package mixing 7 concerns | No boundaries; everything can call everything |
|
||||||
|
| 3 | **Shared mutable `*[]Job`** between GUI and `Scheduler` | GUI mutates the slice with no lock; scheduler locks the same slice → data race |
|
||||||
|
| 4 | `onChange` mutates Fyne widgets **from the scheduler goroutine** | Latent crash/corruption — Fyne requires UI updates on the main thread |
|
||||||
|
| 5 | `Job` mixes durable config and runtime state (`yaml:"-"` fields) | The "noise" the model fights to exclude lives in the same struct |
|
||||||
|
| 6 | Errors swallowed everywhere (`_ = store.SaveJobs(...)`) | Save failures are invisible to the user |
|
||||||
|
| 7 | No service/controller layer; GUI reaches into `store.Paths`, drives scheduler directly | Business logic is tangled into widget callbacks |
|
||||||
|
| 8 | Schedule strings re-parsed every tick; no `Schedule` value type | Validation scattered; no single source of truth |
|
||||||
|
| 9 | Tests only cover `core`; GUI and orchestration untestable | Documented gap in `docs/TESTS.md` |
|
||||||
|
|
||||||
|
> Note on layout: the project intentionally **keeps the `src/` directory**. The
|
||||||
|
> `src/` → `internal/` move was considered and rejected — it is cosmetic for a
|
||||||
|
> non-imported desktop app and not worth the import-path churn. All packages
|
||||||
|
> below live under `src/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target architecture
|
||||||
|
|
||||||
|
The central change is to **insert an application-service layer** that owns all
|
||||||
|
state and exposes intent-based methods. This turns the UI into a thin view and
|
||||||
|
the core packages into stateless engines, dissolving problems 3, 4, 6, and 7.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ intents ┌─────────────────┐ calls ┌──────────────┐
|
||||||
|
│ ui (Fyne) │ ───────────▶ │ app.Service │ ─────────▶ │ core engines │
|
||||||
|
│ thin views │ ◀─────────── │ (sole owner of │ │ scheduler / │
|
||||||
|
│ fyne.Do only │ events │ state + mutex) │ ◀───────── │ runner / │
|
||||||
|
└──────────────┘ └─────────────────┘ records │ storage │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **One writer.** `app.Service` holds the job list + runtime state behind a
|
||||||
|
mutex. The UI never mutates state directly — it calls `CreateJob`, `RunNow`,
|
||||||
|
`SetGlobalPause`, etc.
|
||||||
|
- **Events flow back** through an observer interface. The UI's listener is the
|
||||||
|
*only* place that touches widgets, and it marshals onto the main thread with
|
||||||
|
`fyne.Do`.
|
||||||
|
- **Core engines are stateless / injected** — scheduler and runner operate on
|
||||||
|
data passed in, not a shared slice.
|
||||||
|
|
||||||
|
### 2.1 Package layout (all under `src/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/gosentry/
|
||||||
|
main.go # flag parse → ui.Run
|
||||||
|
|
||||||
|
src/
|
||||||
|
domain/ # pure types, zero external deps
|
||||||
|
job.go # Job (durable config only — no yaml:"-")
|
||||||
|
runtime.go # JobRuntime (LastRun/NextRun/State/Output/Logs)
|
||||||
|
record.go # RunRecord
|
||||||
|
config.go # Config + StartInTrayArgument
|
||||||
|
schedule.go # Schedule value object: Parse / Validate / Next()
|
||||||
|
|
||||||
|
storage/ # persistence + path resolution + migration
|
||||||
|
store.go # Load/SaveConfig, Load/SaveJobs
|
||||||
|
paths.go # ResolvePaths
|
||||||
|
yaml.go # writeYAML helper
|
||||||
|
migration.go # pysentry → gosentry legacy handling
|
||||||
|
|
||||||
|
scheduler/
|
||||||
|
scheduler.go # timing loop; drives Service via callbacks
|
||||||
|
clock.go # Clock interface (real + fake for tests)
|
||||||
|
|
||||||
|
runner/
|
||||||
|
runner.go # RunJob orchestration
|
||||||
|
invocation.go # build exec.Cmd (shared)
|
||||||
|
invocation_windows.go # cmd.exe quoting
|
||||||
|
invocation_other.go # sh -c
|
||||||
|
exitcodes.go # parse / accept success codes
|
||||||
|
logfile.go # writeRunLog + sanitizeFileName
|
||||||
|
cleanup.go # CleanupLogs
|
||||||
|
|
||||||
|
platform/
|
||||||
|
winproc/ # hidden-window helper shared by runner + autostart
|
||||||
|
winproc_windows.go # CREATE_NO_WINDOW / HideWindow
|
||||||
|
winproc_other.go # no-op
|
||||||
|
autostart/
|
||||||
|
autostart.go # Manager interface + Status type
|
||||||
|
windows.go linux.go other.go
|
||||||
|
desktop/
|
||||||
|
desktop_linux.go other.go
|
||||||
|
|
||||||
|
app/
|
||||||
|
service.go # owns state; CreateJob/UpdateJob/Delete/RunNow/...
|
||||||
|
events.go # Event types + Observer registration
|
||||||
|
format.go # display strings (moved out of GUI)
|
||||||
|
|
||||||
|
ui/ # renamed from src/gui; thin Fyne views
|
||||||
|
run.go # Run(): lifecycle, window, tray wiring
|
||||||
|
mainwindow.go # tab assembly + event listener (fyne.Do)
|
||||||
|
jobs_view.go # list + details panel + toolbar
|
||||||
|
job_dialog.go # new/edit form
|
||||||
|
history_view.go # history table
|
||||||
|
settings_view.go # settings form
|
||||||
|
tray.go # system tray
|
||||||
|
singleinstance.go # localhost IPC
|
||||||
|
layout.go # minWidthLayout
|
||||||
|
```
|
||||||
|
|
||||||
|
Import paths follow the existing convention, e.g.
|
||||||
|
`gitea.mixdep.ru/mix/gosentry/src/domain`,
|
||||||
|
`gitea.mixdep.ru/mix/gosentry/src/app`.
|
||||||
|
|
||||||
|
### 2.2 Dependency direction (must stay acyclic)
|
||||||
|
|
||||||
|
```
|
||||||
|
domain ← (no deps)
|
||||||
|
storage ← domain
|
||||||
|
runner ← domain, platform/winproc
|
||||||
|
scheduler← domain
|
||||||
|
app ← domain, storage, scheduler, runner
|
||||||
|
ui ← app, domain (Fyne)
|
||||||
|
platform/autostart, platform/desktop ← (own deps; winproc for windows)
|
||||||
|
cmd ← ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Key design decisions
|
||||||
|
|
||||||
|
1. **Split durable vs. runtime in the domain.** `domain.Job` becomes pure YAML
|
||||||
|
config (no `yaml:"-"`). Runtime state moves to `domain.JobRuntime`, held by
|
||||||
|
the service keyed by job ID. (Resolves #5.)
|
||||||
|
2. **`Schedule` value object.** `schedule.Parse(string) (Schedule, error)`
|
||||||
|
validates once and exposes `Next(time.Time)`. (Resolves #8.)
|
||||||
|
3. **Autostart behind a `Manager` interface**, selected per platform — mockable,
|
||||||
|
no package-level functions.
|
||||||
|
4. **Injectable `Clock`** in the scheduler → deterministic tests.
|
||||||
|
5. **Errors surface to the UI.** Service methods return errors; status bar shows
|
||||||
|
them. No more `_ =` on saves. (Resolves #6.)
|
||||||
|
6. **Thread-safety contract:** core engines never import Fyne; the UI listener is
|
||||||
|
the sole widget mutator and always wraps updates in `fyne.Do`. (Resolves #4.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Task sequence
|
||||||
|
|
||||||
|
Tasks are ordered so the tree **compiles and all tests pass after every task**.
|
||||||
|
Each task is a small, reviewable unit.
|
||||||
|
|
||||||
|
**Model guidance**
|
||||||
|
- `haiku` — mechanical moves, renames, no judgment required.
|
||||||
|
- `sonnet` — localized logic changes with clear scope.
|
||||||
|
- `opus` — architecture-shaping work (new layers, concurrency, public APIs).
|
||||||
|
|
||||||
|
**Effort guidance** — reasoning depth, not size: `low` / `medium` / `high`.
|
||||||
|
|
||||||
|
### Phase 0 — Safety net
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T0.1 | Add `scripts/test.sh` + `.bat` running `go vet ./...` and `go test -race ./...`. Document in `docs/TESTS.md`. | haiku | low |
|
||||||
|
| T0.2 | Add characterization tests that pin current behavior at seams to be moved: store load→save round-trip, scheduler `nextRunTime`, end-to-end `RunJob` log output. (Some exist; fill gaps.) | sonnet | medium |
|
||||||
|
|
||||||
|
### Phase 1 — Split the flat `core` package (no logic change)
|
||||||
|
|
||||||
|
Mechanical moves + import fixes only. Behavior identical.
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T1.1 | Create `src/domain`; move `Job`, `RunRecord`, `Config`, `JobsFile`, `StartInTrayArgument` from `model.go`. Keep `yaml:"-"` fields for now (split happens in Phase 2). Update all references. | sonnet | medium |
|
||||||
|
| T1.2 | Create `src/platform/winproc`; move `configureHiddenWindow` + hidden-window flags out of `runner_windows.go` / `runner_other.go`. This breaks the future autostart→runner coupling early. | sonnet | medium |
|
||||||
|
| T1.3 | Create `src/runner`; move `runner.go`, `runner_windows.go`, `runner_other.go`, `runner_test.go`. Point at `winproc`. Split helpers into `invocation*.go`, `exitcodes.go`, `logfile.go`, `cleanup.go` as the file moves. | sonnet | medium |
|
||||||
|
| T1.4 | Create `src/scheduler`; move `scheduler.go`, `scheduler_test.go`. Still takes `*[]domain.Job` for now. | sonnet | medium |
|
||||||
|
| T1.5 | Create `src/storage`; move `store.go`, `paths.go`, `store_test.go`. | sonnet | medium |
|
||||||
|
| T1.6 | Create `src/platform/autostart`; move `autostart_*.go` + tests. Point at `winproc`. | sonnet | medium |
|
||||||
|
| T1.7 | Create `src/platform/desktop`; move `desktop_linux.go`, `desktop_other.go`. | haiku | low |
|
||||||
|
| T1.8 | Delete the now-empty `src/core`; run full build + tests on both platforms (or with build tags) to confirm parity. | haiku | low |
|
||||||
|
|
||||||
|
### Phase 2 — Domain cleanup
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T2.1 | Add `src/domain/schedule.go`: `Schedule` value object with `Parse`, `Validate`, `Next(time.Time)`. Unit-test it. Keep `nextRunTime` as a thin wrapper initially. | opus | high |
|
||||||
|
| T2.2 | Migrate `scheduler` to use `Schedule` (parse on load/edit, not per tick). Remove duplicated parsing. | sonnet | medium |
|
||||||
|
| T2.3 | Split `domain.Job` (durable) from `domain.JobRuntime` (transient). Remove all `yaml:"-"` fields and `nextDue` from `Job`. Add `runtime.go`. | opus | high |
|
||||||
|
| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. | sonnet | medium |
|
||||||
|
|
||||||
|
> After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime`
|
||||||
|
> split is wired through temporary glue. Phase 3 removes the sharing.
|
||||||
|
|
||||||
|
### Phase 3 — Application service layer
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T3.1 | Create `src/app/service.go`: `Service` owning `[]domain.Job` + `map[int]*domain.JobRuntime` behind a `sync.Mutex`. Constructor wires `storage`. | opus | high |
|
||||||
|
| T3.2 | Add `src/app/events.go`: `Event` types (job changed, run recorded, scheduler state) + `Observer` registration. Single-threaded dispatch contract documented. | opus | high |
|
||||||
|
| T3.3 | Move state-mutating operations into the service: `CreateJob`, `UpdateJob`, `DeleteJob`, `SetEnabled`, `RunNow`, `SetGlobalPause`, `UpdateSettings`. Each returns `error`. | opus | high |
|
||||||
|
| T3.4 | Convert `scheduler` to operate through the service (no `*[]Job`). Scheduler asks the service for due jobs and reports records back; service is the sole writer. Inject `Clock`. | opus | high |
|
||||||
|
| T3.5 | Move display/format helpers (`displayFolder`, `displayArguments`, `displayRunMode`, `statusText`, …) from GUI into `src/app/format.go`. | haiku | low |
|
||||||
|
| T3.6 | Add `src/app` unit tests (no Fyne): create/edit/delete, enable/pause, global pause, run-now path with a fake runner + fake clock. Big coverage win. | opus | high |
|
||||||
|
|
||||||
|
### Phase 4 — Carve up the GUI
|
||||||
|
|
||||||
|
Rename `src/gui` → `src/ui` and break `app.go` into focused files. The UI now
|
||||||
|
talks only to `app.Service` and reacts to events via `fyne.Do`.
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T4.1 | Rename package `gui` → `ui`; split lifecycle into `run.go` + `mainwindow.go`. Wire the event listener and route every widget update through `fyne.Do`. (Resolves #4.) | opus | high |
|
||||||
|
| T4.2 | Extract `jobs_view.go` (list + details + toolbar), driven by service calls + events. | sonnet | medium |
|
||||||
|
| T4.3 | Extract `job_dialog.go`; validate schedule via `domain.Schedule.Validate`. | sonnet | medium |
|
||||||
|
| T4.4 | Extract `history_view.go`. | sonnet | medium |
|
||||||
|
| T4.5 | Extract `settings_view.go`; surface save/autostart/cleanup errors to the status label. (Resolves #6 in UI.) | sonnet | medium |
|
||||||
|
| T4.6 | Extract `tray.go`, `singleinstance.go`, `layout.go`. | haiku | low |
|
||||||
|
| T4.7 | Confirm `app.go` is gone and `ui` imports only `app` + `domain` + Fyne. Manual smoke test on each platform. | sonnet | medium |
|
||||||
|
|
||||||
|
### Phase 5 — Hardening & docs
|
||||||
|
|
||||||
|
| Task | Description | Model | Effort |
|
||||||
|
|------|-------------|-------|--------|
|
||||||
|
| T5.1 | Replace remaining `_ = ...Save...` with propagated/surfaced errors across service + storage. | sonnet | medium |
|
||||||
|
| T5.2 | Introduce `autostart.Manager` interface + per-platform impls; inject into the service instead of calling package funcs. | sonnet | medium |
|
||||||
|
| T5.3 | Fill documented test gaps: folder filtering, log cleanup (count + age), settings persistence/migration, concurrent run prevention. | sonnet | high |
|
||||||
|
| T5.4 | Run `go test -race ./...` clean. Confirm no data race remains. | haiku | low |
|
||||||
|
| T5.5 | Update `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and the README "Project Layout" section to the new structure. | sonnet | medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.1 Task completion checklist
|
||||||
|
|
||||||
|
Track progress here. Mark tasks complete as they land and pass review.
|
||||||
|
|
||||||
|
### Phase 0 — Safety net
|
||||||
|
- [x] T0.1 — Add test script + `go vet` + `go test -race`
|
||||||
|
- [x] T0.2 — Add characterization tests
|
||||||
|
|
||||||
|
### Phase 1 — Split flat `core` package
|
||||||
|
- [x] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc
|
||||||
|
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
|
||||||
|
- [x] T1.3 — Create `src/runner`; move runner logic
|
||||||
|
- [x] T1.4 — Create `src/scheduler`; move scheduler
|
||||||
|
- [x] T1.5 — Create `src/storage`; move store/paths
|
||||||
|
- [x] T1.6 — Create `src/platform/autostart`; move autostart logic
|
||||||
|
- [x] T1.7 — Create `src/platform/desktop`; move desktop integration
|
||||||
|
- [ ] T1.8 — Delete empty `src/core`; build + test both platforms
|
||||||
|
|
||||||
|
### Phase 2 — Domain cleanup
|
||||||
|
- [ ] T2.1 — Add `src/domain/schedule.go`; Schedule value object
|
||||||
|
- [ ] T2.2 — Migrate `scheduler` to use Schedule
|
||||||
|
- [ ] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
|
||||||
|
- [ ] T2.4 — Update `storage`: load/save Job only; move runtime init
|
||||||
|
|
||||||
|
### Phase 3 — Application service layer
|
||||||
|
- [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex
|
||||||
|
- [ ] T3.2 — Add `src/app/events.go`; Event types + Observer
|
||||||
|
- [ ] T3.3 — Add state-mutating operations to service
|
||||||
|
- [ ] T3.4 — Convert `scheduler` to use service; inject Clock
|
||||||
|
- [ ] T3.5 — Move display helpers to `src/app/format.go`
|
||||||
|
- [ ] T3.6 — Add `src/app` unit tests (no Fyne)
|
||||||
|
|
||||||
|
### Phase 4 — Carve up the GUI
|
||||||
|
- [ ] T4.1 — Rename `gui` → `ui`; split app.go into run.go + mainwindow.go
|
||||||
|
- [ ] T4.2 — Extract `jobs_view.go`
|
||||||
|
- [ ] T4.3 — Extract `job_dialog.go`
|
||||||
|
- [ ] T4.4 — Extract `history_view.go`
|
||||||
|
- [ ] T4.5 — Extract `settings_view.go`
|
||||||
|
- [ ] T4.6 — Extract `tray.go`, `singleinstance.go`, `layout.go`
|
||||||
|
- [ ] T4.7 — Confirm app.go is gone; smoke test both platforms
|
||||||
|
|
||||||
|
### Phase 5 — Hardening & docs
|
||||||
|
- [ ] T5.1 — Surface errors from service + storage
|
||||||
|
- [ ] T5.2 — Introduce `autostart.Manager` interface
|
||||||
|
- [ ] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency)
|
||||||
|
- [ ] T5.4 — Run `go test -race ./...` clean on both platforms
|
||||||
|
- [ ] T5.5 — Update docs (ARCHITECTURE.md, TESTS.md, README)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Definition of done
|
||||||
|
|
||||||
|
- `go vet ./...` clean; `go test -race ./...` green on Windows and Linux.
|
||||||
|
- No package outside `ui` imports Fyne; no engine mutates UI state.
|
||||||
|
- `domain.Job` has no `yaml:"-"` fields.
|
||||||
|
- `app.Service` is the only writer of job/runtime state.
|
||||||
|
- `src/ui` contains no file over ~250 lines; no single file over ~400.
|
||||||
|
- `docs/ARCHITECTURE.md` matches the shipped structure.
|
||||||
|
|
||||||
|
## 5. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Cross-platform code moves break the non-host OS build | Build with both `GOOS=windows` and `GOOS=linux` after each platform-touching task (T1.2, T1.3, T1.6, T1.7). |
|
||||||
|
| Concurrency change (Phase 3/4) introduces subtle deadlocks | Keep the service mutex non-reentrant; never call back into the UI while holding it; cover with `-race` tests in T3.6. |
|
||||||
|
| Behavior drift during moves | Characterization tests (T0.2) pin behavior before structural change. |
|
||||||
|
| Large diff hard to review | Each task is a separate commit/PR; phases land independently. |
|
||||||
+51
-37
@@ -2,6 +2,57 @@
|
|||||||
|
|
||||||
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
|
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Using the test scripts
|
||||||
|
|
||||||
|
The repository provides convenience scripts to run all tests with static analysis:
|
||||||
|
|
||||||
|
**Unix/Linux/macOS:**
|
||||||
|
```bash
|
||||||
|
./scripts/test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bash
|
||||||
|
scripts\test.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts run:
|
||||||
|
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
|
||||||
|
2. `go test -race ./...` — tests with race condition detection enabled
|
||||||
|
|
||||||
|
### Manual test commands
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all tests with race detection:
|
||||||
|
```bash
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with verbose output:
|
||||||
|
```bash
|
||||||
|
go test -v ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a specific test by name:
|
||||||
|
```bash
|
||||||
|
go test -run TestRunJobWritesLogFile ./src/core
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with code coverage:
|
||||||
|
```bash
|
||||||
|
go test -cover ./src/core
|
||||||
|
go test -coverprofile=coverage.out ./src/core
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test Files Overview
|
## Test Files Overview
|
||||||
|
|
||||||
### store_test.go
|
### store_test.go
|
||||||
@@ -107,43 +158,6 @@ Tests Linux autostart entry creation via XDG Desktop Entry files.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Run all tests in the package
|
|
||||||
```bash
|
|
||||||
cd D:\Local\Git\gosentry
|
|
||||||
go test ./src/core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run tests with verbose output
|
|
||||||
```bash
|
|
||||||
go test -v ./src/core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run specific test by name
|
|
||||||
```bash
|
|
||||||
go test -run TestRunJobWritesLogFile ./src/core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Windows-only tests (on Windows)
|
|
||||||
```bash
|
|
||||||
go test -v ./src/core # Windows build tags are active
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Linux-only tests (on Linux)
|
|
||||||
```bash
|
|
||||||
go test -v ./src/core # Linux build tags are active
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run with code coverage
|
|
||||||
```bash
|
|
||||||
go test -cover ./src/core
|
|
||||||
go test -coverprofile=coverage.out ./src/core
|
|
||||||
go tool cover -html=coverage.out
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Design Principles
|
## Test Design Principles
|
||||||
|
|
||||||
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
|
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
REM GoSentry test runner
|
||||||
|
REM Runs go vet and go test with race detection
|
||||||
|
|
||||||
|
echo Running go vet...
|
||||||
|
go vet ./...
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo ✗ go vet failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Running go test with race detection...
|
||||||
|
go test -race ./...
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo ✗ go test failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ✓ All tests passed
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# GoSentry test runner
|
||||||
|
# Runs go vet and go test with race detection
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running go vet..."
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running go test with race detection..."
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ All tests passed"
|
||||||
@@ -1,68 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
|
|
||||||
// can keep the scheduler running without flashing the main window. Manual
|
|
||||||
// launches omit this flag and open the normal window.
|
|
||||||
const StartInTrayArgument = "--start-in-tray"
|
|
||||||
|
|
||||||
// Config is stored in gosentry.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"`
|
|
||||||
MaxLogFiles int `yaml:"max_log_files"`
|
|
||||||
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
|
||||||
StartOnLogin bool `yaml:"start_on_login"`
|
|
||||||
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
|
||||||
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 GoSentry 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"`
|
|
||||||
Folder string `yaml:"folder,omitempty"`
|
|
||||||
Schedule string `yaml:"schedule"`
|
|
||||||
Command string `yaml:"command"`
|
|
||||||
Arguments string `yaml:"arguments,omitempty"`
|
|
||||||
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
|
||||||
StartOnly bool `yaml:"start_only,omitempty"`
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
LastRun string `yaml:"-"`
|
|
||||||
NextRun string `yaml:"-"`
|
|
||||||
LastState string `yaml:"-"`
|
|
||||||
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"`
|
|
||||||
JobName string `yaml:"job_name"`
|
|
||||||
Trigger string `yaml:"trigger,omitempty"`
|
|
||||||
State string `yaml:"state"`
|
|
||||||
Detail string `yaml:"detail"`
|
|
||||||
LogFile string `yaml:"log_file,omitempty"`
|
|
||||||
Output string `yaml:"output,omitempty"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// The config file stays beside the executable so the portable build behaves
|
|
||||||
// predictably: moving the program folder moves its settings with it.
|
|
||||||
ConfigFileName = "gosentry.yaml"
|
|
||||||
// Older builds were named PySentry. Keep the old config name readable during
|
|
||||||
// the rename window so portable installations can start once and rewrite the
|
|
||||||
// settings to gosentry.yaml without manual file copying.
|
|
||||||
LegacyConfigFileName = "pysentry.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 {
|
|
||||||
ExecutablePath string
|
|
||||||
AppDir string
|
|
||||||
ConfigPath string
|
|
||||||
JobsDir string
|
|
||||||
JobsPath string
|
|
||||||
LogsDir string
|
|
||||||
DesktopIcon string
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
appDir := filepath.Dir(executable)
|
|
||||||
configPath := filepath.Join(appDir, ConfigFileName)
|
|
||||||
return Paths{
|
|
||||||
ExecutablePath: executable,
|
|
||||||
AppDir: appDir,
|
|
||||||
ConfigPath: configPath,
|
|
||||||
JobsDir: appDir,
|
|
||||||
JobsPath: filepath.Join(appDir, JobsFileName),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
const commandTimeout = 30 * time.Second
|
|
||||||
const commandWaitDelay = 2 * 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()
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
var output string
|
|
||||||
var state string
|
|
||||||
var detail string
|
|
||||||
if job.StartOnly {
|
|
||||||
invocation := jobInvocation(context.Background(), *job)
|
|
||||||
state, detail, output = startJobOnly(invocation, *job, started)
|
|
||||||
} else {
|
|
||||||
invocation := jobInvocation(runCtx, *job)
|
|
||||||
command := invocation.command
|
|
||||||
command.WaitDelay = commandWaitDelay
|
|
||||||
if invocation.hideWindow {
|
|
||||||
configureHiddenWindow(command)
|
|
||||||
}
|
|
||||||
command.Stdout = &stdout
|
|
||||||
command.Stderr = &stderr
|
|
||||||
|
|
||||||
err := command.Run()
|
|
||||||
duration := time.Since(started).Round(time.Millisecond)
|
|
||||||
output = formatOutput(stdout.String(), stderr.String())
|
|
||||||
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
job.LastRun = now.Format("2006-01-02 15:04:05")
|
|
||||||
job.LastState = state
|
|
||||||
job.Output = output
|
|
||||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
|
||||||
|
|
||||||
record := RunRecord{
|
|
||||||
Time: job.LastRun,
|
|
||||||
JobID: job.ID,
|
|
||||||
JobName: job.Name,
|
|
||||||
Trigger: trigger,
|
|
||||||
State: state,
|
|
||||||
Detail: detail,
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
|
||||||
entries, err := os.ReadDir(logsDir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type logFile struct {
|
|
||||||
path string
|
|
||||||
modTime time.Time
|
|
||||||
}
|
|
||||||
var logs []logFile
|
|
||||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
|
||||||
for _, entry := range entries {
|
|
||||||
// Only GoSentry 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
|
|
||||||
}
|
|
||||||
path := filepath.Join(logsDir, entry.Name())
|
|
||||||
info, err := entry.Info()
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxFiles <= 0 || len(logs) <= maxFiles {
|
|
||||||
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:] {
|
|
||||||
_ = os.Remove(old.path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
|
|
||||||
if strings.TrimSpace(logsDir) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
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\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
|
||||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFileName(name string) string {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return "job"
|
|
||||||
}
|
|
||||||
var builder strings.Builder
|
|
||||||
for _, r := range name {
|
|
||||||
switch {
|
|
||||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
|
||||||
builder.WriteRune(r)
|
|
||||||
case r == '-', r == '_':
|
|
||||||
builder.WriteRune(r)
|
|
||||||
default:
|
|
||||||
builder.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := strings.Trim(builder.String(), "_")
|
|
||||||
if result == "" {
|
|
||||||
return "job"
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
|
|
||||||
command := invocation.command
|
|
||||||
if invocation.hideWindow {
|
|
||||||
configureHiddenWindow(command)
|
|
||||||
}
|
|
||||||
err := command.Start()
|
|
||||||
duration := time.Since(started).Round(time.Millisecond)
|
|
||||||
if err != nil {
|
|
||||||
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
|
||||||
}
|
|
||||||
pid := command.Process.Pid
|
|
||||||
if releaseErr := command.Process.Release(); releaseErr != nil {
|
|
||||||
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
|
||||||
}
|
|
||||||
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startOnlyOutput(job Job, pid int) string {
|
|
||||||
var builder strings.Builder
|
|
||||||
builder.WriteString("status:\n")
|
|
||||||
if pid > 0 {
|
|
||||||
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
|
||||||
} else {
|
|
||||||
builder.WriteString("Process did not start.\n\n")
|
|
||||||
}
|
|
||||||
builder.WriteString("command:\n")
|
|
||||||
builder.WriteString(job.Command + "\n\n")
|
|
||||||
builder.WriteString("arguments:\n")
|
|
||||||
builder.WriteString(logArguments(job.Arguments))
|
|
||||||
builder.WriteString("\n\nstart_only:\ntrue")
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
|
|
||||||
if err == nil {
|
|
||||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
|
||||||
}
|
|
||||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
|
||||||
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
|
||||||
}
|
|
||||||
if errors.Is(err, exec.ErrWaitDelay) {
|
|
||||||
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
var exitError *exec.ExitError
|
|
||||||
if errors.As(err, &exitError) {
|
|
||||||
exitCode := exitError.ExitCode()
|
|
||||||
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
|
||||||
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
|
||||||
}
|
|
||||||
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
|
||||||
}
|
|
||||||
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
|
||||||
for _, accepted := range parseExitCodes(successExitCodes) {
|
|
||||||
if exitCode == accepted {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseExitCodes(value string) []int {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return []int{0}
|
|
||||||
}
|
|
||||||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
|
||||||
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
|
||||||
})
|
|
||||||
result := make([]int, 0, len(fields))
|
|
||||||
seen := map[int]bool{}
|
|
||||||
for _, field := range fields {
|
|
||||||
code, err := strconv.Atoi(strings.TrimSpace(field))
|
|
||||||
if err != nil || seen[code] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[code] = true
|
|
||||||
result = append(result, code)
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return []int{0}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func successExitCodesText(job Job) string {
|
|
||||||
codes := parseExitCodes(job.SuccessExitCodes)
|
|
||||||
parts := make([]string, 0, len(codes))
|
|
||||||
for _, code := range codes {
|
|
||||||
parts = append(parts, strconv.Itoa(code))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
type commandInvocation struct {
|
|
||||||
command *exec.Cmd
|
|
||||||
hideWindow bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func jobInvocation(ctx context.Context, job Job) commandInvocation {
|
|
||||||
command := strings.TrimSpace(job.Command)
|
|
||||||
arguments := commandArguments(job.Arguments)
|
|
||||||
if len(arguments) > 0 || commandPathExists(command) {
|
|
||||||
return commandInvocation{
|
|
||||||
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
|
||||||
hideWindow: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shell mode remains for existing jobs and for commands that intentionally
|
|
||||||
// use builtins, redirection, variables, or chained command syntax.
|
|
||||||
return commandInvocation{
|
|
||||||
command: shellCommand(ctx, command),
|
|
||||||
hideWindow: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandArguments(arguments string) []string {
|
|
||||||
var result []string
|
|
||||||
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
|
||||||
return r == '\n' || r == '\r'
|
|
||||||
}) {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line != "" {
|
|
||||||
result = append(result, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandPathExists(command string) bool {
|
|
||||||
command = unquoteCommandPath(strings.TrimSpace(command))
|
|
||||||
if command == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
info, err := os.Stat(command)
|
|
||||||
return err == nil && !info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func unquoteCommandPath(command string) string {
|
|
||||||
return strings.Trim(strings.TrimSpace(command), `"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logArguments(arguments string) string {
|
|
||||||
if strings.TrimSpace(arguments) == "" {
|
|
||||||
return "<empty>"
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 == "" {
|
|
||||||
stderr = "<empty>"
|
|
||||||
}
|
|
||||||
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunningOutputIncludesInvocation(t *testing.T) {
|
|
||||||
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
|
|
||||||
job := Job{
|
|
||||||
Name: "Backup",
|
|
||||||
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
|
|
||||||
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
|
|
||||||
SuccessExitCodes: "0,1",
|
|
||||||
}
|
|
||||||
|
|
||||||
output := runningOutput(job, "Manual", started)
|
|
||||||
for _, want := range []string{
|
|
||||||
"Running since 2026-06-17 23:40:00",
|
|
||||||
"Manual",
|
|
||||||
job.Command,
|
|
||||||
job.Arguments,
|
|
||||||
"0,1",
|
|
||||||
"start_only",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(output, want) {
|
|
||||||
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
Paths Paths
|
|
||||||
Config Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenStore() (*Store, []Job, error) {
|
|
||||||
paths, err := ResolvePaths()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &Store{Paths: paths}
|
|
||||||
config, err := loadOrCreateConfig(paths)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
return store, jobs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SaveConfig() error {
|
|
||||||
s.applyConfigPaths()
|
|
||||||
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeYAML(s.Paths.ConfigPath, s.Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SaveJobs(jobs []Job) error {
|
|
||||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
MaxLogFiles: 100,
|
|
||||||
MaxLogAgeDays: 30,
|
|
||||||
StartOnLogin: false,
|
|
||||||
KeepRunningInTray: true,
|
|
||||||
NotifyOnFailure: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := paths.ConfigPath
|
|
||||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
|
|
||||||
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
|
|
||||||
// The rename from PySentry to GoSentry changed the preferred config
|
|
||||||
// filename. Read the old file once if it is still present so portable
|
|
||||||
// installs continue to start without a manual migration step. The
|
|
||||||
// caller later saves the loaded config back through SaveConfig, which
|
|
||||||
// naturally rewrites it under gosentry.yaml.
|
|
||||||
configPath = legacyPath
|
|
||||||
} else {
|
|
||||||
return config, writeYAML(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
return config, writeYAML(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
||||||
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) == "" {
|
|
||||||
config.LogsDir = "logs"
|
|
||||||
}
|
|
||||||
if config.MaxLogFiles <= 0 {
|
|
||||||
config.MaxLogFiles = 100
|
|
||||||
}
|
|
||||||
if config.MaxLogAgeDays <= 0 {
|
|
||||||
config.MaxLogAgeDays = 30
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var file JobsFile
|
|
||||||
if err := yaml.Unmarshal(data, &file); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file.Jobs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeJobs(jobs []Job) {
|
|
||||||
next := 1
|
|
||||||
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 {
|
|
||||||
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) == "" {
|
|
||||||
// An empty command would fail in a confusing way. A safe echo command
|
|
||||||
// gives the user something observable and harmless instead.
|
|
||||||
job.Command = echoCommand("GoSentry job ran")
|
|
||||||
}
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
|
||||||
if job.SuccessExitCodes == "" {
|
|
||||||
job.SuccessExitCodes = "0"
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
|
||||||
return resolveConfiguredDir(appDir, jobsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) applyConfigPaths() {
|
|
||||||
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
|
||||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
|
||||||
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeYAML(path string, value any) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := yaml.Marshal(value)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultJobs() []Job {
|
|
||||||
return []Job{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Hello scheduler",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Write timestamp",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "*/1 * * * *",
|
|
||||||
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Name: "Paused sample",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "'", "'\\''") + "'"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,38 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
|
||||||
|
// can keep the scheduler running without flashing the main window. Manual
|
||||||
|
// launches omit this flag and open the normal window.
|
||||||
|
const StartInTrayArgument = "--start-in-tray"
|
||||||
|
|
||||||
|
// Config is stored in gosentry.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"`
|
||||||
|
MaxLogFiles int `yaml:"max_log_files"`
|
||||||
|
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
||||||
|
StartOnLogin bool `yaml:"start_on_login"`
|
||||||
|
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Job is the user-visible scheduled command.
|
||||||
|
//
|
||||||
|
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
||||||
|
// while GoSentry 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"`
|
||||||
|
Folder string `yaml:"folder,omitempty"`
|
||||||
|
Schedule string `yaml:"schedule"`
|
||||||
|
Command string `yaml:"command"`
|
||||||
|
Arguments string `yaml:"arguments,omitempty"`
|
||||||
|
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||||
|
StartOnly bool `yaml:"start_only,omitempty"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
LastRun string `yaml:"-"`
|
||||||
|
NextRun string `yaml:"-"`
|
||||||
|
LastState string `yaml:"-"`
|
||||||
|
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 `yaml:"-"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
JobName string `yaml:"job_name"`
|
||||||
|
Trigger string `yaml:"trigger,omitempty"`
|
||||||
|
State string `yaml:"state"`
|
||||||
|
Detail string `yaml:"detail"`
|
||||||
|
LogFile string `yaml:"log_file,omitempty"`
|
||||||
|
Output string `yaml:"output,omitempty"`
|
||||||
|
}
|
||||||
+28
-22
@@ -14,6 +14,12 @@ import (
|
|||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/core"
|
"gitea.mixdep.ru/mix/gosentry/src/core"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
@@ -39,8 +45,8 @@ const singleInstanceShowCommand = "show"
|
|||||||
// The GUI package aliases core types to keep widget callbacks short. The actual
|
// 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
|
// durable model still lives in src/core, so GUI code does not define a second
|
||||||
// copy of the scheduler data.
|
// copy of the scheduler data.
|
||||||
type job = core.Job
|
type job = domain.Job
|
||||||
type event = core.RunRecord
|
type event = domain.RunRecord
|
||||||
|
|
||||||
func Run(startInTray bool) {
|
func Run(startInTray bool) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
@@ -159,11 +165,11 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||||
store, jobs, err := core.OpenStore()
|
store, jobs, err := storage.OpenStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
|
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
|
||||||
}
|
}
|
||||||
if iconPath, err := core.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
|
if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
|
||||||
store.Paths.DesktopIcon = iconPath
|
store.Paths.DesktopIcon = iconPath
|
||||||
}
|
}
|
||||||
events := collectActivity(jobs)
|
events := collectActivity(jobs)
|
||||||
@@ -261,7 +267,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
jobLogs.Refresh()
|
jobLogs.Refresh()
|
||||||
history.Refresh()
|
history.Refresh()
|
||||||
}
|
}
|
||||||
var scheduler *core.Scheduler
|
var sched *scheduler.Scheduler
|
||||||
|
|
||||||
list := widget.NewList(
|
list := widget.NewList(
|
||||||
func() int { return len(filteredJobs) },
|
func() int { return len(filteredJobs) },
|
||||||
@@ -351,8 +357,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
|
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
|
||||||
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
|
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
|
||||||
events = append(events, updated)
|
events = append(events, updated)
|
||||||
if scheduler != nil {
|
if sched != nil {
|
||||||
scheduler.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
}
|
}
|
||||||
_ = store.SaveJobs(jobs)
|
_ = store.SaveJobs(jobs)
|
||||||
folderSelect.Options = folderOptions(jobs)
|
folderSelect.Options = folderOptions(jobs)
|
||||||
@@ -371,7 +377,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !scheduler.RunNow(selected) {
|
if !sched.RunNow(selected) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
@@ -389,8 +395,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
jobs[index].NextRun = "Scheduler paused"
|
jobs[index].NextRun = "Scheduler paused"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if scheduler != nil {
|
if sched != nil {
|
||||||
scheduler.SetPaused(true)
|
sched.SetPaused(true)
|
||||||
}
|
}
|
||||||
events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused"))
|
events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused"))
|
||||||
} else {
|
} else {
|
||||||
@@ -404,8 +410,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
jobs[index].NextRun = "Waiting for scheduler"
|
jobs[index].NextRun = "Waiting for scheduler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if scheduler != nil {
|
if sched != nil {
|
||||||
scheduler.SetPaused(false)
|
sched.SetPaused(false)
|
||||||
}
|
}
|
||||||
events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed"))
|
events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed"))
|
||||||
}
|
}
|
||||||
@@ -424,8 +430,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
||||||
current.Logs = append([]event{resumed}, current.Logs...)
|
current.Logs = append([]event{resumed}, current.Logs...)
|
||||||
events = append(events, resumed)
|
events = append(events, resumed)
|
||||||
if scheduler != nil {
|
if sched != nil {
|
||||||
scheduler.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current.LastState = "Paused"
|
current.LastState = "Paused"
|
||||||
@@ -433,8 +439,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
||||||
current.Logs = append([]event{paused}, current.Logs...)
|
current.Logs = append([]event{paused}, current.Logs...)
|
||||||
events = append(events, paused)
|
events = append(events, paused)
|
||||||
if scheduler != nil {
|
if sched != nil {
|
||||||
scheduler.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = store.SaveJobs(jobs)
|
_ = store.SaveJobs(jobs)
|
||||||
@@ -501,13 +507,13 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
jobLogs,
|
jobLogs,
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
|
sched = scheduler.NewScheduler(store, &jobs, func(record domain.RunRecord) {
|
||||||
// Scheduled runs happen on the scheduler goroutine. The callback updates
|
// Scheduled runs happen on the scheduler goroutine. The callback updates
|
||||||
// the shared in-memory event list so History reflects background activity.
|
// the shared in-memory event list so History reflects background activity.
|
||||||
events = append(events, record)
|
events = append(events, record)
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
scheduler.Start()
|
sched.Start()
|
||||||
|
|
||||||
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
||||||
jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
|
jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
|
||||||
@@ -901,12 +907,12 @@ func logFileName(path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
func settingsView(w fyne.Window, store *storage.Store, jobs *[]job) fyne.CanvasObject {
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
refreshAutostartStatus := func() {
|
refreshAutostartStatus := func() {
|
||||||
ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
|
ok, message := autostart.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
|
||||||
if ok {
|
if ok {
|
||||||
autostartStatus.SetText("OK: " + message)
|
autostartStatus.SetText("OK: " + message)
|
||||||
return
|
return
|
||||||
@@ -971,7 +977,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
|
|||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
|
if err := autostart.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
return
|
return
|
||||||
@@ -986,7 +992,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
|
|||||||
}
|
}
|
||||||
// Cleanup runs on settings save so a user who tightens retention limits
|
// Cleanup runs on settings save so a user who tightens retention limits
|
||||||
// sees the new policy take effect right away.
|
// sees the new policy take effect right away.
|
||||||
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
if err := runner.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const autostartDesktopFileName = "gosentry.desktop"
|
const autostartDesktopFileName = "gosentry.desktop"
|
||||||
@@ -43,7 +45,7 @@ Exec=%s %s
|
|||||||
%s
|
%s
|
||||||
Terminal=false
|
Terminal=false
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
|
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
|
||||||
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return false, "Autostart desktop entry is missing"
|
return false, "Autostart desktop entry is missing"
|
||||||
}
|
}
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
return false, "Autostart desktop entry points to another executable"
|
return false, "Autostart desktop entry points to another executable"
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLinuxAutostartStartsInTray(t *testing.T) {
|
func TestLinuxAutostartStartsInTray(t *testing.T) {
|
||||||
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
t.Fatalf("read desktop entry: %v", err)
|
t.Fatalf("read desktop entry: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
t.Fatalf("desktop entry does not start in tray: %s", data)
|
t.Fatalf("desktop entry does not start in tray: %s", data)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !windows && !linux
|
//go:build !windows && !linux
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const autostartName = "GoSentry"
|
const autostartName = "GoSentry"
|
||||||
@@ -69,7 +72,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if !sameWindowsPath(actual, executablePath) {
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
return false, "Autostart shortcut points to another executable"
|
return false, "Autostart shortcut points to another executable"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(arguments) != StartInTrayArgument {
|
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
|
||||||
return false, "Autostart shortcut does not start in tray"
|
return false, "Autostart shortcut does not start in tray"
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
@@ -101,11 +104,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
|
|||||||
command.Env = append(os.Environ(),
|
command.Env = append(os.Environ(),
|
||||||
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||||
"GOSENTRY_TARGET_PATH="+executablePath,
|
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||||
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
|
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
|
||||||
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||||
"GOSENTRY_ICON_PATH="+iconPath,
|
"GOSENTRY_ICON_PATH="+iconPath,
|
||||||
)
|
)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
if output, err := command.CombinedOutput(); err != nil {
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||||
}
|
}
|
||||||
@@ -123,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
|
|||||||
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
||||||
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
output, err := command.CombinedOutput()
|
output, err := command.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||||
@@ -153,7 +156,7 @@ func removeIfExists(path string) error {
|
|||||||
func cleanupLegacyRegistryAutostart() error {
|
func cleanupLegacyRegistryAutostart() error {
|
||||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
_ = command.Run()
|
_ = command.Run()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -162,7 +165,7 @@ func cleanupLegacyRegistryAutostart() error {
|
|||||||
func legacyRegistryAutostartExists() bool {
|
func legacyRegistryAutostartExists() bool {
|
||||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
if command.Run() == nil {
|
if command.Run() == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
+7
-5
@@ -1,12 +1,14 @@
|
|||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRegistryRunValue(t *testing.T) {
|
func TestParseRegistryRunValue(t *testing.T) {
|
||||||
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
if !sameWindowsPath(actual, targetPath) {
|
if !sameWindowsPath(actual, targetPath) {
|
||||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||||
}
|
}
|
||||||
if arguments != StartInTrayArgument {
|
if arguments != domain.StartInTrayArgument {
|
||||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
|
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
|||||||
if !sameWindowsPath(actual, targetPath) {
|
if !sameWindowsPath(actual, targetPath) {
|
||||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||||
}
|
}
|
||||||
if arguments != StartInTrayArgument {
|
if arguments != domain.StartInTrayArgument {
|
||||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
|
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package desktop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||||
@@ -40,6 +41,10 @@ StartupWMClass=%s
|
|||||||
return iconPath, nil
|
return iconPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quoteDesktopExec(path string) string {
|
||||||
|
return strconv.Quote(path)
|
||||||
|
}
|
||||||
|
|
||||||
func xdgDataHome() (string, error) {
|
func xdgDataHome() (string, error) {
|
||||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||||
if dataHome == "" {
|
if dataHome == "" {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !linux
|
//go:build !linux
|
||||||
|
|
||||||
package core
|
package desktop
|
||||||
|
|
||||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package winproc
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// ConfigureHiddenWindow is a no-op on non-Windows platforms: launching sh -c
|
||||||
|
// from a desktop process does not create a new console window in the same way
|
||||||
|
// Windows does.
|
||||||
|
func ConfigureHiddenWindow(command *exec.Cmd) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package winproc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureHiddenWindow suppresses the console window that Windows would
|
||||||
|
// otherwise flash when running a child process from a GUI application.
|
||||||
|
// CREATE_NO_WINDOW keeps cmd.exe and simple console tools quiet while
|
||||||
|
// stdout/stderr are still captured through pipes.
|
||||||
|
func ConfigureHiddenWindow(command *exec.Cmd) {
|
||||||
|
if command.SysProcAttr == nil {
|
||||||
|
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
command.SysProcAttr.CreationFlags |= 0x08000000
|
||||||
|
command.SysProcAttr.HideWindow = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||||
|
entries, err := os.ReadDir(logsDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type logFile struct {
|
||||||
|
path string
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
var logs []logFile
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
|
for _, entry := range entries {
|
||||||
|
// Only GoSentry 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
|
||||||
|
}
|
||||||
|
path := filepath.Join(logsDir, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxFiles <= 0 || len(logs) <= maxFiles {
|
||||||
|
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:] {
|
||||||
|
_ = os.Remove(old.path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
||||||
|
for _, accepted := range parseExitCodes(successExitCodes) {
|
||||||
|
if exitCode == accepted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExitCodes(value string) []int {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return []int{0}
|
||||||
|
}
|
||||||
|
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
result := make([]int, 0, len(fields))
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for _, field := range fields {
|
||||||
|
code, err := strconv.Atoi(strings.TrimSpace(field))
|
||||||
|
if err != nil || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
result = append(result, code)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return []int{0}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func SuccessExitCodesText(job domain.Job) string {
|
||||||
|
codes := parseExitCodes(job.SuccessExitCodes)
|
||||||
|
parts := make([]string, 0, len(codes))
|
||||||
|
for _, code := range codes {
|
||||||
|
parts = append(parts, strconv.Itoa(code))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commandInvocation struct {
|
||||||
|
command *exec.Cmd
|
||||||
|
hideWindow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
|
||||||
|
command := strings.TrimSpace(job.Command)
|
||||||
|
arguments := commandArguments(job.Arguments)
|
||||||
|
if len(arguments) > 0 || commandPathExists(command) {
|
||||||
|
return commandInvocation{
|
||||||
|
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
||||||
|
hideWindow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell mode remains for existing jobs and for commands that intentionally
|
||||||
|
// use builtins, redirection, variables, or chained command syntax.
|
||||||
|
return commandInvocation{
|
||||||
|
command: shellCommand(ctx, command),
|
||||||
|
hideWindow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandArguments(arguments string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
||||||
|
return r == '\n' || r == '\r'
|
||||||
|
}) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPathExists(command string) bool {
|
||||||
|
command = unquoteCommandPath(strings.TrimSpace(command))
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(command)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquoteCommandPath(command string) string {
|
||||||
|
return strings.Trim(strings.TrimSpace(command), `"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogArguments(arguments string) string {
|
||||||
|
if strings.TrimSpace(arguments) == "" {
|
||||||
|
return "<empty>"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logArguments(arguments string) string { return LogArguments(arguments) }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
|
||||||
package core
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -12,9 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
|||||||
// and avoids a hard dependency on a larger shell such as bash.
|
// and avoids a hard dependency on a larger shell such as bash.
|
||||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
return exec.CommandContext(ctx, "sh", "-c", command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureHiddenWindow(command *exec.Cmd) {
|
|
||||||
// Non-Windows platforms do not create a new console window for sh -c from a
|
|
||||||
// desktop process in the same way Windows does, so no extra process attribute
|
|
||||||
// is required here.
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool {
|
|||||||
command[1] == ':' &&
|
command[1] == ':' &&
|
||||||
(command[2] == '\\' || command[2] == '/')
|
(command[2] == '\\' || command[2] == '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureHiddenWindow(command *exec.Cmd) {
|
|
||||||
// GoSentry is a GUI scheduler, so child commands should not flash a console
|
|
||||||
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
|
|
||||||
// quiet while stdout/stderr are still captured through pipes.
|
|
||||||
if command.SysProcAttr == nil {
|
|
||||||
command.SysProcAttr = &syscall.SysProcAttr{}
|
|
||||||
}
|
|
||||||
command.SysProcAttr.CreationFlags |= 0x08000000
|
|
||||||
command.SysProcAttr.HideWindow = true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string {
|
||||||
|
if strings.TrimSpace(logsDir) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
||||||
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFileName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "job"
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||||
|
builder.WriteRune(r)
|
||||||
|
case r == '-', r == '_':
|
||||||
|
builder.WriteRune(r)
|
||||||
|
default:
|
||||||
|
builder.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := strings.Trim(builder.String(), "_")
|
||||||
|
if result == "" {
|
||||||
|
return "job"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commandTimeout = 30 * time.Second
|
||||||
|
const commandWaitDelay = 2 * time.Second
|
||||||
|
|
||||||
|
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.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()
|
||||||
|
|
||||||
|
var output string
|
||||||
|
var state string
|
||||||
|
var detail string
|
||||||
|
if job.StartOnly {
|
||||||
|
invocation := jobInvocation(context.Background(), *job)
|
||||||
|
state, detail, output = startJobOnly(invocation, *job, started)
|
||||||
|
} else {
|
||||||
|
var stdoutBuf strings.Builder
|
||||||
|
var stderrBuf strings.Builder
|
||||||
|
invocation := jobInvocation(runCtx, *job)
|
||||||
|
command := invocation.command
|
||||||
|
command.WaitDelay = commandWaitDelay
|
||||||
|
if invocation.hideWindow {
|
||||||
|
winproc.ConfigureHiddenWindow(command)
|
||||||
|
}
|
||||||
|
command.Stdout = &stdoutBuf
|
||||||
|
command.Stderr = &stderrBuf
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
|
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
||||||
|
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
job.LastRun = now.Format("2006-01-02 15:04:05")
|
||||||
|
job.LastState = state
|
||||||
|
job.Output = output
|
||||||
|
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
||||||
|
|
||||||
|
record := domain.RunRecord{
|
||||||
|
Time: job.LastRun,
|
||||||
|
JobID: job.ID,
|
||||||
|
JobName: job.Name,
|
||||||
|
Trigger: trigger,
|
||||||
|
State: state,
|
||||||
|
Detail: detail,
|
||||||
|
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([]domain.RunRecord{record}, job.Logs...)
|
||||||
|
if len(job.Logs) > 50 {
|
||||||
|
job.Logs = job.Logs[:50]
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||||
|
command := invocation.command
|
||||||
|
if invocation.hideWindow {
|
||||||
|
winproc.ConfigureHiddenWindow(command)
|
||||||
|
}
|
||||||
|
err := command.Start()
|
||||||
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
||||||
|
}
|
||||||
|
pid := command.Process.Pid
|
||||||
|
if releaseErr := command.Process.Release(); releaseErr != nil {
|
||||||
|
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
||||||
|
}
|
||||||
|
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOnlyOutput(job domain.Job, pid int) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("status:\n")
|
||||||
|
if pid > 0 {
|
||||||
|
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
||||||
|
} else {
|
||||||
|
builder.WriteString("Process did not start.\n\n")
|
||||||
|
}
|
||||||
|
builder.WriteString("command:\n")
|
||||||
|
builder.WriteString(job.Command + "\n\n")
|
||||||
|
builder.WriteString("arguments:\n")
|
||||||
|
builder.WriteString(logArguments(job.Arguments))
|
||||||
|
builder.WriteString("\n\nstart_only:\ntrue")
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) {
|
||||||
|
if err == nil {
|
||||||
|
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||||
|
}
|
||||||
|
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||||
|
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
|
if errors.Is(err, exec.ErrWaitDelay) {
|
||||||
|
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
exitCode := exitError.ExitCode()
|
||||||
|
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
||||||
|
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
||||||
|
}
|
||||||
|
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
||||||
|
}
|
||||||
|
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == "" {
|
||||||
|
stderr = "<empty>"
|
||||||
|
}
|
||||||
|
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,11 +7,155 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func echoCommand(message string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "echo " + message
|
||||||
|
}
|
||||||
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobLogFileAllHeaders(t *testing.T) {
|
||||||
|
logsDir := t.TempDir()
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 99,
|
||||||
|
Name: "Log Header Test",
|
||||||
|
Command: echoCommand("header test output"),
|
||||||
|
SuccessExitCodes: "0,1",
|
||||||
|
}
|
||||||
|
|
||||||
|
record := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||||
|
if record.LogFile == "" {
|
||||||
|
t.Fatal("expected log file to be written")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(record.LogFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"job_id: 99",
|
||||||
|
"job_name: Log Header Test",
|
||||||
|
"trigger: Schedule",
|
||||||
|
"state: OK",
|
||||||
|
"detail: ",
|
||||||
|
"command: " + job.Command,
|
||||||
|
"arguments: <empty>",
|
||||||
|
"success_exit_codes: 0,1",
|
||||||
|
"start_only: false",
|
||||||
|
"stdout:",
|
||||||
|
"stderr:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Errorf("log file missing %q:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The time header must use the documented format.
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.HasPrefix(line, "time: ") {
|
||||||
|
ts := strings.TrimPrefix(line, "time: ")
|
||||||
|
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
|
||||||
|
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobRecordFields(t *testing.T) {
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 55,
|
||||||
|
Name: "Record Fields Test",
|
||||||
|
Command: echoCommand("record field check"),
|
||||||
|
}
|
||||||
|
|
||||||
|
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||||
|
|
||||||
|
if record.JobID != job.ID {
|
||||||
|
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
||||||
|
}
|
||||||
|
if record.JobName != job.Name {
|
||||||
|
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
|
||||||
|
}
|
||||||
|
if record.Trigger != "Schedule" {
|
||||||
|
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
|
||||||
|
}
|
||||||
|
if record.State != "OK" {
|
||||||
|
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
|
||||||
|
}
|
||||||
|
if record.LogFile == "" {
|
||||||
|
t.Error("LogFile should be a non-empty path")
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
|
||||||
|
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(record.Output, "stdout:") {
|
||||||
|
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(record.Output, "stderr:") {
|
||||||
|
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOutput(t *testing.T) {
|
||||||
|
got := formatOutput("hello world", "some error")
|
||||||
|
want := "stdout:\nhello world\n\nstderr:\nsome error"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOutputEmptyStreams(t *testing.T) {
|
||||||
|
got := formatOutput("", "")
|
||||||
|
if !strings.Contains(got, "stdout:\n<empty>") {
|
||||||
|
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "stderr:\n<empty>") {
|
||||||
|
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogArguments(t *testing.T) {
|
||||||
|
cases := []struct{ input, want string }{
|
||||||
|
{"", "<empty>"},
|
||||||
|
{" ", "<empty>"},
|
||||||
|
{"--flag", "--flag"},
|
||||||
|
{"--flag\r\n--value", "--flag\n--value"},
|
||||||
|
{"--flag\n--value", "--flag\n--value"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := logArguments(tc.input); got != tc.want {
|
||||||
|
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFileName(t *testing.T) {
|
||||||
|
cases := []struct{ input, want string }{
|
||||||
|
{"Hello Test", "Hello_Test"},
|
||||||
|
{"job-1_ok", "job-1_ok"},
|
||||||
|
{"!!!", "job"},
|
||||||
|
{"", "job"},
|
||||||
|
{"A/B:C", "A_B_C"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := sanitizeFileName(tc.input); got != tc.want {
|
||||||
|
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunJobWritesLogFile(t *testing.T) {
|
func TestRunJobWritesLogFile(t *testing.T) {
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 42,
|
ID: 42,
|
||||||
Name: "Hello Test",
|
Name: "Hello Test",
|
||||||
Command: echoCommand("hello from test"),
|
Command: echoCommand("hello from test"),
|
||||||
@@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 43,
|
ID: 43,
|
||||||
Name: "Quoted Windows Command",
|
Name: "Quoted Windows Command",
|
||||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||||
@@ -75,7 +219,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
|||||||
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 44,
|
ID: 44,
|
||||||
Name: "Unquoted Windows Command",
|
Name: "Unquoted Windows Command",
|
||||||
Command: scriptPath,
|
Command: scriptPath,
|
||||||
@@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 45,
|
ID: 45,
|
||||||
Name: "Separate Arguments",
|
Name: "Separate Arguments",
|
||||||
Command: `C:\Windows\System32\cmd.exe`,
|
Command: `C:\Windows\System32\cmd.exe`,
|
||||||
@@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 46,
|
ID: 46,
|
||||||
Name: "Accepted Exit Code",
|
Name: "Accepted Exit Code",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 47,
|
ID: 47,
|
||||||
Name: "Rejected Exit Code",
|
Name: "Rejected Exit Code",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
arguments = "/C\nexit /b 7"
|
arguments = "/C\nexit /b 7"
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 48,
|
ID: 48,
|
||||||
Name: "Start Only",
|
Name: "Start Only",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 49,
|
ID: 49,
|
||||||
Name: "Missing Start Only",
|
Name: "Missing Start Only",
|
||||||
Command: "definitely-missing-gosentry-command",
|
Command: "definitely-missing-gosentry-command",
|
||||||
@@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
|
|||||||
t.Skip("Windows window visibility only")
|
t.Skip("Windows window visibility only")
|
||||||
}
|
}
|
||||||
|
|
||||||
invocation := jobInvocation(context.Background(), Job{
|
invocation := jobInvocation(context.Background(), domain.Job{
|
||||||
Command: `C:\Windows\System32\cmd.exe`,
|
Command: `C:\Windows\System32\cmd.exe`,
|
||||||
Arguments: "/C\necho visible direct process",
|
Arguments: "/C\necho visible direct process",
|
||||||
})
|
})
|
||||||
@@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) {
|
|||||||
t.Skip("Windows window visibility only")
|
t.Skip("Windows window visibility only")
|
||||||
}
|
}
|
||||||
|
|
||||||
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
|
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
|
||||||
if !invocation.hideWindow {
|
if !invocation.hideWindow {
|
||||||
t.Fatal("shell command should request hidden startup window")
|
t.Fatal("shell command should request hidden startup window")
|
||||||
}
|
}
|
||||||
configureHiddenWindow(invocation.command)
|
winproc.ConfigureHiddenWindow(invocation.command)
|
||||||
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
|
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
|
||||||
t.Fatal("expected shell command to be hidden")
|
t.Fatal("expected shell command to be hidden")
|
||||||
}
|
}
|
||||||
@@ -253,7 +397,7 @@ func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
|
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
|
|
||||||
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
|
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
|
||||||
if command.SysProcAttr == nil {
|
if command.SysProcAttr == nil {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,9 +20,9 @@ var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month
|
|||||||
// this keeps the early architecture simple while storage and scheduling are
|
// this keeps the early architecture simple while storage and scheduling are
|
||||||
// still in one desktop process.
|
// still in one desktop process.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *Store
|
store *storage.Store
|
||||||
jobs *[]Job
|
jobs *[]domain.Job
|
||||||
onChange func(RunRecord)
|
onChange func(domain.RunRecord)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -27,7 +30,7 @@ type Scheduler struct {
|
|||||||
paused bool
|
paused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
|
func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s := &Scheduler{
|
s := &Scheduler{
|
||||||
store: store,
|
store: store,
|
||||||
@@ -125,7 +128,7 @@ func (s *Scheduler) tick(now time.Time) {
|
|||||||
if !s.paused {
|
if !s.paused {
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
|
if !job.Enabled || job.NextDue.IsZero() || now.Before(job.NextDue) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Run only one due job per tick for now. That avoids overlapping shell
|
// Run only one due job per tick for now. That avoids overlapping shell
|
||||||
@@ -150,23 +153,23 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
|||||||
job.LastState = "Running"
|
job.LastState = "Running"
|
||||||
job.NextRun = "Running"
|
job.NextRun = "Running"
|
||||||
job.Output = runningOutput(jobCopy, trigger, time.Now())
|
job.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||||
job.nextDue = time.Time{}
|
job.NextDue = time.Time{}
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
record := runner.RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
||||||
current.LastRun = record.Time
|
current.LastRun = record.Time
|
||||||
current.LastState = record.State
|
current.LastState = record.State
|
||||||
current.Output = record.Output
|
current.Output = record.Output
|
||||||
current.Logs = append([]RunRecord{record}, current.Logs...)
|
current.Logs = append([]domain.RunRecord{record}, current.Logs...)
|
||||||
if len(current.Logs) > 50 {
|
if len(current.Logs) > 50 {
|
||||||
current.Logs = current.Logs[:50]
|
current.Logs = current.Logs[:50]
|
||||||
}
|
}
|
||||||
s.prepareNextRun(current, time.Now())
|
s.prepareNextRun(current, time.Now())
|
||||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
_ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -178,7 +181,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) findJobByIDLocked(id int) *Job {
|
func (s *Scheduler) findJobByIDLocked(id int) *domain.Job {
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
if (*s.jobs)[index].ID == id {
|
if (*s.jobs)[index].ID == id {
|
||||||
return &(*s.jobs)[index]
|
return &(*s.jobs)[index]
|
||||||
@@ -187,7 +190,7 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runningOutput(job Job, trigger string, started time.Time) string {
|
func runningOutput(job domain.Job, trigger string, started time.Time) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString("status:\n")
|
builder.WriteString("status:\n")
|
||||||
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
|
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
|
||||||
@@ -196,9 +199,9 @@ func runningOutput(job Job, trigger string, started time.Time) string {
|
|||||||
builder.WriteString("command:\n")
|
builder.WriteString("command:\n")
|
||||||
builder.WriteString(job.Command + "\n\n")
|
builder.WriteString(job.Command + "\n\n")
|
||||||
builder.WriteString("arguments:\n")
|
builder.WriteString("arguments:\n")
|
||||||
builder.WriteString(logArguments(job.Arguments))
|
builder.WriteString(runner.LogArguments(job.Arguments))
|
||||||
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
||||||
builder.WriteString(successExitCodesText(job))
|
builder.WriteString(runner.SuccessExitCodesText(job))
|
||||||
builder.WriteString("\n\nstart_only:\n")
|
builder.WriteString("\n\nstart_only:\n")
|
||||||
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
||||||
return builder.String()
|
return builder.String()
|
||||||
@@ -216,15 +219,15 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
|
|||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
|
func (s *Scheduler) prepareNextRun(job *domain.Job, from time.Time) {
|
||||||
next, ok := nextRunTime(job.Schedule, from)
|
next, ok := nextRunTime(job.Schedule, from)
|
||||||
if !ok {
|
if !ok {
|
||||||
job.NextRun = "Invalid schedule"
|
job.NextRun = "Invalid schedule"
|
||||||
job.nextDue = time.Time{}
|
job.NextDue = time.Time{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.nextDue = next
|
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 nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
|
||||||
|
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
schedule string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"", "empty string"},
|
||||||
|
{" ", "whitespace only"},
|
||||||
|
{"@every", "bare @every without duration"},
|
||||||
|
{"@every xyz", "invalid @every duration string"},
|
||||||
|
{"@every -1s", "negative @every duration"},
|
||||||
|
{"@every 0s", "zero @every duration"},
|
||||||
|
{"not-a-cron", "invalid cron expression"},
|
||||||
|
{"60 * * * *", "cron minute out of range"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, ok := nextRunTime(tc.schedule, from)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("nextRunTime(%q) [%s]: expected false, got true", tc.schedule, tc.desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNextRunSetsDisplayString(t *testing.T) {
|
||||||
|
jobs := []domain.Job{{Schedule: "*/5 * * * *", Enabled: true}}
|
||||||
|
s := &Scheduler{jobs: &jobs}
|
||||||
|
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
s.prepareNextRun(&jobs[0], from)
|
||||||
|
|
||||||
|
want := "2026-06-14 12:05:00"
|
||||||
|
if jobs[0].NextRun != want {
|
||||||
|
t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want)
|
||||||
|
}
|
||||||
|
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
||||||
|
if !jobs[0].NextDue.Equal(wantDue) {
|
||||||
|
t.Errorf("NextDue: got %v, want %v", jobs[0].NextDue, wantDue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
|
||||||
|
jobs := []domain.Job{{Schedule: "not-a-cron", Enabled: true}}
|
||||||
|
s := &Scheduler{jobs: &jobs}
|
||||||
|
|
||||||
|
s.prepareNextRun(&jobs[0], time.Now())
|
||||||
|
|
||||||
|
if jobs[0].NextRun != "Invalid schedule" {
|
||||||
|
t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun)
|
||||||
|
}
|
||||||
|
if !jobs[0].NextDue.IsZero() {
|
||||||
|
t.Errorf("NextDue should be zero for invalid schedule, got %v", jobs[0].NextDue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunningOutputIncludesInvocation(t *testing.T) {
|
||||||
|
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
|
||||||
|
job := domain.Job{
|
||||||
|
Name: "Backup",
|
||||||
|
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
|
||||||
|
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
|
||||||
|
SuccessExitCodes: "0,1",
|
||||||
|
}
|
||||||
|
|
||||||
|
output := runningOutput(job, "Manual", started)
|
||||||
|
for _, want := range []string{
|
||||||
|
"Running since 2026-06-17 23:40:00",
|
||||||
|
"Manual",
|
||||||
|
job.Command,
|
||||||
|
job.Arguments,
|
||||||
|
"0,1",
|
||||||
|
"start_only",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The config file stays beside the executable so the portable build behaves
|
||||||
|
// predictably: moving the program folder moves its settings with it.
|
||||||
|
ConfigFileName = "gosentry.yaml"
|
||||||
|
// Older builds were named PySentry. Keep the old config name readable during
|
||||||
|
// the rename window so portable installations can start once and rewrite the
|
||||||
|
// settings to gosentry.yaml without manual file copying.
|
||||||
|
LegacyConfigFileName = "pysentry.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 {
|
||||||
|
ExecutablePath string
|
||||||
|
AppDir string
|
||||||
|
ConfigPath string
|
||||||
|
JobsDir string
|
||||||
|
JobsPath string
|
||||||
|
LogsDir string
|
||||||
|
DesktopIcon string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Dir(executable)
|
||||||
|
configPath := filepath.Join(appDir, ConfigFileName)
|
||||||
|
return Paths{
|
||||||
|
ExecutablePath: executable,
|
||||||
|
AppDir: appDir,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
JobsDir: appDir,
|
||||||
|
JobsPath: filepath.Join(appDir, JobsFileName),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
Paths Paths
|
||||||
|
Config domain.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenStore() (*Store, []domain.Job, error) {
|
||||||
|
paths, err := ResolvePaths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &Store{Paths: paths}
|
||||||
|
config, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return store, jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
s.applyConfigPaths()
|
||||||
|
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.ConfigPath, s.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||||
|
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
|
config := domain.Config{
|
||||||
|
JobsDir: ".",
|
||||||
|
LogsDir: "logs",
|
||||||
|
MaxLogFiles: 100,
|
||||||
|
MaxLogAgeDays: 30,
|
||||||
|
StartOnLogin: false,
|
||||||
|
KeepRunningInTray: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := paths.ConfigPath
|
||||||
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
|
||||||
|
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
|
||||||
|
// The rename from PySentry to GoSentry changed the preferred config
|
||||||
|
// filename. Read the old file once if it is still present so portable
|
||||||
|
// installs continue to start without a manual migration step. The
|
||||||
|
// caller later saves the loaded config back through SaveConfig, which
|
||||||
|
// naturally rewrites it under gosentry.yaml.
|
||||||
|
configPath = legacyPath
|
||||||
|
} else {
|
||||||
|
return config, writeYAML(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return config, writeYAML(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return domain.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) == "" {
|
||||||
|
config.LogsDir = "logs"
|
||||||
|
}
|
||||||
|
if config.MaxLogFiles <= 0 {
|
||||||
|
config.MaxLogFiles = 100
|
||||||
|
}
|
||||||
|
if config.MaxLogAgeDays <= 0 {
|
||||||
|
config.MaxLogAgeDays = 30
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateJobs(path string) ([]domain.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, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file.Jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
|
next := 1
|
||||||
|
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 {
|
||||||
|
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) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
|
job.Command = echoCommand("GoSentry job ran")
|
||||||
|
}
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
||||||
|
if job.SuccessExitCodes == "" {
|
||||||
|
job.SuccessExitCodes = "0"
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||||
|
return resolveConfiguredDir(appDir, jobsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyConfigPaths() {
|
||||||
|
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||||
|
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||||
|
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeYAML(path string, value any) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(value)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultJobs() []domain.Job {
|
||||||
|
return []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Hello scheduler",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Write timestamp",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "*/1 * * * *",
|
||||||
|
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Paused sample",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "jobs.yaml")
|
||||||
|
|
||||||
|
original := []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
Name: "Backup data",
|
||||||
|
Folder: "Maintenance",
|
||||||
|
Schedule: "0 2 * * *",
|
||||||
|
Command: "/usr/bin/backup",
|
||||||
|
Arguments: "--compress\n--verbose",
|
||||||
|
SuccessExitCodes: "0,1",
|
||||||
|
StartOnly: true,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateJobs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
g, w := got[0], original[0]
|
||||||
|
if g.ID != w.ID {
|
||||||
|
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
|
||||||
|
}
|
||||||
|
if g.Name != w.Name {
|
||||||
|
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
|
||||||
|
}
|
||||||
|
if g.Folder != w.Folder {
|
||||||
|
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
|
||||||
|
}
|
||||||
|
if g.Schedule != w.Schedule {
|
||||||
|
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
|
||||||
|
}
|
||||||
|
if g.Command != w.Command {
|
||||||
|
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
|
||||||
|
}
|
||||||
|
if g.Arguments != w.Arguments {
|
||||||
|
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
|
||||||
|
}
|
||||||
|
if g.SuccessExitCodes != w.SuccessExitCodes {
|
||||||
|
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
|
||||||
|
}
|
||||||
|
if g.StartOnly != w.StartOnly {
|
||||||
|
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
|
||||||
|
}
|
||||||
|
if g.Enabled != w.Enabled {
|
||||||
|
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime fields must not survive the save→load round-trip.
|
||||||
|
if g.LastRun != "" {
|
||||||
|
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
|
||||||
|
}
|
||||||
|
if g.LastState != "" {
|
||||||
|
t.Errorf("LastState should be empty after load, got %q", g.LastState)
|
||||||
|
}
|
||||||
|
if g.Logs != nil {
|
||||||
|
t.Errorf("Logs should be nil after load, got %v", g.Logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
|
||||||
|
want := domain.Config{
|
||||||
|
JobsDir: "/custom/jobs",
|
||||||
|
LogsDir: "/custom/logs",
|
||||||
|
MaxLogFiles: 50,
|
||||||
|
MaxLogAgeDays: 14,
|
||||||
|
StartOnLogin: true,
|
||||||
|
KeepRunningInTray: false,
|
||||||
|
NotifyOnFailure: false,
|
||||||
|
}
|
||||||
|
if err := writeYAML(paths.ConfigPath, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.JobsDir != want.JobsDir {
|
||||||
|
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
||||||
|
}
|
||||||
|
if got.LogsDir != want.LogsDir {
|
||||||
|
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||||
|
}
|
||||||
|
if got.MaxLogFiles != want.MaxLogFiles {
|
||||||
|
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
|
||||||
|
}
|
||||||
|
if got.MaxLogAgeDays != want.MaxLogAgeDays {
|
||||||
|
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
|
||||||
|
}
|
||||||
|
if got.StartOnLogin != want.StartOnLogin {
|
||||||
|
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
|
||||||
|
}
|
||||||
|
if got.KeepRunningInTray != want.KeepRunningInTray {
|
||||||
|
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
|
||||||
|
}
|
||||||
|
if got.NotifyOnFailure != want.NotifyOnFailure {
|
||||||
|
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
||||||
|
jobs := []domain.Job{
|
||||||
|
{Enabled: true},
|
||||||
|
{Enabled: false},
|
||||||
|
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
|
||||||
|
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
|
||||||
|
if jobs[0].ID != 1 {
|
||||||
|
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
|
||||||
|
}
|
||||||
|
if jobs[0].Name != "Untitled job" {
|
||||||
|
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
|
||||||
|
}
|
||||||
|
if jobs[0].Schedule != "@every 1m" {
|
||||||
|
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
|
||||||
|
}
|
||||||
|
if jobs[0].SuccessExitCodes != "0" {
|
||||||
|
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
|
||||||
|
}
|
||||||
|
if jobs[0].LastState != "Ready" {
|
||||||
|
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
|
||||||
|
}
|
||||||
|
if jobs[0].NextRun != "After start" {
|
||||||
|
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled job is marked Paused.
|
||||||
|
if jobs[1].LastState != "Paused" {
|
||||||
|
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
|
||||||
|
}
|
||||||
|
if jobs[1].NextRun != "Paused" {
|
||||||
|
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-set fields survive normalization unchanged.
|
||||||
|
if jobs[2].ID != 5 {
|
||||||
|
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
|
||||||
|
}
|
||||||
|
if jobs[2].SuccessExitCodes != "0,1" {
|
||||||
|
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||||
|
jobs := []domain.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: []domain.RunRecord{
|
||||||
|
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(domain.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user