Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion cmd/spinloop/work.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func workCmd() *cobra.Command {
Long: `works the orchestrator's work list — the backlog it works — from
the shell, as a client of the work list API the orchestrator serves: add an
item, read the work, read an item's kept output, stop a running item,
remove an item.
re-queue a failed item, remove an item.

Each subcommand takes --url, the API's base address, and presents the API's
token — from --api-token, else --api-token-file, else the SPINLOOP_API_TOKEN
Expand All @@ -53,6 +53,7 @@ fails before it calls the API, naming the flag.`,
workAddCmd(),
workListCmd(),
workAbortCmd(),
workRetryCmd(),
workRemoveCmd(),
workLogsCmd(),
workBoardCmd(),
Expand Down Expand Up @@ -384,6 +385,44 @@ the command reports its answer: a refusal reads the way the API states it.`,
return c
}

// workRetryCmd builds `work retry`.
func workRetryCmd() *cobra.Command {
var base, apiToken, apiTokenFile string
c := &cobra.Command{
Use: "retry <id>",
Short: "put a failed item back in the backlog",
Long: `puts a failed item back in the backlog, through the work list API
the orchestrator serves: the item's record removed, and the run's next pass
admits it again. The item's fields are unchanged, and its kept output stays
until the new attempt writes over it.

Only a failed item can be retried: an item the run records backlog, running
or done is refused, naming the item and its state, and an id the file does
not carry is refused, naming it. The API answers once the item is back in
the backlog, and the command reports its answer: a refusal reads the way the
API states it.`,
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
b, token, err := workTarget("work retry", base, apiToken, apiTokenFile)
if err != nil {
return err
}
if _, err := workRequest(b, token, http.MethodPost, "/v1/items/"+url.PathEscape(id)+"/retry", nil); err != nil {
return err
}
fmt.Printf("item %q is back in the backlog\n", id)
return nil
},
}
fs := c.Flags()
workAPIFlags(fs, &base, &apiToken, &apiTokenFile)
c.ValidArgsFunction = itemIDSlot
return c
}

// workRemoveCmd builds `work remove`.
func workRemoveCmd() *cobra.Command {
var base, apiToken, apiTokenFile string
Expand Down
34 changes: 26 additions & 8 deletions cmd/spinloop/work_board_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type workBoardVerb string

const (
workAbort workBoardVerb = "abort"
workRetry workBoardVerb = "retry"
workRemove workBoardVerb = "remove"
workAdd workBoardVerb = "add"
)
Expand Down Expand Up @@ -209,7 +210,7 @@ type workBoardModel struct {
formAsk bool // the discard question stands in front of the form
form workBoardForm

confirm bool // a removal stands in front of the board, waiting on its yes
confirm workBoardVerb // the action (remove or retry) standing in front of the board, waiting on its yes; empty when none

width, height int
}
Expand Down Expand Up @@ -381,20 +382,25 @@ func (m *workBoardModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, m.updateFormKey(msg)
}
if m.confirm {
if m.confirm != "" {
switch msg.String() {
case "y":
v := m.selectedItem()
m.confirm = false
verb := m.confirm
m.confirm = ""
if v == nil {
return m, nil
}
return m, m.beginAction(workRemove, v.ID)
return m, m.beginAction(verb, v.ID)
case "n", "esc":
m.confirm = false
m.statusLine = "declined — nothing removed"
declined := "nothing removed"
if m.confirm == workRetry {
declined = "nothing retried"
}
m.confirm = ""
m.statusLine = "declined — " + declined
case "q", "ctrl+c":
m.confirm = false
m.confirm = ""
return m, tea.Quit
}
return m, nil
Expand Down Expand Up @@ -431,9 +437,14 @@ func (m *workBoardModel) updateBoardKey(msg tea.KeyMsg) tea.Cmd {
if v := m.selectedItem(); v != nil {
return m.beginAction(workAbort, v.ID)
}
case "t":
// As with abort, the API refuses a retry of what has not failed.
if m.selectedItem() != nil {
m.confirm = workRetry
}
case "x":
if m.selectedItem() != nil {
m.confirm = true
m.confirm = workRemove
}
case "n":
m.formOpen = true
Expand Down Expand Up @@ -573,6 +584,11 @@ func (m *workBoardModel) beginAction(verb workBoardVerb, id string) tea.Cmd {
_, err := workRequest(base, token, "POST", "/v1/items/"+url.PathEscape(id)+"/abort", nil)
return workBoardActionMsg{verb: verb, id: id, err: err}
}
case workRetry:
run = func() tea.Msg {
_, err := workRequest(base, token, "POST", "/v1/items/"+url.PathEscape(id)+"/retry", nil)
return workBoardActionMsg{verb: verb, id: id, err: err}
}
case workRemove:
run = func() tea.Msg {
_, err := workRequest(base, token, "DELETE", "/v1/items/"+url.PathEscape(id), nil)
Expand Down Expand Up @@ -610,6 +626,8 @@ func workBoardActionLine(msg workBoardActionMsg) string {
switch msg.verb {
case workAbort:
return fmt.Sprintf("item %q stopped: it is back in the backlog", msg.id)
case workRetry:
return fmt.Sprintf("item %q is back in the backlog", msg.id)
case workRemove:
return fmt.Sprintf("item %q removed", msg.id)
}
Expand Down
13 changes: 8 additions & 5 deletions cmd/spinloop/work_board_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,21 +379,21 @@ func padTo(line string, w int) string {
}

// footerLine is the board's bottom line: the keys that would do something
// where the cursor stands, replaced by the removal question while one is
// where the cursor stands, replaced by the removal or retry question while one is
// pending and by an in-flight action's progress while a call is out; the
// status line rides at the end.
func (m workBoardModel) footerLine(w int, keys string) string {
line := dashKeyHints(keys)
if m.action.verb != "" {
line = m.action.progress(workBoardNow())
}
if m.confirm {
if m.confirm != "" {
v := m.selectedItem()
id := ""
if v != nil {
id = fmt.Sprintf(" %q", v.ID)
}
line = "remove item" + id + "?" + dashHintGap +
line = string(m.confirm) + " item" + id + "?" + dashHintGap +
dashKeyHints("y yes"+dashHintGap+"n no")
}
if m.statusLine != "" {
Expand All @@ -406,8 +406,8 @@ func (m workBoardModel) footerLine(w int, keys string) string {
}

// boardKeys names the keys that would do something for what is selected:
// abort is named only on a running card, remove only on one that is not,
// detail only where there is an item to open.
// abort is named only on a running card, retry only on a failed one, remove
// only on one that is not running, detail only where there is an item to open.
func (m workBoardModel) boardKeys() string {
parts := []string{}
cols := m.columnIndexes()
Expand All @@ -420,6 +420,9 @@ func (m workBoardModel) boardKeys() string {
if v.State == orchestrator.StateRunning {
parts = append(parts, "a abort")
} else {
if v.State == orchestrator.StateFailed {
parts = append(parts, "t retry")
}
parts = append(parts, "x remove")
}
}
Expand Down
105 changes: 105 additions & 0 deletions cmd/spinloop/work_board_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func (a *wbAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
list := func(v string) string {
tail := strings.TrimPrefix(r.URL.Path, "/v1/items/")
tail = strings.TrimSuffix(tail, "/log")
tail = strings.TrimSuffix(tail, "/retry")
return strings.TrimSuffix(tail, "/abort")
}
switch {
Expand Down Expand Up @@ -93,6 +94,25 @@ func (a *wbAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Tags: body.Tags, Priority: body.Priority, State: orchestrator.StateBacklog,
})
out(http.StatusOK, map[string]any{"ok": true})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/retry"):
id := list(r.URL.Path)
for i := range a.items {
if a.items[i].ID == id {
if a.items[i].State != orchestrator.StateFailed {
out(http.StatusConflict, map[string]any{"error": map[string]string{
"message": fmt.Sprintf("item %q is not failed: it is %s", id, a.items[i].State),
}})
return
}
a.items[i].State = orchestrator.StateBacklog
a.items[i].Node, a.items[i].EndedAt, a.items[i].Why = "", "", ""
out(http.StatusOK, map[string]any{"ok": true})
return
}
}
out(http.StatusNotFound, map[string]any{"error": map[string]string{
"message": fmt.Sprintf("the work list does not carry item %q", id),
}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/abort"):
id := list(r.URL.Path)
for i := range a.items {
Expand Down Expand Up @@ -1316,3 +1336,88 @@ func TestWorkBoard_ProgramSmoke(t *testing.T) {
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second))
}

func TestWorkBoard_RetryMovesAFailedCardBackToBacklog(t *testing.T) {
a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateFailed)}, nil)
m := newWBTestModel(t, a)
wbRound(t, m)
wbAct(t, m, "right", "t", "y")
if a.callCount("POST /v1/items/crank/retry") != 1 {
t.Fatal("the retry did not reach the API")
}
if !strings.Contains(m.statusLine, `item "crank" is back in the backlog`) {
t.Errorf("status = %q, want the retried line", m.statusLine)
}
view := wbPlain(m.View())
if !strings.Contains(view, "Failed 0") || !strings.Contains(view, "Backlog 1") {
t.Errorf("the card did not move back:\n%s", view)
}
}

func TestWorkBoard_RetryOfAnItemThatHasNotFailedIsRefusedTheAPISWay(t *testing.T) {
a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil)
m := newWBTestModel(t, a)
wbRound(t, m)
wbAct(t, m, "t", "y")
if !strings.Contains(m.statusLine, `item "solo" is not failed`) {
t.Errorf("status = %q, want the API's own refusal", m.statusLine)
}
if !strings.Contains(wbPlain(m.View()), "solo") {
t.Error("the refusal took the board down with it")
}
}

func TestWorkBoard_RetryIsNamedOnlyOnAFailedCard(t *testing.T) {
cases := map[string]bool{
orchestrator.StateBacklog: false,
orchestrator.StateRunning: false,
orchestrator.StateDone: false,
orchestrator.StateFailed: true,
}
for state, want := range cases {
a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", state)}, nil)
m := newWBTestModel(t, a)
wbRound(t, m)
keys := m.boardKeys()
if m.selectedItem() == nil {
wbKeys(t, m, "right")
keys = m.boardKeys()
}
for i := 0; i < 3 && m.selectedItem() == nil; i++ {
wbKeys(t, m, "right")
keys = m.boardKeys()
}
if got := strings.Contains(keys, "t retry"); got != want {
t.Errorf("%s card: keys %q, retry named = %v, want %v", state, keys, got, want)
}
}
}

func TestWorkBoard_RetryAsksFirst(t *testing.T) {
a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateFailed)}, nil)
m := newWBTestModel(t, a)
wbRound(t, m)
wbKeys(t, m, "right", "t")
footer := wbPlain(m.footerLine(m.effWidth(), m.boardKeys()))
if !strings.Contains(footer, `retry item "crank"?`) {
t.Errorf("the question did not stand: %q", footer)
}
if a.callCount("/retry") != 0 {
t.Error("the retry was sent before the yes")
}
wbKeys(t, m, "n")
if a.callCount("/retry") != 0 {
t.Error("a declined retry was sent anyway")
}
if !strings.Contains(m.statusLine, "nothing retried") {
t.Errorf("status = %q, want the declined line", m.statusLine)
}
if !strings.Contains(wbPlain(m.View()), "Failed 1") {
t.Error("the declined card left the Failed column")
}
// Escape abandons the question the same way.
wbKeys(t, m, "t", "esc")
if a.callCount("/retry") != 0 {
t.Error("an abandoned retry was sent anyway")
}
}
43 changes: 43 additions & 0 deletions cmd/spinloop/work_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,49 @@ func TestWorkAbort_TheRefusalsReadTheWayTheAPIStatesThem(t *testing.T) {
}
}

func TestWorkRetry_CallsTheRetryPathAndReports(t *testing.T) {
base, got := workAPIStub(t, http.StatusOK, map[string]any{"ok": true, "id": "a"})
out, err := runWork(t, "retry", "a", "--url", base)
if err != nil {
t.Fatalf("the retry: %v (out %s)", err, out)
}
if !strings.Contains(out, `item "a" is back in the backlog`) {
t.Errorf("the retry reports the item back in the backlog: %s", out)
}
if got.method != "POST" || got.path != "/v1/items/a/retry" {
t.Errorf("the retry calls the API's retry path for the id: %s %s", got.method, got.path)
}
}

func TestWorkRetry_TheRefusalsReadTheWayTheAPIStatesThem(t *testing.T) {
base, _ := workAPIStub(t, http.StatusConflict,
workAPIErrorReply(`item "a" is not failed: it is running`))
_, err := runWork(t, "retry", "a", "--url", base)
if err == nil || !strings.Contains(err.Error(), "it is running") {
t.Errorf("a not-failed item is refused, naming its state: %v", err)
}

base, _ = workAPIStub(t, http.StatusNotFound,
workAPIErrorReply(`the items file carries no item with id "b"`))
_, err = runWork(t, "retry", "b", "--url", base)
if err == nil || !strings.Contains(err.Error(), `no item with id "b"`) {
t.Errorf("an id the file does not carry is refused, naming it: %v", err)
}
}

func TestWorkRetry_NeedsOneIDAndAnAddress(t *testing.T) {
if _, err := runWork(t, "retry", "--url", "http://127.0.0.1:1"); err == nil {
t.Error("retry with no id is refused")
}
if _, err := runWork(t, "retry", "a", "b", "--url", "http://127.0.0.1:1"); err == nil {
t.Error("retry with two ids is refused")
}
_, err := runWork(t, "retry", "a")
if err == nil || !strings.Contains(err.Error(), "--url") {
t.Errorf("retry with no address names the flag: %v", err)
}
}

func TestWorkList_PlainLinesInFileOrder(t *testing.T) {
reply := map[string]any{"object": "list", "data": []any{
map[string]any{"id": "a", "instructions": "do a", "dir": ".", "state": "backlog"},
Expand Down
4 changes: 4 additions & 0 deletions docs/commands/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ shows in that state, not `backlog`.
| `POST /v1/items` | Add an item to the file and the backlog: the file's validation on its fields, and the file stays a valid items file after the write. |
| `DELETE /v1/items/{id}` | Take an item out of the work list: the items file, its record, and its kept output, all of it. |
| `POST /v1/items/{id}/abort` | Stop a running item's agent the way a clean interrupt stops it — the polite signal, the grace, then the hard end — and put the item back in the backlog, where the run admits it again on a later pass. |
| `POST /v1/items/{id}/retry` | Put a failed item back in the backlog: its record is removed, so the run admits it again on a later pass. The items file is unchanged and the failed attempt's output stays until the new attempt writes over it. |
| Any other path or method | A `404` naming the paths the API serves. |

The mutations refuse rather than force:
Expand All @@ -156,6 +157,9 @@ The mutations refuse rather than force:
- An **abort** is refused a `409` where the item is not running — naming the
item and its state — and a `404` where the file does not carry the id,
naming it.
- A **retry** is refused a `409` where the item is not failed — naming the
item and its state — and a `404` where the file does not carry the id,
naming it.

### The API's token

Expand Down
Loading
Loading