mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* Checkpoint UI polish * Fix structured apply patch handling * Restore tool previews when resuming sessions * Improve theme-aware code rendering * Replace persistent sidebar with run details * Remove inert transcript interactions * Cover resumed edit previews * Smooth active run status feedback * Preserve semantic colors behind overlays * Normalize ChatGPT model aliases * Render staged image attachments in terminal * Polish transcript and tool feedback * Resolve rebased diff renderer conflict * Address UI polish review findings * Fix smoke test regressions * Harden patch and transcript edge cases * Bind structured patches to workspace root * Use unique structured patch temp files * Retry structured patch temp allocation * Reuse rooted patch temp allocator * Fix Windows CI process checks * Fix structured patch safety boundaries Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files. * Resolve plan test seam after rebase Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes. * Address final review correctness gaps Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility. * Harden patch transactions and review edges Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture. * Bind patch rollback to committed versions Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races. * Align structured patch failure semantics Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation. * Preserve patch context bytes Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
624 lines
18 KiB
Go
624 lines
18 KiB
Go
package background
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Status string
|
|
|
|
const (
|
|
StatusRunning Status = "running"
|
|
StatusCompleted Status = "completed"
|
|
StatusError Status = "error"
|
|
StatusKilled Status = "killed"
|
|
)
|
|
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
SpecialistName string `json:"specialistName,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
ParentID string `json:"parentId,omitempty"`
|
|
PID int `json:"pid,omitempty"`
|
|
Status Status `json:"status"`
|
|
OutputFile string `json:"outputFile"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
CompletedAt time.Time `json:"completedAt,omitempty"`
|
|
ExitCode int `json:"exitCode,omitempty"`
|
|
}
|
|
|
|
type RegisterInput struct {
|
|
TaskID string
|
|
Type string
|
|
SpecialistName string
|
|
Description string
|
|
ParentID string
|
|
PID int
|
|
OutputFile string
|
|
}
|
|
|
|
type ManagerOptions struct {
|
|
RootDir string
|
|
Env map[string]string
|
|
Now func() time.Time
|
|
KillProcess func(pid int) error
|
|
}
|
|
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
tasks map[string]Task
|
|
warnings []string
|
|
rootDir string
|
|
now func() time.Time
|
|
killProcess func(pid int) error
|
|
}
|
|
|
|
var taskIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
|
|
|
func NewManager(rootDir string) (*Manager, error) {
|
|
return NewManagerWithOptions(ManagerOptions{RootDir: rootDir})
|
|
}
|
|
|
|
func NewManagerWithOptions(options ManagerOptions) (*Manager, error) {
|
|
rootDir := strings.TrimSpace(options.RootDir)
|
|
if rootDir == "" {
|
|
rootDir = DefaultRoot(options.Env)
|
|
}
|
|
rootDir = filepath.Clean(rootDir)
|
|
if err := os.MkdirAll(rootDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create background task directory: %w", err)
|
|
}
|
|
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
killProcess := options.KillProcess
|
|
if killProcess == nil {
|
|
killProcess = terminateProcess
|
|
}
|
|
manager := &Manager{tasks: map[string]Task{}, rootDir: rootDir, now: now, killProcess: killProcess}
|
|
if err := manager.loadTasks(); err != nil {
|
|
return nil, err
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
func DefaultRoot(env map[string]string) string {
|
|
dataHome := strings.TrimSpace(envValue(env, "XDG_DATA_HOME"))
|
|
home := strings.TrimSpace(envValue(env, "HOME"))
|
|
if home == "" {
|
|
if userHome, err := os.UserHomeDir(); err == nil {
|
|
home = userHome
|
|
}
|
|
}
|
|
base := dataHome
|
|
if base == "" {
|
|
base = filepath.Join(home, ".local", "share")
|
|
}
|
|
return filepath.Join(base, "zero", "background")
|
|
}
|
|
|
|
func (manager *Manager) RootDir() string {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return manager.rootDir
|
|
}
|
|
|
|
func (manager *Manager) LoadWarnings() []string {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return append([]string(nil), manager.warnings...)
|
|
}
|
|
|
|
func (manager *Manager) Register(input RegisterInput) (string, error) {
|
|
taskID := strings.TrimSpace(input.TaskID)
|
|
if !validTaskID(taskID) {
|
|
return "", fmt.Errorf("invalid background task id %q", input.TaskID)
|
|
}
|
|
taskType := strings.TrimSpace(input.Type)
|
|
if taskType == "" {
|
|
return "", fmt.Errorf("background task %s requires a type", taskID)
|
|
}
|
|
if input.PID < 0 {
|
|
return "", fmt.Errorf("invalid background task pid %d", input.PID)
|
|
}
|
|
outputFile, err := manager.outputFile(taskID, input.OutputFile)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if _, exists := manager.tasks[taskID]; exists {
|
|
return "", fmt.Errorf("background task already registered: %s", taskID)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(outputFile), 0o700); err != nil {
|
|
return "", fmt.Errorf("create background task output directory: %w", err)
|
|
}
|
|
file, err := os.OpenFile(outputFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
return "", fmt.Errorf("background task output already exists: %s", outputFile)
|
|
}
|
|
return "", fmt.Errorf("create background task output file: %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return "", fmt.Errorf("close background task output file: %w", err)
|
|
}
|
|
|
|
task := Task{
|
|
ID: taskID,
|
|
Type: taskType,
|
|
SpecialistName: strings.TrimSpace(input.SpecialistName),
|
|
Description: strings.TrimSpace(input.Description),
|
|
ParentID: strings.TrimSpace(input.ParentID),
|
|
PID: input.PID,
|
|
Status: StatusRunning,
|
|
OutputFile: outputFile,
|
|
StartedAt: manager.now(),
|
|
}
|
|
manager.tasks[taskID] = task
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
delete(manager.tasks, taskID)
|
|
_ = os.Remove(outputFile)
|
|
return "", err
|
|
}
|
|
return outputFile, nil
|
|
}
|
|
|
|
func (manager *Manager) SetPID(taskID string, pid int) error {
|
|
taskID = strings.TrimSpace(taskID)
|
|
if pid <= 0 {
|
|
return fmt.Errorf("invalid background task pid %d", pid)
|
|
}
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok {
|
|
return fmt.Errorf("background task not found: %s", taskID)
|
|
}
|
|
task.PID = pid
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
return err
|
|
}
|
|
manager.tasks[taskID] = task
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) UpdateStatus(taskID string, status Status, exitCode int) error {
|
|
taskID = strings.TrimSpace(taskID)
|
|
if !validStatus(status) {
|
|
return fmt.Errorf("invalid background task status %q", status)
|
|
}
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok {
|
|
return fmt.Errorf("background task not found: %s", taskID)
|
|
}
|
|
task.Status = status
|
|
task.ExitCode = exitCode
|
|
if status == StatusRunning {
|
|
task.CompletedAt = time.Time{}
|
|
} else if task.CompletedAt.IsZero() {
|
|
task.CompletedAt = manager.now()
|
|
}
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
return err
|
|
}
|
|
manager.tasks[taskID] = task
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) MarkExited(taskID string, status Status, exitCode int) error {
|
|
taskID = strings.TrimSpace(taskID)
|
|
if status != StatusCompleted && status != StatusError {
|
|
return fmt.Errorf("invalid background task exit status %q", status)
|
|
}
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok {
|
|
return fmt.Errorf("background task not found: %s", taskID)
|
|
}
|
|
if task.Status != StatusRunning {
|
|
return nil
|
|
}
|
|
task.Status = status
|
|
task.ExitCode = exitCode
|
|
if task.CompletedAt.IsZero() {
|
|
task.CompletedAt = manager.now()
|
|
}
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
return err
|
|
}
|
|
manager.tasks[taskID] = task
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) Get(taskID string) (Task, bool) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[strings.TrimSpace(taskID)]
|
|
return task, ok
|
|
}
|
|
|
|
func (manager *Manager) Kill(taskID string) error {
|
|
taskID = strings.TrimSpace(taskID)
|
|
|
|
pid, err := manager.killTarget(taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !manager.isRunningPID(taskID, pid) {
|
|
return nil
|
|
}
|
|
// Record the kill intent BEFORE terminating. terminateProcess can block until
|
|
// the child exits, during which the background Wait-goroutine may reap it and
|
|
// call MarkExited; marking killed first makes that MarkExited a no-op (it only
|
|
// acts on a running task), so a user-initiated stop stays "killed" instead of
|
|
// being clobbered to "error".
|
|
marked, err := manager.markKilledIfStillRunning(taskID, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !marked {
|
|
// The task exited (or was reused) between the running check and now — the
|
|
// pid may be stale, so do NOT signal it.
|
|
return nil
|
|
}
|
|
if err := manager.killProcess(pid); err != nil {
|
|
// Couldn't terminate — undo the optimistic kill mark so the still-running
|
|
// task is not falsely reported as killed.
|
|
killErr := fmt.Errorf("kill background task %s: %w", taskID, err)
|
|
if restoreErr := manager.restoreRunningAfterFailedKill(taskID, pid); restoreErr != nil {
|
|
return errors.Join(killErr, fmt.Errorf("restore running state for %s: %w", taskID, restoreErr))
|
|
}
|
|
return killErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) KillRunning() error {
|
|
var errs []error
|
|
for _, task := range manager.List() {
|
|
if task.Status != StatusRunning {
|
|
continue
|
|
}
|
|
if err := manager.Kill(task.ID); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (manager *Manager) killTarget(taskID string) (int, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok {
|
|
return 0, fmt.Errorf("background task not found: %s", taskID)
|
|
}
|
|
if task.Status != StatusRunning {
|
|
return 0, fmt.Errorf("background task %s is %s", taskID, task.Status)
|
|
}
|
|
if task.PID <= 0 {
|
|
return 0, fmt.Errorf("background task %s has no pid", taskID)
|
|
}
|
|
return task.PID, nil
|
|
}
|
|
|
|
func (manager *Manager) isRunningPID(taskID string, pid int) bool {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
return ok && task.Status == StatusRunning && task.PID == pid
|
|
}
|
|
|
|
// markKilledIfStillRunning marks the task killed if it is still running. The
|
|
// bool reports whether it actually marked: false means the task already exited
|
|
// (or was reused), in which case the caller must NOT signal the pid — it may be
|
|
// stale.
|
|
func (manager *Manager) markKilledIfStillRunning(taskID string, pid int) (bool, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok {
|
|
return false, fmt.Errorf("background task not found: %s", taskID)
|
|
}
|
|
if task.Status != StatusRunning {
|
|
return false, nil
|
|
}
|
|
if task.PID != pid {
|
|
return false, fmt.Errorf("background task %s pid changed before kill completed", taskID)
|
|
}
|
|
task.Status = StatusKilled
|
|
task.ExitCode = -1
|
|
if task.CompletedAt.IsZero() {
|
|
task.CompletedAt = manager.now()
|
|
}
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
return false, err
|
|
}
|
|
manager.tasks[taskID] = task
|
|
return true, nil
|
|
}
|
|
|
|
// restoreRunningAfterFailedKill reverts a task that was optimistically marked
|
|
// killed back to running when the terminate failed (the process is presumed
|
|
// still alive). It only touches a task still killed with the same pid, so a real
|
|
// exit recorded meanwhile is left untouched. It returns the persistence error so
|
|
// the caller can surface a failed rollback rather than leaving the task wrongly
|
|
// marked killed.
|
|
func (manager *Manager) restoreRunningAfterFailedKill(taskID string, pid int) error {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
task, ok := manager.tasks[taskID]
|
|
if !ok || task.Status != StatusKilled || task.PID != pid {
|
|
return nil
|
|
}
|
|
task.Status = StatusRunning
|
|
task.ExitCode = 0
|
|
task.CompletedAt = time.Time{}
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
return err
|
|
}
|
|
manager.tasks[taskID] = task
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) loadTasks() error {
|
|
entries, err := os.ReadDir(manager.rootDir)
|
|
if err != nil {
|
|
return fmt.Errorf("read background task directory: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
manager.warnf("skipped symlink background task metadata: %s", filepath.Join(manager.rootDir, entry.Name()))
|
|
continue
|
|
}
|
|
taskID := strings.TrimSuffix(entry.Name(), ".json")
|
|
if !validTaskID(taskID) {
|
|
manager.warnf("skipped invalid background task metadata file %q", entry.Name())
|
|
continue
|
|
}
|
|
path := manager.metadataFile(taskID)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
manager.warnf("skipped unreadable background task metadata %s: %s", path, err)
|
|
continue
|
|
}
|
|
var task Task
|
|
if err := json.Unmarshal(data, &task); err != nil {
|
|
manager.warnf("skipped invalid background task metadata %s: %s", path, err)
|
|
continue
|
|
}
|
|
task, changed, err := manager.normalizeLoadedTask(taskID, task)
|
|
if err != nil {
|
|
manager.warnf("skipped invalid background task metadata %s: %s", path, err)
|
|
continue
|
|
}
|
|
if changed {
|
|
if err := manager.persistTaskLocked(task); err != nil {
|
|
manager.warnf("failed to repair background task metadata %s: %s", path, err)
|
|
}
|
|
}
|
|
manager.tasks[task.ID] = task
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task, bool, error) {
|
|
changed := false
|
|
task.ID = strings.TrimSpace(task.ID)
|
|
if task.ID == "" {
|
|
task.ID = fileTaskID
|
|
changed = true
|
|
}
|
|
if task.ID != fileTaskID {
|
|
return Task{}, false, fmt.Errorf("metadata id %q does not match file id %q", task.ID, fileTaskID)
|
|
}
|
|
if !validTaskID(task.ID) {
|
|
return Task{}, false, fmt.Errorf("invalid task id %q", task.ID)
|
|
}
|
|
if trimmed := strings.TrimSpace(task.Type); trimmed != task.Type {
|
|
task.Type = trimmed
|
|
changed = true
|
|
}
|
|
if task.Type == "" {
|
|
return Task{}, false, fmt.Errorf("background task %s requires a type", task.ID)
|
|
}
|
|
if !validStatus(task.Status) {
|
|
return Task{}, false, fmt.Errorf("invalid background task status %q", task.Status)
|
|
}
|
|
if task.PID < 0 {
|
|
return Task{}, false, fmt.Errorf("invalid background task pid %d", task.PID)
|
|
}
|
|
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
|
if err != nil {
|
|
var migrated bool
|
|
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
|
if !migrated {
|
|
return Task{}, false, err
|
|
}
|
|
changed = true
|
|
}
|
|
if outputFile != task.OutputFile {
|
|
changed = true
|
|
}
|
|
task.OutputFile = outputFile
|
|
if task.Status == StatusRunning {
|
|
task.Status = StatusError
|
|
task.PID = 0
|
|
task.ExitCode = -1
|
|
if task.CompletedAt.IsZero() {
|
|
task.CompletedAt = manager.now()
|
|
}
|
|
changed = true
|
|
manager.warnf("marked reloaded running background task %s as error; original process ownership was lost", task.ID)
|
|
}
|
|
return task, changed, nil
|
|
}
|
|
|
|
func (manager *Manager) persistTaskLocked(task Task) error {
|
|
data, err := json.MarshalIndent(task, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode background task metadata: %w", err)
|
|
}
|
|
path := manager.metadataFile(task.ID)
|
|
file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create background task metadata temp file: %w", err)
|
|
}
|
|
tmp := file.Name()
|
|
if _, err := file.Write(append(data, '\n')); err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("write background task metadata: %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("close background task metadata: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("replace background task metadata: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) metadataFile(taskID string) string {
|
|
return filepath.Join(manager.rootDir, taskID+".json")
|
|
}
|
|
|
|
func (manager *Manager) warnf(format string, args ...any) {
|
|
message := fmt.Sprintf(format, args...)
|
|
manager.warnings = append(manager.warnings, message)
|
|
log.Printf("zero background: %s", message)
|
|
}
|
|
|
|
func (manager *Manager) OutputPath(taskID string) string {
|
|
task, ok := manager.Get(taskID)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return task.OutputFile
|
|
}
|
|
|
|
func (manager *Manager) ListByParent(parentID string) []Task {
|
|
parentID = strings.TrimSpace(parentID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
tasks := []Task{}
|
|
for _, task := range manager.tasks {
|
|
if task.ParentID == parentID {
|
|
tasks = append(tasks, task)
|
|
}
|
|
}
|
|
sortTasks(tasks)
|
|
return tasks
|
|
}
|
|
|
|
func (manager *Manager) List() []Task {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
tasks := make([]Task, 0, len(manager.tasks))
|
|
for _, task := range manager.tasks {
|
|
tasks = append(tasks, task)
|
|
}
|
|
sortTasks(tasks)
|
|
return tasks
|
|
}
|
|
|
|
func (manager *Manager) outputFile(taskID string, requested string) (string, error) {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested == "" {
|
|
return filepath.Join(manager.rootDir, taskID+".ndjson"), nil
|
|
}
|
|
path := requested
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(manager.rootDir, path)
|
|
}
|
|
path = filepath.Clean(path)
|
|
rel, err := filepath.Rel(manager.rootDir, path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve background task output file: %w", err)
|
|
}
|
|
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
|
return "", fmt.Errorf("background task output file must be inside %s", manager.rootDir)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
|
// or XDG data root. It accepts only the canonical per-task output
|
|
// name in the historic zero/background layout, and only when the corresponding
|
|
// regular file already exists inside the active manager root.
|
|
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
|
requested = strings.TrimSpace(requested)
|
|
if !filepath.IsAbs(requested) {
|
|
return "", false
|
|
}
|
|
requested = filepath.Clean(requested)
|
|
expectedName := taskID + ".ndjson"
|
|
if filepath.Base(requested) != expectedName {
|
|
return "", false
|
|
}
|
|
legacyRoot := filepath.Dir(requested)
|
|
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
|
return "", false
|
|
}
|
|
canonical := filepath.Join(manager.rootDir, expectedName)
|
|
info, err := os.Lstat(canonical)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return "", false
|
|
}
|
|
return canonical, true
|
|
}
|
|
|
|
func validTaskID(taskID string) bool {
|
|
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
|
}
|
|
|
|
func validStatus(status Status) bool {
|
|
switch status {
|
|
case StatusRunning, StatusCompleted, StatusError, StatusKilled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sortTasks(tasks []Task) {
|
|
sort.SliceStable(tasks, func(left int, right int) bool {
|
|
if tasks[left].StartedAt.Equal(tasks[right].StartedAt) {
|
|
return tasks[left].ID < tasks[right].ID
|
|
}
|
|
return tasks[left].StartedAt.After(tasks[right].StartedAt)
|
|
})
|
|
}
|
|
|
|
func envValue(env map[string]string, key string) string {
|
|
if env != nil {
|
|
return env[key]
|
|
}
|
|
return os.Getenv(key)
|
|
}
|