package runstate import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "time" ) type Active struct { RunID string `json:"run_id"` ChildID string `json:"child_id"` Repo string `json:"repo"` Job string `json:"job"` Rev string `json:"rev"` Ref string `json:"ref,omitempty"` Trigger string `json:"trigger"` StartedAt time.Time `json:"started_at"` } type Store struct{ DataDir string } func (s Store) Write(active Active) error { if err := active.validate(active.RunID); err != nil { return err } dir := filepath.Join(s.DataDir, "active") if err := os.MkdirAll(dir, 0o755); err != nil { return err } active.StartedAt = active.StartedAt.UTC() data, err := json.Marshal(active) if err != nil { return err } data = append(data, '\n') path := filepath.Join(dir, active.RunID+".json") tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) return err } return nil } func (s Store) Remove(runID string) error { if err := cleanID(runID); err != nil { return err } return os.Remove(filepath.Join(s.DataDir, "active", runID+".json")) } func (s Store) List() ([]Active, []error, error) { dir := filepath.Join(s.DataDir, "active") entries, err := os.ReadDir(dir) if err != nil { return nil, nil, err } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) var out []Active var warnings []error for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } data, err := os.ReadFile(filepath.Join(dir, entry.Name())) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err)) continue } var active Active if err := json.Unmarshal(data, &active); err != nil { warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err)) continue } stem := strings.TrimSuffix(entry.Name(), ".json") if err := active.validate(stem); err != nil { warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err)) continue } out = append(out, active) } return out, warnings, nil } func cleanID(id string) error { if id == "" || filepath.Base(id) != id { return fmt.Errorf("unsafe run id %q", id) } return nil } func (a Active) validate(stem string) error { if err := cleanID(a.RunID); err != nil { return err } if a.RunID != stem { return fmt.Errorf("run id %q does not match filename", a.RunID) } ordinal, err := strconv.Atoi(a.ChildID) if err != nil || ordinal < 1 { return fmt.Errorf("invalid child id %q", a.ChildID) } if strings.TrimSpace(a.Repo) == "" || strings.TrimSpace(a.Job) == "" || strings.TrimSpace(a.Rev) == "" { return fmt.Errorf("active record requires repo, job, and revision") } if a.Trigger != "push" && a.Trigger != "manual" && a.Trigger != "schedule" { return fmt.Errorf("invalid trigger %q", a.Trigger) } if a.StartedAt.IsZero() { return fmt.Errorf("active record requires start time") } return nil }