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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,11 @@ spinloop serve [path] [--dry-run] [-a] # run the PROVIDER's inference server
spinloop daemon [--api-addr <addr>] [--loopback] # supervise an engine via the control API — reads
# no Spinloop, starts nothing until asked over the API
spinloop fleet <status|metrics|logs|dashboard|route|start|stop>
# observe and drive the engines in
# fleet.yaml (dashboard is the
# interactive tiled view)
# observe and drive the engines in
# fleet.yaml (dashboard is the
# interactive tiled view)
spinloop up [node… | path] # start what the directory holds: every node of a
# fleet.yaml, else the Spinloop's server
spinloop export [--provider <name>] # print the current config as a Spinloop
spinloop init-providers [path] # write the built-in catalogue out to edit
spinloop harness [<spinloop>] [-H <name>] [--spinloop[=<path>]] [args...]
Expand Down Expand Up @@ -373,6 +375,10 @@ spinloop serve # builds a llama-server command and runs it
spinloop serve --dry-run # just print the command — no server
```

One word does the same: `spinloop up` runs `serve` for the directory's
`Spinloop` — and `fleet start` for the fleet, when a `fleet.yaml` is in the
directory instead. See [docs/commands/up.md](docs/commands/up.md).

For flags a `Spinloop` doesn't model (`-ngl`, `--jinja`, KV-cache types, draft
models), point at a llama.cpp preset `.ini` with `PRESET` and `serve` flattens
the chosen section into the command instead — with anything the `Spinloop` states
Expand Down
1 change: 1 addition & 0 deletions cmd/spinloop/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ harness could be configured with, spinloop show what it has been.`,
aliasCmd(),
unaliasCmd(),
serveCmd(),
upCmd(),
daemonCmd(),
exportCmd(),
initProvidersCmd(),
Expand Down
70 changes: 70 additions & 0 deletions cmd/spinloop/up.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"os"

"github.com/spf13/cobra"

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

// upCmd is `spinloop up`: the one-word way to start the engine for what is in
// the current directory. A fleet.yaml there makes it a fleet start — every
// node, or the ones named — and without one it is serve, resolving the
// Spinloop the same way. It is a dispatcher: each branch runs the other
// command's own body, so up cannot drift from what fleet start and serve do.
func upCmd() *cobra.Command {
c := &cobra.Command{
Use: "up",
Short: "start the engine this directory holds: the fleet, or the Spinloop's server",
Long: `starts the engine for what is in the current directory. With a
fleet.yaml here it starts the fleet's engines — every node, or the ones named
— as spinloop fleet start does; without one it serves the Spinloop exactly as
spinloop serve does, resolving it the same way (a path, a registered alias,
SPINLOOP_ALIAS, or ./Spinloop). A fleet.yaml wins over a Spinloop. It takes
no flags; for the full options of either, use spinloop fleet start or
spinloop serve directly.`,
Args: cobra.ArbitraryArgs,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return runUp(args)
},
}
c.ValidArgsFunction = upSlot
return c
}

// cmdUp runs the up command through the tree — the seam the suite calls
// directly.
func cmdUp(args []string) error { return execCmd(upCmd(), args) }

// runUp is the body of `spinloop up`: a fleet.yaml in the working directory
// makes it a fleet start, and anything else a serve.
func runUp(args []string) error {
if info, err := os.Stat(fleet.DefaultFile); err == nil && !info.IsDir() {
cfg, err := fleet.Resolve("")
if err != nil {
return err
}
// A bare up starts the whole fleet: fleet start refuses to run bare,
// and picking one node out of several would be a guess.
return runFleetDrive("start", cfg, len(args) == 0, args, fleetStartCall(cfg))
}
return runServe(args, false, false, "", "")
}

// upSlot is up's completion: the fleet's node names while a fleet file is in
// the working directory, the Spinloop slot otherwise. Completion must stay
// silent on failure, so a fleet file that cannot be read offers nothing.
func upSlot(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if info, err := os.Stat(fleet.DefaultFile); err == nil && !info.IsDir() {
cfg, err := fleet.Resolve("")
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return cfg.Names(), cobra.ShellCompDirectiveNoFileComp
}
return aliasSlot(nil, args, "")
}
191 changes: 191 additions & 0 deletions cmd/spinloop/up_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package main

