Files
artemis_control/cmd/artemis/run.go
Sam Rolfe 2604fea483 feat: Phase 3 recipes file, spotify_play, and locked-screen refusal
Phase 3 of AGENTS.md section 11.

Recipes file (FR-5)
- recipes/ holds one hand-editable file per task; recipes/spotify_play.recipe
  is the first one.
- internal/recipe parses directives, guards, parameters and steps, and runs
  them with a per-step and a whole-run timeout (FR-11).
- Steps name text ("tap the element whose text is Search"), never coordinates.
- guards name screens Artemis must never tap on, so a signed-out or
  first-run Spotify fails with its own reason (FR-8).
- Every run appends task, params, steps, result and duration to
  logs/runs.jsonl (FR-10).

spotify_play
- launch, search, play, then wait for the transport button to say "Pause".
- internal/ui/rows.go adds tap_row: a result row is a clickable container with
  a title and a subtitle, so a one-line search suggestion is not a result, and
  the tap lands on the row's centre rather than on its "More options" button.
  This fixes a real wrong tap: a plain text tap hit a 126x12 pixel
  "More options for song Honky Tonk Women - Live" button.

Locked screen (AGENTS.md section 13 Q2, decided)
- Artemis fails loudly on a secure keyguard and never types a PIN. No secret is
  stored. A non-secure keyguard is still dismissed.
- internal/ui/keyguard.go reads the KeyguardServiceDelegate block plus
  cmd lock_settings is-user-secure.
- Every acting command wakes the screen before it checks the keyguard: a dozing
  phone reports showing=false, because the lock screen is not drawn yet.
- AGENTS.md records the decision and that storing a PIN for auto-unlock is
  rejected for now.

Verified live: the phone locked itself mid-session, and keyguard, tap and
run spotify_play all refused with "phone is locked; unlock it and retry".
The live end-to-end playback run still needs an unlocked phone.
2026-10-16 13:24:22 +11:00

123 lines
2.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/recipe"
)
// 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 <task> [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)
}
// appendRunLog adds one JSON line per run: task, params, steps, result and
// duration (FR-10).
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
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
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
}
_, err = f.Write(append(raw, '\n'))
return err
}