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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/spinloop/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@ func noPositionals(_ *cobra.Command, _ []string, _ string) ([]string, cobra.Shel
return nil, cobra.ShellCompDirectiveNoFileComp
}

// itemIDSlot is the work subcommands' own positional — an item's id. There
// is no local registry to draw candidates from the way aliasSlot has
// aliasNames (an id only means anything against a specific run's work
// list, reached over the network, which completion does not call out to):
// it offers none, only suppressing file completion so typing an id is not
// interrupted by filename suggestions that are never valid ids.
func itemIDSlot(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}

// aliasSlot is the Spinloop slot: registered names plus a path, for the first
// positional only.
func aliasSlot(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
Expand Down
19 changes: 19 additions & 0 deletions cmd/spinloop/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,25 @@ func TestComplete_UnaliasOffersAliasNames(t *testing.T) {
}
}

// TestComplete_WorkItemIDSlotsOfferNoFilePaths checks work logs/abort/
// remove's own positional: no registry to draw a real item id from, so it
// offers no candidates, but must still suppress file completion — an id is
// never a filename, and before this the slot fell through to the default,
// offering the working directory's own files.
func TestComplete_WorkItemIDSlotsOfferNoFilePaths(t *testing.T) {
isolateConfig(t)

for _, sub := range []string{"logs", "abort", "remove"} {
got, directive := complete(t, "work", sub, "")
if len(got) != 0 {
t.Errorf("work %s: candidates offered where there is no registry to draw from: %v", sub, got)
}
if directive != directiveNoFile {
t.Errorf("work %s: directive = %q, want %q (an id is never a file path)", sub, directive, directiveNoFile)
}
}
}

// TestComplete_SpinloopCommandsOfferAliasesAndPaths checks the commands that take
// either.
func TestComplete_SpinloopCommandsOfferAliasesAndPaths(t *testing.T) {
Expand Down
31 changes: 23 additions & 8 deletions cmd/spinloop/work.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
// The `work` command family: the orchestrator's work list — the backlog the
// orchestrator works — worked from the shell as a client of the work list API
// the orchestrator serves: add an item, read the work, stop a running item,
// remove an item. The commands name the API's address and present 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 orchestrator serves: add an item, read the work, read an item's kept
// output, stop a running item, remove an item. The commands name the API's
// address and present its token; the run's view of the items is the source
// of truth, and a refusal reads the way the API states it.
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -40,7 +39,8 @@ func workCmd() *cobra.Command {
Short: "work the orchestrator's work list",
Long: `works the orchestrator's work list — the backlog it works — from
the shell, as a client of the work list API the orchestrator serves: add an
item, read the work, stop a running item, remove an item.
item, read the work, read an item's kept output, stop a running item,
remove an item.

Each subcommand takes --url, the API's base address, and presents the API's
token — from --api-token, else --api-token-file, else the SPINLOOP_API_TOKEN
Expand All @@ -54,6 +54,7 @@ fails before it calls the API, naming the flag.`,
workListCmd(),
workAbortCmd(),
workRemoveCmd(),
workLogsCmd(),
)
return c
}
Expand Down Expand Up @@ -122,6 +123,17 @@ func workRequest(base, token, method, path string, body any) ([]byte, error) {
return data, nil
}

// workAPIErr is a non-2xx answer from the work list API: Error() reads the
// way the API states the refusal, and status is kept so a caller that
// cares — a follow noticing its item was removed — can tell a "not found"
// apart from any other refusal without parsing the message.
type workAPIErr struct {
status int
msg string
}

func (e *workAPIErr) Error() string { return e.msg }

// workAPIError is a non-2xx answer from the work list API, as the error the
// command reports: the API's message where it states one — the refusal reads
// the way the API states it — the status and the address where it does not.
Expand All @@ -131,10 +143,11 @@ func workAPIError(base string, status int, data []byte) error {
Message string `json:"message"`
} `json:"error"`
}
msg := fmt.Sprintf("the work list API at %s answered %d", base, status)
if err := json.Unmarshal(data, &out); err == nil && out.Error.Message != "" {
return errors.New(out.Error.Message)
msg = out.Error.Message
}
return fmt.Errorf("the work list API at %s answered %d", base, status)
return &workAPIErr{status: status, msg: msg}
}

// workAddBody is one item as the work list API takes it: the fields the items
Expand Down Expand Up @@ -366,6 +379,7 @@ the command reports its answer: a refusal reads the way the API states it.`,
}
fs := c.Flags()
workAPIFlags(fs, &base, &apiToken, &apiTokenFile)
c.ValidArgsFunction = itemIDSlot
return c
}

Expand Down Expand Up @@ -401,5 +415,6 @@ reads the way the API states it.`,
}
fs := c.Flags()
workAPIFlags(fs, &base, &apiToken, &apiTokenFile)
c.ValidArgsFunction = itemIDSlot
return c
}
182 changes: 182 additions & 0 deletions cmd/spinloop/work_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/spinloop-ai/spinloop/internal/orchestrator"
)

