diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbd2915..ef9bbc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- feat: watch the work list on a live kanban board with `spinloop work board` (#246) + ## [1.42.0] - 2026-09-20 ### Added - feat: make metrics and logs top-level verbs diff --git a/cmd/spinloop/work.go b/cmd/spinloop/work.go index 7eaddff1..bec3f064 100644 --- a/cmd/spinloop/work.go +++ b/cmd/spinloop/work.go @@ -55,6 +55,7 @@ fails before it calls the API, naming the flag.`, workAbortCmd(), workRemoveCmd(), workLogsCmd(), + workBoardCmd(), ) return c } diff --git a/cmd/spinloop/work_board.go b/cmd/spinloop/work_board.go new file mode 100644 index 00000000..c94bedba --- /dev/null +++ b/cmd/spinloop/work_board.go @@ -0,0 +1,80 @@ +// `work board`: the kanban view of a running orchestrator's work list. +// This is the command layer — flags, the terminal check, and the program; +// the model and the renderers live in work_board_model.go and +// work_board_render.go, so the screen logic is tested without a command +// and the command without a screen. + +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// cmdWorkBoard is the seam the suite calls, the family's own. +func cmdWorkBoard(args []string) error { return execCmd(workBoardCmd(), args) } + +func workBoardCmd() *cobra.Command { + var base, apiToken, apiTokenFile string + c := &cobra.Command{ + Use: "board", + Short: "watch the work list on a kanban board", + Long: `watches the orchestrator's work list as a live kanban board — a +column per state (Backlog, Running, Done, Failed), a card per item — +re-read from the work list API on a cadence, so cards move as the run +works. + +The arrow keys move the selection, enter opens the item's detail (its full +instructions, its tags and record, and its kept output tailed live), esc +closes it. n opens a form that adds an item through the API — the same add +` + "`work add`" + ` sends — a stops a running item, x removes one that is not, +after asking. r reads again at once; q or Ctrl+C leaves. Keys are offered +only where they would do something. + +Like every work command the board names the API with --url and presents +its token; the run's view of the items is the source of truth, and a +refusal reads the way the API states it. The board needs a terminal; to +report the same work into a pipe, use spinloop work list instead.`, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + b, token, err := workTarget("work board", base, apiToken, apiTokenFile) + if err != nil { + return err + } + return runWorkBoard(b, token) + }, + } + fs := c.Flags() + workAPIFlags(fs, &base, &apiToken, &apiTokenFile) + c.ValidArgsFunction = noPositionals + return c +} + +// runWorkBoard opens the view. The terminal check comes after the target +// resolves — a missing --url names the flag wherever the board is asked +// for — and before anything is drawn: a piped invocation never +// half-enters the view. +func runWorkBoard(base, token string) error { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return fmt.Errorf("the work board needs an interactive terminal — " + + "report the work into a pipe with spinloop work list instead") + } + return runWorkBoardProgram(newWorkBoardModel(base, token)) +} + +// runWorkBoardProgram runs the view on the alternate screen, the model +// held by pointer so the first round's answers survive — Bubble Tea +// restores the terminal on the way out, whatever key got here. +func runWorkBoardProgram(m *workBoardModel) error { + prog := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := prog.Run(); err != nil { + return fmt.Errorf("work board: %w", err) + } + return nil +} diff --git a/cmd/spinloop/work_board_model.go b/cmd/spinloop/work_board_model.go new file mode 100644 index 00000000..00e7f6ef --- /dev/null +++ b/cmd/spinloop/work_board_model.go @@ -0,0 +1,826 @@ +// The `work board` model: the columns, the selection, the detail, the add +// form, and how keys and messages move them. As with the fleet dashboard's +// model, every rule here is plain Go over plain data — the clock, the +// intervals and the API's address are inputs — so the suite drives the +// whole screen without a terminal. The board is a client of the work list +// API alone: like the one-shot work commands, it never touches the items +// file, the state, or the logs. + +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// The board's cadences and its clock, variables so a test never waits on +// one or is at the mercy of the other. The refresh cadence is the fleet +// dashboard's own pace: one GET per tick, and the run it watches changes +// on the scale of agent turns, not milliseconds. The tail cadence matches +// `work logs -f`, which the detail pane tails the same way. +var ( + workBoardRefreshInterval = 5 * time.Second + workBoardTailInterval = 1 * time.Second + workBoardSpinInterval = 100 * time.Millisecond +) + +// workBoardStaleThreshold is how many refresh intervals a reading may age +// before the title bar says so rather than draw it as the present state of +// the run — the dashboard's rule, and the same three rather than one so a +// single late round does not flicker the board grey. +const workBoardStaleThreshold = 3 + +// workBoardStaleAfter is when the board's reading counts as aged. A +// function of the cadence, so a test that shortens the cadence shortens +// the threshold with it. +func workBoardStaleAfter() time.Duration { + return workBoardStaleThreshold * workBoardRefreshInterval +} + +// workBoardNow is the board's clock, a variable so elapsed times a test +// renders are the test's to fix. +var workBoardNow = time.Now + +// workBoardVerb is one of the board's actions on an item, as its status +// lines name it. +type workBoardVerb string + +const ( + workAbort workBoardVerb = "abort" + workRemove workBoardVerb = "remove" + workAdd workBoardVerb = "add" +) + +// progress is what the status line shows while the action is in flight: +// the tool's one spinner, the verb, and how long the call has been out — +// an abort holds the call for the run's stop grace, so the wait has to +// show it is moving. +func (a workBoardAction) progress(now time.Time) string { + line := spinnerFrame(now) + " " + string(a.verb) + "ing " + a.id + if a.since.IsZero() { + return line + } + elapsed := now.Sub(a.since) + if elapsed < 0 { + elapsed = 0 + } + return line + " " + formatDuration(int(elapsed.Seconds())) +} + +// workBoardAction is the one action in flight: the board sends one call at +// a time, so one slot carries it — the verb, the item it concerns, and +// when it began. The zero value is an idle board. +type workBoardAction struct { + verb workBoardVerb + id string + since time.Time +} + +// workBoardForm is the add form: five fields, the cursor on one of them, +// and — where the API refused the last send — the refusal itself, kept +// visibly for the corrected send to replace. +type workBoardForm struct { + fields []textinput.Model // id, instructions, dir, tags, priority — workFormLabel's order + cursor int // the field taking keystrokes + err string // the API's refusal of the last send; cleared by the next +} + +// The form's fields, in the order the form draws them and the labels it +// draws beside them. Required marks the three an item cannot do without — +// a mark, not a validator: the API stays the only judge of an item's +// shape, and says so itself. +var ( + workFormLabels = []string{"id*", "instructions*", "dir*", "tags", "priority"} + workFormPromptW = 13 + workFormPriority = 4 // the index of the one field the form guards itself +) + +// newWorkBoardForm builds the form with its inputs sized and blink off: +// the board is a standing surface, and a caret blinking on its own is +// both animation the spec does not ask for and movement a byte-stable +// render test cannot allow. +func newWorkBoardForm(width int) workBoardForm { + f := workBoardForm{fields: make([]textinput.Model, len(workFormLabels))} + for i := range f.fields { + ti := textinput.New() + ti.Cursor.Blink = false + ti.Width = width + ti.Prompt = "" + f.fields[i] = ti + } + f.focus(0) + return f +} + +// focus moves the caret to one field: into it, and out of whichever was. +func (f *workBoardForm) focus(i int) { + if i < 0 || i >= len(f.fields) { + return + } + f.fields[f.cursor].Blur() + f.cursor = i + f.fields[i].Focus() +} + +// dirty reports whether anything has been typed — which decides whether an +// escape closes the form or first asks to discard. +func (f *workBoardForm) dirty() bool { + for _, ti := range f.fields { + if ti.Value() != "" { + return true + } + } + return false +} + +// values returns the form as the item the API will be asked to add. Tags +// are the field split on spaces — the same values `work add --tag` takes +// one flag at a time; whether they are well-formed is the API's to say. +func (f *workBoardForm) values() (workAddBody, error) { + priority := 0 + if v := strings.TrimSpace(f.fields[workFormPriority].Value()); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return workAddBody{}, fmt.Errorf("priority %q is not a number", v) + } + priority = n + } + var tags []string + if v := strings.Join(strings.Fields(f.fields[3].Value()), " "); v != "" { + tags = strings.Fields(v) + } + return workAddBody{ + ID: f.fields[0].Value(), + Instructions: f.fields[1].Value(), + Dir: f.fields[2].Value(), + Tags: tags, + Priority: priority, + }, nil +} + +// clear drops the form's text and any refusal, ready for the next item. +func (f *workBoardForm) clear() { + for i := range f.fields { + f.fields[i].SetValue("") + f.fields[i].SetCursor(0) + } + f.cursor = 0 + f.err = "" + f.focus(0) +} + +// workBoardModel is the program's state. The items are the last reading; +// the cursor names a column and a row within it; the detail, the removal +// question and the add form are the three modes a screen stands in, and +// keys route form → question → detail → board. +type workBoardModel struct { + base string + token string + + items []orchestrator.ItemView // the last reading, in the API's order + readingAt time.Time // when it was taken + readErr string // why the last round failed; "" when it did not + + cursor [2]int // column, then row within that column + scrolls [4]int // the first visible card row per column + busy bool // a read round is in flight + action workBoardAction // the one action in flight, if any + + statusLine string + + detail bool + detailItem orchestrator.ItemView // the item in view, kept from the reading + detailGen int // bumped per open; stale log replies are discarded + detailBusy bool // a log round is in flight + detailSeen string // the whole log as last fetched, for the suffix + detailLog string // the tailed lines, trimmed to the pane + detailNote string // why the pane is empty + + formOpen bool + 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 + + width, height int +} + +// The board's columns, in the order the spec stands them. +var workBoardColumns = []struct { + title string + state string +}{ + {"Backlog", orchestrator.StateBacklog}, + {"Running", orchestrator.StateRunning}, + {"Done", orchestrator.StateDone}, + {"Failed", orchestrator.StateFailed}, +} + +// Msgs. + +type workBoardTickMsg time.Time +type workBoardSpinMsg time.Time +type workBoardTailTickMsg time.Time + +// workBoardReadMsg is one completed round of the list. at is when the +// answer arrived; Update draws it only if it is newer than what is on +// screen — the dashboard's race guard, for the same reason. +type workBoardReadMsg struct { + items []orchestrator.ItemView + at time.Time + err error +} + +// workBoardActionMsg is one completed action. +type workBoardActionMsg struct { + verb workBoardVerb + id string + err error +} + +// workBoardTailMsg is one completed poll of the item in view's log. gen +// ties it to the detail view that started it. +type workBoardTailMsg struct { + gen int + log string + gone bool // the item left the list: its log 404s, and the tail ends + err error +} + +func workBoardTickCmd() tea.Cmd { + return tea.Tick(workBoardRefreshInterval, func(time.Time) tea.Msg { return workBoardTickMsg{} }) +} + +func workBoardSpinCmd() tea.Cmd { + return tea.Tick(workBoardSpinInterval, func(time.Time) tea.Msg { return workBoardSpinMsg{} }) +} + +func workBoardTailTickCmd() tea.Cmd { + return tea.Tick(workBoardTailInterval, func(time.Time) tea.Msg { return workBoardTailTickMsg{} }) +} + +// newWorkBoardModel builds the board over the API it will call. The first +// round arrives on Init, as everywhere else; a board that has read nothing +// yet draws four empty columns, not an error. +func newWorkBoardModel(base, token string) *workBoardModel { + return &workBoardModel{base: base, token: token} +} + +func (m *workBoardModel) Init() tea.Cmd { + return tea.Batch(workBoardTickCmd(), m.startRound()) +} + +// columnIndexes buckets the reading into the four columns, each column +// holding its items in the API's order — the order `work list` prints, so +// the two surfaces cannot show the run in two orders. +func (m *workBoardModel) columnIndexes() [4][]int { + var cols [4][]int + for i, v := range m.items { + for c := range workBoardColumns { + if v.State == workBoardColumns[c].state { + cols[c] = append(cols[c], i) + break + } + } + } + return cols +} + +// selectedItem is the card under the cursor, or nil where the cursor +// stands on nothing — an empty column, or an empty board. +func (m *workBoardModel) selectedItem() *orchestrator.ItemView { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if c >= len(cols) || r < 0 || r >= len(cols[c]) { + return nil + } + v := m.items[cols[c][r]] + return &v +} + +func (m *workBoardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clamp() + case workBoardTickMsg: + // One tick, rescheduling itself, starting a round only when none is + // in flight — a slow API stretches a round rather than overlapping + // the next. + return m, tea.Batch(workBoardTickCmd(), m.startRound()) + case workBoardReadMsg: + m.busy = false + if msg.err != nil { + m.readErr = msg.err.Error() + return m, nil + } + if msg.at.Before(m.readingAt) { + return m, nil // an older answer than what is on screen + } + m.readErr = "" + m.items = msg.items + m.readingAt = msg.at + m.retainDetail() + m.clamp() + case workBoardSpinMsg: + if m.action.verb == "" { + return m, nil + } + return m, workBoardSpinCmd() + case workBoardActionMsg: + m.action = workBoardAction{} + if msg.verb == workAdd { + // The form carries its own answer: closed on acceptance, still + // open under the API's refusal so a field can be corrected. + if msg.err == nil { + m.formOpen = false + m.formAsk = false + m.form.clear() + m.statusLine = fmt.Sprintf("item %q added", msg.id) + } else { + m.form.err = msg.err.Error() + } + } else { + m.statusLine = workBoardActionLine(msg) + } + // What the action changed is what the operator is waiting to see: + // a round is due now rather than at the tick. + return m, m.startRound() + case workBoardTailTickMsg: + if !m.detail { + return m, nil + } + return m, tea.Batch(workBoardTailTickCmd(), m.startTailRound()) + case workBoardTailMsg: + m.detailBusy = false + if !m.detail || msg.gen != m.detailGen { + return m, nil // the view closed, or reopened on another item + } + m.applyTail(msg) + case tea.KeyMsg: + return m.updateKey(msg) + } + return m, nil +} + +// updateKey routes a key by mode: the form, then the removal question, +// then the detail, then the board. +func (m *workBoardModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.formOpen { + if m.formAsk { + return m, m.updateFormAsk(msg) + } + return m, m.updateFormKey(msg) + } + if m.confirm { + switch msg.String() { + case "y": + v := m.selectedItem() + m.confirm = false + if v == nil { + return m, nil + } + return m, m.beginAction(workRemove, v.ID) + case "n", "esc": + m.confirm = false + m.statusLine = "declined — nothing removed" + case "q", "ctrl+c": + m.confirm = false + return m, tea.Quit + } + return m, nil + } + if m.detail { + if msg.String() == "esc" { + m.detail = false + } + return m, nil + } + return m, m.updateBoardKey(msg) +} + +func (m *workBoardModel) updateBoardKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "q", "ctrl+c": + return tea.Quit + case "up": + m.moveRow(-1) + case "down": + m.moveRow(1) + case "left": + m.moveColumn(-1) + case "right": + m.moveColumn(1) + case "enter": + if v := m.selectedItem(); v != nil { + return m.openDetail(*v) + } + case "a": + // No client-side guard on state: a backlog abort is the API's + // refusal to make, in its own words. Nor one on the action slot: + // beginAction answers a second key with its own status line. + if v := m.selectedItem(); v != nil { + return m.beginAction(workAbort, v.ID) + } + case "x": + if m.selectedItem() != nil { + m.confirm = true + } + case "n": + m.formOpen = true + m.formAsk = false + m.form = newWorkBoardForm(m.formFieldWidth()) + case "r": + return m.startRound() + } + return nil +} + +// updateFormKey answers the keys the add form reads. Typing and the caret +// keys belong to the focused textinput; up/down step the field cursor; +// enter advances and, on the last field, sends. The priority field takes +// only digits and a leading minus — a non-number could not even be asked +// of the API, so the keystroke simply does not enter the field. +func (m *workBoardModel) updateFormKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc": + if m.form.dirty() { + m.formAsk = true // discard is a deliberate act, not one escape + } else { + m.closeForm("nothing added") + } + return nil + case "up": + m.form.focus(m.form.cursor - 1) + return nil + case "down", "tab": + m.form.focus(m.form.cursor + 1) + return nil + case "enter": + if m.form.cursor < len(m.form.fields)-1 { + m.form.focus(m.form.cursor + 1) + return nil + } + return m.sendForm() + } + if m.form.cursor == workFormPriority && msg.Type == tea.KeyRunes { + allowed := make([]rune, 0, len(msg.Runes)) + for _, r := range msg.Runes { + if (r >= '0' && r <= '9') || r == '-' { + allowed = append(allowed, r) + } + } + if len(allowed) == 0 { + return nil + } + msg.Runes = allowed + msg.Type = tea.KeyRunes + } + ti, cmd := m.form.fields[m.form.cursor].Update(msg) + m.form.fields[m.form.cursor] = ti + return cmd +} + +// sendForm is the form's enter on the last field: the assembled item goes +// to the API — the API alone judges its shape — and the form stays open +// under the refusal, its text intact, if the API says no. +func (m *workBoardModel) sendForm() tea.Cmd { + body, err := m.form.values() + if err != nil { + m.form.err = err.Error() // the one shape fault the form can state: its own priority field + return nil + } + m.form.err = "" + return m.beginAdd(body) +} + +func (m *workBoardModel) closeForm(status string) { + m.formOpen = false + m.formAsk = false + m.form.clear() + m.statusLine = status +} + +// updateFormAsk answers the discard question: it defaults to keeping the +// work, so only a deliberate yes closes and says nothing was sent. +func (m *workBoardModel) updateFormAsk(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "y": + m.closeForm("nothing added") + case "n", "esc": + m.formAsk = false + case "q", "ctrl+c": + m.formOpen = false + m.formAsk = false + return tea.Quit + } + return nil +} + +// startRound opens one read of the list, if none is in flight. The answer +// comes back as a msg stamped with the time it arrived, not the time the +// round started. +func (m *workBoardModel) startRound() tea.Cmd { + if m.busy { + return nil + } + m.busy = true + base, token := m.base, m.token + return func() tea.Msg { + data, err := workRequest(base, token, "GET", "/v1/items", nil) + if err != nil { + return workBoardReadMsg{at: workBoardNow(), err: err} + } + var out struct { + Data []orchestrator.ItemView `json:"data"` + } + if err := json.Unmarshal(data, &out); err != nil { + return workBoardReadMsg{at: workBoardNow(), err: fmt.Errorf("reading the work list: %w", err)} + } + return workBoardReadMsg{items: out.Data, at: workBoardNow()} + } +} + +// beginAction sets off one call against an item. One action at a time: +// a second key while a call is out is answered by the status line, not a +// second call. The call rides the same bound the one-shot commands put on +// every call. +func (m *workBoardModel) beginAction(verb workBoardVerb, id string) tea.Cmd { + if m.action.verb != "" { + m.statusLine = "still " + string(m.action.verb) + "ing " + m.action.id + return nil + } + base, token := m.base, m.token + m.action = workBoardAction{verb: verb, id: id, since: workBoardNow()} + // The repaint chain that animates the spinner is scheduled as a + // command, not pushed through the program's Send: a Send from inside + // Update deadlocks the loop — it cannot read a message until Update + // returns, and Update is the one sending. The tick handler keeps + // the chain restacking while the call is out. + var run tea.Cmd + switch verb { + case workAbort: + run = func() tea.Msg { + _, err := workRequest(base, token, "POST", "/v1/items/"+url.PathEscape(id)+"/abort", 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) + return workBoardActionMsg{verb: verb, id: id, err: err} + } + } + return tea.Batch(run, workBoardSpinCmd()) +} + +// beginAdd sends the form's item to the API's own add path. The form +// validates nothing about the item's shape — the API is the only judge, +// and the form is still open to receive its answer — but the priority the +// form assembled must still parse, since the form guards that field. +func (m *workBoardModel) beginAdd(body workAddBody) tea.Cmd { + if m.action.verb != "" { + m.statusLine = "still " + string(m.action.verb) + "ing " + m.action.id + return nil + } + base, token := m.base, m.token + m.action = workBoardAction{verb: workAdd, id: body.ID, since: workBoardNow()} + run := func() tea.Msg { + _, err := workRequest(base, token, "POST", "/v1/items", body) + return workBoardActionMsg{verb: workAdd, id: body.ID, err: err} + } + return tea.Batch(run, workBoardSpinCmd()) +} + +// workBoardActionLine is the status line's account of a finished action: +// the API's own words for a refusal, unchanged — the refusal reads the +// way the API states it — and the plain fact for a success. +func workBoardActionLine(msg workBoardActionMsg) string { + if msg.err != nil { + return msg.err.Error() + } + switch msg.verb { + case workAbort: + return fmt.Sprintf("item %q stopped: it is back in the backlog", msg.id) + case workRemove: + return fmt.Sprintf("item %q removed", msg.id) + } + return fmt.Sprintf("item %q added", msg.id) +} + +// openDetail freezes the cursor onto the item in view, takes a copy of it +// (the reading behind may move on), and starts its tail. +func (m *workBoardModel) openDetail(v orchestrator.ItemView) tea.Cmd { + m.detail = true + m.detailItem = v + m.detailGen++ + m.detailBusy = false + m.detailSeen = "" + m.detailLog = "" + m.detailNote = "" + return tea.Batch(workBoardTailTickCmd(), m.startTailRound()) +} + +// startTailRound polls the item in view's kept output once. The API +// answers with the whole kept log, so the round fetches it and applyTail +// appends only what is new — the same trick `work logs -f` plays. +func (m *workBoardModel) startTailRound() tea.Cmd { + if m.detailBusy || m.detailItem.ID == "" { + return nil + } + if v := m.itemByID(m.detailItem.ID); v != nil && v.State != orchestrator.StateBacklog && v.State != orchestrator.StateRunning { + return nil // the item ended: whatever the last round brought is the whole log + } + m.detailBusy = true + gen := m.detailGen + base, token, id := m.base, m.token, m.detailItem.ID + return func() tea.Msg { + log, err := workLogFetch(base, token, id) + if err != nil { + return workBoardTailMsg{gen: gen, gone: workItemGone(err), err: err} + } + return workBoardTailMsg{gen: gen, log: log} + } +} + +// applyTail folds one poll into the pane: the suffix beyond what was last +// seen is appended, the pane keeps only what it can show, and a 404 — the +// item gone from the list, its kept output with it — ends the tail as +// cleanly as a follow ends. +func (m *workBoardModel) applyTail(msg workBoardTailMsg) { + if msg.gone { + m.detailNote = "the item is no longer in the list" + return + } + if msg.err != nil { + if m.detailLog == "" { + m.detailNote = msg.err.Error() + } + return + } + added := msg.log + if strings.HasPrefix(msg.log, m.detailSeen) { + added = msg.log[len(m.detailSeen):] + } + m.detailSeen = msg.log + if added != "" { + m.detailLog = lastLines(m.detailLog+added, m.detailCapacity()) + m.detailNote = "" + } else if m.detailLog == "" { + m.detailNote = "no kept output yet" + } +} + +// itemByID finds an item in the latest reading by id. +func (m *workBoardModel) itemByID(id string) *orchestrator.ItemView { + for i := range m.items { + if m.items[i].ID == id { + v := m.items[i] + return &v + } + } + return nil +} + +// retainDetail keeps the view current with the reading: the item's own +// fields refresh where it is still listed; where it has left the list the +// view keeps what it had, and its tail ends on its own terms. +func (m *workBoardModel) retainDetail() { + if !m.detail { + return + } + if v := m.itemByID(m.detailItem.ID); v != nil { + m.detailItem = *v + } +} + +// clamp re-fits the cursor and every column's window to the reading and +// the frame — after each read (cards may have moved or gone) and each +// resize (the window is smaller or roomier than it was). +func (m *workBoardModel) clamp() { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if c >= len(cols) || (len(cols[c]) == 0 && m.anyCards(cols)) { + // The cursor's column emptied while another has cards: step to the + // nearest one that has, the way the arrows skip empties. + if next := m.nearestColumn(c); next >= 0 { + c = next + } + } + if r >= len(cols[c]) { + r = len(cols[c]) - 1 + } + if r < 0 { + r = 0 + } + m.cursor = [2]int{c, r} + for i := range m.scrolls { + m.scrolls[i] = workBoardClampScroll(m.scrolls[i], len(cols[i]), m.visibleCards()) + } + m.keepVisible() +} + +func (m *workBoardModel) anyCards(cols [4][]int) bool { + for _, col := range cols { + if len(col) > 0 { + return true + } + } + return false +} + +// nearestColumn is the closest column holding cards, searched outwards +// from where the cursor stands; -1 when every column is empty. +func (m *workBoardModel) nearestColumn(from int) int { + for d := 1; d < len(workBoardColumns); d++ { + if from-d >= 0 && len(m.columnIndexes()[from-d]) > 0 { + return from - d + } + if from+d < len(workBoardColumns) && len(m.columnIndexes()[from+d]) > 0 { + return from + d + } + } + return -1 +} + +// moveRow steps within the cursor's column; the window follows. +func (m *workBoardModel) moveRow(delta int) { + cols := m.columnIndexes() + c := m.cursor[0] + r := m.cursor[1] + delta + if r < 0 { + r = 0 + } + if r >= len(cols[c]) { + r = len(cols[c]) - 1 + } + if r < 0 { + r = 0 + } + m.cursor = [2]int{c, r} + m.keepVisible() +} + +// moveColumn steps to the next column that holds cards, skipping the ones +// that do not; a column with nothing in it is nowhere to select. +func (m *workBoardModel) moveColumn(delta int) { + cols := m.columnIndexes() + c := m.cursor[0] + delta + for c >= 0 && c < len(workBoardColumns) && len(cols[c]) == 0 { + c += delta + } + if c < 0 || c >= len(workBoardColumns) { + return + } + m.cursor = [2]int{c, 0} + m.keepVisible() +} + +// keepVisible scrolls the cursor's column until its card is on screen. +func (m *workBoardModel) keepVisible() { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + avail := m.visibleCards() + if r < m.scrolls[c] || r >= m.scrolls[c]+avail { + m.scrolls[c] = r + } + m.scrolls[c] = workBoardClampScroll(m.scrolls[c], len(cols[c]), avail) +} + +// workBoardClampScroll bounds a column's window so it never scrolls past +// the cards it has. +func workBoardClampScroll(top, cards, avail int) int { + limit := cards - avail + if limit < 0 { + limit = 0 + } + if top > limit { + return limit + } + if top < 0 { + return 0 + } + return top +} + +func (m workBoardModel) effWidth() int { + if m.width < 1 { + return 80 + } + return m.width +} + +func (m workBoardModel) effHeight() int { + if m.height < 1 { + return 24 + } + return m.height +} diff --git a/cmd/spinloop/work_board_render.go b/cmd/spinloop/work_board_render.go new file mode 100644 index 00000000..4d8504fc --- /dev/null +++ b/cmd/spinloop/work_board_render.go @@ -0,0 +1,638 @@ +// The `work board` renderers: the columns and their cards, the item's +// detail, and the add form. The contract is the fleet dashboard's: every +// line is clipped (never wrapped) and every block is pre-sized before +// lipgloss frames it, so the grid stays rectangular at any terminal size. +// The colours come from palette.go in the two groups that must not be +// swapped — the state words and marks wear the state colours `work list` +// uses, and the brand accent appears only on the tool's own chrome and on +// the thing the operator has selected. + +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// Card geometry: three content lines — id, instructions, meta — inside a +// rounded frame, plus one gap line between cards. Column headers, the +// title bar and the footer are the frame's fixed rows. +const ( + workBoardCardH = 5 + workBoardCardStep = workBoardCardH + 1 + workBoardMinColW = 16 + workBoardFixedH = 3 // title bar + column header row + footer +) + +// ansiFaint stands the board back behind the add form: the surface behind +// the modal keeps drawing, dimmed, because the board keeps living. +const ansiFaint = "\033[2m" + +// View draws the frame: the title bar, the columns, the footer — with the +// add form standing over them while it is open. +func (m workBoardModel) View() string { + if m.detail { + return m.detailView() + } + w := m.effWidth() + now := workBoardNow() + cols := m.columnIndexes() + widths := workBoardColWidths(w, m.narrow()) + + parts := []string{dashTitleBar("work board", m.titleDetail(now), w)} + colsH := 1 + m.visibleCards()*workBoardCardStep + narrow := m.narrow() + var blocks [][]string + var drawn []int + for c := range workBoardColumns { + if narrow && c != m.cursor[0] { + continue + } + blocks = append(blocks, m.columnBlock(c, cols[c], widths, colsH, now)) + drawn = append(drawn, c) + } + parts = append(parts, workBoardJoinRows(blocks, pickWidths(widths, drawn, w, narrow))) + parts = append(parts, m.footerLine(w, m.boardKeys())) + view := strings.Join(parts, "\n") + if m.formOpen { + view = m.formOverlay(view) + } + return view +} + +// titleDetail is the title bar's right half: where the board is reading, +// how much it holds, and — once the reading has aged past three +// cadences — how old it is, because a stale reading drawn identically to +// a fresh one is not the run's present state. +func (m workBoardModel) titleDetail(now time.Time) string { + detail := fmt.Sprintf("%s %d items", m.base, len(m.items)) + if age := m.readingAge(now); age != "" { + return detail + " reading " + age + } + return detail +} + +// readingAge is the "3m ago" the title bar carries once the reading is +// stale, or "" while it is current. +func (m workBoardModel) readingAge(now time.Time) string { + if m.readingAt.IsZero() { + return "" + } + age := now.Sub(m.readingAt) + if age < workBoardStaleAfter() { + return "" + } + return formatDuration(int(age.Seconds())) + " ago" +} + +// narrow is the one-column-per-screen mode: below the point where four +// columns are legible, the cursor's column stands alone at full width +// rather than four unreadable strips. +func (m workBoardModel) narrow() bool { + return (m.effWidth()-3)/4 < workBoardMinColW +} + +// workBoardColWidths splits the body across the columns, spending every +// column of width — the remainder from the division goes to the leading +// columns one at a time. +func workBoardColWidths(w int, narrow bool) []int { + if narrow { + return []int{w} + } + n := len(workBoardColumns) + base, extra := (w-(n-1))/n, (w-(n-1))%n + widths := make([]int, n) + for i := range widths { + widths[i] = base + if i < extra { + widths[i]++ + } + } + return widths +} + +// visibleCards is how many cards a column shows between the column +// headers and the footer. +func (m workBoardModel) visibleCards() int { + r := (m.effHeight() - workBoardFixedH) / workBoardCardStep + if r < 1 { + return 1 + } + return r +} + +// columnBlock is one column: its header, then the window of cards the +// cursor is looking at — always the selection's neighbourhood, which is +// how a fuller column than the screen drops cards nowhere silently. +func (m workBoardModel) columnBlock(c int, idx []int, widths []int, height int, now time.Time) []string { + w := widths[len(widths)-1] // the full width in narrow mode, else this column's own + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + marker := " " + if m.cursor[0] == c { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(brandAccent)).Render("▸ ") + } + count := dim.Render(fmt.Sprintf("%d", len(idx))) + header := dashClip(marker+workBoardColumns[c].title+" "+count, w) + + lines := []string{header} + avail := (height - 1) / workBoardCardStep + top := m.scrolls[c] + for slot := 0; slot < avail; slot++ { + i := top + slot + if i >= len(idx) { + break + } + // The card is a block: its lines join the column line by line, + // exactly as the dashboard's grid joins its tiles — appending the + // block as one entry would drop its newlines into a single row. + lines = append(lines, strings.Split(m.card(m.items[idx[i]], idx[i] == m.selectedIndex(), w, now), "\n")...) + lines = append(lines, "") + } + if len(idx) == 0 { + lines = append(lines, dim.Render(" —")) + } + for len(lines) < height { + lines = append(lines, "") + } + for i := range lines { + lines[i] = dashClip(lines[i], w) + } + return lines +} + +// selectedIndex is the flat item index under the cursor, or -1. +func (m workBoardModel) selectedIndex() int { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if r < 0 || r >= len(cols[c]) { + return -1 + } + return cols[c][r] +} + +// card is one item: its id (and priority, where it has one) on the bar, +// its instructions clipped to the card, and a meta line stating its +// state in the colour that reports it, beside what that state means — +// the node and elapsed time of a running item, the end of an ended one. +func (m workBoardModel) card(v orchestrator.ItemView, selected bool, w int, now time.Time) string { + inner := w - 2 + bar := dashClip(workBoardCardBar(v, inner), inner) + body := dashClip(workBoardWrapOne(v.Instructions, inner), inner) + meta := dashClip(workBoardCardMeta(v, now, inner), inner) + + style := lipgloss.NewStyle(). + Width(inner).Height(3). + Border(lipgloss.RoundedBorder()) + if selected { + style = style.BorderForeground(lipgloss.Color(brandAccent)) + } else { + style = style.BorderForeground(lipgloss.Color("240")) + } + return style.Render(bar + "\n" + body + "\n" + meta) +} + +// workBoardCardBar is a card's header line: the state mark, the id, and +// the priority where the item carries one. +func workBoardCardBar(v orchestrator.ItemView, inner int) string { + line := workBoardStateMark(v.State) + " " + v.ID + if v.Priority != 0 { + line += fmt.Sprintf(" p%d", v.Priority) + } + return line +} + +// workBoardStateMark is the card's coloured dot — the state read at a +// glance, in the same colours `work list` puts on the state word. +func workBoardStateMark(state string) string { + return workBoardStateColour(state) + "●" + ansiReset +} + +// workBoardStateColour is the raw-ANSI colour of a state, drawn from the +// very words `work list` colours with: amber is running, green done, red +// failed; backlog is everything not happening, so it is the faded grey. +// The brand accent is not in this switch and does not colour a card. +func workBoardStateColour(state string) string { + switch state { + case orchestrator.StateRunning: + return ansiYellow + case orchestrator.StateDone: + return ansiGreen + case orchestrator.StateFailed: + return ansiRed + default: + return ansiGrey + } +} + +// workBoardCardMeta is a card's third line: the state word as the work +// list colours it, and beside it what the state carries — the node and +// the time a running item has been up, the end of an ended item. +func workBoardCardMeta(v orchestrator.ItemView, now time.Time, inner int) string { + state := workListColouredState(v.State, true) + switch v.State { + case orchestrator.StateRunning: + extra := v.Node + if e := workBoardElapsed(v.StartedAt, now); e != "" { + if extra != "" { + extra += " " + } + extra += e + } + return state + " " + extra + case orchestrator.StateDone, orchestrator.StateFailed: + return state + " ended " + workBoardClock(v.EndedAt) + default: + return state + } +} + +// workBoardElapsed is how long a running item has been up, counted when +// it is drawn — so it counts up as the operator watches, and never +// carries the time the reading was taken as the time it matters. +func workBoardElapsed(rfc3339 string, now time.Time) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return "" + } + secs := int(now.Sub(t).Seconds()) + if secs < 0 { + secs = 0 + } + return formatDuration(secs) +} + +// workBoardClock is an instant as HH:MM, or a dash where the record has +// none or none the board can read. +func workBoardClock(rfc3339 string) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return "-" + } + return t.Local().Format("15:04") +} + +// workBoardWrapOne is one instructions line clipped to width; the card +// shows the beginning and the detail pane holds the rest. +func workBoardWrapOne(s string, w int) string { + return dashClip(strings.ReplaceAll(s, "\n", " "), w) +} + +// workBoardWrap breaks text into lines of at most w columns, on spaces, +// hard-cutting any single word longer than the line. It is the detail +// pane's wrap: full instructions, kept to the frame's width. +func workBoardWrap(s string, w int) []string { + if w < 1 { + w = 1 + } + var out []string + for _, para := range strings.Split(s, "\n") { + line := "" + for _, word := range strings.Fields(para) { + for lipgloss.Width(word) > w { + if line != "" { + out = append(out, line) + line = "" + } + out = append(out, dashClip(word, w)) + word = ansiCutRest(word, w) + } + if line == "" { + line = word + } else if lipgloss.Width(line)+1+lipgloss.Width(word) <= w { + line += " " + word + } else { + out = append(out, line) + line = word + } + } + out = append(out, line) + } + if len(out) == 0 { + out = []string{""} + } + return out +} + +// ansiCutRest drops the first w display columns of a plain string, the +// remainder of a word that overflowed. +func ansiCutRest(s string, w int) string { + // Plain text only (instructions arrive plain from the API), so + // counting runes is counting columns. + runes := []rune(s) + if w >= len(runes) { + return "" + } + return string(runes[w:]) +} + +// pickWidths is the width of each drawn column: the one full width when +// the board is narrow, otherwise the drawn columns' own slices of the +// four-way split. +func pickWidths(widths []int, drawn []int, w int, narrow bool) []int { + out := make([]int, len(drawn)) + for i, c := range drawn { + if narrow { + out[i] = w + } else { + out[i] = widths[c] + } + } + return out +} + +// workBoardJoinRows lays the column blocks side by side, one display line +// at a time — the grid's join, which keeps every row the frame's width +// however uneven the columns' contents are. +func workBoardJoinRows(blocks [][]string, widths []int) string { + lines := make([]string, 0, workBoardCardStep*16) + n := 0 + for _, b := range blocks { + if len(b) > n { + n = len(b) + } + } + for line := 0; line < n; line++ { + cells := make([]string, len(blocks)) + for c, b := range blocks { + cell := "" + if line < len(b) { + cell = b[line] + } + cells[c] = padTo(cell, widths[c]) + } + lines = append(lines, strings.TrimRight(strings.Join(cells, " "), " ")) + } + return strings.Join(lines, "\n") +} + +// padTo pads a clipped line to exactly w display columns. +func padTo(line string, w int) string { + if n := lipgloss.Width(line); n < w { + return line + strings.Repeat(" ", w-n) + } + return line +} + +// 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 +// 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 { + v := m.selectedItem() + id := "" + if v != nil { + id = fmt.Sprintf(" %q", v.ID) + } + line = "remove item" + id + "?" + dashHintGap + + dashKeyHints("y yes"+dashHintGap+"n no") + } + if m.statusLine != "" { + line += " " + m.statusLine + } + if m.busy { + line += " reading…" + } + return dashClip(line, w) +} + +// 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. +func (m workBoardModel) boardKeys() string { + parts := []string{} + cols := m.columnIndexes() + if m.anyCards(cols) { + parts = append(parts, "↑↓←→ select") + } + v := m.selectedItem() + if v != nil { + parts = append(parts, "enter detail") + if v.State == orchestrator.StateRunning { + parts = append(parts, "a abort") + } else { + parts = append(parts, "x remove") + } + } + parts = append(parts, "n add", "r refresh", "q quit") + return strings.Join(parts, dashHintGap) +} + +// detailView is the full-screen item: the fields the reading carries, the +// instructions whole, and the kept log tailed into what remains. +// detailLayout is the detail's one arithmetic: the fields that fit the +// frame, and the log rows the rest of the frame can show. The view and +// the tail both ask here, so the buffer never holds lines the pane +// could not draw. +func (m workBoardModel) detailLayout() (fields []string, logAvail int) { + fields = workBoardFitFields(m.detailFields(), m.effHeight()) + logAvail = m.effHeight() - 5 - len(fields) + if logAvail < 1 { + logAvail = 1 + } + return fields, logAvail +} + +func (m workBoardModel) detailView() string { + w := m.effWidth() + v := m.detailItem + fields, logAvail := m.detailLayout() + + state := workListColouredState(v.State, true) + header := dashTitleBar("work board · "+v.ID, state, w) + divider := strings.Repeat("─", w) + + logLines := strings.Split(strings.TrimRight(m.detailLog, "\n"), "\n") + if m.detailLog == "" { + note := m.detailNote + if note == "" { + note = "waiting for the log…" + } + logLines = []string{note} + } + if len(logLines) > logAvail { + logLines = logLines[len(logLines)-logAvail:] + } + + parts := make([]string, 0, len(fields)+len(logLines)+4) + parts = append(parts, header, divider) + for _, line := range fields { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + for _, line := range logLines { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + parts = append(parts, m.footerLine(w, "esc back")) + return strings.Join(parts, "\n") +} + +// detailFields is the detail's whole item: every field the record +// carries, then the instructions unwrapped to the frame's width. The +// labels are the dim ink, the values the terminal's own. +func (m workBoardModel) detailFields() []string { + v := m.detailItem + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + label := func(s string) string { return dim.Render(s) } + var out []string + add := func(labelStr, value string) { + if value == "" { + return + } + out = append(out, label(labelStr+" ")+value) + } + add("dir", v.Dir) + add("tags", strings.Join(v.Tags, " ")) + add("node", v.Node) + add("started", workBoardClock(v.StartedAt)) + add("ended", workBoardClock(v.EndedAt)) + if v.State == orchestrator.StateFailed && v.Why != "" { + // The reason has something to say, unlike the one-line fields: + // the label carries its first line and the rest wraps beneath + // the label's room, its red kept on every row. + const indent = " " // as wide as "why ", the label's room + w := m.effWidth() - len(indent) + for i, line := range workBoardWrap(v.Why, w) { + if i == 0 { + out = append(out, label("why ")+ansiRed+line+ansiReset) + } else { + out = append(out, indent+ansiRed+line+ansiReset) + } + } + } + if m.readingAge(workBoardNow()) != "" { + add("reading", dim.Render(m.readingAge(workBoardNow()))) + } + out = append(out, label("instructions")) + out = append(out, workBoardWrap(v.Instructions, m.effWidth())...) + return out +} + +// workBoardFitFields keeps the field section inside the frame. The log +// pane already trims itself to what it can show; the fields owe the +// terminal the same honesty — what fits is drawn, and a dim note counts +// the rows the frame left behind rather than letting them pile past the +// bottom edge unseen. +func workBoardFitFields(fields []string, h int) []string { + room := h - 6 // header, three dividers, a line of log, footer + if room < 1 { + room = 1 + } + if len(fields) <= room { + return fields + } + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + note := dim.Render("⋯ +" + fmt.Sprintf("%d", len(fields)-(room-1)) + " lines") + return append(append([]string{}, fields[:room-1]...), note) +} + +// detailCapacity is how many log lines the pane can show — the same +// figure the tail trims its buffer to, so the buffer never holds what +// the view could never draw. +func (m workBoardModel) detailCapacity() int { + _, logAvail := m.detailLayout() + return logAvail +} + +// formFieldWidth is the width each textinput is given inside the form: +// the modal less its frame, the field marker, and the label column. +func (m workBoardModel) formFieldWidth() int { + w := m.formWidth() - 2 - 2 - workFormPromptW - 2 + if w < 10 { + return 10 + } + return w +} + +func (m workBoardModel) formWidth() int { + w := m.effWidth() - 4 + if w > 64 { + return 64 + } + if w < 30 { + return m.effWidth() + } + return w +} + +// formOverlay draws the add form over the board: the board's lines stand +// behind faint — still drawn, still live, stepped back — and the modal +// box sits centred over them. +func (m workBoardModel) formOverlay(view string) string { + w := m.effWidth() + lines := strings.Split(view, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ansiFaint + line + ansiReset + } + } + box := m.formBox() + boxLines := strings.Split(box, "\n") + pad := (m.effHeight() - len(boxLines)) / 2 + if pad < 0 { + pad = 0 + } + left := (w - lipgloss.Width(boxLines[0])) / 2 + if left < 0 { + left = 0 + } + for i, bline := range boxLines { + row := pad + i + if row >= len(lines) { + break + } + lines[row] = strings.Repeat(" ", left) + bline + } + return strings.Join(lines, "\n") +} + +// formBox is the modal: the five fields with their labels, the active one +// marked and its caret standing in it, the API's refusal of the last send +// where there is one, and the keys the form answers to. +func (m workBoardModel) formBox() string { + inner := m.formWidth() - 2 + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + bold := lipgloss.NewStyle().Bold(true) + + var b strings.Builder + b.WriteString(dashClip(" new work item"+strings.Repeat(" ", max(0, inner-15)), inner)) + b.WriteByte('\n') + for i, label := range workFormLabels { + marker := " " + labelStyle := dim + if m.form.cursor == i { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(brandAccent)).Render("❯ ") + labelStyle = lipgloss.NewStyle() + } + cell := marker + labelStyle.Render(fmt.Sprintf("%-*s", workFormPromptW, label)) + cell += m.form.fields[i].View() + b.WriteString(dashClip(cell, inner)) + b.WriteByte('\n') + } + if m.form.err != "" { + b.WriteString(dashClip(ansiRed+m.form.err+ansiReset, inner)) + b.WriteByte('\n') + } + if m.formAsk { + b.WriteString(dashClip(bold.Render("discard this item?")+" everything typed is kept until you say yes"+ + dashHintGap+dashKeyHints("y discard"+dashHintGap+"n keep"), inner)) + } else { + b.WriteString(dashClip(dashKeyHints("enter next/send"+dashHintGap+"up/down field"+dashHintGap+ + "esc cancel"+dashHintGap+"* required"), inner)) + } + style := lipgloss.NewStyle(). + Width(inner). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(brandAccent)) + return style.Render(b.String()) +} diff --git a/cmd/spinloop/work_board_test.go b/cmd/spinloop/work_board_test.go new file mode 100644 index 00000000..8a3d2b14 --- /dev/null +++ b/cmd/spinloop/work_board_test.go @@ -0,0 +1,1318 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + teatest "github.com/charmbracelet/x/exp/teatest" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// The board's whole screen logic is driven here without a terminal: a fake +// work list API answers, keys and messages go straight into Update, and +// the view is read like a printout. The intervals and the clock are the +// package variables the model reads, so nothing in a test ever waits on a +// ticker or renders a moving time the test did not choose. + +// wbAPI is a fake work list API: the orchestrator's real shapes, the +// orchestrator's real refusals, and a record of every call the board +// made — which is how the tests prove the negative ones too. +type wbAPI struct { + mu sync.Mutex + items []orchestrator.ItemView + logs map[string]string + calls []string // "METHOD /path" + lastAdd workAddBody + failGET bool // answer GET /v1/items with a fault, as a stopped orchestrator would + srv *httptest.Server +} + +func newWBAPI(t *testing.T, items []orchestrator.ItemView, logs map[string]string) *wbAPI { + t.Helper() + a := &wbAPI{items: items, logs: logs} + if a.logs == nil { + a.logs = map[string]string{} + } + a.srv = httptest.NewServer(a) + t.Cleanup(a.srv.Close) + return a +} + +func (a *wbAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + a.calls = append(a.calls, r.Method+" "+r.URL.Path) + out := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + json.NewEncoder(w).Encode(v) + } + } + list := func(v string) string { + tail := strings.TrimPrefix(r.URL.Path, "/v1/items/") + tail = strings.TrimSuffix(tail, "/log") + return strings.TrimSuffix(tail, "/abort") + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/items": + if a.failGET { + out(http.StatusInternalServerError, + map[string]any{"error": map[string]string{"message": "the orchestrator is not answering"}}) + return + } + out(http.StatusOK, map[string]any{"data": a.items}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/items": + var body workAddBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + out(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "unreadable body"}}) + return + } + a.lastAdd = body + for _, v := range a.items { + if v.ID == body.ID { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is already in the list", body.ID), + }}) + return + } + } + a.items = append(a.items, orchestrator.ItemView{ + ID: body.ID, Instructions: body.Instructions, Dir: body.Dir, + 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, "/abort"): + id := list(r.URL.Path) + for i := range a.items { + if a.items[i].ID == id { + if a.items[i].State != orchestrator.StateRunning { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is not running (%s)", id, a.items[i].State), + }}) + return + } + a.items[i].State = orchestrator.StateBacklog + a.items[i].Node = "" + 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.MethodDelete && strings.HasPrefix(r.URL.Path, "/v1/items/"): + id := strings.TrimPrefix(r.URL.Path, "/v1/items/") + for i := range a.items { + if a.items[i].ID == id { + if a.items[i].State == orchestrator.StateRunning { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is running — abort it first", id), + }}) + return + } + a.items = append(a.items[:i], a.items[i+1:]...) + delete(a.logs, id) + 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.MethodGet && strings.HasSuffix(r.URL.Path, "/log"): + id := list(r.URL.Path) + for _, v := range a.items { + if v.ID == id { + out(http.StatusOK, map[string]any{"log": a.logs[id]}) + return + } + } + out(http.StatusNotFound, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("the work list does not carry item %q", id), + }}) + default: + out(http.StatusNotFound, nil) + } +} + +func (a *wbAPI) setLog(id, log string) { + a.mu.Lock() + defer a.mu.Unlock() + a.logs[id] = log +} + +func (a *wbAPI) callCount(fragment string) int { + a.mu.Lock() + defer a.mu.Unlock() + n := 0 + for _, c := range a.calls { + if strings.Contains(c, fragment) { + n++ + } + } + return n +} + +func (a *wbAPI) lastBody() workAddBody { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastAdd +} + +// newWBTestModel builds a board over the fake API with the cadences the +// test drives — never a live ticker — and a fixed clock. +func newWBTestModel(t *testing.T, a *wbAPI) *workBoardModel { + t.Helper() + restore := []func(){ + setVar(&workBoardRefreshInterval, time.Hour), + setVar(&workBoardTailInterval, time.Hour), + setVar(&workBoardSpinInterval, time.Hour), + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000, 0) }), + } + t.Cleanup(func() { + for _, f := range restore { + f() + } + }) + m := newWorkBoardModel(a.srv.URL, "tok") + m.width, m.height = 100, 30 + return m +} + +func setVar[T any](p *T, v T) func() { + old := *p + *p = v + return func() { *p = old } +} + +// wbKey names a keystroke the way the footer names it. +func wbKey(name string) tea.KeyMsg { + switch name { + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEscape} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(name)} + } +} + +// wbKeys feeds keys through Update and returns the last command back. +func wbKeys(t *testing.T, m *workBoardModel, keys ...string) tea.Cmd { + t.Helper() + var cmd tea.Cmd + for _, k := range keys { + _, cmd = m.Update(wbKey(k)) + } + return cmd +} + +// wbRound runs one read of the list to its answer and folds it in. +func wbRound(t *testing.T, m *workBoardModel) { + t.Helper() + wbLand(t, m, m.startRound()) +} + +// wbLand lands the call a key set off: the answer is folded in, and the +// read the answer kicks is landed too — the round trip the program would +// play. An action's command is a batch when it also starts the spinner's +// repaint chain: the call is the chain's first command, and its timer is +// left unrun — a test would only wait on it. +func wbLand(t *testing.T, m *workBoardModel, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + t.Fatal("no call to land (busy?)") + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + msg = batch[0]() + } + _, next := m.Update(msg) + if next != nil { + m.Update(next()) + } +} + +// wbAct is press-and-land: keys, then the call they started. +func wbAct(t *testing.T, m *workBoardModel, keys ...string) { + t.Helper() + wbLand(t, m, wbKeys(t, m, keys...)) +} + +var wbANSI = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func wbPlain(s string) string { return wbANSI.ReplaceAllString(s, "") } + +func wbItem(id, state string) orchestrator.ItemView { + v := orchestrator.ItemView{ID: id, Instructions: "do the " + id, Dir: "./" + id, State: state} + switch state { + case orchestrator.StateRunning: + v.Node = "node-1" + v.StartedAt = time.Unix(1700000000, 0).Add(-125 * time.Second).UTC().Format(time.RFC3339) + case orchestrator.StateDone, orchestrator.StateFailed: + v.EndedAt = time.Unix(1700000000, 0).Add(-40 * time.Minute).UTC().Format(time.RFC3339) + } + if state == orchestrator.StateFailed { + v.Why = "the agent gave up" + } + return v +} + +// --- the command and its gate --- + +func TestWorkBoard_CommandSeamRefusesWithoutATerminal(t *testing.T) { + err := cmdWorkBoard([]string{"--url", "http://127.0.0.1:1", "--api-token", "t"}) + if err == nil || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("seam error = %v, want the terminal refusal", err) + } +} + +func TestWorkBoard_TicksRescheduleWithoutDoublingRounds(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if m.busy { + t.Fatal("the board sat busy after a round") + } + // A tick reschedules itself and starts a round; a second tick while + // that round is out must not send another one. + _, cmd := m.Update(workBoardTickMsg{}) + if cmd == nil { + t.Fatal("a tick scheduled nothing") + } + if !m.busy { + t.Fatal("the tick started no round") + } + before := a.callCount("GET /v1/items") + _, cmd = m.Update(workBoardTickMsg{}) // rounds must not overlap + if cmd == nil { + t.Error("a waiting tick unscheduled itself") + } + m.Update(workBoardReadMsg{items: nil, at: workBoardNow()}) + if a.callCount("GET /v1/items") != before { + t.Error("the overlapping tick spent a call") + } + // Idle messages answer nothing: no chain while nothing is happening. + if _, cmd := m.Update(workBoardSpinMsg{}); cmd != nil { + t.Error("a spin message chained with no action in flight") + } + if _, cmd := m.Update(workBoardTailTickMsg{}); cmd != nil { + t.Error("a tail tick chained with no detail open") + } +} + +func TestWorkBoard_ActionProgressAndSpinChain(t *testing.T) { + fixed := time.Unix(1700000000, 0) + act := workBoardAction{verb: workAbort, id: "crank", since: fixed.Add(-3 * time.Second)} + line := act.progress(fixed) + if !strings.Contains(line, "aborting crank") || !strings.Contains(line, "3s") { + t.Errorf("progress line = %q", line) + } + + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right") + cmd := wbKeys(t, m, "a") // the call goes out and stays unanswered + if cmd == nil { + t.Fatal("the abort started nothing") + } + // The repaint chain rides a command alongside the call — never the + // program's Send, which from inside Update would deadlock the loop. + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("the abort is %T, want a two-part batch", cmd()) + } + // While the call is out the chain keeps going… + _, cmd = m.Update(workBoardSpinMsg{}) + if cmd == nil { + t.Error("the spinner chain stopped mid-action") + } + // …and stops the moment the action answers. + m.Update(workBoardActionMsg{verb: workAbort, id: "crank"}) + if _, cmd := m.Update(workBoardSpinMsg{}); cmd != nil { + t.Error("the spinner chained past the answer") + } +} + +func TestWorkBoard_SecondActionWhileOneIsOutSendsNothing(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + cmd := wbKeys(t, m, "right", "a") + if cmd == nil { + t.Fatal("the abort started nothing") + } + // The first call is still out when the key arrives again. + if cmd2 := wbKeys(t, m, "a"); cmd2 != nil { + t.Error("a second abort was set off while the first was out") + } + if !strings.Contains(m.statusLine, "still aborting") { + t.Errorf("status = %q, want the still-busy line", m.statusLine) + } + // The form is turned the same away, and by the same words. + wbKeys(t, m, "n") + wbKeys(t, m, "later", "enter", "someday", "enter", "./d", "enter", "enter", "enter") + if cmd2 := wbKeys(t, m, "enter"); cmd2 != nil { + t.Error("an add was set off while the abort was out") + } + if !strings.Contains(m.statusLine, "still aborting") { + t.Errorf("status = %q, want the still-busy line", m.statusLine) + } + wbLand(t, m, cmd) +} + +func TestWorkBoard_GeometryKeepsItsFloors(t *testing.T) { + m := workBoardModel{width: 10, height: 4} + if m.visibleCards() != 1 || m.detailCapacity() < 1 || m.formFieldWidth() < 10 { + t.Errorf("a crippled frame starved: cards=%d log=%d field=%d", + m.visibleCards(), m.detailCapacity(), m.formFieldWidth()) + } + if got := (workBoardAction{verb: workRemove, id: "x"}).progress(time.Unix(1700000000, 0)); got == "" || strings.Contains(got, " 0s") { + t.Errorf("an unstarted action reads %q", got) + } + // A form taller than the screen: it stands at the top and loses the + // rows the screen has no room for — it does not panic, and what + // fits is what shows. + m = workBoardModel{width: 64, height: 9} + m.formOpen = true + m.form = newWorkBoardForm(m.formFieldWidth()) + view := wbPlain(m.View()) + if !strings.Contains(view, "new work item") { + t.Errorf("the cramped form lost its title:\n%s", view) + } + if n := len(strings.Split(m.View(), "\n")); n > 9 { + t.Errorf("the cramped frame overflowed its screen: %d lines", n) + } + // The cut rest of a word shorter than the cut is nothing. + if ansiCutRest("short", 10) != "" { + t.Error("cutting past the end left something") + } +} + +func TestWorkBoard_NarrowTerminalDrawsTheCursorColumnAlone(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + }, nil) + m := newWBTestModel(t, a) + m.width = 30 // far too narrow for four columns + wbRound(t, m) + if !m.narrow() { + t.Fatal("30 columns were not called narrow") + } + view := wbPlain(m.View()) + if !strings.Contains(view, "solo") || strings.Contains(view, "crank") { + t.Errorf("narrow board drew more than the cursor's column:\n%s", view) + } + wbKeys(t, m, "right") + view = wbPlain(m.View()) + if !strings.Contains(view, "crank") || strings.Contains(view, "solo") { + t.Errorf("stepping did not carry the narrow board to the next column:\n%s", view) + } + // The form still fits: labels survive the floor width. + wbKeys(t, m, "n") + view = wbPlain(m.View()) + for _, want := range []string{"new work item", "instructions*", "priority"} { + if !strings.Contains(view, want) { + t.Errorf("the narrow form lost %q:\n%s", want, view) + } + } + // Narrower still: the field hits its floor and the form still draws. + wbKeys(t, m, "esc") + m.width = 20 + wbKeys(t, m, "n") + if !strings.Contains(wbPlain(m.View()), "new work item") { + t.Error("the floor-width form did not draw") + } +} + +func TestWorkBoard_PriorityThatCouldNotParseIsCaughtBeforeTheAPI(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "later", "enter", "someday", "enter", "./d", "enter", "enter", "1-3") + cmd := wbKeys(t, m, "enter") // the filter allows 1-3; the parser does not + if cmd != nil { + t.Error("an unparseable priority was sent to the API") + } + if !strings.Contains(m.form.err, "not a number") { + t.Errorf("form carries no fault: %q", m.form.err) + } + if !m.formOpen { + t.Error("the form closed over its own fault") + } + if a.callCount("POST /v1/items") != 0 { + t.Error("the API was asked for the broken item anyway") + } + // Corrected: it goes. + wbKeys(t, m, "backspace", "backspace", "2") + cmd = wbKeys(t, m, "enter") + if cmd == nil { + t.Fatal("the corrected send went nowhere") + } + wbLand(t, m, cmd) + if a.callCount("POST /v1/items") != 1 { + t.Error("the corrected send did not reach the API") + } +} + +func TestWorkBoard_QuitFromTheDiscardQuestion(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "x", "esc") + if !m.formAsk { + t.Fatal("no discard question stood") + } + cmd := wbKeys(t, m, "q") + if cmd == nil || fmt.Sprintf("%T", cmd()) != "tea.QuitMsg" { + t.Error("q from the discard question did not end the program") + } +} + +func TestWorkBoard_AFaultyTailKeepsWhatWasShown(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") + // A fault over an empty pane is the note; over a filled one it + // changes nothing — the rows already shown stand. + m.Update(workBoardTailMsg{gen: m.detailGen, err: errBoardTest}) + if !strings.Contains(wbPlain(m.View()), "the API went quiet") { + t.Error("the empty pane carried no fault") + } + m.Update(workBoardTailMsg{gen: m.detailGen, log: "kept line\n"}) + m.Update(workBoardTailMsg{gen: m.detailGen, err: errBoardTest}) + view := wbPlain(m.View()) + if !strings.Contains(view, "kept line") { + t.Errorf("a fault erased the shown log:\n%s", view) + } + // Stale answers — an older gen — are discarded, not folded in. + m.Update(workBoardTailMsg{gen: m.detailGen + 1, log: "stale\n"}) + if strings.Contains(wbPlain(m.View()), "stale") { + t.Error("a stale tail answer landed") + } +} + +var errBoardTest = fmt.Errorf("the API went quiet") + +func TestWorkBoard_ClockReadingToleratesGarbage(t *testing.T) { + bad := wbItem("solo", orchestrator.StateBacklog) + bad.StartedAt = "not a time" + a := newWBAPI(t, []orchestrator.ItemView{bad}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if got := workBoardClock(bad.StartedAt); got != "-" { + t.Errorf("an unreadable instant rendered %q, want a dash", got) + } + if got := workBoardElapsed(bad.StartedAt, workBoardNow()); got != "" { + t.Errorf("an unreadable start rendered %q, want silence", got) + } + if workBoardClampScroll(-1, 5, 3) != 0 || workBoardClampScroll(9, 0, 3) != 0 { + t.Error("a window escaped its cards") + } + // A word too wide for the pane is cut, not wrapped or dropped. + lines := workBoardWrap("x"+strings.Repeat("y", 200), 10) + for _, l := range lines { + if len(l) > 10 { + t.Errorf("a wrapped line overflowed the width: %q", l) + } + } +} + +func TestWorkBoard_NoURLNamesTheFlag(t *testing.T) { + _, err := runWork(t, "board") + if err == nil || !strings.Contains(err.Error(), "--url") { + t.Fatalf("error = %v, want one naming --url", err) + } +} + +func TestWorkBoard_PipedRunRefusedNamingWorkList(t *testing.T) { + _, err := runWork(t, "board", "--url", "http://127.0.0.1:1", "--api-token", "t") + if err == nil { + t.Fatal("a piped board was not refused") + } + if !strings.Contains(err.Error(), "spinloop work list") { + t.Errorf("error = %q, want it to name spinloop work list", err) + } + if strings.Contains(err.Error(), "--url") { + t.Errorf("error = %q, want the terminal refusal, not the flag one", err) + } +} + +func TestWorkBoard_TwoTokenFlagsRefused(t *testing.T) { + _, err := runWork(t, "board", "--url", "http://127.0.0.1:1", + "--api-token", "a", "--api-token-file", "/dev/null") + if err == nil || !strings.Contains(err.Error(), "--api-token") || !strings.Contains(err.Error(), "--api-token-file") { + t.Fatalf("error = %v, want one naming both token flags", err) + } +} + +// --- columns and cards --- + +func TestWorkBoard_ColdRunDrawsFourEmptyColumns(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := wbPlain(m.View()) + for _, want := range []string{"Backlog 0", "Running 0", "Done 0", "Failed 0", "—"} { + if !strings.Contains(view, want) { + t.Errorf("cold view missing %q:\n%s", want, view) + } + } +} + +func TestWorkBoard_ColumnsHoldTheirStatesWithCounts(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + wbItem("past", orchestrator.StateDone), + wbItem("bust", orchestrator.StateFailed), + wbItem("later", orchestrator.StateBacklog), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := wbPlain(m.View()) + for _, want := range []string{"Backlog 2", "Running 1", "Done 1", "Failed 1"} { + if !strings.Contains(view, want) { + t.Errorf("view missing %q:\n%s", want, view) + } + } + for _, id := range []string{"solo", "crank", "past", "bust", "later"} { + if !strings.Contains(view, id) { + t.Errorf("view missing card %q:\n%s", id, view) + } + } +} + +func TestWorkBoard_RunningCardCarriesNodeElapsedAndStateColour(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + if !strings.Contains(view, "\x1b[33mrunning") { + t.Error("a running card does not carry the amber the work list gives running") + } + plain := wbPlain(view) + if !strings.Contains(plain, "node-1") { + t.Errorf("running card missing node:\n%s", plain) + } + if !strings.Contains(plain, "2m 5s") { + t.Errorf("running card missing elapsed:\n%s", plain) + } + // The count-up is a function of the clock when the card is drawn. + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000+120, 0) }) + later := wbPlain(m.View()) + if !strings.Contains(later, "4m 5s") { + t.Errorf("elapsed did not count up with the clock:\n%s", later) + } +} + +func TestWorkBoard_StateColoursMatchTheWorkList(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("past", orchestrator.StateDone), wbItem("bust", orchestrator.StateFailed), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + if !strings.Contains(view, "\x1b[92mdone") { + t.Error("a done card does not carry the work list's green") + } + if !strings.Contains(view, "\x1b[31mfailed") { + t.Error("a failed card does not carry the work list's red") + } +} + +func TestWorkBoard_LongInstructionsClipNotWrap(t *testing.T) { + long := wbItem("long", orchestrator.StateBacklog) + long.Instructions = strings.Repeat("word ", 80) + "ENDWORD" + a := newWBAPI(t, []orchestrator.ItemView{long}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + plain := wbPlain(view) + if strings.Contains(plain, "ENDWORD") { + t.Error("the clipped tail of the instructions leaked into the card") + } + // The card keeps its shape: the frame's line count is the fixed one. + if n, want := len(strings.Split(view, "\n")), 3+m.visibleCards()*workBoardCardStep; n != want { + t.Errorf("board drew %d lines, want %d:\n%s", n, want, view) + } +} + +func TestWorkBoard_ColumnWindowFollowsTheSelection(t *testing.T) { + var items []orchestrator.ItemView + for i := 0; i < 10; i++ { + items = append(items, wbItem(fmt.Sprintf("item%02d", i), orchestrator.StateBacklog)) + } + a := newWBAPI(t, items, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + first := wbPlain(m.View()) + if !strings.Contains(first, "item00") || strings.Contains(first, "item09") { + t.Errorf("window did not open on the selection:\n%s", first) + } + wbKeys(t, m, "up") // the top of a column is its own wall + if m.cursor[1] != 0 { + t.Errorf("up at the top walked to row %d", m.cursor[1]) + } + wbKeys(t, m, "down", "down", "down", "down", "down", "down") + moved := wbPlain(m.View()) + if !strings.Contains(moved, "item06") { + t.Errorf("moving down did not bring the selection into view:\n%s", moved) + } + if !strings.Contains(moved, "item04") || strings.Contains(moved, "item00") { + t.Errorf("the window did not leave the passed cards behind:\n%s", moved) + } +} + +func TestWorkBoard_ArrowsSkipEmptyColumns(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("wait", orchestrator.StateBacklog), wbItem("gone", orchestrator.StateDone), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right") + if m.cursor[0] != 2 { + t.Errorf("cursor column = %d, want 2 (Running is empty; the cursor lands on Done)", m.cursor[0]) + } + wbKeys(t, m, "right") + if m.cursor[0] != 2 { + t.Errorf("cursor walked past the last card column to %d", m.cursor[0]) + } +} + +func TestWorkBoard_CardMovesBetweenReads(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if v := strings.Count(wbPlain(m.View()), "Running 1"); v != 1 { + t.Fatal("crank was not running before the move") + } + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + view := wbPlain(m.View()) + if !strings.Contains(view, "Running 0") || !strings.Contains(view, "Done 1") { + t.Errorf("the card did not move with the run:\n%s", view) + } +} + +// --- refresh and staleness --- + +func TestWorkBoard_DroppedAPIAgesTheBoardAndRecovers(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + a.failGET = true + wbRound(t, m) + // The reading has now aged past three cadences: the board is still + // alive, still drawn, and honest about its age. + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000+4*3600, 0) }) + view := wbPlain(m.View()) + if !strings.Contains(view, "solo") { + t.Errorf("a failed round emptied the board:\n%s", view) + } + if !strings.Contains(view, "reading") || !strings.Contains(view, "ago") { + t.Errorf("the stale reading is not marked with its age:\n%s", view) + } + a.failGET = false + wbRound(t, m) + if strings.Contains(wbPlain(m.View()), " ago") { + t.Error("the age mark survived a good read") + } +} + +func TestWorkBoard_RReadsAtOnce(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + before := a.callCount("GET /v1/items") + cmd := wbKeys(t, m, "r") + if cmd == nil { + t.Fatal("r started no round") + } + wbLand(t, m, cmd) + if a.callCount("GET /v1/items") != before+1 { + t.Error("r did not ask the API at once") + } +} + +func TestWorkBoard_QuitKeysEndTheProgram(t *testing.T) { + for _, k := range []string{"q", "ctrl+c"} { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + cmd := wbKeys(t, m, k) + if cmd == nil { + t.Fatalf("%q quit nothing", k) + } + if got := fmt.Sprintf("%T", cmd()); got != "tea.QuitMsg" { + t.Errorf("%q sent %s, want the quit message", k, got) + } + } +} + +// --- detail and tail --- + +func TestWorkBoard_DetailShowsTheWholeFailedItem(t *testing.T) { + failed := wbItem("bust", orchestrator.StateFailed) + failed.Instructions = strings.Repeat("detail ", 40) + "FULLSTOP" + failed.Tags = []string{"kind=fix", "area=api"} + a := newWBAPI(t, []orchestrator.ItemView{failed}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + mb := wbKeys(t, m, "right", "right", "right", "enter") // to the Failed column, then in + if mb == nil { + t.Fatal("enter opened nothing") + } + // The batch's tail chain is never run by hand — its tick is the test's + // hour-long stand-in for the live one — but the round it started is + // driven directly below by the tail tests. + if !m.detail { + t.Fatal("the detail did not open") + } + view := wbPlain(m.View()) + for _, want := range []string{"FULLSTOP", "./bust", "kind=fix", "area=api", "the agent gave up", "esc back"} { + if !strings.Contains(view, want) { + t.Errorf("detail missing %q:\n%s", want, view) + } + } +} + +func TestWorkBoard_DetailKeepsTallInstructionsInsideTheFrame(t *testing.T) { + big := wbItem("bust", orchestrator.StateFailed) + big.Instructions = strings.Repeat("sentence that keeps going and going. ", 120) + "ENDSTOP" + a := newWBAPI(t, []orchestrator.ItemView{big}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + view := wbPlain(m.View()) + lines := strings.Split(view, "\n") + // Nothing may fall past the bottom edge unseen: the frame closes at + // the terminal's height, footer and all. + if len(lines) != m.effHeight() { + t.Errorf("the detail drew %d lines in a frame of %d:\n%s", len(lines), m.effHeight(), view) + } + for _, want := range []string{"esc back", "./bust", "⋯ +"} { + if !strings.Contains(view, want) { + t.Errorf("detail missing %q — the frame did not close honestly:\n%s", want, view) + } + } + // What does not fit is counted, not silently shown in part: the + // tail of the instructions yields to the note. + if strings.Contains(view, "ENDSTOP") { + t.Error("the overflow past the note still showed its tail") + } +} + +func TestWorkBoard_DetailWrapsTheFailureReason(t *testing.T) { + failed := wbItem("bust", orchestrator.StateFailed) + // A reason far wider than the frame: its tail must still be readable, + // not chopped at the edge — the whole point of wrapping it. + failed.Why = "the agent gave up because " + strings.Repeat("the harness demanded more ", 20) + "ENDREASON" + a := newWBAPI(t, []orchestrator.ItemView{failed}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + lines := strings.Split(wbPlain(m.View()), "\n") + if !strings.Contains(strings.Join(lines, "\n"), "ENDREASON") { + t.Fatalf("the reason was cut off at the frame's edge:\n%s", strings.Join(lines, "\n")) + } + // The reason holds together on its own rows under the label, and no + // line overruns the frame it is clipped to. + var reasonRows int + for _, l := range lines { + if strings.HasPrefix(l, "why") || strings.HasPrefix(l, " ") { + if strings.Contains(l, "harness demanded") || strings.Contains(l, "agent gave up") || strings.Contains(l, "ENDREASON") { + reasonRows++ + } + } + } + if reasonRows < 2 { + t.Errorf("the reason did not wrap across rows (saw %d):\n%s", reasonRows, strings.Join(lines, "\n")) + } + for _, l := range lines { + if n := lipgloss.Width(l); n > m.effWidth() { + t.Errorf("a detail line ran to %d columns, past the frame of %d:\n%q", n, m.effWidth(), l) + } + } +} + +func TestWorkBoard_DetailEscReturnsAndRefusesQuit(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("bust", orchestrator.StateFailed)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + cmd := wbKeys(t, m, "q") + if cmd != nil { + if got := fmt.Sprintf("%T", cmd()); got == "tea.quitMsg" { + t.Error("the board quit from inside the detail") + } + } + if !m.detail { + t.Error("q disturbed the detail") + } + wbKeys(t, m, "esc") + if m.detail { + t.Error("esc did not return to the board") + } + if m.cursor[0] != 3 || m.cursor[1] != 0 { + t.Errorf("the selection moved: %v", m.cursor) + } +} + +func TestWorkBoard_DetailTailsAndStops(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, + map[string]string{"crank": "first line\n"}) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") // openDetail starts the first round itself + + // A first fetch shows what is there; an empty pane is a note, not a + // fault. The answer below is the one openDetail's started round is + // answering with — injected as the program would deliver it. + m.Update(workBoardTailMsg{gen: m.detailGen, log: "first line\n"}) + if !strings.Contains(wbPlain(m.View()), "first line") { + t.Error("the tail did not show the kept output") + } + // The agent writes more: the next poll appends only what is new. + a.setLog("crank", "first line\nsecond line\n") + msg := m.startTailRound() + if msg == nil { + t.Fatal("the tail did not poll again") + } + m.Update(msg()) + view := wbPlain(m.View()) + if !strings.Contains(view, "second line") || strings.Count(view, "first line") != 1 { + t.Errorf("the tail did not append the suffix once:\n%s", view) + } + // The item ends: the tail stops, whatever the last poll brought standing. + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + if cmd := m.startTailRound(); cmd != nil { + t.Error("the tail kept polling an ended item") + } +} + +func TestWorkBoard_DetailOfAnItemWithNoLogIsEmptyNotAFault(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") + m.Update(workBoardTailMsg{gen: m.detailGen, log: ""}) + view := wbPlain(m.View()) + if !strings.Contains(view, "no kept output yet") { + t.Errorf("empty pane without a note:\n%s", view) + } + // The item is removed mid-view: the next poll 404s, and the tail ends + // with a note — not with a fault, and not with the pane going blank. + a.mu.Lock() + a.items = nil + a.mu.Unlock() + wbRound(t, m) + msg := m.startTailRound() + if msg == nil { + t.Fatal("the tail did not poll once more to find the item gone") + } + m.Update(msg()) + view = wbPlain(m.View()) + if !strings.Contains(view, "no longer in the list") { + t.Errorf("gone item left no note:\n%s", view) + } +} + +// --- actions --- + +func TestWorkBoard_AbortMovesTheCardBackToBacklog(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "right", "a") + if a.callCount("POST /v1/items/crank/abort") != 1 { + t.Fatal("the abort did not reach the API") + } + if !strings.Contains(m.statusLine, "back in the backlog") { + t.Errorf("status = %q, want the stopped line", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "Running 0") || !strings.Contains(view, "Backlog 1") { + t.Errorf("the card did not move back:\n%s", view) + } +} + +func TestWorkBoard_BacklogAbortIsRefusedTheAPISWay(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "a") + if !strings.Contains(m.statusLine, `item "solo" is not running`) { + 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_RemovalAsksFirst(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "x") + footer := wbPlain(m.footerLine(m.effWidth(), m.boardKeys())) + if !strings.Contains(footer, `remove item "solo"?`) { + t.Errorf("the question did not stand: %q", footer) + } + if a.callCount("DELETE") != 0 { + t.Error("the removal was sent before the yes") + } + wbKeys(t, m, "n") + if a.callCount("DELETE") != 0 { + t.Error("a declined removal was sent anyway") + } + if !strings.Contains(m.statusLine, "nothing removed") { + t.Errorf("status = %q, want the declined line", m.statusLine) + } + if !strings.Contains(wbPlain(m.View()), "solo") { + t.Error("the declined card vanished") + } +} + +func TestWorkBoard_RemovalOnYesGoesThrough(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), wbItem("later", orchestrator.StateBacklog), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "x", "y") + if a.callCount("DELETE /v1/items/solo") != 1 { + t.Fatal("the yes sent no DELETE") + } + if !strings.Contains(m.statusLine, `"solo" removed`) { + t.Errorf("status = %q", m.statusLine) + } + view := wbPlain(m.View()) + if strings.Contains(view, "● solo") || !strings.Contains(view, "later") || !strings.Contains(view, "Backlog 1") { + t.Errorf("the board outlived its card:\n%s", view) + } +} + +func TestWorkBoard_RunningRemovalRefusedNamingTheAbort(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "right", "x", "y") + if !strings.Contains(m.statusLine, "abort it first") { + t.Errorf("status = %q, want the refusal naming the abort", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "● crank") || !strings.Contains(view, "Running 1") { + t.Errorf("the refused removal took the card away:\n%s", view) + } +} + +// --- the add form --- + +func TestWorkBoard_FormOpensSizedAndStill(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + if !m.formOpen { + t.Fatal("n opened no form") + } + for i, f := range m.form.fields { + if f.Cursor.Blink { + t.Errorf("field %d blinks on its own", i) + } + } + view := wbPlain(m.View()) + for _, want := range []string{"new work item", "id*", "instructions*", "dir*", "tags", "priority", "❯"} { + if !strings.Contains(view, want) { + t.Errorf("form missing %q:\n%s", want, view) + } + } + // Byte-stable with the clock fixed: nothing moves on its own. + if m.View() != m.View() { + t.Error("the form's view is not byte-stable") + } +} + +func TestWorkBoard_PriorityFieldTakesOnlyNumbers(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + calls := len(a.calls) + wbKeys(t, m, "down", "down", "down", "down") // to priority + wbKeys(t, m, "12") + wbKeys(t, m, "abc") + wbKeys(t, m, "3") + if got := m.form.fields[workFormPriority].Value(); got != "123" { + t.Errorf("priority field = %q, want 123 — the letters must not stand", got) + } + if len(a.calls) != calls { + t.Error("keystrokes called the API") + } +} + +func TestWorkBoard_FormAddsEndToEnd(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "karma", "enter", "make it so", "enter", "./repo", "enter", "enter") + cmd := wbKeys(t, m, "7", "enter") // enter on the last field sends + if cmd == nil { + t.Fatal("the last enter sent nothing") + } + wbLand(t, m, cmd) + if a.callCount("POST /v1/items") != 1 { + t.Fatal("the send reached no POST") + } + body := a.lastBody() + if body.ID != "karma" || body.Instructions != "make it so" || body.Dir != "./repo" || body.Priority != 7 { + t.Errorf("the API was sent %+v", body) + } + if m.formOpen { + t.Error("the accepted form did not close") + } + if !strings.Contains(m.statusLine, `"karma" added`) { + t.Errorf("status = %q", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "Backlog 2") || !strings.Contains(view, "karma") { + t.Errorf("the new card does not stand under Backlog:\n%s", view) + } +} + +func TestWorkBoard_FormRefusalIsCorrectedInPlace(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("dup", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "dup", "enter", "again", "enter", "./d", "enter", "enter") + cmd := wbKeys(t, m, "enter") // send with the id the list already carries + if cmd == nil { + t.Fatal("the first enter sent nothing") + } + wbLand(t, m, cmd) // a refused send still spends its follow-up read + if !m.formOpen { + t.Fatal("a refusal closed the form") + } + if !strings.Contains(m.form.err, `item "dup" is already in the list`) { + t.Errorf("form carries no API refusal: %q", m.form.err) + } + // Correct the id in the form and send again: up to the first field, + // rub it out, type the new one, walk back down. + wbKeys(t, m, "up", "up", "up", "up") + wbKeys(t, m, "backspace", "backspace", "backspace", "fresh") + cmd = wbKeys(t, m, "down", "down", "down", "down", "enter") + if cmd == nil { + t.Fatal("the second enter sent nothing") + } + wbLand(t, m, cmd) + if m.formOpen { + t.Error("the corrected send did not close the form") + } + if a.callCount("POST /v1/items") != 2 { + t.Errorf("POSTs = %d, want 2", a.callCount("POST /v1/items")) + } + if a.lastBody().ID != "fresh" { + t.Errorf("the second send was %+v", a.lastBody()) + } + if !strings.Contains(wbPlain(m.View()), "Backlog 2") { + t.Error("the corrected item never reached the board") + } +} + +func TestWorkBoard_FormEscapeGuard(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + // Empty: esc closes and says so, sending nothing. + wbKeys(t, m, "n", "esc") + if m.formOpen { + t.Error("esc did not close an empty form") + } + if !strings.Contains(m.statusLine, "nothing added") { + t.Errorf("status = %q", m.statusLine) + } + // Typed: esc asks once; declining keeps everything; discarding sends nothing. + wbKeys(t, m, "n") + wbKeys(t, m, "half a thought", "esc") + if !m.formOpen || !m.formAsk { + t.Fatal("esc on typed text neither kept the form nor asked") + } + wbKeys(t, m, "n") + if !m.formOpen || m.formAsk || m.form.fields[0].Value() != "half a thought" { + t.Error("the kept form lost its place or its text") + } + wbKeys(t, m, "esc", "y") + if m.formOpen { + t.Error("a deliberate discard did not close the form") + } + if a.callCount("POST /v1/items") != 0 { + t.Error("the form sent anything") + } +} + +func TestWorkBoard_BoardLivesBehindTheForm(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + view := wbPlain(m.View()) + if !strings.Contains(view, "new work item") { + t.Error("the round closed the form") + } + if !strings.Contains(view, "Done 1") { + t.Errorf("the board behind the form stopped moving:\n%s", view) + } + wbKeys(t, m, "esc") + if !strings.Contains(wbPlain(m.View()), "Done 1") { + t.Error("the card did not stand where the board was left") + } +} + +// --- completion and the program itself --- + +func TestWorkBoard_CompletionIsQuietAndTakeNoPositionals(t *testing.T) { + cands, directive := completeQuiet(t, "work", "board", "--") + if !hasAll(cands, "--url", "--api-token", "--api-token-file") { + t.Errorf("flags offered = %v", cands) + } + cands, directive = completeQuiet(t, "work", "board", "some-id") + if len(cands) != 0 { + t.Errorf("positionals offered: %v", cands) + } + if directive != ":4" { // NoFileComp: an id is never a path here + t.Errorf("directive = %q, want :4", directive) + } +} + +// wbFeed reads the live program's frames into one accumulated screen, +// colour stripped: a board that has nothing new to say stops redrawing, +// so a per-frame reader would miss words it needed — the whole screen +// read so far is the honest needle ground. +type wbFeed struct { + mu sync.Mutex + plain strings.Builder + done chan struct{} + closed bool +} + +func newWBFeed(t *testing.T, out io.Reader) *wbFeed { + t.Helper() + f := &wbFeed{done: make(chan struct{})} + go func() { + buf := make([]byte, 32*1024) + for { + select { + case <-f.done: + return + default: + } + n, err := out.Read(buf) + if n > 0 { + f.mu.Lock() + f.plain.WriteString(wbPlain(string(buf[:n]))) + f.mu.Unlock() + } + if err != nil && err != io.EOF { + return // the program is done; what was read is all there is + } + // teatest's output is a buffer: empty is not closed. The + // next frame is a poll away, so idle gently and keep reading. + time.Sleep(20 * time.Millisecond) + } + }() + t.Cleanup(func() { close(f.done) }) + return f +} + +func (f *wbFeed) until(t *testing.T, what string) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + f.mu.Lock() + got := strings.Contains(f.plain.String(), what) + f.mu.Unlock() + if got { + return + } + time.Sleep(20 * time.Millisecond) + } + f.mu.Lock() + screen := f.plain.String() + f.mu.Unlock() + if n := len(screen); n > 1200 { + screen = screen[n-1200:] + } + t.Fatalf("the board never showed %q — screen so far:\n%s", what, screen) +} + +func TestWorkBoard_ProgramSmoke(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + }, nil) + restore := []func(){ + setVar(&workBoardRefreshInterval, 50*time.Millisecond), + setVar(&workBoardNow, time.Now), + } + defer func() { + for _, f := range restore { + f() + } + }() + m := newWorkBoardModel(a.srv.URL, "tok") + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(140, 30)) + feed := newWBFeed(t, tm.Output()) + feed.until(t, "Backlog 1") + feed.until(t, "crank") + // The real loop now, not the test's hand: an abort's call and its + // spinner tick ride a batch, which only the program itself expands + // — the answer lands on the status line and the kicked read moves + // the card through the very loop the binary runs. + tm.Send(tea.KeyMsg{Type: tea.KeyRight}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + feed.until(t, `item "crank" stopped`) + feed.until(t, "Backlog 2") + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) +} diff --git a/docs/commands/index.md b/docs/commands/index.md index f7a30547..336b7127 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -18,7 +18,7 @@ help` the usage summary. | [`spinloop fleet`](fleet.md) | Drive the engines on every machine you run: start, stop, deploy, route | | [`spinloop gateway`](gateway.md) | Serve the fleet under one OpenAI-compatible endpoint | | [`spinloop orchestrator`](orchestrator.md) | Work a backlog of items against the fleet, at the fleet's declared pace | -| [`spinloop work`](work.md) | Drive the orchestrator's work list from the shell: add, list, logs, abort, remove | +| [`spinloop work`](work.md) | Drive the orchestrator's work list from the shell: add, list, logs, abort, remove — or watch it live on `board` | | [`spinloop remote`](remote.md) | Run the model on a cloud GPU that stops when you do | | [`spinloop hf`](hf.md) | Write a `Spinloop` for a Hugging Face model, from its page reference | | [`spinloop alias`](alias.md) | Name a `Spinloop` so the name works anywhere a path does | diff --git a/docs/commands/work.md b/docs/commands/work.md index 9046f9b5..2c2f6174 100644 --- a/docs/commands/work.md +++ b/docs/commands/work.md @@ -3,7 +3,8 @@ Work the [orchestrator](orchestrator.md)'s work list — the backlog it works — from the shell, as a client of the [work list API](orchestrator.md#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. +output, stop a running item, remove an item — or watch the whole run on a +live board. ```sh spinloop work add --url http://127.0.0.1:4010 --id fix-parser --instructions "fix the failing tests" --dir ./parser @@ -11,6 +12,7 @@ spinloop work list --url http://127.0.0.1:4010 spinloop work logs --url http://127.0.0.1:4010 fix-parser -f spinloop work abort --url http://127.0.0.1:4010 fix-parser spinloop work remove --url http://127.0.0.1:4010 docs-refresh +spinloop work board --url http://127.0.0.1:4010 ``` Each subcommand takes `--url`, the API's base address — the one the @@ -129,6 +131,53 @@ A running item cannot be removed: the refusal names it and the abort that goes first. An id the file does not carry is refused, naming it. The API answers once the item is out, and the command reports its answer. +## Watching the board + +```sh +spinloop work board --url http://127.0.0.1:4010 +``` + +The board: the same work list as a live kanban — four columns, +`Backlog`, `Running`, `Done`, `Failed`, a card per item — re-read from +the API on a cadence, so a card moves as the run works it. It is a +client of the API like every other command here: it reads no file and +writes no file, and every action it offers goes through the same paths +the one-shot commands call. + +Each card carries the item's id, its instructions clipped to the card's +width, and its priority where it has one; a running card adds the node +it is on and how long it has been up, counting up as you watch. States +wear the colours `work list` gives them. + +Keys are offered in the footer only where they would do something for +what the cursor stands on: + +- `↑`/`↓`/`←`/`→` move the selection — sideways to the next column + holding a card. +- `enter` opens the item's detail: its full instructions, dir, tags, + timings and failure reason, with its kept output tailed beneath as + `work logs -f` tails it, ending when the item ends or drops out. + `esc` returns; the board cannot be quit from inside the detail. +- `a` aborts a running item, `x` removes one that is not — the removal + asks first, and declining sends nothing. A refusal from the API + reads on the status line the way the API states it. +- `n` opens the add form — the same add `work add` sends, through the + API's add path. Its five fields stand before you at once (id, + instructions and dir marked required); `up`/`down` step the field + cursor, `enter` advances, and on the last field sends. The form + keeps the API's refusal visible for a corrected send; `esc` closes + an empty form and, with anything typed, asks before discarding. + The board keeps moving behind it. +- `r` reads again at once; `q` or `Ctrl+C` leaves. + +When the API goes quiet the board does not go with it: it keeps +drawing its last reading and marks the title bar with its age until a +good read returns. + +The board needs an interactive terminal. Piped or redirected, it +refuses and names `spinloop work list` as the command for the same +work into a pipe. + ## What it does not do - It reads no file and writes no file: the commands never touch the items file, @@ -145,7 +194,7 @@ once the item is out, and the command reports its answer. | Flag | Meaning | | ---- | ------- | -| `--url
` | The work list API's base address — `add`, `list`, `logs`, `abort`, `remove` | +| `--url ` | The work list API's base address — `add`, `list`, `logs`, `abort`, `remove`, `board` | | `--api-token