import (
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/spf13/cobra"

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

// upFleet writes a one-node fleet.yaml (and the node's Spinloop) in a temp dir
// pointing at srv, chdirs there, and returns the dir — so up resolves
// ./fleet.yaml the way a user would.
func upFleet(t *testing.T, srv *httptest.Server) string {
t.Helper()
t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir())
host, port := hostPort(t, srv)
dir := writeFleetFile(t, fmt.Sprintf(
"nodes:\n - name: one\n host: %s\n port: %d\n file: ./one.Spinloop\n",
host, port))
mustWrite(t, filepath.Join(dir, "one.Spinloop"), "PROVIDER llamacpp\nMODEL org/m:Q4\n")
return dir
}

// upServeDir chdirs into an empty temp dir with an isolated spinloop config
// and returns it — a directory with no fleet file and, at first, no Spinloop.
func upServeDir(t *testing.T) string {
t.Helper()
t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
return dir
}

// TestCmdUp_FleetDir_BareStartsEveryNode checks the fleet branch's default:
// a bare up starts the whole fleet, where a bare fleet start refuses to run.
func TestCmdUp_FleetDir_BareStartsEveryNode(t *testing.T) {
upFleet(t, stubNode(t, "stopped"))
out := captureStdout(t, func() {
if err := cmdUp(nil); err != nil {
t.Fatalf("up: %v", err)
}
})
if !strings.Contains(out, "one: using") || !strings.Contains(out, "one running") {
t.Errorf("the node was not started:\n%s", out)
}
}

func TestCmdUp_FleetDir_NamedNode(t *testing.T) {
upFleet(t, stubNode(t, "stopped"))
out := captureStdout(t, func() {
if err := cmdUp([]string{"one"}); err != nil {
t.Fatalf("up one: %v", err)
}
})
if !strings.Contains(out, "one: using") || !strings.Contains(out, "one running") {
t.Errorf("the node was not started:\n%s", out)
}
}

func TestCmdUp_FleetDir_UnknownNode(t *testing.T) {
upFleet(t, stubNode(t, "stopped"))
err := cmdUp([]string{"nope"})
if err == nil || !strings.Contains(err.Error(), `no node "nope"`) ||
!strings.Contains(err.Error(), "one") {
t.Fatalf("want the unknown-node error naming the known nodes, got %v", err)
}
}

// TestCmdUp_FleetWinsOverSpinloop checks the dispatch order: a Spinloop beside
// the fleet file is ignored, and the fleet is started. If the serve branch ran
// instead, it would look for a real engine binary and fail.
func TestCmdUp_FleetWinsOverSpinloop(t *testing.T) {
dir := upFleet(t, stubNode(t, "stopped"))
mustWrite(t, filepath.Join(dir, spinloop.DefaultFile), "PROVIDER llamacpp\nMODEL org/m:Q4\n")
out := captureStdout(t, func() {
if err := cmdUp(nil); err != nil {
t.Fatalf("up: %v", err)
}
})
if !strings.Contains(out, "one: using") {
t.Errorf("the fleet was not started:\n%s", out)
}
}

// TestCmdUp_ServesTheDirectorySpinloop checks the serve branch from a bare up:
// the engine the Spinloop names is actually launched.
func TestCmdUp_ServesTheDirectorySpinloop(t *testing.T) {
dir := upServeDir(t)
mustWrite(t, filepath.Join(dir, spinloop.DefaultFile), "PROVIDER llamacpp\nMODEL org/m:Q4\n")
argsFile := filepath.Join(t.TempDir(), "args")
stubLlamaServer(t, argsFile)
if err := cmdUp(nil); err != nil {
t.Fatalf("up: %v", err)
}
if _, err := os.Stat(argsFile); err != nil {
t.Fatalf("the engine was not run: %v", err)
}
}