// workLogsInterval is how often a follow asks for more output, and checks
// the item's own state. A variable so tests need not wait on it.
var workLogsInterval = 1 * time.Second

// workLogsCmd builds `work logs`.
func workLogsCmd() *cobra.Command {
var base, apiToken, apiTokenFile string
var follow bool
c := &cobra.Command{
Use: "logs <id>",
Short: "print an item's kept agent output",
Long: `prints an item's kept agent output, through the work list API the
orchestrator serves. An item with no output yet is printed as empty, not a
fault, and an id the run does not carry is refused, naming it, the way the
API states it.

-f/--follow polls for new output and prints it as it arrives, the way
tail -f does: it keeps polling through backlog and running — an item
named before, or just as, it starts is still followed — and stops once
the item is done or failed, or the operator interrupts.`,
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
b, token, err := workTarget("work logs", base, apiToken, apiTokenFile)
if err != nil {
return err
}
if follow {
return followWorkLogs(b, token, id)
}
return runWorkLogsOnce(b, token, id, os.Stdout)
},
}
fs := c.Flags()
workAPIFlags(fs, &base, &apiToken, &apiTokenFile)
fs.BoolVarP(&follow, "follow", "f", false, "keep printing new output as it arrives")
c.ValidArgsFunction = itemIDSlot
return c
}

// runWorkLogsOnce prints an item's kept output once.
func runWorkLogsOnce(base, token, id string, w io.Writer) error {
log, err := workLogFetch(base, token, id)
if err != nil {
return err
}
fmt.Fprint(w, log)
return nil
}

// workLogReply is the work list API's own answer shape for an item's log:
// carrying no field at all where there is none yet, the way handleLog
// answers `"log": null`.
type workLogReply struct {
Log *string `json:"log"`
}

// workLogFetch reads an item's kept output through the work list API. An
// item with none yet answers "", not an error.
func workLogFetch(base, token, id string) (string, error) {
data, err := workRequest(base, token, http.MethodGet, "/v1/items/"+url.PathEscape(id)+"/log", nil)
if err != nil {
return "", err
}
var out workLogReply
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("reading item %q's log from the work list API at %s: %w", id, base, err)
}
if out.Log == nil {
return "", nil
}
return *out.Log, nil
}

// workItemState reads one item's state from the work list API's own list:
// found and its state where the list still carries the id, found false
// where it has dropped out of the list entirely — a removal.
func workItemState(base, token, id string) (state string, found bool, err error) {
data, err := workRequest(base, token, http.MethodGet, "/v1/items", nil)
if err != nil {
return "", false, err
}
var out struct {
Data []orchestrator.ItemView `json:"data"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", false, fmt.Errorf("reading the work list from the work list API at %s: %w", base, err)
}
for _, v := range out.Data {
if v.ID == id {
return v.State, true, nil
}
}
return "", false, nil
}

// followWorkLogs polls an item's log and its own state until it ends, or
// the operator interrupts.
func followWorkLogs(base, token, id string) error {
return followUntilInterrupted(func(ctx context.Context) error {
return followWorkLogsLoop(ctx, base, token, id, os.Stdout)
})
}

// followWorkLogsLoop is the polling itself, with the interrupt wiring left
// to its caller so it can be driven directly. Each tick fetches the log,
// prints what is new, then checks the item's own state: the loop keeps
// going while it is backlog or running, and ends — after that tick's log
// poll, so nothing written right at the end is missed — once the state is
// done or failed, or the item has dropped out of the list entirely (a
// removal mid-follow), which ends the follow the same clean way.
func followWorkLogsLoop(ctx context.Context, base, token, id string, w io.Writer) error {
var last string
for {
log, err := workLogFetch(base, token, id)
if err != nil {
if workItemGone(err) {
return nil
}
return err
}
last = printWorkLogSuffix(w, log, last)

state, found, err := workItemState(base, token, id)
if err != nil {
return err
}
if !found || (state != orchestrator.StateBacklog && state != orchestrator.StateRunning) {
return nil
}

select {
case <-ctx.Done():
return nil
case <-time.After(workLogsInterval):
}
}
}

// printWorkLogSuffix prints whatever of log is new since last, and returns
// log as the new "last" for the next call: where log still starts with
// last, only the suffix beyond it is new; otherwise — the kept output
// changed underneath, which does not happen in an item's own lifetime, but
// is not worth crashing on — log is printed in full, as if nothing had
// been printed yet.
func printWorkLogSuffix(w io.Writer, log, last string) string {
if strings.HasPrefix(log, last) {
fmt.Fprint(w, log[len(last):])
} else {
fmt.Fprint(w, log)
}
return log
}

// workItemGone reports whether err is the work list API's own "id not
// carried" refusal (a 404) — what a removal mid-follow leaves behind. A
// follow reads that as a clean end, not a failure.
func workItemGone(err error) bool {
var apiErr *workAPIErr
return errors.As(err, &apiErr) && apiErr.status == http.StatusNotFound
}
Loading
Loading