package main import ( "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb" "gitea.lab.audasmedia.com.au/sam/artemis_control/internal/recipe" ) // The run log is the FR-10 audit trail, so it is bounded rather than endless. // It is not deleted wholesale: the recent runs are the evidence that caught the // lock refusal and the wrong-element tap. When the live file passes the bound it // rotates to runs.jsonl.1, the previous .1 becomes .2, and the oldest file is // dropped, so about DefaultLogKeep files of history survive. const ( // DefaultLogMaxBytes is the live file size that triggers a rotation. DefaultLogMaxBytes = 1 << 20 // 1 MiB // DefaultLogKeep is how many rotated files are kept. DefaultLogKeep = 5 ) // recipesDir is where hand-editable recipes live. func recipesDir() string { if d := os.Getenv("ARTEMIS_RECIPES"); d != "" { return d } return "recipes" } // runLogPath is the JSON-lines action log (FR-10). func runLogPath() string { if p := os.Getenv("ARTEMIS_LOG"); p != "" { return p } return filepath.Join("logs", "runs.jsonl") } func cmdRecipes(args []string) error { recipes, err := recipe.List(recipesDir()) if err != nil { return err } for _, r := range recipes { fmt.Printf("%-16s %s\n", r.Task, r.Description) } return nil } // cmdRun runs a named recipe: artemis run spotify_play query="Rolling Stones". func cmdRun(r *adb.Runner, args []string) error { if len(args) < 1 { return fmt.Errorf("usage: artemis run [name=value ...]") } task := args[0] params := map[string]string{} for _, kv := range args[1:] { name, value, ok := strings.Cut(kv, "=") if !ok { return fmt.Errorf("parameter %q must be name=value", kv) } params[strings.TrimSpace(name)] = value } rec, err := recipe.Find(recipesDir(), task) if err != nil { return err } if missing := rec.MissingParams(params); len(missing) > 0 { return fmt.Errorf("recipe %s needs %s", task, strings.Join(missing, ", ")) } var res *recipe.RunResult err = ensurePhone(r, func() error { res = recipe.Run(r, rec, recipe.Options{Params: params, Log: os.Stdout}) if logErr := appendRunLog(runLogPath(), res); logErr != nil { fmt.Fprintf(os.Stderr, "artemis: could not write the run log: %v\n", logErr) } if !res.OK { return fmt.Errorf("%s", res.Error) } return nil }) fmt.Printf("\n%s\n", summaryLine(task, res)) return err } func summaryLine(task string, res *recipe.RunResult) string { if res == nil { return "task " + task + " did not run" } steps := len(res.Steps) if res.OK { return fmt.Sprintf("ok: %s ran %d steps in %s", task, steps, res.Duration.Round(time.Millisecond)) } return fmt.Sprintf("failed: %s stopped at step %d in %s: %s", task, steps, res.Duration.Round(time.Millisecond), res.Error) } // logMaxBytes is the live log size that triggers a rotation. func logMaxBytes() int64 { if v := os.Getenv("ARTEMIS_LOG_MAX_BYTES"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { return n } } return DefaultLogMaxBytes } // logKeep is how many rotated run logs survive a rotation. func logKeep() int { if v := os.Getenv("ARTEMIS_LOG_KEEP"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return DefaultLogKeep } // appendRunLog adds one JSON line per run: task, params, steps, result and // duration (FR-10). It rotates the file when the write would pass the bound. func appendRunLog(path string, res *recipe.RunResult) error { if res == nil { return nil } if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } entry := struct { Time string `json:"time"` *recipe.RunResult }{Time: time.Now().Format(time.RFC3339), RunResult: res} raw, err := json.Marshal(entry) if err != nil { return err } raw = append(raw, '\n') if err := rotateRunLog(path, int64(len(raw)), logMaxBytes(), logKeep()); err != nil { return err } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() _, err = f.Write(raw) return err } // rotateRunLog makes room for an entry of nextBytes. // // When the live file is at or over maxBytes it moves to path.1, each existing // rotated file shifts up by one (path.1 -> path.2 ...), and the oldest file is // deleted, so keep rotated files survive. A single entry larger than maxBytes // is still written: the bound must never drop an audit record. func rotateRunLog(path string, nextBytes, maxBytes int64, keep int) error { if maxBytes <= 0 { maxBytes = DefaultLogMaxBytes } if keep < 1 { keep = DefaultLogKeep } info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil } return err } if info.Size()+nextBytes <= maxBytes { return nil } // The oldest file goes first, then the rest shift up from the highest // number down, so no rename can clobber a file that has not moved yet. if err := os.Remove(rotatedName(path, keep)); err != nil && !os.IsNotExist(err) { return err } for i := keep - 1; i >= 1; i-- { if err := os.Rename(rotatedName(path, i), rotatedName(path, i+1)); err != nil && !os.IsNotExist(err) { return err } } return os.Rename(path, rotatedName(path, 1)) } // rotatedName is the name of the n-th rotated run log (runs.jsonl.1, ...). func rotatedName(path string, n int) string { return fmt.Sprintf("%s.%d", path, n) }