func TestCmdUp_ServesAPath(t *testing.T) {
upServeDir(t)
spinloopPath := filepath.Join(t.TempDir(), spinloop.DefaultFile)
mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/m:Q4\n")
argsFile := filepath.Join(t.TempDir(), "args")
stubLlamaServer(t, argsFile)
if err := cmdUp([]string{spinloopPath}); err != nil {
t.Fatalf("up %s: %v", spinloopPath, err)
}
if _, err := os.Stat(argsFile); err != nil {
t.Fatalf("the engine was not run: %v", err)
}
}

// TestCmdUp_ResolvesTheEnvironmentAlias checks a directory with no ./Spinloop
// but a SPINLOOP_ALIAS: up resolves it the way serve does, so a directory
// where serve works is a directory where up works.
func TestCmdUp_ResolvesTheEnvironmentAlias(t *testing.T) {
upServeDir(t)
registerSpinloop(t, "PROVIDER llamacpp\nALIAS q3\nMODEL org/m:Q4\n")
t.Setenv("SPINLOOP_ALIAS", "q3")
argsFile := filepath.Join(t.TempDir(), "args")
stubLlamaServer(t, argsFile)
if err := cmdUp(nil); err != nil {
t.Fatalf("up: %v", err)
}
if _, err := os.Stat(argsFile); err != nil {
t.Fatalf("the engine was not run: %v", err)
}
}

func TestCmdUp_ResolvesARegisteredAlias(t *testing.T) {
upServeDir(t)
registerSpinloop(t, "PROVIDER llamacpp\nALIAS q3\nMODEL org/m:Q4\n")
argsFile := filepath.Join(t.TempDir(), "args")
stubLlamaServer(t, argsFile)
if err := cmdUp([]string{"q3"}); err != nil {
t.Fatalf("up q3: %v", err)
}
if _, err := os.Stat(argsFile); err != nil {
t.Fatalf("the engine was not run: %v", err)
}
}

// TestCmdUp_FailsWhenNothingResolves checks the neither case: no fleet file and
// no resolvable Spinloop fails with serve's own error, not one of up's own.
func TestCmdUp_FailsWhenNothingResolves(t *testing.T) {
upServeDir(t)
err := cmdUp(nil)
if err == nil || !strings.Contains(err.Error(), "no Spinloop found") {
t.Fatalf("want serve's no-Spinloop error, got %v", err)
}
}

func TestUpSlot_FleetDirOffersNodeNames(t *testing.T) {
upFleet(t, stubNode(t, "stopped"))
cands, dir := upSlot(nil, nil, "")
if dir != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %v, want NoFileComp", dir)
}
if !slices.Contains(cands, "one") {
t.Errorf("node name not offered: %v", cands)
}
}

func TestUpSlot_OutsideFleetOffersTheSpinloopSlot(t *testing.T) {
upServeDir(t)
cands, dir := upSlot(nil, nil, "")
// The Spinloop slot allows paths (Default) and offers no node names.
if dir != cobra.ShellCompDirectiveDefault {
t.Errorf("directive = %v, want Default", dir)
}
if slices.Contains(cands, "one") {
t.Errorf("a node name leaked into the Spinloop slot: %v", cands)
}
}

