Refactoring complete: v0.4.0 architectural milestone (#1)

## Summary

Completed Phase 5 refactoring and reached the target architecture.

**Architectural milestone achieved:**
- Service layer owns all state and is the sole writer
- UI is a thin Fyne view, all widget updates marshaled via `fyne.Do`
- Core engines are stateless and injectable
- Domain types are pure (no `yaml:"-"` fields)
- Full module builds and `go vet ./...` clean

## Changes

- Bump version: 0.3.6 → 0.4.0
- Update CHANGELOG with Phase 5 summary
- Add ROADMAP "Refactoring Follow-Ups" section

## Known follow-up work

1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag
2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines)

See ROADMAP.md for details.

---------

Co-authored-by: mixeme <mix.public@ya.ru>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-22 08:05:10 +03:00
parent d24211cab2
commit 01fd572a89
74 changed files with 5424 additions and 2712 deletions
+137
View File
@@ -0,0 +1,137 @@
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()
timestamp := now.Format("2006-01-02 15:04:05")
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
// The runner is now pure with respect to the job: it returns a RunRecord and
// lets the caller fold that record into the job's JobRuntime. Run state no
// longer lives on Job, so there is nothing on the job to mutate here.
return domain.RunRecord{
Time: timestamp,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
}
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
}