feat: Phase 4 — spotify_radio, and a bound on logs/runs.jsonl
spotify_radio opens a track's own context menu and starts its song radio.
The labels came from a fresh uiautomator dump on the phone, not from a guess:
the entry is "Go to song radio" and it sits below the fold, so the recipe
scrolls before it matches.
Recipe format:
- menu_row "{query}" finds the row like tap_row, then presses the action
button inside that row's own subtree (ui.Row.Action), so the More options
button of any other row is never a candidate. It records the row as {row}.
- a step can name its own failure: wait ... else "reason". The named reason
comes first and the screen's labels still follow (FR-8).
- scroll up|down moves the current list.
- playing with no title waits for the media session to be PLAYING. A radio
changes track every few minutes, so a title match would fail on a working
radio; spotify_play still passes the title it wants.
ui:
- pickBestMatch ignores a match with no usable bounds. An element scrolled out
of its list reports [0,0][0,0], and tapping its "centre" would be a blind tap
in the corner. A wait now only succeeds once the element is on screen.
Run log (FR-10): appendRunLog rotates runs.jsonl when a write would pass
ARTEMIS_LOG_MAX_BYTES (default 1 MiB), keeping ARTEMIS_LOG_KEEP (default 5)
files. History is bounded, never deleted wholesale, and the per-entry cap of
6 screen labels of 60 characters is unchanged.
Live evidence in docs/phase-4-song-radio.md: ok:true in 56.6s, the menu that
opened belonged to the pressed row, the station name "Honky Tonk Women Radio"
on the phone, and the radio playing past its seed track (Layla) per the media
session and dumpsys audio. Both new failure modes were demonstrated failing
with distinct reasons. The mini-bar's transport label stays "Play" while
playing on this build, which is why playback is confirmed from the media
session.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,18 @@ import (
|
||||
"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 != "" {
|
||||
@@ -91,8 +104,28 @@ func summaryLine(task string, res *recipe.RunResult) string {
|
||||
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).
|
||||
// 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
|
||||
@@ -102,11 +135,6 @@ func appendRunLog(path string, res *recipe.RunResult) error {
|
||||
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"`
|
||||
@@ -117,6 +145,61 @@ func appendRunLog(path string, res *recipe.RunResult) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(raw, '\n'))
|
||||
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)
|
||||
}
|
||||
|
||||
148
cmd/artemis/run_test.go
Normal file
148
cmd/artemis/run_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/recipe"
|
||||
)
|
||||
|
||||
// TestRotateRunLogKeepsRecentFiles proves the bound: the live file moves aside,
|
||||
// the older files shift up, and the oldest file is dropped once the limit is
|
||||
// reached. No history is deleted until the keep count is actually exceeded.
|
||||
func TestRotateRunLogKeepsRecentFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runs.jsonl")
|
||||
|
||||
// No file yet: nothing to rotate and no error.
|
||||
if err := rotateRunLog(path, 100, 1000, 2); err != nil {
|
||||
t.Fatalf("rotateRunLog on a missing file: %v", err)
|
||||
}
|
||||
|
||||
// A file that still fits is left alone.
|
||||
if err := os.WriteFile(path, []byte("small\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rotateRunLog(path, 10, 1000, 2); err != nil {
|
||||
t.Fatalf("rotateRunLog under the bound: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(rotatedName(path, 1)); !os.IsNotExist(err) {
|
||||
t.Fatalf("a file under the bound must not rotate")
|
||||
}
|
||||
|
||||
// Three rotations with keep=2: live is empty, .1 holds the second write,
|
||||
// .2 holds the first, and the first file is gone.
|
||||
for i := 1; i <= 3; i++ {
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("write-%d\n", i)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rotateRunLog(path, 1000, 1, 2); err != nil {
|
||||
t.Fatalf("rotate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
rotatedName(path, 1): "write-3\n",
|
||||
rotatedName(path, 2): "write-2\n",
|
||||
}
|
||||
for name, body := range want {
|
||||
got, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(got) != body {
|
||||
t.Errorf("%s = %q, want %q", filepath.Base(name), got, body)
|
||||
}
|
||||
}
|
||||
// The third rotation moved the live file aside, so the next append creates
|
||||
// it again (appendRunLog opens with O_CREATE).
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("the live file should have moved to %s", filepath.Base(rotatedName(path, 1)))
|
||||
}
|
||||
// write-1 fell off the end, because keep=2 keeps only two rotated files.
|
||||
if _, err := os.Stat(rotatedName(path, 3)); !os.IsNotExist(err) {
|
||||
t.Errorf("keep=2 must not leave a third rotated file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendRunLogRotatesAndKeepsEveryEntry drives the real logging path with a
|
||||
// tiny bound and checks that every entry survives in one of the files, that
|
||||
// each line is still complete JSON, and that the file count stays bounded.
|
||||
func TestAppendRunLogRotatesAndKeepsEveryEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runs.jsonl")
|
||||
t.Setenv("ARTEMIS_LOG_MAX_BYTES", "600")
|
||||
t.Setenv("ARTEMIS_LOG_KEEP", "3")
|
||||
|
||||
const entries = 40
|
||||
for i := 0; i < entries; i++ {
|
||||
res := &recipe.RunResult{
|
||||
Task: "spotify_radio",
|
||||
Params: map[string]string{"query": fmt.Sprintf("query %d", i)},
|
||||
OK: true,
|
||||
Steps: []recipe.StepResult{{Index: 1, Verb: "launch", OK: true}},
|
||||
}
|
||||
if err := appendRunLog(path, res); err != nil {
|
||||
t.Fatalf("appendRunLog %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
files := []string{path}
|
||||
for i := 1; i <= logKeep(); i++ {
|
||||
files = append(files, rotatedName(path, i))
|
||||
}
|
||||
for i := logKeep() + 1; i <= logKeep()+1; i++ {
|
||||
if _, err := os.Stat(rotatedName(path, i)); !os.IsNotExist(err) {
|
||||
t.Errorf("rotated file %d exists past the keep bound", i)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
total := 0
|
||||
for _, name := range files {
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
size := int64(len(raw))
|
||||
if size > logMaxBytes() {
|
||||
t.Errorf("%s is %d bytes, over the %d byte bound", filepath.Base(name), size, logMaxBytes())
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var entry struct {
|
||||
Time string `json:"time"`
|
||||
Task string `json:"task"`
|
||||
Params map[string]string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatalf("%s holds a truncated line %q: %v", filepath.Base(name), line, err)
|
||||
}
|
||||
if key := entry.Params["query"]; key != "" {
|
||||
if seen[key] {
|
||||
t.Errorf("entry %q was written twice", key)
|
||||
}
|
||||
seen[key] = true
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rotation bound of four small files cannot hold all 40 entries, so the
|
||||
// oldest records are gone. What survives is a contiguous, complete tail.
|
||||
if total == 0 {
|
||||
t.Errorf("no entries survived rotation")
|
||||
}
|
||||
if total >= entries {
|
||||
t.Errorf("kept %d entries, want fewer than all %d: a tiny bound must drop the oldest", total, entries)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user