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
2 changes: 1 addition & 1 deletion cmd/spinloop/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
// path readSpinloop resolves as SPINLOOP_ALIAS > ./Spinloop.
fs.Lookup("spinloop").NoOptDefVal = "true"
fs.StringVar(&providers, "providers", "", "path to a providers.yaml override")
fs.StringVar(&route.fleetPath, "fleet", "", "route through this fleet file (overrides the Spinloop's FLEET)")
fs.StringVarP(&route.fleetPath, "fleet", "f", "", "route through this fleet file (overrides the Spinloop's FLEET)")
fs.StringVar(&route.node, "node", "", "pin the launch to this fleet node")
fs.StringVar(&route.prefer, "prefer", "", "rank fleet nodes by `idle` or `active` (overrides the fleet file)")
fs.BoolVar(&route.noWake, "no-wake", false, "fail rather than starting an engine on an idle fleet node")
Expand Down
3 changes: 2 additions & 1 deletion cmd/spinloop/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ var harnessValueFlags = map[string]bool{
"-H": true,
"--providers": true,
"--fleet": true,
"-f": true,
"--node": true,
"--prefer": true,
"--wake-timeout": true,
Expand Down Expand Up @@ -275,7 +276,7 @@ func harnessSlot(_ *cobra.Command, args []string, toComplete string) ([]string,
switch last {
case "--set", "--harness", "-H":
return harness.Names(), cobra.ShellCompDirectiveNoFileComp
case "--providers", "--fleet":
case "--providers", "--fleet", "-f":
return nil, cobra.ShellCompDirectiveDefault
case "--node", "--prefer", "--wake-timeout":
return nil, cobra.ShellCompDirectiveNoFileComp
Expand Down
4 changes: 4 additions & 0 deletions cmd/spinloop/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ func TestComplete_FlagValues(t *testing.T) {
if _, directive := complete(t, "apply", "--providers", ""); directive != directiveFile {
t.Errorf("--providers should complete paths, got %q", directive)
}
// -f is the short form of the fleet file on harness: it completes paths.
if _, directive := complete(t, "harness", "-f", ""); directive != directiveFile {
t.Errorf("harness -f should complete paths, got %q", directive)
}
if got, _ := complete(t, "completion", ""); !hasAll(got, "bash", "zsh", "powershell") {
t.Errorf("shells missing from %v", got)
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/spinloop/fleet.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func fleetStatusCmd() *cobra.Command {
return nil
},
}
c.Flags().StringVar(&path, "fleet", "", fleetFileUsage)
c.Flags().StringVarP(&path, "fleet", "f", "", fleetFileUsage)
c.ValidArgsFunction = noPositionals
compRegister(c, "fleet", compFiles)
return c
Expand Down Expand Up @@ -133,7 +133,7 @@ func fleetMetricsCmd() *cobra.Command {
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
fs.StringVar(&format, "format", "bar", "output format: bar (default), table or json")
fs.BoolVarP(&watch, "watch", "w", false, "redraw the fleet every 60 seconds")
c.ValidArgsFunction = noPositionals
Expand Down Expand Up @@ -278,7 +278,7 @@ func fleetStartCmd() *cobra.Command {
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
fs.BoolVar(&all, "all", false, "start every node in the fleet")
c.ValidArgsFunction = noPositionals
compRegister(c, "fleet", compFiles)
Expand Down Expand Up @@ -313,7 +313,7 @@ func fleetStopCmd() *cobra.Command {
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
fs.BoolVar(&all, "all", false, "stop every node in the fleet")
c.ValidArgsFunction = noPositionals
compRegister(c, "fleet", compFiles)
Expand Down Expand Up @@ -440,7 +440,7 @@ derivation, consent, and registration behavior as "spinloop remote deploy".`,
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
fs.BoolVar(&all, "all", false, "deploy every kind: remote node in the fleet")
fs.BoolVarP(&dryRun, "dry-run", "n", false, "print the config that would be deployed, without sending it")
fs.BoolVar(&overwrite, "overwrite", false, "proceed against an already-registered or live environment")
Expand Down Expand Up @@ -741,7 +741,7 @@ func fleetRouteCmd() *cobra.Command {
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
fs.StringVar(&node, "node", "", "report this node rather than choosing one")
fs.StringVar(&prefer, "prefer", "", "rank nodes by `idle` or `active` (overrides the fleet file)")
c.ValidArgsFunction = aliasSlot
Expand Down
2 changes: 1 addition & 1 deletion cmd/spinloop/fleet_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ metrics --watch instead.`,
},
}
fs := c.Flags()
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage)
c.ValidArgsFunction = noPositionals
compRegister(c, "fleet", compFiles)
return c
Expand Down
3 changes: 3 additions & 0 deletions cmd/spinloop/fleet_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func fleetLogsCmd() *cobra.Command {
},
}
fs := c.Flags()
// --fleet takes no short form here: -f is already --follow, and a flag
// cannot carry two meanings on one command line. Every other fleet
// subcommand offers -f for the fleet file.
fs.StringVar(&path, "fleet", "", fleetFileUsage)
fs.BoolVarP(&follow, "follow", "f", false, followUsage)
fs.IntVar(&limit, "limit", 200, "lines of backlog to print per node")
Expand Down
76 changes: 76 additions & 0 deletions cmd/spinloop/fleet_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -79,6 +82,79 @@ func TestCmdFleetLogsLabelsSeveralNodes(t *testing.T) {
}
}

// -f is the follow flag on logs, so it sits on one line with --fleet, which
// alone names the fleet file.
func TestCmdFleetLogsFollowTakesTheFleetFile(t *testing.T) {
prev := fleetLogsInterval
fleetLogsInterval = 50 * time.Millisecond
t.Cleanup(func() { fleetLogsInterval = prev })

var mu sync.Mutex
polls := 0
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/logs", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
polls++
mu.Unlock()
json.NewEncoder(w).Encode(daemon.LogsResponse{Content: "hello\n", NextOffset: 6, Size: 6})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
host, port := hostPort(t, srv)
dir := t.TempDir()
path := filepath.Join(dir, "cluster.yaml")
mustWrite(t, path, fmt.Sprintf("nodes:\n - name: box\n host: %s\n port: %d\n", host, port))
// Somewhere else entirely, so only --fleet can find it.
t.Chdir(t.TempDir())

done := make(chan error, 1)
go func() { done <- cmdFleet([]string{"logs", "-f", "--fleet", path}) }()

// The first poll is the proof of the parse: if -f had been the fleet-file
// flag it would have taken --fleet as its value and failed before
// contacting anything.
deadline := time.Now().Add(10 * time.Second)
for {
mu.Lock()
n := polls
mu.Unlock()
if n > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("no poll arrived; the command did not enter follow mode")
}
select {
case err := <-done:
t.Fatalf("the command exited before any poll: %v", err)
case <-time.After(10 * time.Millisecond):
}
}
if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Errorf("an interrupted follow is a clean exit, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("the follow did not stop on interrupt")
}
}

// -f is follow on logs, so the word after it is a node name, not a fleet file.
func TestCmdFleetLogsShortFlagIsNotTheFleetFile(t *testing.T) {
oneLogFleet(t, "hello\n")
err := cmdFleet([]string{"logs", "-f", "nope"})
if err == nil || !strings.Contains(err.Error(), `no node "nope"`) {
t.Fatalf("want the unknown-node error for the word after -f, got %v", err)
}
if strings.Contains(err.Error(), "no fleet file") {
t.Errorf("-f took its value as a fleet file: %v", err)
}
}

func TestCmdFleetLogsReadsOneNamedNode(t *testing.T) {
a := logNode(t, "from a\n", true)
b := logNode(t, "from b\n", true)
Expand Down
69 changes: 69 additions & 0 deletions cmd/spinloop/fleet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"testing"
"time"

"github.com/spf13/cobra"
"github.com/spinloop-ai/spinloop/internal/daemon"
"github.com/spinloop-ai/spinloop/internal/fleet"
"github.com/spinloop-ai/spinloop/internal/metrics"
Expand Down Expand Up @@ -559,6 +560,74 @@ func TestCmdFleetExplicitPath(t *testing.T) {
}
}

// The short form is the flag: -f names the fleet file the same way --fleet
// does.
func TestCmdFleetExplicitPathShortForm(t *testing.T) {
up := stubNode(t, "running")
host, port := hostPort(t, up)
dir := t.TempDir()
path := filepath.Join(dir, "cluster.yaml")
body := fmt.Sprintf("nodes:\n - name: solo\n host: %s\n port: %d\n", host, port)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
// Somewhere else entirely, so only -f can find it.
t.Chdir(t.TempDir())
out := captureStdout(t, func() {
if err := cmdFleet([]string{"status", "-f", path}); err != nil {
t.Error(err)
}
})
if !strings.Contains(out, "solo") {
t.Errorf("explicit -f not used:\n%s", out)
}
}

// TestFleetFlagShortForm pins the -f shorthand on every --fleet-carrying
// command, and its absence on logs, where -f is --follow.
func TestFleetFlagShortForm(t *testing.T) {
isolateConfig(t)
root := newRootCmd()
fleet := commandUnder(t, root, "fleet")
for _, name := range []string{"status", "metrics", "start", "stop", "deploy", "route", "dashboard"} {
sub := commandUnder(t, fleet, name)
f := sub.Flags().Lookup("fleet")
if f == nil {
t.Errorf("fleet %s: no --fleet flag", name)
continue
}
if f.Shorthand != "f" {
t.Errorf("fleet %s: shorthand = %q, want \"f\"", name, f.Shorthand)
}
}
if f := commandUnder(t, root, "harness").Flags().Lookup("fleet"); f == nil || f.Shorthand != "f" {
t.Errorf("harness: --fleet lacks the -f shorthand")
}
logs := commandUnder(t, fleet, "logs")
if f := logs.Flags().Lookup("fleet"); f == nil {
t.Error("fleet logs: no --fleet flag")
} else if f.Shorthand != "" {
t.Errorf("fleet logs: --fleet carries shorthand %q, want none", f.Shorthand)
}
if f := logs.Flags().Lookup("follow"); f == nil {
t.Error("fleet logs: no --follow flag")
} else if f.Shorthand != "f" {
t.Errorf("fleet logs: --follow shorthand = %q, want \"f\"", f.Shorthand)
}
}

// commandUnder finds a named subcommand in a parent's tree.
func commandUnder(t *testing.T, parent *cobra.Command, name string) *cobra.Command {
t.Helper()
for _, c := range parent.Commands() {
if c.Name() == name {
return c
}
}
t.Fatalf("command %s not found under %s", name, parent.Name())
return nil
}

func TestCmdFleetUnknownSubcommand(t *testing.T) {
if err := cmdFleet([]string{"wat"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("unknown subcommand should error, got %v", err)
Expand Down
28 changes: 28 additions & 0 deletions cmd/spinloop/route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,34 @@ func TestFleetFlagOverridesTheInstruction(t *testing.T) {
})
}

// The short form is the flag: -f names the fleet a launch routes through,
// overriding the Spinloop's own FLEET.
func TestHarnessFleetFlagShortForm(t *testing.T) {
isolateConfig(t)
node := newRoutableNode(t, "qwen3-27b", true, 10)
dir := t.TempDir()
flagFleet := fleetFileIn(t, dir, "nodes:\n"+node.entry("from-flag"))
// The Spinloop names a fleet that does not exist, so a launch that
// succeeds has parsed -f as the fleet file.
spinloopDir := routedSpinloop(t, "qwen3-27b", filepath.Join(dir, "nonexistent.yaml"))

argsFile := filepath.Join(t.TempDir(), "args")
stubHarnessBinary(t, "opencode", argsFile)
stderr := captureStderr(t, func() {
captureStdout(t, func() {
if err := cmdHarness([]string{"--spinloop=" + spinloopDir, "-f", flagFleet, "--", "run"}); err != nil {
t.Fatalf("cmdHarness -f: %v", err)
}
})
})
if _, err := os.ReadFile(argsFile); err != nil {
t.Fatalf("harness was not launched: %v", err)
}
if !strings.Contains(stderr, "from-flag") {
t.Errorf("the route announcement should name the flag's node, got:\n%s", stderr)
}
}

// A pinned BASEURL is the explicit answer, so nothing is selected.
func TestPinnedBaseURLSkipsRouting(t *testing.T) {
node := newRoutableNode(t, "qwen3-27b", true, 10)
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ deploy`](remote.md), applied per node.

| Flag | Meaning |
| ---- | ------- |
| `--fleet <path>` | The fleet file (default `./fleet.yaml`) |
| `-f`, `--fleet <path>` | The fleet file (default `./fleet.yaml`) — `logs` takes it long-form only, since `-f` is its follow flag |
| `--all` | `start`/`stop`/`deploy`: act on every node (or every `kind: remote` node, for `deploy`) instead of named ones |
| `--node <name>` | `route` only: report this node rather than choosing one |
| `--prefer` | `route` only: rank by `idle` or `active`, overriding the file |
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ variable chooses which Spinloop, never whether you are configured. See
| `--set` | Store the default harness and exit |
| `--get` | Print the active harness instead of launching |
| `--providers` | Path to a custom catalogue, for the applied Spinloop |
| `--fleet` | Route through this fleet file (overrides the Spinloop's `FLEET`) |
| `-f`, `--fleet` | Route through this fleet file (overrides the Spinloop's `FLEET`) |
| `--node` | Pin the launch to one fleet node |
| `--prefer` | Rank fleet nodes by `idle` or `active` (overrides the fleet file) |
| `--no-wake` | Fail rather than starting an engine on an idle fleet node |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-04
Loading