func TestUpSlot_UnreadableFleetFileStaysSilent(t *testing.T) {
dir := upServeDir(t)
mustWrite(t, filepath.Join(dir, "fleet.yaml"), "nodes: [")
cands, d := upSlot(nil, nil, "")
if cands != nil || d != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("got (%v, %v), want (nil, NoFileComp)", cands, d)
}
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Four words carry the whole tool:
| [`spinloop alias`](commands/alias.md) | Name a `Spinloop` so the name works anywhere a path does |
| [`spinloop unalias`](commands/unalias.md) | Drop a registered name |
| [`spinloop serve`](commands/serve.md) | Run the inference server for the model a `Spinloop` names |
| [`spinloop up`](commands/up.md) | Start the engine this directory holds: the fleet, or the `Spinloop`'s server |
| [`spinloop daemon`](commands/serve.md#the-control-api---api-and-spinloop-daemon) | Supervise an engine over the [control API](http-api.md) |
| [`spinloop fleet`](commands/fleet.md) | Observe and drive the engines on every machine you run |
| [`spinloop remote`](commands/remote.md) | Run the model on a cloud GPU that stops when you do |
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/alias.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# spinloop alias

Register an [`Spinloop` file](../spinloop-file.md) under a short name. The name
then works wherever a Spinloop path does — `apply`, `unapply`, `serve`,
then works wherever a Spinloop path does — `apply`, `unapply`, `serve`, `up`,
`harness` — from any directory.

```sh
Expand Down
2 changes: 2 additions & 0 deletions docs/commands/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ them:
- harness names after `-H`, `--harness`, or `--set`
- your [registered aliases](alias.md) wherever a Spinloop path goes —
`spinloop unalias <TAB>` offers exactly the names you have
- the fleet's node names after `up` in a fleet directory — the Spinloop slot
elsewhere
- the supported shells after `completion`

## See also
Expand Down
5 changes: 4 additions & 1 deletion docs/commands/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@ spinloop fleet stop --all
With neither a node nor `--all` they list the fleet and do nothing, rather
than acting on the whole fleet by accident; `--all` together with node names
is refused as ambiguous. An unknown name fails before anything is touched,
naming the nodes you could have meant. Several targeted nodes are driven
naming the nodes you could have meant. A fleet directory's
[`spinloop up`](up.md) skips the choice: bare `up` starts every node,
`up <node>` the named ones. Several targeted nodes are driven
independently — one node's failure is reported against it alone and does not
stop the others, and the command exits non-zero if any of them failed. The
daemon's own rules still hold: starting a node whose engine is already
Expand Down Expand Up @@ -526,6 +528,7 @@ deploy`](remote.md), applied per node.

## See also

- [`spinloop up`](up.md) — the one-word start, from a fleet directory
- [`examples/fleet-local/`](../../examples/fleet-local/) — a fleet of one, on your own machine
- [`examples/fleet-docker/`](../../examples/fleet-docker/) — a runnable fleet
- [`spinloop daemon`](serve.md) — what runs on each node
Expand Down
2 changes: 2 additions & 0 deletions docs/commands/serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ output.

## See also

- [`spinloop up`](up.md) — the one-word form: this, from a directory holding
the Spinloop
- [`spinloop fleet`](fleet.md) — one spinloop observing the daemons on every
machine you run
- Worked examples with real models: [`examples/`](../../examples/)
Expand Down
37 changes: 37 additions & 0 deletions docs/commands/up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# spinloop up

Start the engine for what is in the current directory — one word for the two
ways an engine gets started:

```sh
spinloop up # a fleet.yaml here: every node; otherwise ./Spinloop's server
spinloop up gpu-box # a fleet.yaml here: just that node
spinloop up ./Spinloop # no fleet.yaml: serve that file
```

## The directory decides

| What is here | `up` runs |
| ------------ | --------- |
| A `fleet.yaml` | [`spinloop fleet start`](fleet.md) — every node, or the ones named |
| A resolvable `Spinloop`, no fleet file | [`spinloop serve`](serve.md) — same resolution, same output |

A `fleet.yaml` wins when both are present: a fleet directory is a fleet.

The branches are the real commands, not copies of them:

- In a fleet directory, a bare `up` starts **every** node — the `--all` form,
because a bare `fleet start` refuses to guess. `up <node>` starts the named
node(s); an unknown name fails the way `fleet start` does.
- Without a fleet file, `up` resolves its Spinloop exactly as `serve` does —
a path, a registered [`alias`](alias.md), `SPINLOOP_ALIAS`, then
`./Spinloop` — prints the command, and runs it. A directory with nothing to
resolve fails with serve's own error.

`up` takes no flags; for the full options of either branch, use
[`spinloop fleet start`](fleet.md) or [`spinloop serve`](serve.md) directly.

## See also

- [`spinloop serve`](serve.md) — the local engine, in full
- [`spinloop fleet`](fleet.md) — the fleet, in full
3 changes: 3 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ spinloop serve # runs llama-server for it
spinloop apply # points the agent at it
```

Prefer one word? `spinloop up` starts the server here — and the whole fleet,
wherever a `fleet.yaml` lives.

## 7. Name the ones you keep

```sh
Expand Down
Loading