diff --git a/README.md b/README.md index 39d95809..d1a6d41b 100644 --- a/README.md +++ b/README.md @@ -623,23 +623,23 @@ Running a model on your own cloud GPU box? [`remote/`](remote/) deploys one. while you are using it, and stops itself after a period of idleness. ```sh -spinloop cloud start --env dev-2 --print-env # boot the instance, wait for the +spinloop cloud start --node dev-2 --print-env # boot the instance, wait for the # model to load, then print OPENAI_BASE_URL / # OPENAI_API_KEY exports for eval -spinloop status --env --env dev-2 # instance state, endpoint health, +spinloop status --node --node dev-2 # instance state, endpoint health, # and when it last did any work -spinloop metrics --env --env dev-2 # tokens, GPU, CPU and RAM — plus +spinloop metrics --node --node dev-2 # tokens, GPU, CPU and RAM — plus # the same last-active -spinloop logs --env --env dev-2 # what the engine (or the boot) +spinloop logs --node --node dev-2 # what the engine (or the boot) # said, even after it's gone -spinloop cloud pause --env dev-2 # stop now, but keep it re-wakeable -spinloop cloud restart --env dev-2 # fresh engine, same address: stop +spinloop cloud pause --node dev-2 # stop now, but keep it re-wakeable +spinloop cloud restart --node dev-2 # fresh engine, same address: stop # it, then wake it -spinloop cloud keep 4h --env dev-2 # hold it against the idle sweep +spinloop cloud keep 4h --node dev-2 # hold it against the idle sweep # for 4 hours (start --keep does the same at wake time) -spinloop cloud schedule set --env dev-2 --start "0 8 * * 1-5" --stop "0 18 * * 1-5" +spinloop cloud schedule set --node dev-2 --start "0 8 * * 1-5" --stop "0 18 * * 1-5" # start it at 08:00 and pause it at 18:00, Monday to Friday -spinloop cloud stop --env dev-2 # terminate now instead of waiting +spinloop cloud stop --node dev-2 # terminate now instead of waiting # for the idle timer ``` @@ -649,12 +649,12 @@ failed before the engine came up (`--source boot`). See [docs/commands/cloud.md](docs/commands/cloud.md#reading-the-logs). Configuration lives in a `cloud.json` per **environment**, named with the -`--env` flag on every command above (`--env dev-2` selects the file at +`--node` flag on every command above (`--node dev-2` selects the file at `clouds/dev-2/cloud.json` under spinloop's config directory, `${SPINLOOP_CONFIG_DIR:-${XDG_CONFIG_HOME:-~/.config}/spinloop}`); with no -`--env`, the `default` environment is used. The Spinloop itself says only what +`--node`, the `default` environment is used. The Spinloop itself says only what the environment serves — the name is a machine-local choice, so it stays out of -the file. `spinloop cloud deploy --env dev-2` writes the file for you when it +the file. `spinloop cloud deploy --node dev-2` writes the file for you when it registers the environment; deploying [`remote/`](remote/) yourself prints the same values: @@ -663,17 +663,17 @@ same values: ``` `base_url` is the endpoint's own address. You never have to quote it back — -`start` and `status` print it, and `spinloop harness apply --env dev-2` can leave +`start` and `status` print it, and `spinloop harness apply --node dev-2` can leave `BASEURL` out of the Spinloop and still point your agent at the endpoint. A `BASEURL` in the Spinloop wins if you do set one. Deployed something and just want to point an agent at it from another -machine? `spinloop harness open --env dev-2` on its own — no Spinloop at all — +machine? `spinloop harness open --node dev-2` on its own — no Spinloop at all — configures the harness straight from what is deployed there: ```sh -spinloop cloud deploy path/to/Spinloop --env dev-2 # from wherever you deployed it -spinloop harness open --env dev-2 --prompt "..." # from anywhere with dev-2 registered +spinloop cloud deploy path/to/Spinloop --node dev-2 # from wherever you deployed it +spinloop harness open --node dev-2 --prompt "..." # from anywhere with dev-2 registered ``` Every URL and the region can be overridden with the matching diff --git a/cmd/spinloop/alias_test.go b/cmd/spinloop/alias_test.go index d55e6f96..9a550a44 100644 --- a/cmd/spinloop/alias_test.go +++ b/cmd/spinloop/alias_test.go @@ -832,7 +832,7 @@ func TestEnvAlias_ReachesCloud(t *testing.T) { t.Chdir(t.TempDir()) // no ./Spinloop, so only the variable can find it t.Setenv("SPINLOOP_ALIAS", "q3") - if err := cmdCloudStop([]string{"--env", "default"}); err != nil { + if err := cmdCloudStop([]string{"--node", "default"}); err != nil { t.Fatalf("cmdCloudStop with SPINLOOP_ALIAS: %v", err) } select { @@ -879,7 +879,7 @@ func TestEnvAlias_RemoteFailsRatherThanFallingBack(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("SPINLOOP_ALIAS", "nope") - err := cmdCloudStop([]string{"--env", "default"}) + err := cmdCloudStop([]string{"--node", "default"}) if err == nil { t.Fatal("expected an error for an unregistered SPINLOOP_ALIAS") } @@ -903,7 +903,7 @@ func TestEnvAlias_RemoteFallsBackWithoutREMOTE(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("SPINLOOP_ALIAS", "q3") - err := cmdCloudStop([]string{"--env", "default"}) + err := cmdCloudStop([]string{"--node", "default"}) if err == nil { t.Fatal("expected an error: there is no default endpoint config either") } diff --git a/cmd/spinloop/apply_test.go b/cmd/spinloop/apply_test.go index 422b2455..91a984b5 100644 --- a/cmd/spinloop/apply_test.go +++ b/cmd/spinloop/apply_test.go @@ -481,7 +481,7 @@ func TestCmdApply_BaseURLFromCloudConfig(t *testing.T) { mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") out := captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-1", spinloopFile}); err != nil { + if err := cmdApply([]string{"--node", "dev-1", spinloopFile}); err != nil { t.Fatalf("cmdApply: %v", err) } }) @@ -513,7 +513,7 @@ func TestCmdApply_SpinloopBaseURLBeatsCloudConfig(t *testing.T) { mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\nBASEURL http://127.0.0.1:9090/v1\n") captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-1", spinloopFile}); err != nil { + if err := cmdApply([]string{"--node", "dev-1", spinloopFile}); err != nil { t.Fatalf("cmdApply: %v", err) } }) @@ -525,7 +525,7 @@ func TestCmdApply_SpinloopBaseURLBeatsCloudConfig(t *testing.T) { } } -// TestCmdApply_UnregisteredEnvironmentFails checks that --env naming an +// TestCmdApply_UnregisteredEnvironmentFails checks that --node naming an // environment that has never been deployed fails, naming the deploy command that // creates it. func TestCmdApply_UnregisteredEnvironmentFails(t *testing.T) { @@ -536,12 +536,12 @@ func TestCmdApply_UnregisteredEnvironmentFails(t *testing.T) { spinloopFile := filepath.Join(spinloopDir, "Spinloop") mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") - err := cmdApply([]string{"--env", "dev-1", spinloopFile}) + err := cmdApply([]string{"--node", "dev-1", spinloopFile}) if err == nil { t.Fatal("expected an error for an unregistered environment") } if !strings.Contains(err.Error(), "not registered") || - !strings.Contains(err.Error(), "`spinloop cloud deploy --env \"dev-1\"`") { + !strings.Contains(err.Error(), "`spinloop cloud deploy --node \"dev-1\"`") { t.Errorf("error = %v, want the not-registered error naming the deploy command", err) } } @@ -562,7 +562,7 @@ func TestCmdApply_RemoteConfigMalformed(t *testing.T) { spinloopFile := filepath.Join(spinloopDir, "Spinloop") mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") - err := cmdApply([]string{"--env", "dev-1", spinloopFile}) + err := cmdApply([]string{"--node", "dev-1", spinloopFile}) if err == nil { t.Fatal("expected an error for a malformed cloud config") } @@ -571,7 +571,7 @@ func TestCmdApply_RemoteConfigMalformed(t *testing.T) { } } -// TestCmdApply_RemoteNameIsProviderName checks that --env keys the harness +// TestCmdApply_RemoteNameIsProviderName checks that --node keys the harness // provider on the environment name — configured from the PROVIDER's catalogue // entry — with the default model reading as / and the base URL taken // from that environment's cloud.json. @@ -591,7 +591,7 @@ func TestCmdApply_RemoteNameIsProviderName(t *testing.T) { mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-1", spinloopFile}); err != nil { + if err := cmdApply([]string{"--node", "dev-1", spinloopFile}); err != nil { t.Fatalf("cmdApply: %v", err) } }) @@ -637,7 +637,7 @@ func TestCmdApply_RemoteProviderLabelledPerEnvironment(t *testing.T) { mustWrite(t, localSpinloop, "PROVIDER llamacpp\nALIAS qwen\nBASEURL http://127.0.0.1:8080/v1\n") captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-2", cloudSpinloop}); err != nil { + if err := cmdApply([]string{"--node", "dev-2", cloudSpinloop}); err != nil { t.Fatalf("cmdApply cloud: %v", err) } if err := cmdApply([]string{localSpinloop}); err != nil { @@ -696,7 +696,7 @@ func TestCmdApply_RemoteReapplyRefreshesLabel(t *testing.T) { mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-2", spinloopFile}); err != nil { + if err := cmdApply([]string{"--node", "dev-2", spinloopFile}); err != nil { t.Fatalf("cmdApply: %v", err) } }) @@ -727,12 +727,12 @@ func TestCmdUnapply_RemoveEnvironmentNamedProvider(t *testing.T) { mustWrite(t, spinloopFile, "PROVIDER llamacpp\nALIAS qwen\n") captureStdout(t, func() { - if err := cmdApply([]string{"--env", "dev-1", spinloopFile}); err != nil { + if err := cmdApply([]string{"--node", "dev-1", spinloopFile}); err != nil { t.Fatalf("cmdApply: %v", err) } }) captureStdout(t, func() { - if err := cmdUnapply([]string{"--env", "dev-1", spinloopFile}); err != nil { + if err := cmdUnapply([]string{"--node", "dev-1", spinloopFile}); err != nil { t.Fatalf("cmdUnapply: %v", err) } }) diff --git a/cmd/spinloop/cloud.go b/cmd/spinloop/cloud.go index 2cc4515e..bcceff79 100644 --- a/cmd/spinloop/cloud.go +++ b/cmd/spinloop/cloud.go @@ -36,7 +36,7 @@ import ( // idle), status reports instance state and endpoint health, keep sets the // retention deadline to prevent the sweep from terminating the instance // early, and deploy sets what the instance will serve from the Spinloop itself. -// Each subcommand acts on one registered environment — the --env flag names +// Each subcommand acts on one registered environment — the --node flag names // it, and without the flag the `default` environment is used; see // resolveCloudConfig. Each also takes an optional Spinloop path, read for // its ENV instructions and adjacent .env only. The group itself does nothing @@ -89,11 +89,11 @@ func applySpinloopEnv(sel spinloop.Selection, spinloopPath string) error { return nil } -// envFlagUsage is the --env flag's help text on every cloud subcommand that +// nodeFlagUsage is the --node flag's help text on every cloud subcommand that // acts on one environment. -const envFlagUsage = "the registered environment to act on (required)" +const nodeFlagUsage = "the registered environment to act on (required)" -// resolveCloudConfig loads the cloud config a subcommand acts on. The --env +// resolveCloudConfig loads the cloud config a subcommand acts on. The --node // flag names an environment in the per-user registry, read from // clouds//cloud.json; a name with no registered configuration fails // saying the environment is not registered and how to create it. Without the @@ -105,7 +105,7 @@ const envFlagUsage = "the registered environment to act on (required)" // when no argument was given) is read for its ENV instructions and adjacent // .env only, applied before any control-plane work: it never selects an // environment. -func resolveCloudConfig(envName, spinloopArg string) (cloud.Config, error) { +func resolveCloudConfig(nodeName, spinloopArg string) (cloud.Config, error) { if spinloopArg != "" { sel, spinloopPath, err := readSpinloop("cloud", spinloopArg) if err != nil { @@ -123,13 +123,13 @@ func resolveCloudConfig(envName, spinloopArg string) (cloud.Config, error) { return cloud.Config{}, err } } - if envName == "" { + if nodeName == "" { return cloud.Config{}, errNoEnvironment() } - if !cloud.IsEnvName(envName) { - return cloud.Config{}, fmt.Errorf("%q is not an environment name: an environment name is a plain identifier, with no path", envName) + if !cloud.IsEnvName(nodeName) { + return cloud.Config{}, fmt.Errorf("%q is not an environment name: an environment name is a plain identifier, with no path", nodeName) } - return cloud.LoadEnvironment(envName, viperGetenv()) + return cloud.LoadEnvironment(nodeName, viperGetenv()) } // errNoEnvironment is what a cloud subcommand fails with when it names no @@ -139,10 +139,10 @@ func resolveCloudConfig(envName, spinloopArg string) (cloud.Config, error) { // next thing to type is in the error rather than a directory listing away. func errNoEnvironment() error { var b strings.Builder - b.WriteString("no environment named: pass --env ") + b.WriteString("no environment named: pass --node ") envs, err := cloud.ListEnvironments() if err != nil || len(envs) == 0 { - b.WriteString(" (none registered yet — `spinloop cloud deploy --env ` creates one)") + b.WriteString(" (none registered yet — `spinloop cloud deploy --node ` creates one)") return errors.New(b.String()) } names := make([]string, len(envs)) @@ -290,7 +290,7 @@ func printCloudEnv(resp *cloud.Response) { } func cloudEnvCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "env", Short: "print the running endpoint's env vars", @@ -302,17 +302,17 @@ starting it.`, ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudEnv(envName, args) + return runCloudEnv(nodeName, args) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) return c } // runCloudEnv is the body of `spinloop cloud env`. -func runCloudEnv(envName string, args []string) error { - cfg, err := resolveCloudConfig(envName, spinloopArg(args)) +func runCloudEnv(nodeName string, args []string) error { + cfg, err := resolveCloudConfig(nodeName, spinloopArg(args)) if err != nil { return err } @@ -327,7 +327,7 @@ func runCloudEnv(envName string, args []string) error { func cloudStartCmd() *cobra.Command { var timeout time.Duration const timeoutUsage = "overall time to wait for the endpoint" - var envName string + var nodeName string var printEnv bool var keepD string c := &cobra.Command{ @@ -341,21 +341,21 @@ agent needs.`, ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudStart(envName, args, timeout, printEnv, keepD) + return runCloudStart(nodeName, args, timeout, printEnv, keepD) }, } fs := c.Flags() fs.DurationVarP(&timeout, "timeout", "t", 15*time.Minute, timeoutUsage) - fs.StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) fs.BoolVar(&printEnv, "print-env", false, "print export lines to stdout for eval") fs.StringVar(&keepD, "keep", "", "retain instance until now + DURATION (e.g. 4h, 60m), preventing the idle sweep from stopping it") return c } // runCloudStart is the body of `spinloop cloud start`. -func runCloudStart(envName string, args []string, timeout time.Duration, printEnv bool, keepD string) error { - cfg, err := resolveCloudConfig(envName, spinloopArg(args)) +func runCloudStart(nodeName string, args []string, timeout time.Duration, printEnv bool, keepD string) error { + cfg, err := resolveCloudConfig(nodeName, spinloopArg(args)) if err != nil { return err } @@ -465,7 +465,7 @@ func runCloudList() error { } func cloudKeepCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "keep", Short: "defer the instance's idle termination", @@ -475,16 +475,16 @@ func cloudKeepCmd() *cobra.Command { ValidArgsFunction: keepSlot, RunE: func(c *cobra.Command, rest []string) error { resolve(c) - return runCloudKeep(envName, rest) + return runCloudKeep(nodeName, rest) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) return c } // runCloudKeep is the body of `spinloop cloud keep`. -func runCloudKeep(envName string, rest []string) error { +func runCloudKeep(nodeName string, rest []string) error { if len(rest) == 0 { return fmt.Errorf("usage: spinloop cloud keep [path]") } @@ -501,7 +501,7 @@ func runCloudKeep(envName string, rest []string) error { if len(rest) > 1 { spinloopPath = rest[1] } - cfg, err := resolveCloudConfig(envName, spinloopPath) + cfg, err := resolveCloudConfig(nodeName, spinloopPath) if err != nil { return err } @@ -515,7 +515,7 @@ func runCloudKeep(envName string, rest []string) error { } func cloudPauseCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "pause", Short: "stop the instance without terminating it", @@ -525,17 +525,17 @@ func cloudPauseCmd() *cobra.Command { ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudPause(envName, args) + return runCloudPause(nodeName, args) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) return c } // runCloudPause is the body of `spinloop cloud pause`. -func runCloudPause(envName string, args []string) error { - cfg, err := resolveCloudConfig(envName, spinloopArg(args)) +func runCloudPause(nodeName string, args []string) error { + cfg, err := resolveCloudConfig(nodeName, spinloopArg(args)) if err != nil { return err } @@ -555,7 +555,7 @@ func cloudRestartCmd() *cobra.Command { var force bool var timeout time.Duration const timeoutUsage = "overall time to wait for the endpoint" - var envName string + var nodeName string c := &cobra.Command{ Use: "restart", Short: "stop the instance and bring it back", @@ -569,14 +569,14 @@ skipped: for when the engine or its daemon will not answer it.`, ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudRestart(envName, args, force, timeout) + return runCloudRestart(nodeName, args, force, timeout) }, } fs := c.Flags() fs.BoolVarP(&force, "force", "F", false, "skip the graceful engine stop") fs.DurationVarP(&timeout, "timeout", "t", 15*time.Minute, timeoutUsage) - fs.StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) return c } @@ -584,8 +584,8 @@ skipped: for when the engine or its daemon will not answer it.`, // instance in the pause manner — without terminating it, so the boot disk and // weights survive and the address does not change — and reuses the wake's own // deadline and retry handling to block until the model serves again. -func runCloudRestart(envName string, args []string, force bool, timeout time.Duration) error { - cfg, err := resolveCloudConfig(envName, spinloopArg(args)) +func runCloudRestart(nodeName string, args []string, force bool, timeout time.Duration) error { + cfg, err := resolveCloudConfig(nodeName, spinloopArg(args)) if err != nil { return err } @@ -626,7 +626,7 @@ func runCloudRestart(envName string, args []string, force bool, timeout time.Dur } func cloudStopCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "stop", Short: "shut the instance down", @@ -637,17 +637,17 @@ func cloudStopCmd() *cobra.Command { ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudStop(envName, args) + return runCloudStop(nodeName, args) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) return c } // runCloudStop is the body of `spinloop cloud stop`. -func runCloudStop(envName string, args []string) error { - cfg, err := resolveCloudConfig(envName, spinloopArg(args)) +func runCloudStop(nodeName string, args []string) error { + cfg, err := resolveCloudConfig(nodeName, spinloopArg(args)) if err != nil { return err } @@ -1150,12 +1150,12 @@ func cloudDeployCmd() *cobra.Command { spinloopVersion string instanceType string apiKeyEnv string - envName string + nodeName string ) c := &cobra.Command{ Use: "deploy", Short: "set what the instance serves, from the Spinloop", - Long: `creates the environment named by --env — its own + Long: `creates the environment named by --node — its own address, API key and allowed CIDR — and says what it serves (PROVIDER picks the engine, just as it does for serve). --spinloop-version pins the spinloop release a fresh boot of the environment installs; without it, a boot @@ -1169,7 +1169,7 @@ from its next fresh launch.`, ValidArgsFunction: aliasSlot, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runCloudDeploy(args, envName, dryRun, overwrite, reseed, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv) + return runCloudDeploy(args, nodeName, dryRun, overwrite, reseed, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv) }, } fs := c.Flags() @@ -1181,18 +1181,18 @@ from its next fresh launch.`, fs.StringVar(&spinloopVersion, "spinloop-version", "", "spinloop release the environment's instances install at boot (default: latest)") fs.StringVar(&instanceType, "instance-type", "", "EC2 instance type the environment's instances launch as (e.g. g6e.xlarge; default: the control plane's default type)") fs.StringVar(&apiKeyEnv, "api-key-env", "", "the environment variable holding the API key to store for this environment (a variable name, never the key itself)") - fs.StringVar(&envName, "env", "", "the environment deploy creates and registers (required)") - compRegister(c, "env", compEnvs) + fs.StringVar(&nodeName, "node", "", "the environment deploy creates and registers (required)") + compRegister(c, "node", compEnvs) return c } // runCloudDeploy is the body of `spinloop cloud deploy`. -func runCloudDeploy(args []string, envName string, dryRun, overwrite, reseed bool, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv string) error { - _, spinloopPath, dc, err := deriveDeployTarget("spinloop cloud deploy ", spinloopArg(args), envName) +func runCloudDeploy(args []string, nodeName string, dryRun, overwrite, reseed bool, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv string) error { + _, spinloopPath, dc, err := deriveDeployTarget("spinloop cloud deploy ", spinloopArg(args), nodeName) if err != nil { return err } - outcome, err := runDeploy(spinloopPath, envName, dc, deployOpts{ + outcome, err := runDeploy(spinloopPath, nodeName, dc, deployOpts{ dryRun: dryRun, overwrite: overwrite, reseed: reseed, @@ -1218,7 +1218,7 @@ func runCloudDeploy(args []string, envName string, dryRun, overwrite, reseed boo // name) gets a message naming itself rather than a hard-coded command line. // // The Spinloop says what the environment serves; the name arrives from -// outside it — the --env flag for `cloud deploy`, the node name for `fleet +// outside it — the --node flag for `cloud deploy`, the node name for `fleet // deploy` — so a node's resolved Spinloop source and a standalone `cloud // deploy` of the same file agree about what they deploy and each names its // own environment. @@ -1239,7 +1239,7 @@ func deriveDeployTarget(usage, spinloopArg, env string) (sel spinloop.Selection, return } if env == "" { - err = fmt.Errorf("deploy must name the environment it creates: pass --env (e.g. --env %s)", dc.ServedModelName) + err = fmt.Errorf("deploy must name the environment it creates: pass --node (e.g. --node %s)", dc.ServedModelName) return } if !cloud.IsEnvName(env) { diff --git a/cmd/spinloop/cloud_deploy_test.go b/cmd/spinloop/cloud_deploy_test.go index 702b1b99..968da5ad 100644 --- a/cmd/spinloop/cloud_deploy_test.go +++ b/cmd/spinloop/cloud_deploy_test.go @@ -536,7 +536,7 @@ func stubDeploySeams(t *testing.T, serverURL, statusState string) { // writeDeploySpinloopCwd writes the Spinloop a deploy reads for the engine, // model and context + its preset, in a new working directory. The environment -// name travels as --env, not in the file. +// name travels as --node, not in the file. func writeDeploySpinloopCwd(t *testing.T) { t.Helper() t.Chdir(t.TempDir()) @@ -572,7 +572,7 @@ func TestCloudDeploy_PostsTheConfigAndRegisters(t *testing.T) { writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv"}); err != nil { t.Errorf("cmdCloudDeploy: %v", err) } }) @@ -629,7 +629,7 @@ func TestCloudDeploy_NotBootstrapped(t *testing.T) { } writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv"}) + err := cmdCloudDeploy([]string{"--node", "testenv"}) if err == nil || !strings.Contains(err.Error(), "spinloop cloud bootstrap") { t.Errorf("want a bootstrap-first error, got %v", err) } @@ -646,13 +646,13 @@ func TestCloudDeploy_RequiresEnvName(t *testing.T) { ), 0o600); err != nil { t.Fatal(err) } - // No --env: deploy cannot tell which environment to create. + // No --node: deploy cannot tell which environment to create. err := cmdCloudDeploy(nil) if err == nil || !strings.Contains(err.Error(), "deploy must name the environment it creates") { - t.Errorf("want an error asking for --env, got %v", err) + t.Errorf("want an error asking for --node, got %v", err) } // A path-shaped value is not an environment name. - err = cmdCloudDeploy([]string{"--env", "./cloud.json"}) + err = cmdCloudDeploy([]string{"--node", "./cloud.json"}) if err == nil || !strings.Contains(err.Error(), "is not an environment name") { t.Errorf("want an environment-name error, got %v", err) } @@ -669,7 +669,7 @@ func TestCloudDeploy_OverwriteGuard(t *testing.T) { t.Fatal(err) } writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv"}) + err := cmdCloudDeploy([]string{"--node", "testenv"}) if err == nil || !strings.Contains(err.Error(), "--overwrite") { t.Errorf("want an overwrite refusal, got %v", err) } @@ -680,7 +680,7 @@ func TestCloudDeploy_OverwriteGuard(t *testing.T) { stubAWSEnv(t) stubDeploySeams(t, "https://unused", "running") writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv"}) + err := cmdCloudDeploy([]string{"--node", "testenv"}) if err == nil || !strings.Contains(err.Error(), "--overwrite") { t.Errorf("want an overwrite refusal for a live instance, got %v", err) } @@ -697,7 +697,7 @@ func TestCloudDeploy_OverwriteGuard(t *testing.T) { stubDeploySeams(t, server.URL, "running") writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--overwrite"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--overwrite"}); err != nil { t.Errorf("cmdCloudDeploy --overwrite: %v", err) } }) @@ -711,7 +711,7 @@ func TestCloudDeploy_RejectsBadCIDR(t *testing.T) { isolateConfig(t) stubDeploySeams(t, "https://unused", "undeployed") writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv", "--allowed-cidr", "bogus"}) + err := cmdCloudDeploy([]string{"--node", "testenv", "--allowed-cidr", "bogus"}) if err == nil || !strings.Contains(err.Error(), "IPv4 CIDR") { t.Errorf("want a CIDR validation error, got %v", err) } @@ -729,7 +729,7 @@ func TestCloudDeploy_DryRunSendsNothing(t *testing.T) { writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--dry-run"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--dry-run"}); err != nil { t.Errorf("cmdCloudDeploy --dry-run: %v", err) } }) @@ -764,7 +764,7 @@ func TestCloudDeploy_SpinloopVersion(t *testing.T) { writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--spinloop-version", "v1.26.1"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--spinloop-version", "v1.26.1"}); err != nil { t.Errorf("cmdCloudDeploy: %v", err) } }) @@ -796,7 +796,7 @@ func TestCloudDeploy_SpinloopVersion(t *testing.T) { stubDeploySeams(t, server.URL, "undeployed") writeDeploySpinloopCwd(t) - if err := cmdCloudDeploy([]string{"--env", "testenv"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv"}); err != nil { t.Errorf("cmdCloudDeploy: %v", err) } // Absent, not null and not "latest": an unpinned deploy sends exactly @@ -811,7 +811,7 @@ func TestCloudDeploy_SpinloopVersion(t *testing.T) { for _, flag := range []string{"latest", "v"} { writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--spinloop-version", flag, "--dry-run"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--spinloop-version", flag, "--dry-run"}); err != nil { t.Errorf("cmdCloudDeploy --spinloop-version %s: %v", flag, err) } }) @@ -825,7 +825,7 @@ func TestCloudDeploy_SpinloopVersion(t *testing.T) { isolateConfig(t) stubDeploySeams(t, "https://unused", "undeployed") writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv", "--spinloop-version", "1.2.6 beta"}) + err := cmdCloudDeploy([]string{"--node", "testenv", "--spinloop-version", "1.2.6 beta"}) if err == nil || !strings.Contains(err.Error(), "--spinloop-version") { t.Errorf("want a --spinloop-version validation error, got %v", err) } @@ -853,7 +853,7 @@ func TestCloudDeploy_InstanceType(t *testing.T) { writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--instance-type", "g6e.2xlarge"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--instance-type", "g6e.2xlarge"}); err != nil { t.Errorf("cmdCloudDeploy: %v", err) } }) @@ -883,7 +883,7 @@ func TestCloudDeploy_InstanceType(t *testing.T) { stubDeploySeams(t, server.URL, "undeployed") writeDeploySpinloopCwd(t) - if err := cmdCloudDeploy([]string{"--env", "testenv"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv"}); err != nil { t.Errorf("cmdCloudDeploy: %v", err) } // Absent, not null and not empty: an untyped deploy sends exactly what @@ -897,7 +897,7 @@ func TestCloudDeploy_InstanceType(t *testing.T) { isolateConfig(t) writeDeploySpinloopCwd(t) out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--dry-run"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--dry-run"}); err != nil { t.Errorf("cmdCloudDeploy --dry-run: %v", err) } }) @@ -910,7 +910,7 @@ func TestCloudDeploy_InstanceType(t *testing.T) { isolateConfig(t) stubDeploySeams(t, "https://unused", "undeployed") writeDeploySpinloopCwd(t) - err := cmdCloudDeploy([]string{"--env", "testenv", "--instance-type", "g6exlarge"}) + err := cmdCloudDeploy([]string{"--node", "testenv", "--instance-type", "g6exlarge"}) if err == nil || !strings.Contains(err.Error(), "--instance-type") { t.Errorf("want a --instance-type validation error, got %v", err) } @@ -941,7 +941,7 @@ func TestCloudDeploy_APIKeyEnvReachesTheRequest(t *testing.T) { t.Setenv("SHARED_KEY", "sk-supplied") out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--api-key-env", "SHARED_KEY"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--api-key-env", "SHARED_KEY"}); err != nil { t.Fatalf("cmdCloudDeploy: %v", err) } }) @@ -973,7 +973,7 @@ func TestCloudDeploy_APIKeyEnvUnsetFailsBeforeSending(t *testing.T) { writeDeploySpinloopCwd(t) os.Unsetenv("NOWHERE_DEPLOY_KEY") - err := cmdCloudDeploy([]string{"--env", "testenv", "--api-key-env", "NOWHERE_DEPLOY_KEY"}) + err := cmdCloudDeploy([]string{"--node", "testenv", "--api-key-env", "NOWHERE_DEPLOY_KEY"}) if err == nil || !strings.Contains(err.Error(), "NOWHERE_DEPLOY_KEY") { t.Errorf("want an error naming the variable, got %v", err) } @@ -997,7 +997,7 @@ func TestCloudDeploy_APIKeyEnvDryRunNamesTheVariable(t *testing.T) { t.Setenv("SHARED_KEY", "sk-supplied") out := captureStdout(t, func() { - if err := cmdCloudDeploy([]string{"--env", "testenv", "--dry-run", "--api-key-env", "SHARED_KEY"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv", "--dry-run", "--api-key-env", "SHARED_KEY"}); err != nil { t.Fatalf("cmdCloudDeploy --dry-run: %v", err) } }) @@ -1060,7 +1060,7 @@ func TestCloudStart_ReportsProgressWhileWaiting(t *testing.T) { writeCloudConfig(t, server.URL) stderr := captureStderr(t, func() { - if err := cmdCloudStart([]string{"--env", "default"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) @@ -1229,7 +1229,7 @@ func TestCloudStart_HeartbeatTracksTheCapacityWaitEnding(t *testing.T) { writeCloudConfig(t, server.URL) stderr := captureStderr(t, func() { - if err := cmdCloudStart([]string{"--env", "default"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default"}); err != nil { t.Fatalf("cmdCloudStart: %v", err) } }) @@ -1276,7 +1276,7 @@ func TestCloudStart_TimeoutShorthand(t *testing.T) { defer func() { os.Stderr = oldStderr; w.Close() }() done := make(chan error, 1) - go func() { done <- cmdCloudStart([]string{"--env", "default", "-t", "80ms"}) }() + go func() { done <- cmdCloudStart([]string{"--node", "default", "-t", "80ms"}) }() select { case err := <-done: if err == nil { @@ -1300,7 +1300,7 @@ func TestCloudStart_StdoutCarriesOnlyTheResult(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudStart([]string{"--env", "default", "--print-env"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default", "--print-env"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) diff --git a/cmd/spinloop/cloud_env_test.go b/cmd/spinloop/cloud_env_test.go index 3075cd57..50d5ab22 100644 --- a/cmd/spinloop/cloud_env_test.go +++ b/cmd/spinloop/cloud_env_test.go @@ -123,7 +123,7 @@ func TestCloudStop_RespectsDotEnv(t *testing.T) { unsetEnvOnCleanup(t, "SPINLOOP_CLOUD_STOP_URL") // An explicit Spinloop path, exercising resolveCloudConfig's explicit-arg branch. - if err := cmdCloudStop([]string{"Spinloop", "--env", "testenv"}); err != nil { + if err := cmdCloudStop([]string{"Spinloop", "--node", "testenv"}); err != nil { t.Fatalf("cmdCloudStop: %v", err) } select { @@ -163,7 +163,7 @@ func TestCloudStop_EnvKeywordOverridesDotEnv(t *testing.T) { } unsetEnvOnCleanup(t, "SPINLOOP_CLOUD_STOP_URL") - if err := cmdCloudStop([]string{"--env", "testenv"}); err != nil { + if err := cmdCloudStop([]string{"--node", "testenv"}); err != nil { t.Fatalf("cmdCloudStop: %v", err) } select { @@ -205,7 +205,7 @@ func TestCloudDeploy_DoesNotForwardEnvToInstance(t *testing.T) { } unsetEnvOnCleanup(t, "SPINLOOP_SECRET_TOKEN", "SPINLOOP_DOTENV_SECRET") - if err := cmdCloudDeploy([]string{"--env", "testenv"}); err != nil { + if err := cmdCloudDeploy([]string{"--node", "testenv"}); err != nil { t.Fatalf("cmdCloudDeploy: %v", err) } for _, leak := range []string{"SPINLOOP_SECRET_TOKEN", "do-not-leak", "SPINLOOP_DOTENV_SECRET", "also-no"} { diff --git a/cmd/spinloop/cloud_environments_test.go b/cmd/spinloop/cloud_environments_test.go index 66728410..b10f0a26 100644 --- a/cmd/spinloop/cloud_environments_test.go +++ b/cmd/spinloop/cloud_environments_test.go @@ -36,7 +36,7 @@ func stateServer(t *testing.T) *httptest.Server { })) } -// A bare environment name on --env resolves through the registry. +// A bare environment name on --node resolves through the registry. func TestCloud_EnvNameResolves(t *testing.T) { isolateConfig(t) stubAWSEnv(t) @@ -49,12 +49,12 @@ func TestCloud_EnvNameResolves(t *testing.T) { t.Fatal(err) } out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "prodenv"}); err != nil { - t.Errorf("status via --env name: %v", err) + if err := cmdStatus([]string{"--node", "prodenv"}); err != nil { + t.Errorf("status via --node name: %v", err) } }) if !strings.Contains(out, "running") { - t.Errorf("--env name should resolve via the registry, got:\n%s", out) + t.Errorf("--node name should resolve via the registry, got:\n%s", out) } } @@ -68,7 +68,7 @@ func TestCloud_DefaultEnvironment(t *testing.T) { t.Chdir(t.TempDir()) // no ./Spinloop here out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Errorf("status via default env: %v", err) } }) @@ -94,7 +94,7 @@ func TestCloud_SupersededFileIsNotRead(t *testing.T) { t.Fatal(err) } t.Chdir(t.TempDir()) - err := cmdMetrics([]string{"--env", "default"}) + err := cmdMetrics([]string{"--node", "default"}) if err == nil { t.Fatal("the superseded file must not configure an environment") } diff --git a/cmd/spinloop/cloud_logs_test.go b/cmd/spinloop/cloud_logs_test.go index f4f55f3e..22255a27 100644 --- a/cmd/spinloop/cloud_logs_test.go +++ b/cmd/spinloop/cloud_logs_test.go @@ -48,7 +48,7 @@ func TestRunnerForAcceptsExactlyTheRunnersWithLogGroups(t *testing.T) { } } -// spinloop logs --env defaults to the engine source and an hour window, and +// spinloop logs --node defaults to the engine source and an hour window, and // threads --source/--since/--limit/--instance through to the environment's // log store query. func TestTopLevelLogsDefaultsToTheEngineSourceAndAnHourWindow(t *testing.T) { @@ -65,7 +65,7 @@ func TestTopLevelLogsDefaultsToTheEngineSourceAndAnHourWindow(t *testing.T) { return cloud.LogResult{}, nil }) - if err := cmdLogs([]string{"--env", "prod"}); err != nil { + if err := cmdLogs([]string{"--node", "prod"}); err != nil { t.Fatal(err) } if got.Source != cloud.LogSourceEngine { @@ -93,7 +93,7 @@ func TestTopLevelLogsPassesTheFlagsThrough(t *testing.T) { return cloud.LogResult{}, nil }) - if err := cmdLogs([]string{"--env", "prod", "--source", "boot", "--since", "15m", + if err := cmdLogs([]string{"--node", "prod", "--source", "boot", "--since", "15m", "--limit", "5", "--instance", "i-42"}); err != nil { t.Fatal(err) } diff --git a/cmd/spinloop/cloud_schedule.go b/cmd/spinloop/cloud_schedule.go index 99924385..b4ec47d2 100644 --- a/cmd/spinloop/cloud_schedule.go +++ b/cmd/spinloop/cloud_schedule.go @@ -39,7 +39,7 @@ skipped while the instance is kept (spinloop cloud keep).`, func cloudScheduleSetCmd() *cobra.Command { var ( - envName string + nodeName string starts []string stops []string timezone string @@ -58,21 +58,21 @@ Europe/London and applies to every schedule given; it defaults to UTC. SilenceUsage: true, RunE: func(c *cobra.Command, _ []string) error { resolve(c) - return runCloudScheduleSet(envName, starts, stops, timezone) + return runCloudScheduleSet(nodeName, starts, stops, timezone) }, } fs := c.Flags() - fs.StringVar(&envName, "env", "", envFlagUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) fs.StringArrayVar(&starts, "start", nil, "cron expression that starts the environment (repeatable)") fs.StringArrayVar(&stops, "stop", nil, "cron expression that stops the environment (repeatable)") fs.StringVar(&timezone, "timezone", "", "IANA time zone for the schedules (default UTC)") - compRegister(c, "env", compEnvs) + compRegister(c, "node", compEnvs) c.ValidArgsFunction = noPositionals return c } func cloudScheduleShowCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "show", Short: "list the environment's schedules and their next runs", @@ -81,17 +81,17 @@ func cloudScheduleShowCmd() *cobra.Command { SilenceUsage: true, RunE: func(c *cobra.Command, _ []string) error { resolve(c) - return runCloudScheduleShow(envName) + return runCloudScheduleShow(nodeName) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) c.ValidArgsFunction = noPositionals return c } func cloudScheduleClearCmd() *cobra.Command { - var envName string + var nodeName string c := &cobra.Command{ Use: "clear", Short: "remove every schedule of the environment", @@ -100,11 +100,11 @@ func cloudScheduleClearCmd() *cobra.Command { SilenceUsage: true, RunE: func(c *cobra.Command, _ []string) error { resolve(c) - return runCloudScheduleClear(envName) + return runCloudScheduleClear(nodeName) }, } - c.Flags().StringVar(&envName, "env", "", envFlagUsage) - compRegister(c, "env", compEnvs) + c.Flags().StringVarP(&nodeName, "node", "n", "", nodeFlagUsage) + compRegister(c, "node", compEnvs) c.ValidArgsFunction = noPositionals return c } @@ -123,11 +123,11 @@ func scheduleListFor(starts, stops []string, timezone string) []cloud.Schedule { } // runCloudScheduleSet is the body of `spinloop cloud schedule set`. -func runCloudScheduleSet(envName string, starts, stops []string, timezone string) error { +func runCloudScheduleSet(nodeName string, starts, stops []string, timezone string) error { if len(starts) == 0 && len(stops) == 0 { return fmt.Errorf("nothing to set: pass --start and/or --stop (use `spinloop cloud schedule clear` to remove schedules)") } - cfg, err := resolveCloudConfig(envName, "") + cfg, err := resolveCloudConfig(nodeName, "") if err != nil { return err } @@ -140,8 +140,8 @@ func runCloudScheduleSet(envName string, starts, stops []string, timezone string } // runCloudScheduleShow is the body of `spinloop cloud schedule show`. -func runCloudScheduleShow(envName string) error { - cfg, err := resolveCloudConfig(envName, "") +func runCloudScheduleShow(nodeName string) error { + cfg, err := resolveCloudConfig(nodeName, "") if err != nil { return err } @@ -154,8 +154,8 @@ func runCloudScheduleShow(envName string) error { } // runCloudScheduleClear is the body of `spinloop cloud schedule clear`. -func runCloudScheduleClear(envName string) error { - cfg, err := resolveCloudConfig(envName, "") +func runCloudScheduleClear(nodeName string) error { + cfg, err := resolveCloudConfig(nodeName, "") if err != nil { return err } diff --git a/cmd/spinloop/cloud_schedule_test.go b/cmd/spinloop/cloud_schedule_test.go index a28833ea..08838b58 100644 --- a/cmd/spinloop/cloud_schedule_test.go +++ b/cmd/spinloop/cloud_schedule_test.go @@ -58,7 +58,7 @@ func TestCloudSchedule_SetSendsTheListAndPrintsIt(t *testing.T) { out := captureStdout(t, func() { err := cmdCloudSchedule([]string{ - "set", "--env", "default", + "set", "--node", "default", "--start", "0 8 * * 1-5", "--stop", "0 18 * * 1-5", "--timezone", "Europe/London", }) if err != nil { @@ -98,7 +98,7 @@ func TestCloudSchedule_SetAcceptsRepeatedFlags(t *testing.T) { got := scheduleFixture(t, http.StatusOK, officeReply) captureStdout(t, func() { err := cmdCloudSchedule([]string{ - "set", "--env", "default", "--start", "0 8 * * 1-5", "--start", "0 13 * * 6", + "set", "--node", "default", "--start", "0 8 * * 1-5", "--start", "0 13 * * 6", }) if err != nil { t.Errorf("set: %v", err) @@ -111,7 +111,7 @@ func TestCloudSchedule_SetAcceptsRepeatedFlags(t *testing.T) { func TestCloudSchedule_SetWithNothingPointsAtClear(t *testing.T) { got := scheduleFixture(t, http.StatusOK, officeReply) - err := cmdCloudSchedule([]string{"set", "--env", "default"}) + err := cmdCloudSchedule([]string{"set", "--node", "default"}) if err == nil || !strings.Contains(err.Error(), "schedule clear") { t.Errorf("expected an error naming clear, got %v", err) } @@ -123,7 +123,7 @@ func TestCloudSchedule_SetWithNothingPointsAtClear(t *testing.T) { func TestCloudSchedule_ShowListsSchedules(t *testing.T) { got := scheduleFixture(t, http.StatusOK, officeReply) out := captureStdout(t, func() { - if err := cmdCloudSchedule([]string{"show", "--env", "default"}); err != nil { + if err := cmdCloudSchedule([]string{"show", "--node", "default"}); err != nil { t.Errorf("show: %v", err) } }) @@ -138,7 +138,7 @@ func TestCloudSchedule_ShowListsSchedules(t *testing.T) { func TestCloudSchedule_ShowSaysWhenThereAreNone(t *testing.T) { scheduleFixture(t, http.StatusOK, `{"environment":"default","schedules":[],"next":{"start":"","stop":""}}`) out := captureStdout(t, func() { - if err := cmdCloudSchedule([]string{"show", "--env", "default"}); err != nil { + if err := cmdCloudSchedule([]string{"show", "--node", "default"}); err != nil { t.Errorf("show: %v", err) } }) @@ -153,7 +153,7 @@ func TestCloudSchedule_ShowSaysWhenThereAreNone(t *testing.T) { func TestCloudSchedule_OmitsANextRunThatDoesNotExist(t *testing.T) { scheduleFixture(t, http.StatusOK, `{"environment":"default","schedules":[{"action":"start","cron":"0 8 * * *","timezone":"UTC"}],"next":{"start":"2026-10-06T08:00:00.000Z","stop":""}}`) out := captureStdout(t, func() { - if err := cmdCloudSchedule([]string{"show", "--env", "default"}); err != nil { + if err := cmdCloudSchedule([]string{"show", "--node", "default"}); err != nil { t.Errorf("show: %v", err) } }) @@ -165,7 +165,7 @@ func TestCloudSchedule_OmitsANextRunThatDoesNotExist(t *testing.T) { func TestCloudSchedule_ClearDeletes(t *testing.T) { got := scheduleFixture(t, http.StatusOK, `{"environment":"default","schedules":[],"next":{}}`) out := captureStdout(t, func() { - if err := cmdCloudSchedule([]string{"clear", "--env", "default"}); err != nil { + if err := cmdCloudSchedule([]string{"clear", "--node", "default"}); err != nil { t.Errorf("clear: %v", err) } }) @@ -179,7 +179,7 @@ func TestCloudSchedule_ClearDeletes(t *testing.T) { func TestCloudSchedule_ControlPlaneRejectionIsTheError(t *testing.T) { scheduleFixture(t, http.StatusBadRequest, `{"error":"invalid cron expression \"x\": expected five fields"}`) - err := cmdCloudSchedule([]string{"set", "--env", "default", "--start", "x"}) + err := cmdCloudSchedule([]string{"set", "--node", "default", "--start", "x"}) if err == nil || !strings.Contains(err.Error(), "expected five fields") { t.Errorf("expected the control plane's reason, got %v", err) } @@ -189,7 +189,7 @@ func TestCloudSchedule_OlderControlPlaneNamesTheFix(t *testing.T) { isolateConfig(t) stubAWSEnv(t) writeCloudConfig(t, "https://start.example/") - err := cmdCloudSchedule([]string{"show", "--env", "default"}) + err := cmdCloudSchedule([]string{"show", "--node", "default"}) if err == nil || !strings.Contains(err.Error(), "spinloop cloud bootstrap") { t.Errorf("expected an error naming bootstrap, got %v", err) } @@ -198,7 +198,7 @@ func TestCloudSchedule_OlderControlPlaneNamesTheFix(t *testing.T) { func TestCloudSchedule_NeedsAnEnvironment(t *testing.T) { isolateConfig(t) err := cmdCloudSchedule([]string{"show"}) - if err == nil || !strings.Contains(err.Error(), "--env") { + if err == nil || !strings.Contains(err.Error(), "--node") { t.Errorf("expected the no-environment error, got %v", err) } } @@ -206,7 +206,7 @@ func TestCloudSchedule_NeedsAnEnvironment(t *testing.T) { func TestCloudSchedule_ClearAndShowReportControlPlaneFailures(t *testing.T) { scheduleFixture(t, http.StatusInternalServerError, `{"error":"scheduler unavailable"}`) for _, verb := range []string{"clear", "show"} { - err := cmdCloudSchedule([]string{verb, "--env", "default"}) + err := cmdCloudSchedule([]string{verb, "--node", "default"}) if err == nil || !strings.Contains(err.Error(), "scheduler unavailable") { t.Errorf("%s: expected the control plane's reason, got %v", verb, err) } diff --git a/cmd/spinloop/cloud_test.go b/cmd/spinloop/cloud_test.go index 7e53d733..2561e413 100644 --- a/cmd/spinloop/cloud_test.go +++ b/cmd/spinloop/cloud_test.go @@ -77,13 +77,13 @@ func TestCloud_Unconfigured(t *testing.T) { // instance, and an instance nobody named is not one to act on. for _, sub := range subs { err := run([]string{"cloud", sub}) - if err == nil || !strings.Contains(err.Error(), "pass --env") { + if err == nil || !strings.Contains(err.Error(), "pass --node") { t.Errorf("cloud %s with no environment should name the flag, got %v", sub, err) } } // Naming one that has no configuration explains the setup. for _, sub := range subs { - err := run([]string{"cloud", sub, "--env", "default"}) + err := run([]string{"cloud", sub, "--node", "default"}) if err == nil || !strings.Contains(err.Error(), "not configured") { t.Errorf("cloud %s without config should explain setup, got %v", sub, err) } @@ -101,7 +101,7 @@ func TestCloudStart_PrintsExports(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudStart([]string{"--env", "default", "--print-env"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default", "--print-env"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) @@ -123,7 +123,7 @@ func TestCloudStart_NoExportsWithoutFlag(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudStart([]string{"--env", "default"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) @@ -153,7 +153,7 @@ func TestCloudStart_FlagAfterPositional(t *testing.T) { } out := captureStdout(t, func() { - if err := cmdCloudStart([]string{dir, "--env", "testenv", "--print-env"}); err != nil { + if err := cmdCloudStart([]string{dir, "--node", "testenv", "--print-env"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) @@ -178,7 +178,7 @@ func TestCloudEnv_PrintsExports(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudEnv([]string{"--env", "default"}); err != nil { + if err := cmdCloudEnv([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudEnv: %v", err) } }) @@ -199,7 +199,7 @@ func TestCloudStatus_PrintsState(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Errorf("cmdStatus: %v", err) } }) @@ -248,7 +248,7 @@ func TestCloudStatus_PrintsVersion(t *testing.T) { } out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Errorf("cmdStatus: %v", err) } }) @@ -271,7 +271,7 @@ func TestCloudStop_PrintsState(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudStop([]string{"--env", "default"}); err != nil { + if err := cmdCloudStop([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudStop: %v", err) } }) @@ -296,7 +296,7 @@ func TestCloudPause_PrintsState(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudPause([]string{"--env", "default"}); err != nil { + if err := cmdCloudPause([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudPause: %v", err) } }) @@ -342,7 +342,7 @@ func TestCloudRestart_Flow(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := run([]string{"cloud", "restart", "--env", "default"}); err != nil { + if err := run([]string{"cloud", "restart", "--node", "default"}); err != nil { t.Errorf("cloud restart: %v", err) } }) @@ -370,7 +370,7 @@ func TestCloudRestart_ForceFlag(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudRestart([]string{"--env", "default", flag}); err != nil { + if err := cmdCloudRestart([]string{"--node", "default", flag}); err != nil { t.Errorf("cloud restart %s: %v", flag, err) } }) @@ -398,7 +398,7 @@ func TestCloudRestart_TimeoutFlag(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudRestart([]string{"--env", "default", "--timeout", "5m"}); err != nil { + if err := cmdCloudRestart([]string{"--node", "default", "--timeout", "5m"}); err != nil { t.Errorf("cloud restart --timeout: %v", err) } }) @@ -419,7 +419,7 @@ func TestCloudRestart_AlreadyStoppedBehavesAsStart(t *testing.T) { writeCloudConfig(t, server.URL) out := captureStdout(t, func() { - if err := cmdCloudRestart([]string{"--env", "default"}); err != nil { + if err := cmdCloudRestart([]string{"--node", "default"}); err != nil { t.Errorf("cloud restart on a stopped environment: %v", err) } }) @@ -455,7 +455,7 @@ func TestCloudRestart_StatusFailureDoesNotGate(t *testing.T) { out := captureStdout(t, func() { errOut := captureStderr(t, func() { - if err := cmdCloudRestart([]string{"--env", "default"}); err != nil { + if err := cmdCloudRestart([]string{"--node", "default"}); err != nil { t.Errorf("cloud restart after a failed status check: %v", err) } }) @@ -486,7 +486,7 @@ func TestCloudRestart_WakeFailureReportsRecovery(t *testing.T) { defer server.Close() writeCloudConfig(t, server.URL) - err := cmdCloudRestart([]string{"--env", "default"}) + err := cmdCloudRestart([]string{"--node", "default"}) if err == nil { t.Fatal("expected a wake failure error") } @@ -532,7 +532,7 @@ func TestCloud_SpinloopDiscovery(t *testing.T) { } out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default", "--spinloop", "Spinloop"}); err != nil { + if err := cmdStatus([]string{"--node", "default", "--spinloop", "Spinloop"}); err != nil { t.Errorf("cmdStatus: %v", err) } }) @@ -543,7 +543,7 @@ func TestCloud_SpinloopDiscovery(t *testing.T) { // --spinloop reads a Spinloop for its ENV instructions and the adjacent // .env; it never selects an environment by itself. One with no ENV lines -// supplies nothing, so with no --env or --fleet either the command fails on +// supplies nothing, so with no --node or --fleet either the command fails on // the missing target, not on the Spinloop. func TestCloud_ExplicitSpinloopDoesNotNameAnEnvironment(t *testing.T) { isolateConfig(t) @@ -572,12 +572,12 @@ func TestCloud_SpinloopFallsBackToTheUserConfig(t *testing.T) { t.Fatal(err) } out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Errorf("cmdStatus: %v", err) } }) if !strings.Contains(out, "stopped") { - t.Errorf("a Spinloop with no --env should fall back to the user config, got:\n%s", out) + t.Errorf("a Spinloop with no --node should fall back to the user config, got:\n%s", out) } } @@ -599,7 +599,7 @@ func TestCloud_IgnoresLowercaseSpinloopFile(t *testing.T) { t.Fatal(err) } out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Errorf("cmdStatus: %v", err) } }) @@ -644,7 +644,7 @@ func TestCloudMetrics_Running(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=table"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=table"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -688,7 +688,7 @@ func TestCloudMetrics_Stopped(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=table"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=table"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -717,7 +717,7 @@ func TestCloudMetrics_WithErrors(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) errOut := captureStderr(t, func() { - if err := cmdMetrics([]string{"--env", "default"}); err != nil { + if err := cmdMetrics([]string{"--node", "default"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -752,7 +752,7 @@ func TestCloudMetrics_DefaultFormat(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default"}); err != nil { + if err := cmdMetrics([]string{"--node", "default"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -789,7 +789,7 @@ func TestCloudMetrics_JsonFormat(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=json"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=json"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -831,7 +831,7 @@ func TestCloudMetrics_JsonFormatWithCost(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=json", "--cost"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=json", "--cost"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -852,7 +852,7 @@ func TestCloudMetrics_InvalidFormat(t *testing.T) { stubAWSEnv(t) writeCloudConfig(t, "http://localhost:0") - err := cmdMetrics([]string{"--env", "default", "--format=csv"}) + err := cmdMetrics([]string{"--node", "default", "--format=csv"}) if err == nil || !strings.Contains(err.Error(), "format") { t.Errorf("expected format error, got %v", err) } @@ -882,7 +882,7 @@ func TestCloudMetrics_BarFormat(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - err := cmdMetrics([]string{"--env", "default", "--format=bar"}) + err := cmdMetrics([]string{"--node", "default", "--format=bar"}) if err != nil { t.Errorf("bar format failed: %v", err) } @@ -927,7 +927,7 @@ func TestCloudMetrics_BarFormatStopped(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - err := cmdMetrics([]string{"--env", "default", "--format=bar"}) + err := cmdMetrics([]string{"--node", "default", "--format=bar"}) if err != nil { t.Errorf("bar format failed: %v", err) } @@ -1003,7 +1003,7 @@ func TestCloudMetrics_WatchMode(t *testing.T) { defer func() { metricsWatchInterval = oldInterval }() out := captureStdout(t, func() { - err := cmdMetrics([]string{"--env", "default", "--watch", "--format=table"}) + err := cmdMetrics([]string{"--node", "default", "--watch", "--format=table"}) // Expects error from the 3rd call. if err == nil { t.Error("watch should exit with error when server fails") @@ -1043,7 +1043,7 @@ func TestCloudMetrics_WatchShortFlag(t *testing.T) { defer func() { metricsWatchInterval = oldInterval }() out := captureStdout(t, func() { - err := cmdMetrics([]string{"--env", "default", "-w", "--format=table"}) + err := cmdMetrics([]string{"--node", "default", "-w", "--format=table"}) if err == nil { t.Error("-w should exit with error when server fails") } @@ -1075,7 +1075,7 @@ func TestCloudMetrics_MultiGPU(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=table"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=table"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -1098,7 +1098,7 @@ func TestCloudMetrics_JsonStopped(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=json"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=json"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -1132,7 +1132,7 @@ func TestCloudMetrics_JsonWithErrors(t *testing.T) { t.Setenv("SPINLOOP_CLOUD_STATS_URL", server.URL) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=json"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=json"}); err != nil { t.Errorf("cmdMetrics: %v", err) } }) @@ -1182,7 +1182,7 @@ func TestCloudStart_ProbeSucceedsNoWarning(t *testing.T) { writeCloudConfig(t, server.URL) stderr := captureStderr(t, func() { - if err := cmdCloudStart([]string{"--env", "default"}); err != nil { + if err := cmdCloudStart([]string{"--node", "default"}); err != nil { t.Errorf("cmdCloudStart: %v", err) } }) @@ -1213,7 +1213,7 @@ func TestCloudStart_ProbeFailsWarns(t *testing.T) { writeCloudConfig(t, server.URL) errOut := captureStderr(t, func() { - err := cmdCloudStart([]string{"--env", "default"}) + err := cmdCloudStart([]string{"--node", "default"}) if err != nil { t.Fatalf("start should exit 0 after a probe warning, got %v", err) } @@ -1249,7 +1249,7 @@ func TestCloudStart_ProbeFailsIPDetectFails(t *testing.T) { writeCloudConfig(t, server.URL) errOut := captureStderr(t, func() { - err := cmdCloudStart([]string{"--env", "default"}) + err := cmdCloudStart([]string{"--node", "default"}) if err != nil { t.Fatalf("start should exit 0 even when probe and IP detection both fail, got %v", err) } @@ -1291,7 +1291,7 @@ func TestCloudMetrics_WatchBuffersBeforeClear(t *testing.T) { defer func() { metricsWatchInterval = oldInterval }() out := captureStdout(t, func() { - cmdMetrics([]string{"--env", "default", "--watch", "--format=table"}) + cmdMetrics([]string{"--node", "default", "--watch", "--format=table"}) }) // The clear-screen escape sequence. @@ -1352,7 +1352,7 @@ func TestCloudKeep_PrintsDeadline(t *testing.T) { os.WriteFile(path, must1(json.Marshal(cfg)), 0o600) out := captureStdout(t, func() { - if err := cmdCloudKeep([]string{"--env", "default", "4h"}); err != nil { + if err := cmdCloudKeep([]string{"--node", "default", "4h"}); err != nil { t.Errorf("cmdCloudKeep: %v", err) } }) @@ -1363,7 +1363,7 @@ func TestCloudKeep_PrintsDeadline(t *testing.T) { // TestCloudKeep_MissingDuration fails. func TestCloudKeep_MissingDuration(t *testing.T) { - err := cmdCloudKeep([]string{"--env", "default"}) + err := cmdCloudKeep([]string{"--node", "default"}) if err == nil || !strings.Contains(err.Error(), "usage") { t.Errorf("expected usage error, got %v", err) } @@ -1371,7 +1371,7 @@ func TestCloudKeep_MissingDuration(t *testing.T) { // TestCloudKeep_InvalidDuration fails. func TestCloudKeep_InvalidDuration(t *testing.T) { - err := cmdCloudKeep([]string{"--env", "default", "4hours"}) + err := cmdCloudKeep([]string{"--node", "default", "4hours"}) if err == nil || !strings.Contains(err.Error(), "invalid duration") { t.Errorf("expected duration parse error, got %v", err) } @@ -1392,7 +1392,7 @@ func TestCloudStart_KeepFlag(t *testing.T) { // Probe reachability will fail, but that's stderr and doesn't affect the test. out := captureStdout(t, func() { - cmdCloudStart([]string{"--env", "default", "--keep", "2h"}) + cmdCloudStart([]string{"--node", "default", "--keep", "2h"}) }) // The keep deadline should be reported on stderr (via progress). // We can check that the request included the retainUntil parameter. diff --git a/cmd/spinloop/code.go b/cmd/spinloop/code.go index 9a18a70e..494d2df8 100644 --- a/cmd/spinloop/code.go +++ b/cmd/spinloop/code.go @@ -80,21 +80,22 @@ func openLaunchCmd(use, short, long string) *cobra.Command { if err != nil { return err } - } else if route.envName != "" { - // No Spinloop was applied, but --env still names where the - // model is served from: configure the harness from what is - // actually deployed there instead of doing nothing with the - // flag. + } else if route.node != "" && route.fleetFile() == "" { + // No Spinloop was applied, and no fleet is in force, so + // --node names where the model is served from: configure the + // harness from what is actually deployed to that registered + // environment instead of doing nothing with the flag. var err error sel, envDir, cloudResp, choice, err = applyFromEnvironment(providers, h, route) if err != nil { return err } - } else if route.fleetPath != "" { - // No Spinloop, but a fleet was named. A gateway resolves the - // model per request, so a fleet that names one needs no - // Spinloop; a fleet that does not still does, and this says - // so. + } else if route.fleetPath != "" || route.node != "" { + // No Spinloop, but a fleet was named — by flag, or by --node + // landing on the working directory's fleet.yaml. A gateway + // resolves the model per request, so a fleet that names one + // needs no Spinloop; a fleet that does not still does, and + // this says so. var err error sel, envDir, cloudResp, choice, err = applyFromGateway(providers, h, route) if err != nil { @@ -112,10 +113,9 @@ func openLaunchCmd(use, short, long string) *cobra.Command { // path readSpinloop resolves as SPINLOOP_ALIAS > ./Spinloop. fs.Lookup("spinloop").NoOptDefVal = "true" fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") - fs.StringVarP(&route.envName, "env", "e", "", "launch against this registered environment (its name keys the provider, its API key is fetched and injected)") - compRegister(c, "env", compEnvs) + fs.StringVarP(&route.node, "node", "n", "", "route to this node: with a fleet in force, pin the launch to that fleet node; otherwise, launch against this registered environment (its name keys the provider, its API key is fetched and injected)") + compRegister(c, "node", compNodes) fs.StringVarP(&route.fleetPath, "fleet", "f", "", "route through this fleet file (default: ./fleet.yaml, when the Spinloop is not named)") - 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") fs.DurationVar(&route.wakeTimeout, "wake-timeout", 0, "how long to wait for a woken node's engine") diff --git a/cmd/spinloop/commands.go b/cmd/spinloop/commands.go index cb58b9f6..fd785912 100644 --- a/cmd/spinloop/commands.go +++ b/cmd/spinloop/commands.go @@ -353,9 +353,9 @@ var movedSubcommands = map[string]string{ "fleet dashboard": "dashboard", "fleet metrics": "metrics", "fleet logs": "logs", - "cloud status": "status --env ", - "cloud metrics": "metrics --env ", - "cloud logs": "logs --env ", + "cloud status": "status --node ", + "cloud metrics": "metrics --node ", + "cloud logs": "logs --node ", } // fleetCmd builds the fleet parent and its subcommands. The parent does @@ -393,7 +393,7 @@ func cloudCmd() *cobra.Command { Short: "control the cloud GPU inference instance", Long: `runs the model on a cloud GPU that exists only while you use it, from the same Spinloop. Each environment is registered under -~/.config/spinloop/clouds// and selected with --env . Each +~/.config/spinloop/clouds// and selected with --node . Each subcommand's --help says what that step does.`, Args: groupArgs, SilenceErrors: true, diff --git a/cmd/spinloop/complete.go b/cmd/spinloop/complete.go index 2e3ca2a7..9452873e 100644 --- a/cmd/spinloop/complete.go +++ b/cmd/spinloop/complete.go @@ -22,6 +22,7 @@ import ( "github.com/spinloop-ai/spinloop/internal/config" "github.com/spinloop-ai/spinloop/internal/daemon" "github.com/spinloop-ai/spinloop/internal/discovery" + "github.com/spinloop-ai/spinloop/internal/fleet" "github.com/spinloop-ai/spinloop/internal/harness" "github.com/spinloop-ai/spinloop/internal/opencode" ) @@ -208,6 +209,62 @@ func compEnvs(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellComp return names, cobra.ShellCompDirectiveNoFileComp } +// compNodeNames completes a --node value: the registered environment names, +// plus the node names of the fleet in force on the command line — the --fleet +// value where given, else ./fleet.yaml when one sits in the working directory. +// An unreadable fleet yields just the environments rather than an error: +// completion must never fail, and the fleet may simply not exist yet. +func compNodeNames(args []string) ([]string, cobra.ShellCompDirective) { + names, _ := compEnvs(nil, nil, "") + seen := make(map[string]bool, len(names)) + for _, n := range names { + seen[n] = true + } + target := "" + for i, w := range args { + switch { + case w == "--fleet" || w == "-f": + if i+1 < len(args) { + target = args[i+1] + } + case strings.HasPrefix(w, "--fleet="): + target = strings.TrimPrefix(w, "--fleet=") + } + } + if target == "" { + if _, err := os.Stat(fleet.DefaultFile); err != nil { + return names, cobra.ShellCompDirectiveNoFileComp + } + target = fleet.DefaultFile + } + cfg, err := fleet.Load(target) + if err != nil { + return names, cobra.ShellCompDirectiveNoFileComp + } + for _, n := range cfg.Nodes { + if !seen[n.Name] { + seen[n.Name] = true + names = append(names, n.Name) + } + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// compNodes is compNodeNames under Cobra's flag-completion signature, for a +// fleet command's --node: the fleet in force feeds the names. Parsing eats +// --fleet out of the positional args before this runs, so the flag's own +// value is appended for compNodeNames to find; on a launch, where parsing is +// off, the raw words are already there. +func compNodes(c *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + words := args + if c != nil { + if f := c.Flags().Lookup("fleet"); f != nil && f.Value.String() != "" { + words = append(append([]string{}, args...), "--fleet", f.Value.String()) + } + } + return compNodeNames(words) +} + // Positional slots. The engine hands a command's ValidArgsFunction the // positional arguments already on the line, which is how a fixed-arity slot // knows it has been filled. @@ -275,6 +332,7 @@ var launchValueFlags = map[string]bool{ "--fleet": true, "-f": true, "--node": true, + "-n": true, "--prefer": true, "--wake-timeout": true, } @@ -291,6 +349,8 @@ func launchSlot(_ *cobra.Command, args []string, toComplete string) ([]string, c switch toComplete[:eq] { case "--spinloop", "-O": return aliasNames(), cobra.ShellCompDirectiveDefault + case "--node", "-n": + return compNodeNames(args) } return nil, cobra.ShellCompDirectiveNoFileComp } @@ -308,7 +368,9 @@ func launchSlot(_ *cobra.Command, args []string, toComplete string) ([]string, c return harness.Names(), cobra.ShellCompDirectiveNoFileComp case "--providers", "--fleet", "-f": return nil, cobra.ShellCompDirectiveDefault - case "--node", "--prefer", "--wake-timeout": + case "--node", "-n": + return compNodeNames(args) + case "--prefer", "--wake-timeout": return nil, cobra.ShellCompDirectiveNoFileComp } } diff --git a/cmd/spinloop/dashboard.go b/cmd/spinloop/dashboard.go index 9aa2b4b2..2488eb7a 100644 --- a/cmd/spinloop/dashboard.go +++ b/cmd/spinloop/dashboard.go @@ -11,14 +11,14 @@ import ( ) func dashboardCmd() *cobra.Command { - var path, envName, spinloopPath string + var path, nodeName, spinloopPath string c := &cobra.Command{ Use: "dashboard", Short: "watch the engines in an interactive tiled view", Long: `An interactive live view of the target: a tile per node, each drawing what the gauge format of fleet metrics prints — state, what it serves, the resource gauges, the token counters — repainted on an interval. A single -registered environment (--env) opens as a board of one. +registered environment (--node) opens as a board of one. The view is read-only apart from four keys: s starts the selected node, k keeps a cloud environment for a duration you type — it asks how long, @@ -42,16 +42,16 @@ metrics --watch instead.`, if err := applyReadSpinloopEnv(spinloopPath); err != nil { return err } - return runFleetDashboard(fleetTarget{envName: envName, fleetPath: path}) + return runFleetDashboard(fleetTarget{nodeName: nodeName, fleetPath: path}) }, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) registerSpinloopEnvFlag(fs, &spinloopPath) c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } diff --git a/cmd/spinloop/flagparse_test.go b/cmd/spinloop/flagparse_test.go index e0462d1b..ff384087 100644 --- a/cmd/spinloop/flagparse_test.go +++ b/cmd/spinloop/flagparse_test.go @@ -29,7 +29,7 @@ func TestPflagParseForms(t *testing.T) { "apply": func() error { return cmdApply([]string{"--nope"}) }, "serve": func() error { return cmdServe([]string{"--nope"}) }, "fleet metrics": func() error { return cmdMetrics([]string{"--nope"}) }, - "cloud start": func() error { return cmdCloudStart([]string{"--env", "default", "--nope"}) }, + "cloud start": func() error { return cmdCloudStart([]string{"--node", "default", "--nope"}) }, "daemon": func() error { return cmdDaemon([]string{"--nope"}) }, } { if err := call(); err == nil || !strings.Contains(err.Error(), "unknown flag: --nope") { @@ -51,7 +51,7 @@ func TestPflagParseForms(t *testing.T) { // the proof that parsing continued past it. for name, call := range map[string]func() error{ "fleet metrics": func() error { return cmdMetrics([]string{"someNode", "--nope"}) }, - "cloud env": func() error { return cmdCloudEnv([]string{"--env", "default", "somePath", "--nope"}) }, + "cloud env": func() error { return cmdCloudEnv([]string{"--node", "default", "somePath", "--nope"}) }, } { if err := call(); err == nil || !strings.Contains(err.Error(), "unknown flag: --nope") { t.Errorf("%s --nope = %v, want unknown-flag error", name, err) diff --git a/cmd/spinloop/fleet.go b/cmd/spinloop/fleet.go index 25cca9bd..689b70d7 100644 --- a/cmd/spinloop/fleet.go +++ b/cmd/spinloop/fleet.go @@ -106,7 +106,7 @@ func runFleetMetricsWatch(cfg *fleet.Config, format string, call fleet.Call) err if ctx.Err() != nil { return nil } - // A single-node target (an --env watch, or a fleet of one) with + // A single-node target (an --node watch, or a fleet of one) with // nothing else to show for the poll ends the watch rather than // redrawing the same failure forever: this is the one node the // caller asked about, and it could not be read. A multi-node fleet @@ -307,9 +307,9 @@ func renderFleetMetricsJSON(w io.Writer, results []fleet.NodeResult) error { // deploy time. func fleetStartCmd() *cobra.Command { var ( - path string - envName string - all bool + path string + nodeName string + all bool ) c := &cobra.Command{ Use: "start", @@ -319,20 +319,20 @@ func fleetStartCmd() *cobra.Command { SilenceUsage: true, RunE: func(c *cobra.Command, args []string) error { resolve(c) - cfg, err := resolveFleetTarget(fleetTarget{envName: envName, fleetPath: path}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: nodeName, fleetPath: path}) if err != nil { return err } - return runFleetDrive("start", cfg, all, args, fleetStartCall(cfg)) + return runFleetDrive("start", cfg, all, args, nodeName, fleetStartCall(cfg)) }, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) fs.BoolVar(&all, "all", false, "start every node in the fleet") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } @@ -341,9 +341,9 @@ func fleetStartCmd() *cobra.Command { // its target selection is shared with start. func fleetStopCmd() *cobra.Command { var ( - path string - envName string - all bool + path string + nodeName string + all bool ) c := &cobra.Command{ Use: "stop", @@ -353,7 +353,7 @@ func fleetStopCmd() *cobra.Command { SilenceUsage: true, RunE: func(c *cobra.Command, args []string) error { resolve(c) - cfg, err := resolveFleetTarget(fleetTarget{envName: envName, fleetPath: path}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: nodeName, fleetPath: path}) if err != nil { return err } @@ -361,16 +361,16 @@ func fleetStopCmd() *cobra.Command { status, err := n.Stop(ctx) return fleet.Result(n.Name(), err, status) } - return runFleetDrive("stop", cfg, all, args, call) + return runFleetDrive("stop", cfg, all, args, nodeName, call) }, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) fs.BoolVar(&all, "all", false, "stop every node in the fleet") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } @@ -414,10 +414,21 @@ func fleetStartCall(cfg *fleet.Config) fleet.Call { // runFleetDrive selects the nodes a mutating fleet command targets and fans // call out over them: no names and no --all fails, listing the fleet's // nodes; --all and names together fails as ambiguous; named nodes fail -// before anything is touched if any name is unknown. Replaces the old -// driveOneNode now that start and stop both take several nodes or --all, -// not just one. -func runFleetDrive(verb string, cfg *fleet.Config, all bool, names []string, call fleet.Call) error { +// before anything is touched if any name is unknown. The --node flag is one +// more way to name the node — it narrows the whole fleet before the names +// are weighed, so `-n gpu-box` stands in for the argument it would select. +func runFleetDrive(verb string, cfg *fleet.Config, all bool, names []string, nodeName string, call fleet.Call) error { + if nodeName != "" { + var err error + if cfg, err = cfg.Only(nodeName); err != nil { + return err + } + // The flag has just narrowed the fleet to this one node; it stands + // in for the name the command would otherwise demand. + if len(names) == 0 && !all { + names = []string{nodeName} + } + } if all && len(names) > 0 { return fmt.Errorf("spinloop fleet %s: --all is ambiguous with node names", verb) } @@ -460,7 +471,7 @@ func runFleetDrive(verb string, cfg *fleet.Config, all bool, names []string, cal func fleetDeployCmd() *cobra.Command { var ( path string - envName string + nodeName string all bool dryRun bool overwrite bool @@ -483,7 +494,7 @@ derivation, consent, and registration behaviour as "spinloop cloud deploy".`, SilenceUsage: true, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runFleetDeploy(fleetTarget{envName: envName, fleetPath: path}, all, args, deployOpts{ + return runFleetDeploy(fleetTarget{nodeName: nodeName, fleetPath: path}, all, args, deployOpts{ dryRun: dryRun, overwrite: overwrite, reseed: reseed, @@ -496,7 +507,7 @@ derivation, consent, and registration behaviour as "spinloop cloud deploy".`, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVar(&nodeName, "node", "", nodeFlagTargetUsage) fs.BoolVar(&all, "all", false, "deploy every kind: cloud 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") @@ -507,7 +518,7 @@ derivation, consent, and registration behaviour as "spinloop cloud deploy".`, fs.StringVar(&apiKeyEnv, "api-key-env", "", "name the environment variable holding the engine key to create or rotate, applied to every targeted node; with no flag each environment keeps its stored key") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } @@ -517,9 +528,16 @@ func runFleetDeploy(target fleetTarget, all bool, names []string, opts deployOpt if err != nil { return err } - if all && len(names) > 0 { + if all && (len(names) > 0 || target.nodeName != "") { return fmt.Errorf("spinloop fleet deploy: --all is ambiguous with node names") } + // The flag's node and the argument list are the two ways to name nodes; + // the flag's is just one more name, verified against the fleet the same + // way. Two spellings of one node collapse; the check below proves it is + // a cloud node of this fleet once. + if target.nodeName != "" { + names = append([]string{target.nodeName}, names...) + } cloudNames := make([]string, 0, len(cfg.Nodes)) for _, n := range cfg.Nodes { @@ -782,7 +800,7 @@ func resolveNodeSpinloop(node fleet.NodeConfig, fleetDir string) (arg, source st // config written. It is how a routing decision is checked before an agent // depends on it, and how an unexpected choice is diagnosed after one. func fleetRouteCmd() *cobra.Command { - var path, envName, node, prefer string + var path, node, prefer string c := &cobra.Command{ Use: "route", Short: "report which node a launch would take", @@ -791,22 +809,21 @@ func fleetRouteCmd() *cobra.Command { SilenceUsage: true, RunE: func(c *cobra.Command, args []string) error { resolve(c) - return runFleetRoute(path, envName, node, prefer, args) + return runFleetRoute(path, node, prefer, args) }, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) - fs.StringVar(&node, "node", "", "report this node rather than choosing one") + fs.StringVarP(&node, "node", "n", "", "report this node rather than choosing one; names a registered environment to route through when no fleet is in force") fs.StringVar(&prefer, "prefer", "", "rank nodes by `idle` or `active` (overrides the fleet file)") c.ValidArgsFunction = aliasSlot compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } // runFleetRoute is the body of `spinloop fleet route`. -func runFleetRoute(path, envName, node, prefer string, args []string) error { +func runFleetRoute(path, node, prefer string, args []string) error { var spinloopPath string if len(args) > 0 { spinloopPath = args[0] @@ -820,18 +837,26 @@ func runFleetRoute(path, envName, node, prefer string, args []string) error { // working directory when the Spinloop was not named explicitly — no // positional and no SPINLOOP_ALIAS, the default Spinloop standing in for // the user. A named Spinloop routes only by flag, as a launch does. + // A --node does not suppress discovery, as it does not for a launch: + // where a fleet is in force the flag is a pin into it, and it names a + // registered environment only where none is. target := path - if envName == "" && target == "" && spinloopPath == "" && !spinloopAliasInForce() { + if target == "" && spinloopPath == "" && !spinloopAliasInForce() { if _, err := os.Stat(fleet.DefaultFile); err == nil { target = fleet.DefaultFile } } - if envName == "" && target == "" { + if node == "" && target == "" { return fmt.Errorf( - "no fleet to route %s through: pass --fleet or --env to say which fleet to route through", + "no fleet to route %s through: pass --fleet or --node to say which fleet to route through", resolvedPath) } - cfg, err := resolveFleetTarget(fleetTarget{envName: envName, fleetPath: target}) + var cfg *fleet.Config + if target != "" { + cfg, err = fleet.Resolve(target) + } else { + cfg, err = fleet.ForEnvironment(node) + } if err != nil { return err } diff --git a/cmd/spinloop/fleet_dashboard.go b/cmd/spinloop/fleet_dashboard.go index d428f694..34e81933 100644 --- a/cmd/spinloop/fleet_dashboard.go +++ b/cmd/spinloop/fleet_dashboard.go @@ -39,6 +39,13 @@ func dashModelFor(target fleetTarget) (dashModel, error) { if err != nil { return dashModel{}, err } + // The flag's node is the whole board: one tile, as the one-row status + // table is its one-row mirror. + if target.nodeName != "" { + if cfg, err = cfg.Only(target.nodeName); err != nil { + return dashModel{}, err + } + } entries := make([]dashEntry, 0, len(cfg.Nodes)) results := make([]fleet.NodeResult, 0, len(cfg.Nodes)) actions := make([]dashAction, 0, len(cfg.Nodes)) diff --git a/cmd/spinloop/fleet_logs.go b/cmd/spinloop/fleet_logs.go index be108d10..a4e71070 100644 --- a/cmd/spinloop/fleet_logs.go +++ b/cmd/spinloop/fleet_logs.go @@ -34,10 +34,19 @@ func runLogs(target fleetTarget, q fleet.LogQuery, follow bool, limit int, forma if err != nil { return err } - // A named node restricts the read; the target still supplies its details, - // so an unknown name is caught here rather than at the socket. - if len(args) > 0 { - if cfg, err = cfg.Only(args[0]); err != nil { + // The flag's node and a positional argument are the two ways to say + // which node; when both say, they must say the same one. The target + // still supplies its details, so an unknown name is caught here rather + // than at the socket. + only := args0(args) + if only == "" { + only = target.nodeName + } else if target.nodeName != "" && target.nodeName != only { + return fmt.Errorf("--node %s and the argument %s name different nodes: name the one to read", + target.nodeName, only) + } + if only != "" { + if cfg, err = cfg.Only(only); err != nil { return err } } @@ -238,3 +247,13 @@ func quietened(results []fleet.NodeResult, first bool) []fleet.NodeResult { } return kept } + +// args0 is the first positional argument, or "" when there is none — the way +// a command reads "the node, if the line named one" without an len-check at +// every use. +func args0(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} diff --git a/cmd/spinloop/fleet_test.go b/cmd/spinloop/fleet_test.go index 3b0e9e0c..61fad293 100644 --- a/cmd/spinloop/fleet_test.go +++ b/cmd/spinloop/fleet_test.go @@ -626,6 +626,24 @@ func TestCmdFleetUnknownNodeNamesTheKnownOnes(t *testing.T) { } } +// `-n ` alone drives that node: the flag that narrowed the fleet +// stands in for the positional name rather than leaving the command to +// demand one. +func TestCmdFleetNodeFlagStandsInForTheName(t *testing.T) { + twoNodeFleet(t, "idle") + out := captureStdout(t, func() { + if err := cmdFleet([]string{"stop", "-n", "up"}); err != nil { + t.Fatalf("fleet stop -n up = %v, want the named node driven", err) + } + }) + if !strings.Contains(out, "up") { + t.Errorf("no row for the driven node:\n%s", out) + } + if strings.Contains(out, "down") { + t.Errorf("the flag let the other node be touched:\n%s", out) + } +} + func TestCmdFleetMissingFileNamesThePath(t *testing.T) { t.Chdir(t.TempDir()) err := cmdStatus(nil) diff --git a/cmd/spinloop/harness_cloud_test.go b/cmd/spinloop/harness_cloud_test.go index 5b10d50a..b9d44547 100644 --- a/cmd/spinloop/harness_cloud_test.go +++ b/cmd/spinloop/harness_cloud_test.go @@ -44,7 +44,7 @@ func envServer(t *testing.T) *httptest.Server { // deployedEnvServer answers the env Lambda call the way an upgraded control // plane does for an environment with a model deployed to it: base_url/api_key -// alongside the deploy-config facts a bare `spinloop harness --env` needs to +// alongside the deploy-config facts a bare `spinloop harness --node` needs to // configure the harness with no Spinloop at all. func deployedEnvServer(t *testing.T) *httptest.Server { t.Helper() @@ -79,7 +79,7 @@ func TestApplyBeforeLaunch_RemoteKeySilencesTheMissingKeyWarning(t *testing.T) { var sel spinloopSelectionResult out := captureStdout(t, func() { sel.selection, sel.envDir, sel.resp, _, sel.err = applyBeforeLaunch( - spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{envName: "dev-1"}) + spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{node: "dev-1"}) }) if sel.err != nil { t.Fatalf("applyBeforeLaunch: %v", sel.err) @@ -102,10 +102,10 @@ func TestApplyBeforeLaunch_RemoteKeySilencesTheMissingKeyWarning(t *testing.T) { } // TestHarness_EnvFlagAfterTheAlias is the regression guard for the ordering -// bug where --env was silently forwarded to the harness, instead of being +// bug where --node was silently forwarded to the harness, instead of being // consumed by spinloop, whenever it followed the Spinloop-naming alias -// (`spinloop harness dev-3 --env dev-1 ...`, the order the flag's own name -// suggests). --env must be honoured, and dropped from what is forwarded, no +// (`spinloop harness dev-3 --node dev-1 ...`, the order the flag's own name +// suggests). --node must be honoured, and dropped from what is forwarded, no // matter which side of the alias it is on. func TestHarness_EnvFlagAfterTheAlias(t *testing.T) { isolateConfig(t) @@ -135,7 +135,7 @@ func TestHarness_EnvFlagAfterTheAlias(t *testing.T) { captureStderr(t, func() { captureStdout(t, func() { - if err := cmdOpen([]string{"dev-3", "--env", "dev-1", "--prompt", "hello"}); err != nil { + if err := cmdOpen([]string{"dev-3", "--node", "dev-1", "--prompt", "hello"}); err != nil { t.Fatalf("cmdOpen: %v", err) } }) @@ -146,7 +146,7 @@ func TestHarness_EnvFlagAfterTheAlias(t *testing.T) { t.Fatalf("harness was not launched: %v", err) } if strings.TrimSpace(string(got)) != "--prompt\nhello" { - t.Errorf("forwarded args = %q, want \"--prompt\\nhello\" (--env consumed, not forwarded)", got) + t.Errorf("forwarded args = %q, want \"--prompt\\nhello\" (--node consumed, not forwarded)", got) } env, err := os.ReadFile(envFile) @@ -162,7 +162,7 @@ func TestHarness_EnvFlagAfterTheAlias(t *testing.T) { } // TestHarness_BareEnvAutoConfiguresAndLaunches is the end-to-end check for the -// UX this change adds: `spinloop harness --env ` with no Spinloop at +// UX this change adds: `spinloop harness --node ` with no Spinloop at // all configures the harness from what is deployed and launches it, trailing // args forwarded exactly as they are for a Spinloop-driven launch. func TestHarness_BareEnvAutoConfiguresAndLaunches(t *testing.T) { @@ -177,7 +177,7 @@ func TestHarness_BareEnvAutoConfiguresAndLaunches(t *testing.T) { BaseURL: "http://198.51.100.1:8000/v1", Region: "eu-west-1", Environment: "dev-3", }) - forwarded, out := launchedArgs(t, []string{"--env", "dev-3", "--prompt", "hello"}) + forwarded, out := launchedArgs(t, []string{"--node", "dev-3", "--prompt", "hello"}) if forwarded != "--prompt\nhello" { t.Errorf("forwarded args = %q, want \"--prompt\\nhello\"", forwarded) } @@ -192,7 +192,7 @@ func TestHarness_BareEnvAutoConfiguresAndLaunches(t *testing.T) { } // TestHarness_AppliedSpinloopStillWinsOverDeployConfig guards the precedence -// rule: applying a Spinloop alongside --env uses the Spinloop's own values, +// rule: applying a Spinloop alongside --node uses the Spinloop's own values, // never the environment's deploy-config, exactly as a hand-written BASEURL // already wins over the environment's registered address. func TestHarness_AppliedSpinloopStillWinsOverDeployConfig(t *testing.T) { @@ -205,7 +205,7 @@ func TestHarness_AppliedSpinloopStillWinsOverDeployConfig(t *testing.T) { dir := cloudSpinloopDir(t, "dev-3", server.URL, "http://198.51.100.1:8000/v1") mustWrite(t, filepath.Join(dir, "Spinloop"), "PROVIDER llamacpp\nALIAS from-spinloop\n") - forwarded, _ := launchedArgs(t, []string{dir, "--env", "dev-3", "--", "run"}) + forwarded, _ := launchedArgs(t, []string{dir, "--node", "dev-3", "--", "run"}) if forwarded != "run" { t.Errorf("forwarded args = %q, want \"run\"", forwarded) } @@ -253,13 +253,13 @@ func TestApplyBeforeLaunch_FailsWhenNoKeyCanBeHad(t *testing.T) { captureStderr(t, func() { captureStdout(t, func() { res.selection, res.envDir, res.resp, _, res.err = applyBeforeLaunch( - spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{envName: "dev-1"}) + spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{node: "dev-1"}) }) }) if res.err == nil { t.Fatal("a launch that cannot authenticate should fail") } - for _, want := range []string{"could not fetch the API key for dev-1", "spinloop cloud start --env dev-1", "OPENAI_API_KEY"} { + for _, want := range []string{"could not fetch the API key for dev-1", "spinloop cloud start --node dev-1", "OPENAI_API_KEY"} { if !strings.Contains(res.err.Error(), want) { t.Errorf("error should mention %q, got: %v", want, res.err) } @@ -287,7 +287,7 @@ func TestApplyBeforeLaunch_CarriesOnWhenTheKeyIsAlreadySet(t *testing.T) { stderr := captureStderr(t, func() { stdout = captureStdout(t, func() { res.selection, res.envDir, res.resp, _, res.err = applyBeforeLaunch( - spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{envName: "dev-1"}) + spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{node: "dev-1"}) }) }) if res.err != nil { @@ -322,7 +322,7 @@ func TestApplyBeforeLaunch_CountsAnEnvInstructionAsTheKey(t *testing.T) { captureStderr(t, func() { captureStdout(t, func() { res.selection, res.envDir, res.resp, _, res.err = applyBeforeLaunch( - spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{envName: "dev-1"}) + spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{node: "dev-1"}) }) }) if res.err != nil { @@ -344,7 +344,7 @@ func TestApplyBeforeLaunch_AnnouncesTheFetch(t *testing.T) { var stdout string stderr := captureStderr(t, func() { stdout = captureStdout(t, func() { - if _, _, _, _, err := applyBeforeLaunch(spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{envName: "dev-1"}); err != nil { + if _, _, _, _, err := applyBeforeLaunch(spinloopPathFlag{set: true, path: dir}, "", h, nil, routeOptions{node: "dev-1"}); err != nil { t.Fatalf("applyBeforeLaunch: %v", err) } }) @@ -383,9 +383,10 @@ func TestApplyBeforeLaunch_LocalSpinloopFetchesNothing(t *testing.T) { } } -// --env and a fleet are two answers to one question — where the model is -// served from — so a launch that states both fails, naming both. -func TestApplyBeforeLaunch_EnvAndFleetConflict(t *testing.T) { +// With a fleet in force, --node is the fleet's: a name that is not one of its +// nodes fails naming the known nodes, and specifically does NOT fall back to a +// registered environment of the same name — the cloud endpoint stays unfetched. +func TestApplyBeforeLaunch_NodeNamesFleetNode(t *testing.T) { isolateConfig(t) stubAWSEnv(t) @@ -396,18 +397,20 @@ func TestApplyBeforeLaunch_EnvAndFleetConflict(t *testing.T) { h, _ := harness.Lookup("opencode") _, _, _, _, err := applyBeforeLaunch(spinloopPathFlag{set: true, path: dir}, "", h, nil, - routeOptions{envName: "dev-1", fleetPath: filepath.Join(fleetDir, "fleet.yaml")}) + routeOptions{node: "dev-1", fleetPath: filepath.Join(fleetDir, "fleet.yaml")}) if err == nil { - t.Fatal("a launch stating both --env and a fleet should fail") + t.Fatal("a pin to a name the fleet has no node for should fail") } - for _, want := range []string{"--env dev-1", "fleet.yaml", "so state one"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("error should mention %q, got %v", want, err) - } + msg := err.Error() + if !strings.Contains(msg, "dev-1") || !strings.Contains(msg, "gpu-a") { + t.Errorf("the failure should name the pin and the fleet's known nodes, got %v", err) + } + if strings.Contains(msg, "not registered") { + t.Errorf("the name must not be sought in the environment registry, got %v", err) } } -// A bare `spinloop harness --env ` — no Spinloop at all — configures +// A bare `spinloop harness --node ` — no Spinloop at all — configures // the harness from what the environment reports is deployed to it. func TestApplyFromEnvironment_ConfiguresFromDeployConfig(t *testing.T) { home := isolateConfig(t) @@ -428,7 +431,7 @@ func TestApplyFromEnvironment_ConfiguresFromDeployConfig(t *testing.T) { var err error captureStderr(t, func() { captureStdout(t, func() { - sel, _, resp, choice, err = applyFromEnvironment("", h, routeOptions{envName: "dev-3"}) + sel, _, resp, choice, err = applyFromEnvironment("", h, routeOptions{node: "dev-3"}) }) }) if err != nil { @@ -445,7 +448,7 @@ func TestApplyFromEnvironment_ConfiguresFromDeployConfig(t *testing.T) { } // The written config is exactly what an equivalent Spinloop - // (`PROVIDER llamacpp\nALIAS q3\nCONTEXT 32768`) applied with --env dev-3 + // (`PROVIDER llamacpp\nALIAS q3\nCONTEXT 32768`) applied with --node dev-3 // would have produced. m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json")) if m["model"] != "dev-3/q3" { @@ -472,14 +475,14 @@ func TestApplyFromEnvironment_UnrecognisedRunnerFails(t *testing.T) { h, _ := harness.Lookup("opencode") captureStderr(t, func() { captureStdout(t, func() { - if _, _, _, _, err := applyFromEnvironment("", h, routeOptions{envName: "dev-3"}); err == nil { + if _, _, _, _, err := applyFromEnvironment("", h, routeOptions{node: "dev-3"}); err == nil { t.Fatal("an unrecognised runner should fail the launch") } }) }) } -// With nothing deployed to the environment, a bare `--env` launch fails +// With nothing deployed to the environment, a bare `--node` launch fails // naming the environment and how to fix it, rather than launching an // unconfigured harness or falling through to applySelection's generic error. func TestApplyFromEnvironment_FailsWithNothingDeployed(t *testing.T) { @@ -496,7 +499,7 @@ func TestApplyFromEnvironment_FailsWithNothingDeployed(t *testing.T) { var err error captureStderr(t, func() { captureStdout(t, func() { - _, _, _, _, err = applyFromEnvironment("", h, routeOptions{envName: "dev-3"}) + _, _, _, _, err = applyFromEnvironment("", h, routeOptions{node: "dev-3"}) }) }) if err == nil { @@ -553,7 +556,7 @@ func TestCloudEnv_StdoutIsEvalSafe(t *testing.T) { var stdout string captureStderr(t, func() { stdout = captureStdout(t, func() { - if err := cmdCloudEnv([]string{"q3", "--env", "dev-1"}); err != nil { + if err := cmdCloudEnv([]string{"q3", "--node", "dev-1"}); err != nil { t.Fatalf("cmdCloudEnv: %v", err) } }) diff --git a/cmd/spinloop/harness_test.go b/cmd/spinloop/harness_test.go index 77f63a6f..e71eeb34 100644 --- a/cmd/spinloop/harness_test.go +++ b/cmd/spinloop/harness_test.go @@ -284,7 +284,7 @@ func TestCode_SameLaunchAsHarnessOpen(t *testing.T) { mustWrite(t, spath, "PROVIDER llamacpp\nMODEL mixtral\n") t.Setenv("SPINLOOP_ALIAS", "q3") - // A registered environment, for the --env case: a bare --env configures the + // A registered environment, for the --node case: a bare --node configures the // harness from what the environment reports as deployed. env := deployedEnvServer(t) defer env.Close() @@ -294,13 +294,13 @@ func TestCode_SameLaunchAsHarnessOpen(t *testing.T) { }) cases := [][]string{ - {}, // bare: nothing applied - {"run", "hello"}, // trailing args forwarded - {"--", "--flag", "x"}, // a leading -- opts out and forwards verbatim - {"q3", "run"}, // a leading alias is applied, then the rest forwards - {"-O", "run"}, // a valueless -O applies the SPINLOOP_ALIAS Spinloop - {"-O=" + spath, "run"}, // -O applies the Spinloop at an explicit path - {"--env", "dev-1", "run"}, // --env configures from the environment + {}, // bare: nothing applied + {"run", "hello"}, // trailing args forwarded + {"--", "--flag", "x"}, // a leading -- opts out and forwards verbatim + {"q3", "run"}, // a leading alias is applied, then the rest forwards + {"-O", "run"}, // a valueless -O applies the SPINLOOP_ALIAS Spinloop + {"-O=" + spath, "run"}, // -O applies the Spinloop at an explicit path + {"--node", "dev-1", "run"}, // --node configures from the environment } for _, args := range cases { openForwarded, _ := launchedArgs(t, args) diff --git a/cmd/spinloop/last_active_test.go b/cmd/spinloop/last_active_test.go index b90a4fbf..b5664dd9 100644 --- a/cmd/spinloop/last_active_test.go +++ b/cmd/spinloop/last_active_test.go @@ -48,7 +48,7 @@ func TestCloudMetricsBarShowsLastActive(t *testing.T) { statsServer(t, runningWithActivity) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=bar"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=bar"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -69,7 +69,7 @@ func TestCloudMetricsTableShowsLastActive(t *testing.T) { statsServer(t, runningWithActivity) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=table"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=table"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -86,7 +86,7 @@ func TestCloudMetricsJSONCarriesLastActive(t *testing.T) { statsServer(t, runningWithActivity) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=json"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=json"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -132,7 +132,7 @@ func TestCloudMetricsStoppedStillShowsLastActive(t *testing.T) { } { t.Run(format, func(t *testing.T) { out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=" + format}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=" + format}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -162,7 +162,7 @@ func TestLastActiveZeroIdleStillRenders(t *testing.T) { } { t.Run(format, func(t *testing.T) { out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=" + format}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=" + format}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -184,7 +184,7 @@ func TestLastActiveOmittedWithoutATimestamp(t *testing.T) { for _, format := range []string{"bar", "table"} { t.Run(format, func(t *testing.T) { out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=" + format}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=" + format}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -223,7 +223,7 @@ func TestCloudStatusShowsLastActive(t *testing.T) { }`) out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Fatalf("cmdStatus: %v", err) } }) @@ -247,7 +247,7 @@ func TestCloudStatusZeroIdleStillRenders(t *testing.T) { statusServer(t, `{"state": "running", "healthy": true, "lastActiveAt": "2026-08-10T10:00:00Z"}`) out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Fatalf("cmdStatus: %v", err) } }) @@ -262,7 +262,7 @@ func TestCloudStatusOmitsLastActiveWhenAbsent(t *testing.T) { statusServer(t, `{"state": "stopped", "healthy": false}`) out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "default"}); err != nil { + if err := cmdStatus([]string{"--node", "default"}); err != nil { t.Fatalf("cmdStatus: %v", err) } }) diff --git a/cmd/spinloop/logs.go b/cmd/spinloop/logs.go index 2ae71419..4d15e913 100644 --- a/cmd/spinloop/logs.go +++ b/cmd/spinloop/logs.go @@ -17,7 +17,7 @@ import ( func logsCmd() *cobra.Command { var ( path string - envName string + nodeName string follow bool limit int format string @@ -34,7 +34,7 @@ func logsCmd() *cobra.Command { answers with — a daemon's log file, or a cloud environment's log store. Naming a node reads only that one. Nodes are read concurrently. -The target is a registered environment (--env), a fleet file (--fleet), or +The target is a registered environment (--node), a fleet file (--fleet), or the fleet.yaml in the working directory. --fleet has no -f here: that is --follow's short form, and a flag cannot carry two meanings on one command line. @@ -52,14 +52,14 @@ read as it would be without them.`, return err } q := fleet.LogQuery{Source: source, Since: since, Instance: instance} - return runLogs(fleetTarget{envName: envName, fleetPath: path}, q, follow, limit, format, args) + return runLogs(fleetTarget{nodeName: nodeName, fleetPath: path}, q, follow, limit, format, args) }, } fs := c.Flags() // --fleet takes no short form here: -f is already --follow, as it is on // every other surface that follows something. fs.StringVar(&path, "fleet", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) registerSpinloopEnvFlag(fs, &spinloopPath) fs.BoolVarP(&follow, "follow", "f", false, followUsage) fs.IntVar(&limit, "limit", 200, "lines of backlog to print per node") @@ -69,7 +69,7 @@ read as it would be without them.`, fs.StringVar(&instance, "instance", "", "restrict to one instance id, on a node whose log holds more than one") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } diff --git a/cmd/spinloop/main.go b/cmd/spinloop/main.go index 6bd42a52..4288f708 100644 --- a/cmd/spinloop/main.go +++ b/cmd/spinloop/main.go @@ -189,7 +189,7 @@ func envFileDir(spinloopPath string) string { // spinloopPath is the Spinloop the selection came from — its own path, not a // pre-computed directory, so a relative PRESET resolves correctly whether the // Spinloop is local or URL-sourced; it is empty when no Spinloop is involved -// (an `spinloop harness add` from flags). envName is the --env flag's value: +// (an `spinloop harness add` from flags). envName is the --node flag's value, // the registered environment the selection is applied against, empty for a // local apply. resolve looks up API key variables — normally // opencode.EnvResolver of the Spinloop's local directory, but `spinloop @@ -232,7 +232,7 @@ func applySelection(sel spinloop.Selection, h harness.Harness, spinloopPath, env } if _, err := os.Stat(envPath); err != nil { if os.IsNotExist(err) { - return fmt.Errorf("environment %q is not registered: run `spinloop cloud deploy --env %q` to create it", envName, envName) + return fmt.Errorf("environment %q is not registered: run `spinloop cloud deploy --node %q` to create it", envName, envName) } return err } @@ -491,12 +491,12 @@ func resolveAlias(arg string) (string, bool, error) { // ./Spinloop when none is given, so a bare `spinloop harness apply` works in a // directory that holds one. func applyCmd() *cobra.Command { - var providers, output, harnessName, envName string + var providers, output, harnessName, nodeName string c := &cobra.Command{ Use: "apply", Short: "apply a Spinloop file (defaults to ./Spinloop)", Long: `applies a Spinloop file — a declarative, Dockerfile-style description of -one provider selection — as if you had run the equivalent add. With --env, +one provider selection — as if you had run the equivalent add. With --node, applies it against a registered cloud environment: the provider is keyed on the environment's name and, absent a BASEURL, takes its address from the environment's registered cloud.json.`, @@ -522,15 +522,15 @@ environment's registered cloud.json.`, if output != "" { sel.Output = output } - return applySelection(sel, h, spinloopPath, envName, "", opencode.EnvResolver(envFileDir(spinloopPath))) + return applySelection(sel, h, spinloopPath, nodeName, "", opencode.EnvResolver(envFileDir(spinloopPath))) }, } fs := c.Flags() fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") fs.StringVarP(&output, "output", "o", "", "max output tokens (overrides the Spinloop's OUTPUT)") fs.StringVarP(&harnessName, "harness", "H", "", "which harness to configure") - fs.StringVarP(&envName, "env", "e", "", "apply against this registered environment (its name keys the provider; its cloud.json supplies the base URL)") - compRegister(c, "env", compEnvs) + fs.StringVarP(&nodeName, "node", "n", "", "apply against this registered environment (its name keys the provider; its cloud.json supplies the base URL)") + compRegister(c, "node", compEnvs) fs.SetInterspersed(false) c.ValidArgsFunction = aliasSlot compRegister(c, "providers", compFiles) @@ -543,12 +543,12 @@ environment's registered cloud.json.`, // given, so a bare `spinloop harness unapply` works in a directory that holds // one. func unapplyCmd() *cobra.Command { - var providers, harnessName, envName string + var providers, harnessName, nodeName string c := &cobra.Command{ Use: "unapply", Short: "remove what a Spinloop file selects", Long: `removes what a Spinloop file selects, as if you had run the equivalent -remove. The inverse of apply: --env must name the same environment apply +remove. The inverse of apply: --node must name the same environment apply applied the Spinloop against, or there is nothing to remove.`, Args: cobra.ArbitraryArgs, SilenceErrors: true, @@ -568,14 +568,14 @@ applied the Spinloop against, or there is nothing to remove.`, return err } sel.Providers = providers - return removeSelection(sel, h, spinloopPath, envName) + return removeSelection(sel, h, spinloopPath, nodeName) }, } fs := c.Flags() fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") fs.StringVarP(&harnessName, "harness", "H", "", "which harness to configure") - fs.StringVarP(&envName, "env", "e", "", "the registered environment the Spinloop was applied against") - compRegister(c, "env", compEnvs) + fs.StringVarP(&nodeName, "node", "n", "", "the registered environment the Spinloop was applied against") + compRegister(c, "node", compEnvs) fs.SetInterspersed(false) c.ValidArgsFunction = aliasSlot compRegister(c, "providers", compFiles) @@ -934,8 +934,8 @@ func exportLimit(sel spinloop.Selection, st harness.ProviderState, values map[st // selection (from flags or a Spinloop file) and hand it here. It is the inverse // of applySelection, so it names the provider the same way — for an // environment that is its name, not the PROVIDER value — to remove exactly what -// apply wrote. envName is the --env flag's value, empty when the selection is -// local; it must match the --env apply wrote the block under, or the removal +// apply wrote. envName is the --node flag's value, empty when the selection is +// local; it must match the --node apply wrote the block under, or the removal // finds nothing and says so. func removeSelection(sel spinloop.Selection, h harness.Harness, spinloopPath, envName string) error { if envName != "" { @@ -1176,9 +1176,9 @@ func overlayLocalEnv(base []string, sel spinloop.Selection, dir string) []string // splitHarnessArgs scans args for `harness`'s own flags and, at most once, a // leading positional naming a Spinloop, wherever either appears among the // arguments meant for spinloop itself; everything from the first argument -// that is neither forwards to the harness untouched. This lets --env (or any +// that is neither forwards to the harness untouched. This lets --node (or any // other of the command's flags) sit before or after the Spinloop name — -// `spinloop harness dev-3 --env prod ...` and `spinloop harness --env prod +// `spinloop harness dev-3 --node prod ...` and `spinloop harness --node prod // dev-3 ...` parse alike — which fs.Parse cannot do on its own: with // interspersed flags disabled it stops at the first positional, and with them // enabled it errors out on the first argument meant for the harness that @@ -1201,6 +1201,13 @@ func splitHarnessArgs(fs *pflag.FlagSet, spinloopPath *spinloopPathFlag, args [] if len(tok) > 1 && tok[0] == '-' { flag, attached, hasAttached := lookupHarnessFlag(fs, tok) if flag == nil { + // --env/-e was this command's flag until the rename; silently + // forwarding it would land the harness's "unrecognised flag" + // on a word the harness never owned, with the launch quietly + // mis-targeted. Name the new spelling instead. + if tok == "--env" || strings.HasPrefix(tok, "--env=") || tok == "-e" { + return nil, fmt.Errorf("%s was renamed --node: pass --node (a fleet node with a fleet in force, a registered environment otherwise)", tok) + } return args[i:], nil } value := attached @@ -1283,7 +1290,7 @@ func namesAnSpinloopOrAlias(arg string) bool { // meant for the flag. // It returns the applied Spinloop's directory and selection, so the launched // agent can be given the same keys the apply resolved, along with the cloud -// endpoint's live environment when --env names an environment. +// endpoint's live environment when --node names an environment. // // The cloud key is fetched before the apply, not after, so the apply resolves // against the environment the agent will actually run with. Fetching it @@ -1308,7 +1315,7 @@ func applyBeforeLaunch(f spinloopPathFlag, providers string, h harness.Harness, } // applyFromEnvironment configures the harness with no Spinloop at all: a bare -// `spinloop harness --env ` reaches here. It is applyBeforeLaunch's +// `spinloop harness --node ` reaches here. It is applyBeforeLaunch's // counterpart for that case — same return shape, same launch continuation — // except there is no Spinloop to read, so routing and the provider selection // come entirely from the named environment's live deploy-config. @@ -1346,8 +1353,14 @@ func applyFromGateway(providers string, h harness.Harness, route routeOptions) ( return fail(err) } if _, ok := cfg.GatewaySection(); !ok { - return fail(fmt.Errorf( - "--fleet needs a Spinloop: it is the Spinloop's model that decides which node can serve you")) + // A gateway-less fleet with no --node value has nothing to route by: + // it is the Spinloop's model that decides which node can serve you. + // A pinned --node needs no model — the name is already the answer — + // so only a nameless launch fails here. + if route.node == "" { + return fail(fmt.Errorf( + "--fleet needs a Spinloop: it is the Spinloop's model that decides which node can serve you")) + } } return applyRoutedSpinloop(spinloop.Selection{Provider: gatewayProviderID}, "", providers, h, route, false) } @@ -1359,7 +1372,7 @@ func applyFromGateway(providers string, h harness.Harness, route routeOptions) ( // way in; `spinloop fleet harness` reads its own, because with none it fails on // its own terms. Both then run this one path. // -// autoConfigure is set only by a bare `spinloop harness --env `: no +// autoConfigure is set only by a bare `spinloop harness --node `: no // Spinloop was read (sel is empty and path is ""), so once the environment's // live response is in hand, its deploy-config — what is actually deployed // there — supplies the provider selection instead, exactly as if a Spinloop @@ -1371,14 +1384,6 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, // As for apply, --providers overrides the catalogue the selection resolves // against (a Spinloop never names one). sel.Providers = providers - // --env and a fleet are two different answers to where the model is served - // from, and the rule against naming both is the fleet commands' too, so it - // is enforced and worded in one place. The fleet passed is the one this - // launch's own discovery settled on — which, unlike a fleet command's, may - // come from the working directory. - if err := checkOneTarget(route.envName, route.fleetFile()); err != nil { - return spinloop.Selection{}, "", nil, nil, err - } envDir := envFileDir(path) if path == "" { // No Spinloop at all — the gateway-only case. There is no Spinloop @@ -1402,6 +1407,16 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, // cloud endpoint's address is written to. sel.BaseURL = choice.BaseURL } + // envTarget is the --node value only when no fleet is in force, so the + // name resolves as a registered environment — a fleet of one. Everything + // cloud-shaped (the endpoint fetch, the provider key, the environment + // labelling) hangs off this, not off the flag: with a fleet in force + // --node answered as a pin, and a name that also sits in the registry + // must not then fetch a cloud endpoint. + envTarget := "" + if route.node != "" && route.fleetFile() == "" { + envTarget = route.node + } // label names what is being applied in the messages below: the Spinloop's // own path normally, or the fleet file when there is no Spinloop at all — // the gateway-only case, where nothing was read from disk to apply. @@ -1420,7 +1435,7 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, } // Before the apply, so a launch that cannot authenticate stops without // having rewritten the harness config. - cloudResp, err := fetchCloudEnv(sel, route.envName, localResolve) + cloudResp, err := fetchCloudEnv(sel, envTarget, localResolve) if err != nil { return spinloop.Selection{}, "", nil, nil, err } @@ -1428,9 +1443,9 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, if cloudResp == nil || !cloudResp.Deployed || cloudResp.Runner == "" || cloudResp.ServedName == "" { return spinloop.Selection{}, "", nil, nil, fmt.Errorf( "nothing is deployed to environment %q to configure the harness with: "+ - "run `spinloop cloud deploy --env %s` to deploy one, "+ + "run `spinloop cloud deploy --node %s` to deploy one, "+ "or `spinloop cloud bootstrap` to update the control plane if %s already has something deployed", - route.envName, route.envName, route.envName) + envTarget, envTarget, envTarget) } provider, err := providerForRunner(cloudResp.Runner) if err != nil { @@ -1441,7 +1456,7 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, if cloudResp.ContextSize > 0 { sel.Context = strconv.Itoa(cloudResp.ContextSize) } - fmt.Printf("Configuring from what is deployed to %s.\n\n", route.envName) + fmt.Printf("Configuring from what is deployed to %s.\n\n", envTarget) } resolve := cloudLaunchResolver(localResolve, cloudResp) if choice != nil && choice.APIKey != "" { @@ -1490,7 +1505,7 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, } } } - if err := applySelection(sel, h, path, route.envName, gatewayLabel, resolve); err != nil { + if err := applySelection(sel, h, path, envTarget, gatewayLabel, resolve); err != nil { return spinloop.Selection{}, "", nil, nil, err } fmt.Println() @@ -1592,7 +1607,7 @@ func fleetLaunchResolver(base func(string) string, key string) func(string) stri const cloudEnvTimeout = 30 * time.Second // fetchCloudEnv returns the live base URL and API key of the endpoint the -// environment named by --env serves, or nil when the flag is absent. The +// environment named by --node serves, or nil when the flag is absent. The // endpoint is started with a key that only the control plane knows, so this is // the one place it can come from; `spinloop harness` puts it in the // environment of the agent it launches. @@ -1619,7 +1634,7 @@ func fetchCloudEnv(sel spinloop.Selection, envName string, resolve func(string) } if _, err := os.Stat(envPath); err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("environment %q is not registered: run `spinloop cloud deploy --env %q` to create it", envName, envName) + return nil, fmt.Errorf("environment %q is not registered: run `spinloop cloud deploy --node %q` to create it", envName, envName) } return nil, err } @@ -1637,7 +1652,7 @@ func fetchCloudEnv(sel spinloop.Selection, envName string, resolve func(string) if localKey(sel, resolve) == "" { return nil, fmt.Errorf( "could not fetch the API key for %s: %w\n"+ - "Start the endpoint with `spinloop cloud start --env %s` if it is stopped, or export %s yourself", + "Start the endpoint with `spinloop cloud start --node %s` if it is stopped, or export %s yourself", envName, err, envName, cloudAPIKeyEnv) } fmt.Fprintf(os.Stderr, diff --git a/cmd/spinloop/metrics.go b/cmd/spinloop/metrics.go index f78fb10f..4be8c5b1 100644 --- a/cmd/spinloop/metrics.go +++ b/cmd/spinloop/metrics.go @@ -18,7 +18,7 @@ import ( func metricsCmd() *cobra.Command { var ( path string - envName string + nodeName string format string spinloopPath string watch bool @@ -31,7 +31,7 @@ func metricsCmd() *cobra.Command { resource use, its token and request counters, and the release the node is running. One block per node, queried concurrently. -The target is a registered environment (--env), a fleet file (--fleet), or +The target is a registered environment (--node), a fleet file (--fleet), or the fleet.yaml in the working directory. A node that cannot be reached is reported rather than omitted, and never fails the command. @@ -51,7 +51,7 @@ cost at all.`, if err := validateMetricsFormat(format); err != nil { return err } - cfg, err := resolveFleetTarget(fleetTarget{envName: envName, fleetPath: path}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: nodeName, fleetPath: path}) if err != nil { return err } @@ -59,6 +59,7 @@ cost at all.`, if withCost { call = fleet.PricedMetricsCall } + cfg = narrowedTarget(cfg, nodeName) if watch { return runFleetMetricsWatch(cfg, format, call) } @@ -68,14 +69,14 @@ cost at all.`, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) registerSpinloopEnvFlag(fs, &spinloopPath) fs.StringVar(&format, "format", "gauge", "output format: gauge (default), bar, table or json") fs.BoolVarP(&watch, "watch", "w", false, "redraw every 60 seconds") fs.BoolVar(&withCost, "cost", false, "include what each priceable node has cost so far") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } diff --git a/cmd/spinloop/node_flag_test.go b/cmd/spinloop/node_flag_test.go new file mode 100644 index 00000000..33790f12 --- /dev/null +++ b/cmd/spinloop/node_flag_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/spinloop-ai/spinloop/internal/cloud" +) + +// The rename's flat rule: `--env` is gone from the launch, and it is not +// even forwarded to the harness — the command names the new spelling. +func TestCodeRejectsRemovedEnvFlag(t *testing.T) { + isolateConfig(t) + err := cmdCode([]string{"--env", "dev-1"}) + if err == nil || !strings.Contains(err.Error(), "--env") || !strings.Contains(err.Error(), "renamed --node") { + t.Fatalf("want a rename failure pointing at --node, got %v", err) + } + err = cmdCode([]string{"-e", "dev-1"}) + if err == nil || !strings.Contains(err.Error(), "renamed --node") { + t.Fatalf("the -e shorthand should say the same, got %v", err) + } +} + +func TestApplyRejectsRemovedEnvFlag(t *testing.T) { + isolateConfig(t) + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "Spinloop"), "PROVIDER llamacpp\nMODEL qwen3-27b\n") + err := cmdApply([]string{"--env", "dev-1", filepath.Join(dir, "Spinloop")}) + if err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("want an unknown-flag failure for --env, got %v", err) + } + err = cmdApply([]string{"-e", "dev-1", filepath.Join(dir, "Spinloop")}) + if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag") { + t.Fatalf("want an unknown-shorthand failure for -e, got %v", err) + } +} + +// With a fleet in force the one --node pins within it: the pinned node answers +// even when another would be preferred by the activity ranking. +func TestCodeNodePinsFleetNode(t *testing.T) { + idleFirst := newRoutableNode(t, "qwen3-27b", true, 3000) + pinned := newRoutableNode(t, "qwen3-27b", true, 300) + isolateConfig(t) + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENAI_BASE_URL", "") + argsFile := filepath.Join(t.TempDir(), "args") + envFile := filepath.Join(t.TempDir(), "env") + stubHarnessBinaryWithEnv(t, argsFile, envFile) + + codeFleetDir(t, + "prefer: idle\nnodes:\n"+idleFirst.entry("gpu-a")+pinned.entry("gpu-b"), + "PROVIDER llamacpp\nMODEL qwen3-27b\n") + captureStdout(t, func() { + if err := cmdCode([]string{"-O", "--node", "gpu-b"}); err != nil { + t.Fatalf("cmdCode: %v", err) + } + }) + data, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + want := "BASE=http://127.0.0.1:" + strconv.Itoa(pinned.enginePort) + "/v1" + if !strings.Contains(string(data), want) { + t.Errorf("the pinned node should serve, got:\n%s", data) + } +} + +// --node with no fleet in force names a registered environment: the bare +// launch (no Spinloop) is configured from what that environment reports +// deployed — the job `--env` did. +func TestCodeNodeWithoutFleetIsAnEnvironment(t *testing.T) { + home := isolateConfig(t) + stubAWSEnv(t) + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENAI_BASE_URL", "") + codeFleetDir(t, "", "") + + server := deployedEnvServer(t) + defer server.Close() + registerEnv(t, "dev-3", cloud.Config{ + StartURL: server.URL, StopURL: server.URL, EnvURL: server.URL, + BaseURL: "http://198.51.100.1:8000/v1", Region: "eu-west-1", Environment: "dev-3", + }) + + argsFile := filepath.Join(t.TempDir(), "args") + envFile := filepath.Join(t.TempDir(), "env") + stubHarnessBinaryWithEnv(t, argsFile, envFile) + + captureStdout(t, func() { + if err := cmdCode([]string{"-n", "dev-3"}); err != nil { + t.Fatalf("cmdCode: %v", err) + } + }) + data, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "KEY=") || !strings.Contains(string(data), "BASE=") { + t.Errorf("the agent should carry the environment's fetched endpoint, got:\n%s", data) + } + config, err := os.ReadFile(filepath.Join(home, ".config", "opencode", "opencode.json")) + if err != nil { + t.Fatalf("the harness config was not written: %v", err) + } + if !strings.Contains(string(config), "dev-3") { + t.Errorf("the provider should be keyed by the environment, got:\n%s", config) + } +} + +// The model-gated demand for a Spinloop belongs to --fleet only: a fleet +// named by flag with no gateway cannot route by model. A fleet merely +// discovered in the working directory is different — naming --node there is +// the operator's whole intent — and a pin answers without a model. +func TestCodeNodeWithGatewaylessFleetWantsSpinloop(t *testing.T) { + isolateConfig(t) + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENAI_BASE_URL", "") + dir := t.TempDir() + fleetPath := filepath.Join(dir, "fleet.yaml") + mustWrite(t, fleetPath, "wake: off\nnodes:\n - name: gpu-a\n host: 127.0.0.1\n port: 1\n") + t.Chdir(t.TempDir()) // no fleet.yaml in force: only the flag brings one + + err := cmdCode([]string{"--fleet", fleetPath}) + if err == nil || !strings.Contains(err.Error(), "--fleet needs a Spinloop") { + t.Fatalf("a bare --fleet with no gateway should ask for a Spinloop, got %v", err) + } +} + +// A discovered gateway-less fleet with a node pin and nothing to route by: +// the pin still says which node the launch wants, so the launch proceeds to +// it rather than demanding a Spinloop it cannot answer with. (It reaches a +// dead node, so it fails there — that is a dead node's failure, not the +// rename's: the message names the node, not a Spinloop.) +func TestCodeNodePinWithDiscoveredFleetReachesNode(t *testing.T) { + isolateConfig(t) + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENAI_BASE_URL", "") + codeFleetDir(t, "wake: off\nnodes:\n - name: gpu-a\n host: 127.0.0.1\n port: 1\n", "") + err := cmdCode([]string{"-n", "gpu-a"}) + if err == nil { + t.Fatal("a pin to a dead node should fail") + } + if strings.Contains(err.Error(), "Spinloop") { + t.Errorf("a pinned node should not be asked to name a Spinloop: %v", err) + } + if !strings.Contains(err.Error(), "gpu-a") { + t.Errorf("the failure should name the pinned node, got %v", err) + } +} + +// The renamed flag's shape on the launch: --node/-n registered, --env gone, +// and the fleet's nodes completing it beside the registered environments. +func TestCodeNodeFlagAndCompletion(t *testing.T) { + isolateConfig(t) + codeFleetDir(t, "nodes:\n - name: gpu-a\n host: 127.0.0.1\n port: 1\n", "") + + c := codeCmd() + f := c.Flags().Lookup("node") + if f == nil { + t.Fatal("--node is not registered on the launch") + } + if f.Shorthand != "n" { + t.Errorf("-n is the --node shorthand, got %q", f.Shorthand) + } + if c.Flags().Lookup("env") != nil { + t.Error("the old --env flag is still registered on the launch") + } + + values, directive := launchSlot(c, []string{"--node"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("a flag's value never completes paths, directive %d", directive) + } + var offered bool + for _, v := range values { + if v == "gpu-a" { + offered = true + } + } + if !offered { + t.Errorf("--node should complete the fleet's nodes, got %v", values) + } +} diff --git a/cmd/spinloop/read_spinloop_env.go b/cmd/spinloop/read_spinloop_env.go index 6df87f6a..c54bc28e 100644 --- a/cmd/spinloop/read_spinloop_env.go +++ b/cmd/spinloop/read_spinloop_env.go @@ -1,5 +1,5 @@ // The Spinloop a read verb may be given. It selects nothing — the target comes -// from --env or --fleet — and is read only for the environment it carries: its +// from --node or --fleet — and is read only for the environment it carries: its // ENV instructions and the .env beside it, applied before any control-plane or // daemon work. That is what lets AWS credentials, a profile, or the // SPINLOOP_CLOUD_* overrides live in a project's Spinloop rather than in the diff --git a/cmd/spinloop/retain_render_test.go b/cmd/spinloop/retain_render_test.go index 3bf6f04a..e9b51b91 100644 --- a/cmd/spinloop/retain_render_test.go +++ b/cmd/spinloop/retain_render_test.go @@ -58,7 +58,7 @@ func TestCloudMetricsBarKeepsOnTheActiveLine(t *testing.T) { }`) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=bar"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=bar"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -89,7 +89,7 @@ func TestCloudMetricsTableKeepsOnTheActiveRow(t *testing.T) { }`) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=table"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=table"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -123,7 +123,7 @@ func TestKeepDurationRendersRelatively(t *testing.T) { "retainUntil": "`+deadline+`" }`) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=bar"}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=bar"}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -149,7 +149,7 @@ func TestCloudMetricsStoppedKeptStillShowsKeep(t *testing.T) { "retainUntil": "`+deadline+`" }`) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=" + format}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=" + format}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) @@ -173,7 +173,7 @@ func TestCloudMetricsOmitsKeepWhenAbsent(t *testing.T) { "idleSeconds": 125 }`) out := captureStdout(t, func() { - if err := cmdMetrics([]string{"--env", "default", "--format=" + format}); err != nil { + if err := cmdMetrics([]string{"--node", "default", "--format=" + format}); err != nil { t.Fatalf("cmdMetrics: %v", err) } }) diff --git a/cmd/spinloop/route.go b/cmd/spinloop/route.go index 552ca4a7..5f47fcbb 100644 --- a/cmd/spinloop/route.go +++ b/cmd/spinloop/route.go @@ -20,16 +20,16 @@ import ( // routeOptions is what the launch flags say about routing. They are inert // unless something names a fleet. type routeOptions struct { - // envName names the registered environment a launch is applied against. - // It conflicts with a fleet — each names where the model is served from. - envName string // fleetPath is the fleet file to route through when given. fleetPath string // spinloopNamed says the worn Spinloop was named by the user — a // positional argument, a --spinloop value, or SPINLOOP_ALIAS — so a // fleet.yaml in the working directory is not picked up for it. spinloopNamed bool - // node pins the selection to one node. + // node is the one name the launch gives for the node its agent talks to, + // from --node/-n. With a fleet in force it pins the selection to that + // fleet's node of the name; with no fleet in force the same name is a + // registered environment — a fleet of one — to apply against. node string // prefer overrides the fleet file's activity preference. prefer string diff --git a/cmd/spinloop/status.go b/cmd/spinloop/status.go index bea6adda..c06e76d6 100644 --- a/cmd/spinloop/status.go +++ b/cmd/spinloop/status.go @@ -15,7 +15,7 @@ import ( ) func statusCmd() *cobra.Command { - var path, envName, spinloopPath string + var path, nodeName, spinloopPath string c := &cobra.Command{ Use: "status", Short: "report every engine's state", @@ -24,7 +24,7 @@ serves, how long since it last did work, and the spinloop version of the daemon running it. One row per node, queried concurrently, so the command takes as long as the slowest node rather than all of them added up. -The target is a registered environment (--env), a fleet file (--fleet), or +The target is a registered environment (--node), a fleet file (--fleet), or the fleet.yaml in the working directory. A node that cannot be reached is a row saying so, not a failure: one unreachable machine never blanks the rest. @@ -39,22 +39,22 @@ applies to every node.`, if err := applyReadSpinloopEnv(spinloopPath); err != nil { return err } - cfg, err := resolveFleetTarget(fleetTarget{envName: envName, fleetPath: path}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: nodeName, fleetPath: path}) if err != nil { return err } - results := cfg.FanOut(context.Background(), fleet.StatusCall) + results := narrowedTarget(cfg, nodeName).FanOut(context.Background(), fleet.StatusCall) renderFleetStatus(os.Stdout, results) return nil }, } fs := c.Flags() fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) - fs.StringVar(&envName, "env", "", envFlagTargetUsage) + fs.StringVarP(&nodeName, "node", "n", "", nodeFlagTargetUsage) registerSpinloopEnvFlag(fs, &spinloopPath) c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) - compRegister(c, "env", compEnvs) + compRegister(c, "node", compNodes) return c } diff --git a/cmd/spinloop/target.go b/cmd/spinloop/target.go index 020222cc..b0cf586e 100644 --- a/cmd/spinloop/target.go +++ b/cmd/spinloop/target.go @@ -1,66 +1,108 @@ // Which fleet a command acts on. Every fleet command and the launch path ask -// the same question — a named environment, a named file, or the working -// directory's fleet.yaml — so they ask it here, and a command line that names -// two targets is refused in one place with one sentence. +// the same question — a fleet file and optionally one node within it, else the +// working directory's fleet.yaml, else a named environment — so they ask it +// here, and one message words the failure where nothing answers. package main import ( "fmt" + "os" + "strings" "github.com/spinloop-ai/spinloop/internal/fleet" ) -// envFlagTargetUsage is the --env flag's help on every fleet subcommand. It -// says what the flag targets rather than what it names, since the fleet -// commands' other target flag names a file. -const envFlagTargetUsage = "act on this registered environment instead of a fleet file" +// nodeFlagTargetUsage is the --node flag's help on every fleet subcommand. +// Beside a fleet — named by --fleet or discovered in the working directory — +// the name picks the one node of it the command acts on. With no fleet in +// force the name is the whole target: a registered environment's node. +const nodeFlagTargetUsage = "act on this node alone: one node of the fleet in force, or a registered environment when no fleet holds the name" // fleetTarget is what a command's flags say about which fleet to act on. // Both fields empty means the working directory's fleet.yaml. type fleetTarget struct { - // envName names a registered environment to act on as a fleet of one. - envName string + // nodeName names the one node to act on: a node of the fleet the + // command resolves — the named file, the working directory's + // fleet.yaml — or, with no fleet holding it, a registered environment + // to act on as a fleet of one. + nodeName string // fleetPath names a fleet file to act on. fleetPath string } // resolveFleetTarget turns what the flags say into the fleet to act on: the -// environment's fleet of one, the named file, or the working directory's -// fleet.yaml. +// named file, or the working directory's fleet.yaml beside which a +// discovered fleet beats the registry, or, with neither, the environment's +// fleet of one. Commands then act on only the --node's node, via +// narrowedTarget. // -// A directory's fleet.yaml is not consulted when an environment is named, and -// is not a conflict with one: a file that happens to be in the working -// directory is not a statement of intent, and a flag is. Only two flags -// conflict. +// A --fleet file answers for the node named beside it: a name the file does +// not hold fails naming the file and its nodes. Only a fleet *discovered* +// in the working directory falls through to the registry — a file that +// happens to be there is not a statement of intent, and a flag is. func resolveFleetTarget(t fleetTarget) (*fleet.Config, error) { - if err := checkOneTarget(t.envName, t.fleetPath); err != nil { + if t.nodeName != "" && t.fleetPath == "" { + // A discovered fleet beats the registry — that is what a name + // beside fleet.yaml answers — but only if it holds the node. A + // name it does not hold is a registered environment's when one + // answers it; when nothing does, a refusal naming the fleet the + // name was checked against is the truest message. + if _, err := os.Stat(fleet.DefaultFile); err == nil { + cfg, ferr := fleet.Load(fleet.DefaultFile) + if ferr != nil { + return nil, ferr + } + for _, n := range cfg.Nodes { + if n.Name == t.nodeName { + return cfg, nil + } + } + if _, rerr := fleet.ForEnvironment(t.nodeName); rerr == nil { + return fleet.ForEnvironment(t.nodeName) + } + return nil, fmt.Errorf( + "node %q is no node of %s (its nodes: %s) and no registered environment: name one of its nodes, or a registered environment", + t.nodeName, fleet.DefaultFile, strings.Join(cfg.Names(), ", ")) + } + return fleet.ForEnvironment(t.nodeName) + } + cfg, err := fleet.Resolve(t.fleetPath) + if err != nil { return nil, err } - if t.envName != "" { - return fleet.ForEnvironment(t.envName) + if t.nodeName != "" { + // --fleet names the fleet and --node the node within it. The + // named file is the stated fleet, so nothing else answers for + // it: no registry fallback, only its own nodes. + for _, n := range cfg.Nodes { + if n.Name == t.nodeName { + return cfg, nil + } + } + return nil, fmt.Errorf( + "node %q is no node of %s (its nodes: %s): name one of them, or drop --fleet and name a registered environment", + t.nodeName, t.fleetPath, strings.Join(cfg.Names(), ", ")) } - return fleet.Resolve(t.fleetPath) + return cfg, nil } -// checkOneTarget refuses a command line that names both an environment and a -// fleet. The two are different answers to where the model is served from — a -// named environment, or a set of nodes to pick from — so naming both is a -// mistake to report rather than a precedence to resolve. -// -// fleetTarget is the fleet as the caller has already worked it out: a --fleet -// value on the fleet commands, and on the launch path the file its own -// discovery settled on. Empty means no fleet was named. -// -// It is shared by the fleet commands and the launch so that one rule is -// enforced from one place and worded once; an operator meets it once. -func checkOneTarget(envName, fleetTarget string) error { - if envName == "" || fleetTarget == "" { - return nil +// narrowedTarget returns the fleet a command with a fleetTarget fans out +// over: the whole config when --node names nothing, or a narrowed copy of it +// holding only that node. A name no node of the resolved fleet answers was +// refused before this ran — resolveFleetTarget for both the named file and a +// discovered one, ForEnvironment for a registry name — so OnlyNames here +// cannot fail; the fall back to the whole config keeps a fan-out honest +// rather than empty should it ever change. +func narrowedTarget(cfg *fleet.Config, nodeName string) *fleet.Config { + if nodeName == "" { + return cfg + } + narrowed, err := cfg.OnlyNames([]string{nodeName}) + if err != nil { + return cfg } - return fmt.Errorf( - "both an environment (--env %s) and a fleet (%s) were given: each names where the model is served from, so state one", - envName, fleetTarget) + return narrowed } // describeTarget names the fleet a command acted on, for the lines that report diff --git a/cmd/spinloop/target_test.go b/cmd/spinloop/target_test.go index 146d2185..116b28d4 100644 --- a/cmd/spinloop/target_test.go +++ b/cmd/spinloop/target_test.go @@ -11,7 +11,7 @@ import ( ) // registerTargetEnv points the registry at a temp config directory and -// registers one environment there, so a --env target has something to +// registers one environment there, so a --node target has something to // resolve. func registerTargetEnv(t *testing.T, name string) { t.Helper() @@ -32,7 +32,7 @@ func TestResolveFleetTarget(t *testing.T) { t.Run("a named environment", func(t *testing.T) { registerTargetEnv(t, "prod") t.Chdir(t.TempDir()) - cfg, err := resolveFleetTarget(fleetTarget{envName: "prod"}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: "prod"}) if err != nil { t.Fatal(err) } @@ -82,34 +82,47 @@ func TestResolveFleetTarget(t *testing.T) { }) } -// Naming both a fleet and an environment is refused, naming both values. -func TestResolveFleetTargetRefusesBoth(t *testing.T) { +// --fleet names the fleet and --node the node within it: the pair resolves +// to the named file, and a name the file lacks fails naming it and its +// nodes — even when a registered environment answers the name, because the +// explicitly named file is the stated fleet and nothing else answers for it. +func TestResolveFleetTargetNamedFleetNarrows(t *testing.T) { registerTargetEnv(t, "prod") dir := t.TempDir() path := filepath.Join(dir, "cluster.yaml") - if err := os.WriteFile(path, []byte(oneNodeFleetBody), 0o600); err != nil { + if err := os.WriteFile(path, []byte( + "nodes:\n - name: gpu-a\n host: a.local\n - name: gpu-b\n host: b.local\n"), 0o600); err != nil { t.Fatal(err) } - _, err := resolveFleetTarget(fleetTarget{envName: "prod", fleetPath: path}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: "gpu-a", fleetPath: path}) + if err != nil { + t.Fatalf("--fleet and one of its nodes: %v", err) + } + narrowed := narrowedTarget(cfg, "gpu-a") + if len(narrowed.Nodes) != 1 || narrowed.Nodes[0].Name != "gpu-a" { + t.Errorf("want only gpu-a, got %+v", narrowed.Nodes) + } + + _, err = resolveFleetTarget(fleetTarget{nodeName: "prod", fleetPath: path}) if err == nil { - t.Fatal("want an error") + t.Fatal("a name the named file lacks should fail, not fall back to the registry") } - for _, want := range []string{"--env prod", path, "so state one"} { + for _, want := range []string{"prod", path, "gpu-a", "gpu-b"} { if !strings.Contains(err.Error(), want) { - t.Errorf("error %q does not mention %q", err, want) + t.Errorf("error %q does not name %q", err, want) } } } // A fleet.yaml merely sitting in the working directory is not a conflict with -// --env: only a flag states a target, so the environment wins and the file is +// --node: only a flag states a target, so the environment wins and the file is // not read (design D4). func TestResolveFleetTargetIgnoresTheDirectoryFileWithEnv(t *testing.T) { registerTargetEnv(t, "prod") writeFleetFile(t, oneNodeFleetBody) - cfg, err := resolveFleetTarget(fleetTarget{envName: "prod"}) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: "prod"}) if err != nil { t.Fatalf("a directory fleet file was treated as a conflict: %v", err) } @@ -123,7 +136,7 @@ func TestResolveFleetTargetIgnoresTheDirectoryFileWithEnv(t *testing.T) { func TestResolveFleetTargetUnregisteredEnv(t *testing.T) { t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir()) t.Chdir(t.TempDir()) - _, err := resolveFleetTarget(fleetTarget{envName: "nope"}) + _, err := resolveFleetTarget(fleetTarget{nodeName: "nope"}) if err == nil { t.Fatal("want an error") } @@ -132,33 +145,6 @@ func TestResolveFleetTargetUnregisteredEnv(t *testing.T) { } } -// checkOneTarget is the rule both the fleet commands and the launch enforce, -// so it passes whenever fewer than two targets are named. -func TestCheckOneTarget(t *testing.T) { - tests := []struct { - name string - env string - fleet string - wantError bool - }{ - {"neither", "", "", false}, - {"only an environment", "prod", "", false}, - {"only a fleet", "", "./fleet.yaml", false}, - {"both", "prod", "./fleet.yaml", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := checkOneTarget(tt.env, tt.fleet) - if tt.wantError && err == nil { - t.Fatal("want an error") - } - if !tt.wantError && err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - // A fleet of one has no path, so what reports the target names the // environment instead of printing an empty string. func TestDescribeTarget(t *testing.T) { @@ -185,8 +171,9 @@ func TestDescribeTarget(t *testing.T) { } } -// Every fleet command that takes a target completes --env from the registered -// environments, the same source `cloud --env` completes from. +// Every fleet command that takes a target completes --node from the +// registered environments and from the fleet in force on the line: the +// working directory's file here, and an explicit --fleet value below. func TestFleetCommandsCompleteEnv(t *testing.T) { registerTargetEnv(t, "prod") registerEnv(t, "staging", cloud.Config{ @@ -195,12 +182,15 @@ func TestFleetCommandsCompleteEnv(t *testing.T) { Region: "us-east-1", Environment: "staging", }) - t.Chdir(t.TempDir()) + dir := t.TempDir() + t.Chdir(dir) + mustWrite(t, filepath.Join(dir, fleet.DefaultFile), + "nodes:\n - name: gpu-a\n host: a.local\n") for _, sub := range []string{"start", "stop", "deploy", "route"} { t.Run(sub, func(t *testing.T) { - got, _ := complete(t, "fleet", sub, "--env", "") - want := map[string]bool{"prod": false, "staging": false} + got, _ := complete(t, "fleet", sub, "--node", "") + want := map[string]bool{"prod": false, "staging": false, "gpu-a": false} for _, c := range got { if _, ok := want[c]; ok { want[c] = true @@ -208,14 +198,35 @@ func TestFleetCommandsCompleteEnv(t *testing.T) { } for name, seen := range want { if !seen { - t.Errorf("fleet %s --env did not offer %q (got %v)", sub, name, got) + t.Errorf("fleet %s --node did not offer %q (got %v)", sub, name, got) } } }) } + + t.Run("an explicit --fleet feeds it", func(t *testing.T) { + other := filepath.Join(dir, "other.yaml") + mustWrite(t, other, "nodes:\n - name: gpu-z\n host: z.local\n") + got, _ := complete(t, "fleet", "start", "--fleet", other, "--node", "") + var sawZ, sawA bool + for _, c := range got { + switch c { + case "gpu-z": + sawZ = true + case "gpu-a": + sawA = true + } + } + if !sawZ { + t.Errorf("--node after --fleet other.yaml did not offer gpu-z (got %v)", got) + } + if sawA { + t.Errorf("--node after --fleet other.yaml offered the directory fleet's gpu-a (got %v)", got) + } + }) } -// The whole claim of a fleet of one: the same environment named by --env and +// The whole claim of a fleet of one: the same environment named by --node and // named by a one-node fleet file produces the same output, so what a command // prints does not depend on how its target was named. func TestFleetStatusEnvMatchesAOneNodeFile(t *testing.T) { @@ -241,8 +252,8 @@ func TestFleetStatusEnvMatchesAOneNodeFile(t *testing.T) { // Named by the flag, from a directory holding no fleet file at all. t.Chdir(t.TempDir()) fromEnv := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "prod"}); err != nil { - t.Errorf("fleet status --env returned %v", err) + if err := cmdStatus([]string{"--node", "prod"}); err != nil { + t.Errorf("fleet status --node returned %v", err) } }) @@ -254,7 +265,7 @@ func TestFleetStatusEnvMatchesAOneNodeFile(t *testing.T) { } } -// A --env target needs no fleet file: the command works in a directory that +// A --node target needs no fleet file: the command works in a directory that // holds none, which is the point of the flag. func TestFleetStatusEnvNeedsNoFleetFile(t *testing.T) { stubAWSEnv(t) @@ -270,8 +281,8 @@ func TestFleetStatusEnvNeedsNoFleetFile(t *testing.T) { t.Chdir(t.TempDir()) out := captureStdout(t, func() { - if err := cmdStatus([]string{"--env", "prod"}); err != nil { - t.Errorf("fleet status --env returned %v", err) + if err := cmdStatus([]string{"--node", "prod"}); err != nil { + t.Errorf("fleet status --node returned %v", err) } }) if !strings.Contains(out, "prod") { @@ -279,9 +290,10 @@ func TestFleetStatusEnvNeedsNoFleetFile(t *testing.T) { } } -// Naming both through the real command line is refused, not just through the -// resolver directly. -func TestFleetStatusRefusesEnvAndFleet(t *testing.T) { +// The pair through the real command line: --fleet beside --node narrows the +// read to that node; a name the named file lacks fails naming it, rather +// than falling through to a registered environment of that name. +func TestFleetStatusFleetAndNodePair(t *testing.T) { t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir()) registerEnv(t, "prod", cloud.Config{ StartURL: "https://s.example/start", @@ -289,15 +301,80 @@ func TestFleetStatusRefusesEnvAndFleet(t *testing.T) { Region: "us-east-1", Environment: "prod", }) - writeFleetFile(t, oneNodeFleetBody) + writeFleetFile(t, "nodes:\n - name: box\n host: 127.0.0.1\n port: 1\n") - err := cmdStatus([]string{"--env", "prod", "--fleet", "fleet.yaml"}) + out := captureStdout(t, func() { + if err := cmdStatus([]string{"--node", "box", "--fleet", "fleet.yaml"}); err != nil { + t.Errorf("--fleet and one of its nodes: %v", err) + } + }) + if !strings.Contains(out, "box") { + t.Errorf("no row for the narrowed node:\n%s", out) + } + + err := cmdStatus([]string{"--node", "prod", "--fleet", "fleet.yaml"}) if err == nil { - t.Fatal("want an error") + t.Fatal("want an error: the named file holds no prod") + } + for _, want := range []string{"prod", "fleet.yaml", "box"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } +} + +// With no fleet in force and no file to check, --node resolves as the +// environment; an unregistered name is refused. (Both branches of the +// discovery check, minus the file.) +func TestResolveTargetNoFileResolvesRegistry(t *testing.T) { + registerTargetEnv(t, "prod") + t.Chdir(t.TempDir()) + cfg, err := resolveFleetTarget(fleetTarget{nodeName: "prod"}) + if err != nil { + t.Fatalf("a registered environment with no fleet in force: %v", err) + } + if len(cfg.Nodes) != 1 || cfg.Nodes[0].Name != "prod" { + t.Errorf("want the one prod node, got %+v", cfg.Nodes) + } + if _, err := resolveFleetTarget(fleetTarget{nodeName: "nope"}); err == nil { + t.Error("an unregistered name with no fleet should fail") + } +} + +// Beside a discovered fleet the flag's node narrows the fan-out to it alone; +// a name the fleet holds resolves as the environment; a name nothing answers +// fails naming both places it looked. +func TestResolveTargetNarrowsDiscoveredFleet(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + mustWrite(t, filepath.Join(dir, fleet.DefaultFile), + "nodes:\n - name: gpu-a\n host: a.local\n - name: gpu-b\n host: b.local\n") + + cfg, err := resolveFleetTarget(fleetTarget{nodeName: "gpu-a"}) + if err != nil { + t.Fatalf("--node gpu-a beside a fleet holding it: %v", err) + } + narrowed := narrowedTarget(cfg, "gpu-a") + if len(narrowed.Nodes) != 1 || narrowed.Nodes[0].Name != "gpu-a" { + t.Errorf("want only gpu-a, got %+v", narrowed.Nodes) + } + + registerTargetEnv(t, "prod") + cfg, err = resolveFleetTarget(fleetTarget{nodeName: "prod"}) + if err != nil { + t.Fatalf("a registry name beside a fleet without it: %v", err) + } + if len(cfg.Nodes) != 1 || cfg.Nodes[0].Name != "prod" { + t.Errorf("want the prod environment's node, got %+v", cfg.Nodes) + } + + _, err = resolveFleetTarget(fleetTarget{nodeName: "ghost"}) + if err == nil { + t.Fatal("a name the fleet and the registry both refuse should fail") } - for _, want := range []string{"--env prod", "fleet.yaml", "so state one"} { + for _, want := range []string{"ghost", fleet.DefaultFile, "gpu-a", "gpu-b"} { if !strings.Contains(err.Error(), want) { - t.Errorf("error %q does not mention %q", err, want) + t.Errorf("error %q does not name %q", err, want) } } } diff --git a/cmd/spinloop/up.go b/cmd/spinloop/up.go index 7095b1fb..06df6261 100644 --- a/cmd/spinloop/up.go +++ b/cmd/spinloop/up.go @@ -50,7 +50,7 @@ func runUp(args []string) error { } // 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 runFleetDrive("start", cfg, len(args) == 0, args, "", fleetStartCall(cfg)) } return runServe(args, false, false, "", "") } diff --git a/docs/commands/alias.md b/docs/commands/alias.md index 42b4b934..2831d17c 100644 --- a/docs/commands/alias.md +++ b/docs/commands/alias.md @@ -40,7 +40,7 @@ It is stored and resolved just like a local one — see export SPINLOOP_ALIAS=big spinloop harness apply # the same as `spinloop harness apply big` spinloop serve -spinloop status --env +spinloop status --node ``` The order is the argument you typed, then `SPINLOOP_ALIAS`, then `./Spinloop`. Two diff --git a/docs/commands/cloud.md b/docs/commands/cloud.md index bcadec12..bb733335 100644 --- a/docs/commands/cloud.md +++ b/docs/commands/cloud.md @@ -10,8 +10,8 @@ spinloop cloud auth # store or report the credential this machine signs wi spinloop cloud bake # bake the runner AMI(s) an environment runs from spinloop cloud deploy # create an endpoint (environment) and tell it what to serve spinloop cloud start # boot it; with --print-env, prints the exports your agent needs -spinloop status --env # is it up? is it healthy? -spinloop logs --env # what did it say? (readable after it's gone) +spinloop status --node # is it up? is it healthy? +spinloop logs --node # what did it say? (readable after it's gone) spinloop cloud pause # stop it now; a later start re-wakes it spinloop cloud restart # fresh engine, same address: stop it and wake it again spinloop cloud keep 4h # prevent the idle sweep from stopping it for 4 hours @@ -86,17 +86,17 @@ picked up automatically once it is available. ## The usual flow ```sh -eval "$(spinloop cloud start --env qwen3.6-27b --print-env)" # boots it (~10 min +eval "$(spinloop cloud start --node qwen3.6-27b --print-env)" # boots it (~10 min # from cold) and sets OPENAI_BASE_URL and # OPENAI_API_KEY -spinloop harness apply --env qwen3.6-27b # point your agent at it -spinloop harness open --env qwen3.6-27b # work -spinloop cloud pause --env qwen3.6-27b # done for now: stopped, re-wakeable with start -spinloop cloud stop --env qwen3.6-27b # done for good: terminate it +spinloop harness apply --node qwen3.6-27b # point your agent at it +spinloop harness open --node qwen3.6-27b # work +spinloop cloud pause --node qwen3.6-27b # done for now: stopped, re-wakeable with start +spinloop cloud stop --node qwen3.6-27b # done for good: terminate it ``` Every command that acts on an endpoint — `start`, `status`, `metrics`, `logs`, -`pause`, `restart`, `keep`, `schedule`, `stop` — selects it with the same `--env ` +`pause`, `restart`, `keep`, `schedule`, `stop` — selects it with the same `--node ` flag; with no flag they use the `default` environment. `start` prints its progress on stderr, and the export lines for `eval` only with `--print-env`, so a plain `start` leaves stdout empty for other uses. @@ -127,10 +127,10 @@ for [`spinloop harness apply`](harness.md#spinloop-harness-apply): a Spinloop fo `BASEURL` and let apply take the address from here, so the address stays with the deployment that owns it. A `BASEURL` in the Spinloop wins if you set one. -Name the environment with the `--env` flag: +Name the environment with the `--node` flag: ```sh -spinloop status --env --env qwen3.6-27b-prod +spinloop status --node qwen3.6-27b-prod ``` The flag selects a **named environment** from the @@ -138,15 +138,15 @@ per-user registry at `~/.config/spinloop/clouds//cloud.json`. This keeps deployment state per-user and per-machine: two projects name two environments without clobbering, and the Spinloop carries none of the URLs at all. `spinloop cloud deploy` registers an environment for you; you can also -create one by hand. A name is a plain identifier — `--env ./cloud.json` fails, +create one by hand. A name is a plain identifier — `--node ./cloud.json` fails, saying an environment name has no path. -**`--env` is required.** There is no environment a command falls back to: half +**`--node` is required.** There is no environment a command falls back to: half of these subcommands start, stop or terminate a cloud instance, and an instance -nobody named is not one to act on. A command given no `--env` fails naming the +nobody named is not one to act on. A command given no `--node` fails naming the flag and listing the environments you have registered, so the next thing to type is in the error. `default` is an ordinary name — call an environment that -if you like, and pass `--env default` to use it. +if you like, and pass `--node default` to use it. A command may also be given a Spinloop path (or a [registered alias](alias.md), or a URL): its `ENV` lines and the `.env` beside it are read @@ -154,15 +154,15 @@ before the command signs its AWS calls, so credentials, region and `SPINLOOP_CLOUD_*` overrides can travel with the Spinloop. The Spinloop does not select the environment — that is the flag's job alone. -`spinloop cloud env --env ` fetches a running endpoint's credentials +`spinloop cloud env --node ` fetches a running endpoint's credentials (`export OPENAI_BASE_URL`/`export OPENAI_API_KEY`, safe to `eval`) without booting it. It also reports what is deployed to the environment — runner, served model, context size — whenever something is: this is what lets -[`spinloop harness open --env `](harness.md#launching-with-no-spinloop-at-all) +[`spinloop harness open --node `](harness.md#launching-with-no-spinloop-at-all) configure the harness from a deployed environment with no Spinloop at all. It appears once the control plane has been redeployed with `spinloop cloud bootstrap`; an older control plane simply omits it, and `spinloop harness open ---env` with no Spinloop fails naming that as the fix. +--node` with no Spinloop fails naming that as the fix. ## Listing environments @@ -239,9 +239,9 @@ a stored key covers them too. ## Checking on an endpoint ```sh -spinloop status --env # is it up, is it healthy, where is it -spinloop metrics --env # what is it doing — tokens, GPU, CPU, RAM -spinloop metrics --env -w # the same, redrawn every 60 seconds +spinloop status --node # is it up, is it healthy, where is it +spinloop metrics --node # what is it doing — tokens, GPU, CPU, RAM +spinloop metrics --node -w # the same, redrawn every 60 seconds ``` `metrics` draws its resource series as **gauge** format by default: the @@ -324,10 +324,10 @@ version, or re-bootstrap). ## Starting and stopping on a schedule ```sh -spinloop cloud schedule set --env dev \ +spinloop cloud schedule set --node dev \ --start "0 8 * * 1-5" --stop "0 18 * * 1-5" --timezone Europe/London -spinloop cloud schedule show --env dev # the schedules, and when each action next runs -spinloop cloud schedule clear --env dev # remove them all +spinloop cloud schedule show --node dev # the schedules, and when each action next runs +spinloop cloud schedule clear --node dev # remove them all ``` A schedule is a five-field cron expression (minute, hour, day of month, month, @@ -386,10 +386,10 @@ endpoint's base URL when it serves, so you can check the address is unchanged. ## Reading the logs ```sh -spinloop logs --env # the last hour of engine output -spinloop logs --env --source boot # the start-up log, before the engine ran -spinloop logs --env --since 6h --limit 500 -spinloop logs --env -f # follow, until you interrupt it +spinloop logs --node # the last hour of engine output +spinloop logs --node --source boot # the start-up log, before the engine ran +spinloop logs --node --since 6h --limit 500 +spinloop logs --node -f # follow, until you interrupt it ``` Instances ship two logs to CloudWatch: the inference engine's own output, and @@ -436,7 +436,7 @@ recovered — only what's shipped from then on. plane and tells it what to serve. It reads the Spinloop, its preset and its PARAM lines — `PROVIDER` picks the engine, so the file that runs a model locally under -[`spinloop serve`](serve.md) deploys the same model remotely — and `--env ` +[`spinloop serve`](serve.md) deploys the same model remotely — and `--node ` names the environment it creates and registers. The flag is required: a deploy without it fails, naming the flag and a name to use. The Spinloop says what the environment serves; the name is a machine-local choice, so it stays out of the @@ -450,7 +450,7 @@ PRESET ./preset.ini # the model and its flags — or PARAM lines instead ``` ```sh -spinloop cloud deploy --env qwen3.6-27b +spinloop cloud deploy --node qwen3.6-27b ``` Deploy discovers the control plane from the bootstrap stack's outputs, then @@ -487,13 +487,13 @@ says so; wait for that before your first `start`, or the model won't be there. Switching model, quantisation, or engine is an edit to those two files and one `deploy` — no redeployment of the infrastructure. A second Spinloop deployed -under a different `--env` name gets its own environment, side by side. +under a different `--node` name gets its own environment, side by side. ```sh -spinloop cloud deploy --env qwen3.6-27b --dry-run # see what would be sent -spinloop cloud deploy --env other-env path/to/Spinloop # deploy a different file -spinloop cloud deploy --env qwen3.6-27b --overwrite # redeploy over the existing environment -spinloop cloud deploy --env qwen3.6-27b --reseed # re-fetch weights already in S3 +spinloop cloud deploy --node qwen3.6-27b --dry-run # see what would be sent +spinloop cloud deploy --node other-env path/to/Spinloop # deploy a different file +spinloop cloud deploy --node qwen3.6-27b --overwrite # redeploy over the existing environment +spinloop cloud deploy --node qwen3.6-27b --reseed # re-fetch weights already in S3 ``` Deploy fetches the weights only when they are not in S3 already. `--reseed` @@ -519,7 +519,7 @@ included) for every `kind: cloud` node a fleet file names — or a chosen few | Flag | Meaning | | ---- | ------- | -| `--env` | The environment the command acts on, by registered name (required by `deploy`; default `default`) | +| `--node`, `-n` | The environment the command acts on, by registered name (required by `deploy`; default `default`; `deploy` names it with `--node` only — `-n` is `--dry-run` there) | | `--print-env` | `start` only: print the export lines (`OPENAI_BASE_URL`/`OPENAI_API_KEY`) to stdout for `eval` | | `--timeout` | How long `start` or `restart` waits for the endpoint (default 15m) | | `-F`, `--force` | `restart` only: skip the graceful engine stop and take the instance down directly | @@ -540,10 +540,10 @@ included) for every `kind: cloud` node a fleet file names — or a chosen few AMIs are shared by every environment, and the stored credential belongs to the machine, not a project. - `deploy` always needs a Spinloop — it's the thing being deployed — and a - required `--env ` for the environment it creates. The others take an + required `--node ` for the environment it creates. The others take an optional Spinloop path, a [registered alias](alias.md), or a URL, read only for its `ENV` lines and `.env` — never to select the environment. Which - environment a command acts on is its `--env` flag, defaulting to the + environment a command acts on is its `--node` flag, defaulting to the `default` environment. - `deploy_url` is optional: a config written before `deploy` existed still works for `start`, `stop`, and `status`. diff --git a/docs/commands/code.md b/docs/commands/code.md index 14fb6998..3bcf73f7 100644 --- a/docs/commands/code.md +++ b/docs/commands/code.md @@ -8,7 +8,7 @@ trailing arguments, so it is a shortcut for the launch, not a second command: spinloop code # launch the active harness spinloop code -O # apply ./Spinloop, then launch spinloop code qwen3.6-27b # apply the aliased Spinloop, then launch -spinloop code --env prod --prompt "hi" # configure from prod's deployment, then launch +spinloop code --node prod --prompt "hi" # configure from prod's deployment, then launch spinloop code --fleet ./fleet.yaml # route through a fleet, or its gateway ``` @@ -20,7 +20,7 @@ complete launch on a machine holding nothing but the fleet file. Every flag and argument `code` accepts is [`harness open`'s](harness.md#spinloop-harness-open), so run `spinloop harness open --help` for the full list, or read that page for -the Spinloop, `--env`, and fleet behaviour in detail. +the Spinloop, `--node`, and fleet behaviour in detail. ## See also diff --git a/docs/commands/dashboard.md b/docs/commands/dashboard.md index e2c5fcf4..017e4b01 100644 --- a/docs/commands/dashboard.md +++ b/docs/commands/dashboard.md @@ -6,7 +6,7 @@ on an interval, with the keys to drive the one you have selected. ```sh spinloop dashboard # the fleet.yaml in this directory spinloop dashboard --fleet ./cluster.yaml -spinloop dashboard --env prod # one environment, as a board of one +spinloop dashboard --node prod # one environment, as a board of one ``` Each tile draws what the gauge format of @@ -37,9 +37,11 @@ title bar names the state beside the log's follow state. ## Which target -The three ways every command that acts on a fleet takes one — `--env `, -`--fleet `, or the working directory's `fleet.yaml`. A single -environment opens as a board of one panel, which needs no fleet file at all. +The three ways every command that acts on a fleet takes one — `--node `, +`--fleet `, or the working directory's `fleet.yaml`. Beside a fleet, +`--node` narrows the board to that node alone; a name a *discovered* fleet +lacks opens a registered environment as a board of one panel, which needs no +fleet file at all. A problem with the target itself — a missing or unparseable fleet file, an environment that is not registered — fails before the view opens. A problem diff --git a/docs/commands/fleet.md b/docs/commands/fleet.md index 6ab84e51..05125048 100644 --- a/docs/commands/fleet.md +++ b/docs/commands/fleet.md @@ -31,24 +31,32 @@ Every `spinloop fleet` command takes its target one of three ways: | | target | | --- | --- | -| `--env ` | one registered environment, as a fleet of one | +| `--node ` (`-n`, except on `deploy`) | a node of the fleet in force, narrowing the command to it — or, with no fleet named, one registered environment as a fleet of one | | `--fleet ` (`-f`, except on `logs`) | that fleet file | | neither | the `fleet.yaml` in the working directory | -`--env` and `--fleet` name two different things, so passing both fails saying -so rather than picking one. A `fleet.yaml` merely sitting in the working -directory is not a conflict: only a flag states a target, so `--env` simply -wins and the file is not read. +`--fleet` names the fleet and `--node` names the node within it: beside a +fleet — the file you name, or the working directory's — `--node` narrows the +command to that node alone. A name the *named* file lacks fails naming the +file and its nodes; only beside a *discovered* `fleet.yaml` does a name the +file lacks fall back to a registered environment of that name, because a file +that merely sits in the working directory states no intent. + +```sh +spinloop fleet start -f cluster.yaml -n gpu-box # that file's gpu-box alone +spinloop status -n gpu-box # the same, in the directory holding cluster.yaml… +spinloop status -n qwen # …or the registered environment, if no local fleet holds the name +``` ### One environment, no fleet file A registered environment and a one-node fleet file naming it describe the same -thing, so `--env` lets you skip writing the file: +thing, so `--node` lets you skip writing the file: ```sh -spinloop status --env qwen # the same row a one-node fleet file gives -spinloop dashboard --env qwen # the tiled view, on one environment -spinloop logs --env qwen # its engine's log +spinloop status --node qwen # the same row a one-node fleet file gives +spinloop dashboard --node qwen # the tiled view, on one environment +spinloop logs --node qwen # its engine's log ``` Because such a fleet has no file, it carries none of the settings a fleet file @@ -58,7 +66,7 @@ used, which a fleet of one has no occasion for; put the environment in a `fleet.yaml` when you want any of them. To launch an agent against a single environment, use -[`spinloop code --env `](code.md), which configures the harness from what +[`spinloop code --node `](code.md), which configures the harness from what that environment reports is deployed. ## Try it without any hardware @@ -130,7 +138,7 @@ has been started at all. `spinloop metrics` renders each node's engine and system metrics in the same `gauge` (default), `bar`, `table`, and `json` formats as -[`spinloop metrics --env `](cloud.md) — they share the renderers, so a node in +[`spinloop metrics --node `](cloud.md) — they share the renderers, so a node in your fleet and a cloud endpoint look the same. `gauge` draws the current reading per series as a filled progress gauge; `--format=bar` draws each series as a sparkline of the node's daemon's retained history instead, and a @@ -442,7 +450,7 @@ or a matching subdirectory added, or `fleet start` fails for that node. A `fleet deploy` creates the AWS environment for one or more `kind: cloud` nodes — the step that otherwise has to happen outside the fleet file -entirely, one `spinloop cloud deploy --env ` at a time, run from the +entirely, one `spinloop cloud deploy --node ` at a time, run from the directory holding each node's Spinloop: ```sh @@ -479,7 +487,7 @@ deploy`](cloud.md), applied per node. | ---- | ------- | | `-f`, `--fleet ` | 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: cloud` node, for `deploy`) instead of named ones | -| `--node ` | `route` only: report this node rather than choosing one | +| `-n`, `--node ` | narrow the command to one node of the fleet — on `start`/`stop` it stands in for the node name, on `route` it reports that node rather than choosing one; `deploy` names it with `--node` only, since `-n` is `--dry-run` there | | `--prefer` | `route` only: rank by `idle` or `active`, overriding the file | | `--format` | `metrics`: `gauge` (default), `bar`, `table`, or `json`; `logs`: `text` (default) or `json` | | `-w`, `--watch` | `metrics` only: redraw on an interval until interrupted | diff --git a/docs/commands/gateway.md b/docs/commands/gateway.md index 37a4faaa..b2e7a247 100644 --- a/docs/commands/gateway.md +++ b/docs/commands/gateway.md @@ -85,7 +85,7 @@ The list is what a request can reach, so it is bounded by what the gateway can start: a running node contributes only what it reports — a running engine is never displaced to make room. A deployed-but-stopped `kind: cloud` environment contributes the model id its own stats reply reports — read -directly from its stored deploy config, the way `spinloop metrics --env ` +directly from its stored deploy config, the way `spinloop metrics --node ` already reads it, since its status reply carries no such facts while stopped — since the gateway can wake it the same way it wakes a `kind: daemon` node; one with nothing deployed contributes nothing, and diff --git a/docs/commands/harness.md b/docs/commands/harness.md index e012f927..d77dd755 100644 --- a/docs/commands/harness.md +++ b/docs/commands/harness.md @@ -54,14 +54,14 @@ a path, attach it to the flag, because anything positional is forwarded to the agent (`spinloop harness open -O run --model x` passes `run --model x` on). The one exception is an argument that names a Spinloop — a path, a directory holding one, or a [registered alias](alias.md) — which is applied rather than -forwarded. It can sit anywhere among the command's own flags (`--env`, `-H`, +forwarded. It can sit anywhere among the command's own flags (`--node`, `-H`, `--fleet`, ...), in any order; the first argument that is neither one of those flags nor a Spinloop name starts the agent's own arguments: ```sh spinloop harness open qwen3.6-27b # apply the aliased Spinloop, launch -spinloop harness open qwen3.6-27b --env prod # the alias and --env in either order -spinloop harness open --env prod qwen3.6-27b # ...parse the same way +spinloop harness open qwen3.6-27b --node prod # the alias and --node in either order +spinloop harness open --node prod qwen3.6-27b # ...parse the same way spinloop harness open qwen3.6-27b -- --agent-arg # ...forwarding --agent-arg spinloop harness open -- qwen3.6-27b # leading -- opts out: forward it ``` @@ -76,33 +76,33 @@ variable chooses which Spinloop, never whether you are configured. See ### Launching with no Spinloop at all -`--env ` on its own — no leading alias or path, no `--spinloop`/`-O` — +`--node ` on its own — no leading alias or path, no `--spinloop`/`-O` — configures the harness from what is actually deployed to that [environment](cloud.md), rather than doing nothing with the flag: ```sh -spinloop harness open --env dev-3 --prompt "..." # configured from dev-3's deployment, then launched +spinloop harness open --node dev-3 --prompt "..." # configured from dev-3's deployment, then launched ``` The environment's runner becomes the provider, its served model name becomes the model, and its context size (when set) becomes the context window — the same result a Spinloop stating the matching `PROVIDER`/`ALIAS`/`CONTEXT` with -`--env dev-3` would produce, without writing one. This is what makes the +`--node dev-3` would produce, without writing one. This is what makes the two-machine flow work: deploy from one machine -(`spinloop cloud deploy --env dev-3`), then on any machine that +(`spinloop cloud deploy --node dev-3`), then on any machine that can reach the same environment — one that has its `cloud.json` in the -registry, however it got there — run `spinloop harness open --env dev-3` with no +registry, however it got there — run `spinloop harness open --node dev-3` with no Spinloop and get the same configuration, live, so a later redeploy is picked up automatically rather than requiring anyone to re-copy anything. -Applying a Spinloop alongside `--env` (a leading alias/path, or `-O`) is +Applying a Spinloop alongside `--node` (a leading alias/path, or `-O`) is unaffected: the Spinloop's own `PROVIDER`, `ALIAS`, `MODEL` and `CONTEXT` win, exactly as a hand-written `BASEURL` already wins over the environment's registered address. -A bare `--env` against an environment with nothing deployed — or a control +A bare `--node` against an environment with nothing deployed — or a control plane too old to report what is deployed — fails before launching, naming the -environment and how to fix it: `spinloop cloud deploy --env +environment and how to fix it: `spinloop cloud deploy --node ` to deploy something, or `spinloop cloud bootstrap` to update the control plane. @@ -112,7 +112,7 @@ control plane. | ---- | ------- | | `-H`, `--harness` | Which harness to launch (or set `SPINLOOP_HARNESS`) | | `-O`, `--spinloop` | Apply this Spinloop before launching (bare: `./Spinloop`) | -| `-e`, `--env` | The registered [environment](cloud.md) to launch against; with no Spinloop applied, configures the harness from what is deployed there — mutually exclusive with fleet routing, since each names where the model is served from | +| `-n`, `--node` | The node the agent talks to: with a fleet in force, pins the launch to that fleet node; with none, the registered [environment](cloud.md) to launch against — and with no Spinloop applied, configures the harness from what is deployed there | | `--providers` | Path to a custom catalogue, for the applied Spinloop | | `-f`, `--fleet` | Route through this fleet file (default: `./fleet.yaml`, when the Spinloop is not named) | | `--node` | Pin the launch to one fleet node | @@ -303,7 +303,7 @@ opencode. After applying, just launch your agent — or do both at once with | Flag | Meaning | | ---- | ------- | -| `-e`, `--env` | The registered [environment](cloud.md) the Spinloop points at: names the harness provider and, with no `BASEURL`, supplies the endpoint's address | +| `-n`, `--node` | The registered [environment](cloud.md) the Spinloop points at: names the harness provider and, with no `BASEURL`, supplies the endpoint's address | | `-o`, `--output` | Max output tokens — overrides the Spinloop's `OUTPUT` | | `-H`, `--harness` | Which harness to configure (or set `SPINLOOP_HARNESS`) | | `--providers` | Path to a custom catalogue (a Spinloop never names one) | @@ -317,14 +317,14 @@ Notes: [Fetching a Spinloop from a URL](../spinloop-file.md#fetching-a-spinloop-from-a-url). - A Spinloop's `PRESET` line is for [`spinloop serve`](serve.md); `apply` ignores it — never fetched, even when it's a URL. -- With `--env `, the Spinloop points at a registered +- With `--node `, the Spinloop points at a registered [environment](cloud.md): the harness provider is keyed on the environment name (so several environments built from the same engine keep their own entries), and a Spinloop with no `BASEURL` takes the endpoint's address from the environment's `cloud.json` `base_url`, which its deployment writes. A `BASEURL` in the Spinloop wins over it. An unregistered name fails, naming - the `spinloop cloud deploy --env ` that would create it. Without - `--env`, apply reads no cloud config at all. + the `spinloop cloud deploy --node ` that would create it. Without + `--node`, apply reads no cloud config at all. ## spinloop harness unapply diff --git a/docs/commands/logs.md b/docs/commands/logs.md index cf36a786..b92dca23 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -6,9 +6,9 @@ daemon's log file or a cloud environment's log store. ```sh spinloop logs # every node in this directory's fleet.yaml spinloop logs studio # just that node -spinloop logs --env prod # one registered environment, no file needed +spinloop logs --node prod # one registered environment, no file needed spinloop logs -f # follow -spinloop logs --env prod --source boot --since 30m +spinloop logs --node prod --source boot --since 30m ``` With no node named it reads every node in the target; naming one reads only @@ -21,7 +21,7 @@ that node's own log. | | target | | --- | --- | -| `--env ` | one registered environment | +| `--node ` (`-n`) | that node of the fleet in force; with no fleet named, a registered environment when none holds the name | | `--fleet ` | that fleet file | | neither | the `fleet.yaml` in the working directory | diff --git a/docs/commands/metrics.md b/docs/commands/metrics.md index 8861d52e..d78aedd5 100644 --- a/docs/commands/metrics.md +++ b/docs/commands/metrics.md @@ -6,7 +6,7 @@ request counters, and what the node it runs on is. ```sh spinloop metrics # the fleet.yaml in this directory spinloop metrics --fleet ./cluster.yaml -spinloop metrics --env prod # one registered environment, no file needed +spinloop metrics --node prod # one registered environment, no file needed spinloop metrics -w # redraw in place every 60 seconds spinloop metrics --cost # add what each priceable node has spent ``` @@ -17,12 +17,15 @@ The three ways every command that reads engines takes one: | | target | | --- | --- | -| `--env ` | one registered environment | +| `--node ` (`-n`) | that node of the fleet in force; with no fleet named, a registered environment when none holds the name | | `--fleet ` (`-f`) | that fleet file | | neither | the `fleet.yaml` in the working directory | -`--env` and `--fleet` name two different things, so passing both fails saying -so. With none of the three resolvable the command fails naming all of them. +`--fleet` names the fleet and `--node` the node within it, so the pair +narrows the read to that node alone — and a name the named file lacks fails +naming it and its nodes. Beside a *discovered* `fleet.yaml`, a name the file +lacks falls back to a registered environment of that name. With none of the +three resolvable the command fails naming all of them. ## Formats diff --git a/docs/commands/status.md b/docs/commands/status.md index 13bc9dbe..45d332d2 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -6,7 +6,7 @@ machines, a single cloud environment, or both. ```sh spinloop status # the fleet.yaml in this directory spinloop status --fleet ./cluster.yaml -spinloop status --env prod # one registered environment, no file needed +spinloop status --node prod # one registered environment, no file needed ``` ``` @@ -28,16 +28,20 @@ The same three ways every command that acts on a fleet takes one: | | target | | --- | --- | -| `--env ` | one registered environment | +| `--node ` (`-n`) | that node of the fleet in force; with no fleet named, a registered environment when none holds the name | | `--fleet ` (`-f`) | that fleet file | | neither | the `fleet.yaml` in the working directory | -`--env` and `--fleet` name two different things, so passing both fails saying -so. With none of the three resolvable the command fails naming all of them — -it does not go looking for an engine on the machine you are sitting at. To -watch a local engine, [`spinloop serve`](serve.md) shows the one it runs; to -read it from elsewhere, run [`spinloop daemon`](serve.md#the-control-api-api-and-spinloop-daemon) -and name that machine in a `fleet.yaml`. +`--fleet` names the fleet and `--node` the node within it, so the pair +narrows the table to that node's row alone — and a name the named file lacks +fails naming it and its nodes. Beside a *discovered* `fleet.yaml`, a name the +file lacks falls back to a registered environment of that name. With none of +the three resolvable the command fails naming all of them — it does not go +looking for an engine on the machine you are sitting at. To watch a local +engine, [`spinloop serve`](serve.md) shows the one it runs; to read it from +elsewhere, run +[`spinloop daemon`](serve.md#the-control-api-api-and-spinloop-daemon) and name +that machine in a `fleet.yaml`. ## What a row says @@ -59,7 +63,7 @@ for it. | flag | applies to | | --- | --- | -| `--env`, `--fleet`, `-O`/`--spinloop` | every target | +| `--node`, `--fleet`, `-O`/`--spinloop` | every target | `status` takes no flag that only some node kinds can answer. `metrics` and `logs` do — see their pages. diff --git a/docs/env-vars.md b/docs/env-vars.md index b169f040..5aa6a81b 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -35,13 +35,13 @@ from the environment or a `.env` beside the Spinloop — never written into an | `SPINLOOP_CLOUD_PACKAGE_MANAGER` | Pin the package manager (`pnpm`/`npm`) `spinloop cloud bootstrap` and `bake` use. | These let the cloud commands run without a `cloud.json` on disk — the config -can come entirely from the environment. `--env ` is still required, and +can come entirely from the environment. `--node ` is still required, and on this path the name you give *is* the environment identifier the control plane acts on, since there is no file to take one from: ```sh SPINLOOP_CLOUD_START_URL=... SPINLOOP_CLOUD_STOP_URL=... SPINLOOP_CLOUD_REGION=... \ - spinloop cloud start --env ci + spinloop cloud start --node ci ``` ## Standard variables spinloop honours diff --git a/docs/guides/cloud.md b/docs/guides/cloud.md index e4253cdc..0429b191 100644 --- a/docs/guides/cloud.md +++ b/docs/guides/cloud.md @@ -29,11 +29,11 @@ log-ins. ## Deploy a model -The Spinloop says what the environment serves; `--env` names the environment — +The Spinloop says what the environment serves; `--node` names the environment — a machine-local choice that stays out of the file: ```sh -spinloop cloud deploy --env qwen3.6-27b +spinloop cloud deploy --node qwen3.6-27b ``` Deploy reads the Spinloop and its preset — `PROVIDER` picks the engine, so the @@ -47,35 +47,35 @@ your own IP may reach the instance (`--allowed-cidr` changes that). ## The usual flow ```sh -spinloop cloud start --env qwen3.6-27b # boots it (~10 min from cold) -eval "$(spinloop cloud start --env qwen3.6-27b --print-env)" # ...and exports +spinloop cloud start --node qwen3.6-27b # boots it (~10 min from cold) +eval "$(spinloop cloud start --node qwen3.6-27b --print-env)" # ...and exports # OPENAI_BASE_URL + OPENAI_API_KEY -spinloop harness apply --env qwen3.6-27b # point your agent at it -spinloop harness open --env qwen3.6-27b # work -spinloop cloud pause --env qwen3.6-27b # done for now: stopped, re-wakeable -spinloop cloud stop --env qwen3.6-27b # done for good: terminated +spinloop harness apply --node qwen3.6-27b # point your agent at it +spinloop harness open --node qwen3.6-27b # work +spinloop cloud pause --node qwen3.6-27b # done for now: stopped, re-wakeable +spinloop cloud stop --node qwen3.6-27b # done for good: terminated ``` -Every command that acts on an endpoint selects it with `--env ` — the +Every command that acts on an endpoint selects it with `--node ` — the flag is **required**, because half of these subcommands start, stop, or terminate a cloud instance, and an instance nobody named is not one to act on. -A command given no `--env` fails naming the flag and listing the environments +A command given no `--node` fails naming the flag and listing the environments you have registered. `start` prints progress on stderr, and the export lines for `eval` only with `--print-env`, so a plain `start` leaves stdout empty. Useful along the way: ```sh -spinloop cloud status --env qwen3.6-27b # up? healthy? where? how long idle? -spinloop cloud logs --env qwen3.6-27b # readable even after it's gone -spinloop cloud keep 4h --env qwen3.6-27b # the idle sweep won't touch it for 4h -spinloop cloud restart --env qwen3.6-27b # fresh engine, same address +spinloop cloud status --node qwen3.6-27b # up? healthy? where? how long idle? +spinloop cloud logs --node qwen3.6-27b # readable even after it's gone +spinloop cloud keep 4h --node qwen3.6-27b # the idle sweep won't touch it for 4h +spinloop cloud restart --node qwen3.6-27b # fresh engine, same address ``` Work office hours? Let the cloud start and stop it for you: ```sh -spinloop cloud schedule set --env qwen3.6-27b \ +spinloop cloud schedule set --node qwen3.6-27b \ --start "0 8 * * 1-5" --stop "0 18 * * 1-5" --timezone Europe/London ``` @@ -89,7 +89,7 @@ share one (each with its `cloud.json` in the registry) can work the same endpoint. Deploy from one, then on the other — with **no Spinloop at all**: ```sh -spinloop harness open --env qwen3.6-27b +spinloop harness open --node qwen3.6-27b ``` The environment's runner becomes the provider, its served model the model, its diff --git a/docs/guides/fleet.md b/docs/guides/fleet.md index eb261d2d..3dc84638 100644 --- a/docs/guides/fleet.md +++ b/docs/guides/fleet.md @@ -17,7 +17,7 @@ spinloop fleet stop gpu-box # stop one or more nodes' engines ``` `status` and `dashboard` are top-level verbs — they take the same target -(`./fleet.yaml`, `--fleet `, or `--env ` for one environment as a +(`./fleet.yaml`, `--fleet `, or `--node ` for one environment as a fleet of one), and the `fleet` subcommands drive the nodes: `start`, `stop`, `deploy`, `route`, `metrics`, and `logs`. diff --git a/docs/guides/harness.md b/docs/guides/harness.md index da913f5a..ea11d1e3 100644 --- a/docs/guides/harness.md +++ b/docs/guides/harness.md @@ -83,12 +83,12 @@ spinloop code # the one-word form of the same launch the agent's exit code is yours: ```sh -spinloop code --env prod # spinloop's own flags, in any order +spinloop code --node prod # spinloop's own flags, in any order spinloop code -- agent-args # -- stops the parsing: everything after is the agent's ``` Where the model is served is a launch concern, learned one of three ways: a -Spinloop you apply, `--env` for a [registered cloud environment](cloud.md), +Spinloop you apply, `--node` for a [registered cloud environment](cloud.md), or a [fleet file](fleet.md) that routes the launch to a node that serves — or is woken to serve — the wanted model. diff --git a/docs/spinloop-file.md b/docs/spinloop-file.md index f362311f..7099e634 100644 --- a/docs/spinloop-file.md +++ b/docs/spinloop-file.md @@ -86,22 +86,22 @@ PRESET ./preset.ini # or PARAM lines in this file instead The Spinloop says *what*; it no longer says *where*. Where is an **environment** — a deployed, registered endpoint — and the environment is named -with a `--env ` flag on the commands that act on it: +with a `--node ` flag on the commands that act on it: ```sh -spinloop cloud deploy --env qwen3.6-27b-prod # from the directory holding the Spinloop -spinloop harness apply --env qwen3.6-27b-prod # point opencode at it -spinloop harness open --env qwen3.6-27b-prod # work -spinloop cloud stop --env qwen3.6-27b-prod # done +spinloop cloud deploy --node qwen3.6-27b-prod # from the directory holding the Spinloop +spinloop harness apply --node qwen3.6-27b-prod # point opencode at it +spinloop harness open --node qwen3.6-27b-prod # work +spinloop cloud stop --node qwen3.6-27b-prod # done ``` -Each `--env ` reads the environment's registered config at +Each `--node ` reads the environment's registered config at `${XDG_CONFIG_HOME:-~/.config}/spinloop/clouds//cloud.json` — the file [`spinloop cloud deploy`](commands/cloud.md) writes when it creates the environment — so deployment state stays per-user and per-machine while the -Spinloop itself stays clean enough to commit. A command given no `--env` uses +Spinloop itself stays clean enough to commit. A command given no `--node` uses the `default` environment, and an unregistered name fails, naming the `deploy ---env` that would create it. A name is a plain identifier: `--env ./x.json` +--node` that would create it. A name is a plain identifier: `--node ./x.json` fails, saying so. See [`spinloop cloud`](commands/cloud.md) for the full lifecycle. @@ -110,7 +110,7 @@ which records it in the environment's `cloud.json` as `base_url`, and [`spinloop harness apply`](commands/harness.md#spinloop-harness-apply) reads it from there. Write a `BASEURL` only to override that. -Applying with `--env` also names the harness provider after the environment +Applying with `--node` also names the harness provider after the environment rather than the engine: the example above is configured under `qwen3.6-27b-prod`, with the model reading as `qwen3.6-27b-prod/qwen3.6-27b`. `PROVIDER` still supplies the engine's settings; only the name changes, so several environments @@ -119,11 +119,12 @@ The provider's display name is qualified by the environment too — `llama.cpp (qwen3.6-27b-prod)` rather than a bare `llama.cpp` — so a cloud environment reads distinctly from a local engine of the same kind in a harness model picker. -A launch may not state both `--env` and a fleet (the `--fleet` flag, or the -`./fleet.yaml` in force when the Spinloop is not named): each names where the -model is served from, so spinloop fails naming both. +A launch's `--node` means whichever target is in force: beside a fleet (the +`--fleet` flag, or the `./fleet.yaml` in force when the Spinloop is not named) +it pins the selection to that fleet's node of the name; with no fleet, it is a +registered environment. -`spinloop harness open --env qwen3.6-27b-prod` also works with **no Spinloop at +`spinloop harness open --node qwen3.6-27b-prod` also works with **no Spinloop at all**: the environment already knows what it is serving, so the harness configures itself from that — the same result as the Spinloop above, without needing a copy of it on the machine doing the launching. See @@ -276,10 +277,10 @@ Rules: to a deployed instance, and on the harness path it shapes only the launched agent, never spinloop's own environment. - The `REMOTE` keyword was removed: a `REMOTE` line fails, naming the line and - the replacement — `spinloop cloud deploy --env ` at deploy time and - `--env ` on `apply`, `unapply`, `harness`, and the `cloud` + the replacement — `spinloop cloud deploy --node ` at deploy time and + `--node ` on `apply`, `unapply`, `harness`, and the `cloud` subcommands. Where a `REMOTE` line pointed at a path or a URL, register the - environment's config with `spinloop cloud deploy --env ` instead, and + environment's config with `spinloop cloud deploy --node ` instead, and name it from the flags. - Keywords are **case-insensitive** — `provider`, `Provider`, and `PROVIDER` are all accepted — but **UPPERCASE is canonical** and is what `spinloop harness export` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ca942bb8..b8f52bc9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -98,7 +98,7 @@ scaled; see [`spinloop serve`](commands/serve.md#parallelism). ## A fleet row is wrong, or a node won't answer - **`config-error` on a `kind: cloud` row** means the environment is not - registered on this machine — `spinloop cloud deploy --env ` (or + registered on this machine — `spinloop cloud deploy --node ` (or `spinloop fleet deploy`) writes its `cloud.json`. - **A node's token is not in *your* shell.** The fleet file names the *variable* (`tokenEnv`), never the value. Set the variable the row names, diff --git a/examples/fleet-cloud/README.md b/examples/fleet-cloud/README.md index 6b9914d2..8e4d88c6 100644 --- a/examples/fleet-cloud/README.md +++ b/examples/fleet-cloud/README.md @@ -11,7 +11,7 @@ never an account, so it is safe to keep under version control. ### 1. Have some environments An environment is created and registered when you deploy into it: `spinloop -cloud deploy --env `, run in a directory whose `Spinloop` describes what +cloud deploy --node `, run in a directory whose `Spinloop` describes what the environment serves, writes that environment's control URLs to `~/.config/spinloop/clouds//cloud.json`. Deploying needs the shared control plane once before it — see [`spinloop diff --git a/examples/fleet-cloud/qwen/Spinloop b/examples/fleet-cloud/qwen/Spinloop index 24f5ab92..345711d6 100644 --- a/examples/fleet-cloud/qwen/Spinloop +++ b/examples/fleet-cloud/qwen/Spinloop @@ -1,5 +1,5 @@ # What the "qwen" environment serves. `spinloop fleet deploy qwen` reads this -# the same way `spinloop cloud deploy --env qwen` does when run in this directory. +# the same way `spinloop cloud deploy --node qwen` does when run in this directory. # See docs/commands/cloud.md for what each instruction does. PROVIDER llamacpp ALIAS qwen3.6-27b diff --git a/examples/fleet-mixed/README.md b/examples/fleet-mixed/README.md index 9a1e514f..1082b5f0 100644 --- a/examples/fleet-mixed/README.md +++ b/examples/fleet-mixed/README.md @@ -41,7 +41,7 @@ spinloop fleet deploy --all # create both environments from this file ``` Creating the environments this way is the same as running `spinloop cloud -deploy --env ` once per node in this file, from the directory holding +deploy --node ` once per node in this file, from the directory holding that node's `Spinloop` — see [`spinloop cloud`](../../docs/commands/cloud.md) — just one command for both. diff --git a/examples/llamacpp/muse-glimmer-30b/README.md b/examples/llamacpp/muse-glimmer-30b/README.md index b4872d2f..e4ce765d 100644 --- a/examples/llamacpp/muse-glimmer-30b/README.md +++ b/examples/llamacpp/muse-glimmer-30b/README.md @@ -410,7 +410,7 @@ MODEL meta-models/Muse-Glimmer-30B-GGUF:kquant-dynamic …and register it under a name of your choosing: ```sh -spinloop cloud deploy --env muse-glimmer-30b +spinloop cloud deploy --node muse-glimmer-30b ``` Be precise with that suffix. The seed downloads everything matching @@ -429,14 +429,14 @@ Adding `MODEL` breaks the local `spinloop serve` path above, since it becomes doesn't resolve. Keep separate Spinloops if you want both. ```sh -spinloop cloud deploy --env muse-glimmer-30b --dry-run -spinloop cloud deploy --env muse-glimmer-30b -eval "$(spinloop cloud start --env muse-glimmer-30b --print-env)" # --print-env +spinloop cloud deploy --node muse-glimmer-30b --dry-run +spinloop cloud deploy --node muse-glimmer-30b +eval "$(spinloop cloud start --node muse-glimmer-30b --print-env)" # --print-env # is what prints the OPENAI_BASE_URL/OPENAI_API_KEY # export lines; without it, start's output is # progress text on stderr and there is # nothing on stdout for eval to run -spinloop cloud stop --env muse-glimmer-30b +spinloop cloud stop --node muse-glimmer-30b ``` Costs and the idle/max-runtime bounds are in diff --git a/examples/llamacpp/muse-glimmer-30b/Spinloop b/examples/llamacpp/muse-glimmer-30b/Spinloop index a942be99..b4721dd5 100644 --- a/examples/llamacpp/muse-glimmer-30b/Spinloop +++ b/examples/llamacpp/muse-glimmer-30b/Spinloop @@ -28,6 +28,6 @@ PRESET ./preset.ini # `spinloop serve` builds the llama-server command f # globs filenames rather than resolving a tag. The glob is case-insensitive, so # this still matches Muse-Glimmer-30B-KQuant-Dynamic-Q4_K_XL.gguf: # MODEL meta-models/Muse-Glimmer-30B-GGUF:kquant-dynamic -# (then `spinloop cloud deploy --env muse-glimmer-30b` from this directory) +# (then `spinloop cloud deploy --node muse-glimmer-30b` from this directory) # Adding MODEL breaks the local `spinloop serve` path above — read the "Deploying # to the cloud" section of README.md before you do. diff --git a/examples/llamacpp/qwen3.8-27b-mtp/README.md b/examples/llamacpp/qwen3.8-27b-mtp/README.md index 261ac3c9..15afe625 100644 --- a/examples/llamacpp/qwen3.8-27b-mtp/README.md +++ b/examples/llamacpp/qwen3.8-27b-mtp/README.md @@ -132,10 +132,10 @@ confirmation before it creates anything. ```sh spinloop cloud bootstrap # once per AWS account: the control plane -spinloop cloud deploy --env qwen3.8-27b-mtp # from this directory -eval "$(spinloop cloud start --env qwen3.8-27b-mtp)" # boots (~10 min cold) -spinloop harness open --env qwen3.8-27b-mtp # work -spinloop cloud stop --env qwen3.8-27b-mtp # done — shut it down now +spinloop cloud deploy --node qwen3.8-27b-mtp # from this directory +eval "$(spinloop cloud start --node qwen3.8-27b-mtp)" # boots (~10 min cold) +spinloop harness open --node qwen3.8-27b-mtp # work +spinloop cloud stop --node qwen3.8-27b-mtp # done — shut it down now ``` The deploy reads the PARAMs the same way `serve` does — `hf` names the diff --git a/examples/llamacpp/qwen3.8-27b/README.md b/examples/llamacpp/qwen3.8-27b/README.md index 87a46921..1a694ff9 100644 --- a/examples/llamacpp/qwen3.8-27b/README.md +++ b/examples/llamacpp/qwen3.8-27b/README.md @@ -211,11 +211,11 @@ vCPU quota in the target region. It creates no instance and no Elastic IP. ### Deploy this Spinloop as an environment ```sh -spinloop cloud deploy --env qwen3.8-27b # from this directory -spinloop cloud deploy --env qwen3.8-27b --dry-run # see what would be sent first +spinloop cloud deploy --node qwen3.8-27b # from this directory +spinloop cloud deploy --node qwen3.8-27b --dry-run # see what would be sent first ``` -The `--env` name is the environment `spinloop cloud` creates and registers. +The `--node` name is the environment `spinloop cloud` creates and registers. `deploy` reads `PROVIDER`, `ALIAS`, `CONTEXT` and `PRESET` from the Spinloop — the same values [`spinloop serve`](../../../docs/commands/serve.md) uses locally — @@ -228,13 +228,13 @@ doesn't have these weights cached yet, deploy fetches them in the background ### Start it, use it, stop it ```sh -eval "$(spinloop cloud start --env qwen3.8-27b)" # boots the instance +eval "$(spinloop cloud start --node qwen3.8-27b)" # boots the instance # (~10 min cold), exports # OPENAI_BASE_URL / OPENAI_API_KEY -spinloop status --env --env qwen3.8-27b # is it up, is it healthy -spinloop harness apply --env qwen3.8-27b # point opencode at the running endpoint -spinloop harness open --env qwen3.8-27b # work -spinloop cloud stop --env qwen3.8-27b # done — shut it down now rather +spinloop status --node --node qwen3.8-27b # is it up, is it healthy +spinloop harness apply --node qwen3.8-27b # point opencode at the running endpoint +spinloop harness open --node qwen3.8-27b # work +spinloop cloud stop --node qwen3.8-27b # done — shut it down now rather # than waiting for the idle timer ``` diff --git a/examples/vllm/qwen3.8-27b/README.md b/examples/vllm/qwen3.8-27b/README.md index 43cc2412..3ac167b7 100644 --- a/examples/vllm/qwen3.8-27b/README.md +++ b/examples/vllm/qwen3.8-27b/README.md @@ -92,10 +92,10 @@ confirmation before it creates anything. ```sh spinloop cloud bootstrap # once per AWS account: the control plane -spinloop cloud deploy --env qwen3.8-27b-vllm # from this directory -eval "$(spinloop cloud start --env qwen3.8-27b-vllm)" # boots (~10 min cold) -spinloop harness open --env qwen3.8-27b-vllm # work -spinloop cloud stop --env qwen3.8-27b-vllm # done — shut it down now +spinloop cloud deploy --node qwen3.8-27b-vllm # from this directory +eval "$(spinloop cloud start --node qwen3.8-27b-vllm)" # boots (~10 min cold) +spinloop harness open --node qwen3.8-27b-vllm # work +spinloop cloud stop --node qwen3.8-27b-vllm # done — shut it down now ``` The deploy reads the same instructions — `MODEL` names the weights, `CONTEXT` diff --git a/internal/cloud/environments.go b/internal/cloud/environments.go index ab6d1bf5..0f3ad590 100644 --- a/internal/cloud/environments.go +++ b/internal/cloud/environments.go @@ -50,7 +50,7 @@ func EnvConfigPath(name string) (string, error) { // IsEnvName reports whether a value is a plain environment name: non-empty, // with no path separator and no .json suffix. Environments are selected by -// name from the registry, never by path, so a --env value (or a fleet node +// name from the registry, never by path, so a --node value (or a fleet node // name) that is not a plain identifier is rejected. func IsEnvName(value string) bool { if value == "" { diff --git a/internal/fleet/config.go b/internal/fleet/config.go index 42c5c3be..a742fc03 100644 --- a/internal/fleet/config.go +++ b/internal/fleet/config.go @@ -380,7 +380,7 @@ func Load(path string) (*Config, error) { if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf( - "no fleet at %s: create one listing your nodes, or name a target — --fleet for a file, --env for a registered environment", path) + "no fleet at %s: create one listing your nodes, or name a target — --fleet for a file, --node for a registered environment", path) } return nil, err } @@ -410,7 +410,7 @@ func Resolve(flagPath string) (*Config, error) { return Load(path) } -// ForEnvironment builds the fleet a `--env ` target names: one cloud +// ForEnvironment builds the fleet a `--node ` target names: one cloud // node, named by the registered environment whose cloud.json holds its // control config. A registered environment and a one-node fleet file naming it // describe the same thing — `fleet deploy` registers an environment under its diff --git a/internal/spinloop/spinloop.go b/internal/spinloop/spinloop.go index 5d59612b..7429b1dc 100644 --- a/internal/spinloop/spinloop.go +++ b/internal/spinloop/spinloop.go @@ -65,7 +65,7 @@ type Selection struct { Parallel string // DisplayName is the harness provider's display name, derived at apply time // rather than parsed from a Spinloop — like BaseURL, it may be filled from the - // named environment. It is set only when an environment's --env flag renames + // named environment. It is set only when an environment's --node flag renames // the provider, to label it distinctly from a local engine of the same kind; // empty otherwise, leaving the catalogue engine name. DisplayName string @@ -149,7 +149,7 @@ func Parse(data []byte) (Selection, error) { if canon == "" { if kw == "remote" { return Selection{}, fmt.Errorf( - "line %d: the REMOTE instruction was removed: name the environment with `spinloop cloud deploy --env ` at deploy time, and pass --env to the commands that act on it (cloud subcommands, apply, unapply, harness)", + "line %d: the REMOTE instruction was removed: name the environment with `spinloop cloud deploy --node ` at deploy time, and pass --node to the commands that act on it (cloud subcommands, apply, unapply, harness)", line) } return Selection{}, fmt.Errorf("line %d: unknown keyword %q (expected PROVIDER, MODEL, ALIAS, CONTEXT, OUTPUT, PARALLEL, BASEURL, PRESET, PARAM, or ENV)", line, fields[0]) diff --git a/internal/spinloop/spinloop_test.go b/internal/spinloop/spinloop_test.go index 5629732c..3d2f3103 100644 --- a/internal/spinloop/spinloop_test.go +++ b/internal/spinloop/spinloop_test.go @@ -230,7 +230,7 @@ func TestFormat_NoParallelOmitsLine(t *testing.T) { } // REMOTE was removed from the grammar: the environment is named with -// `remote deploy --env ` at deploy time and `--env ` at the +// `remote deploy --node ` at deploy time and `--node ` at the // commands that act on it, so a REMOTE line is rejected with a message that // names the replacement. func TestParse_RemoteRejected(t *testing.T) { @@ -238,7 +238,7 @@ func TestParse_RemoteRejected(t *testing.T) { if err == nil { t.Fatal("a REMOTE line should be rejected") } - for _, want := range []string{"line 2", "REMOTE", "removed", "--env"} { + for _, want := range []string{"line 2", "REMOTE", "removed", "--node"} { if !strings.Contains(err.Error(), want) { t.Errorf("error should contain %q, got %q", want, err) } diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/.openspec.yaml b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/.openspec.yaml new file mode 100644 index 00000000..1bb6456d --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/design.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/design.md new file mode 100644 index 00000000..9d05f8a4 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/design.md @@ -0,0 +1,103 @@ +# Design + +## Context + +`--env/-e` is registered per-command across `cmd/spinloop` (apply, unapply, the +launch builder in `code.go`, status, logs, dashboard, the fleet group, and the +cloud group), each with its own `StringVar*` call and its own `compRegister(c, +"env", compEnvs)`. The launch path additionally has a separate `--node` pin +(`routeOptions.node`) routed through `routeThroughFleet`, and an explicit +one-target conflict (`checkOneTarget`) between `--env` and any fleet in force. +The fleet machinery already models a registered cloud environment as a +fleet-of-one node (`fleet.ForEnvironment`), so the rename lands on vocabulary +that the internals already use. + +## Goals / Non-Goals + +**Goals:** +- One flag spelling — `--node`/`-n` — for naming a deployment target on every + command, with no `--env` accepted anywhere. +- On the launch path, one flag that resolves as fleet-node-pin with a fleet in + force and as registered environment without one; no silent inertness — the + old combination `--env` + a directory `fleet.yaml` failed loudly, and the new + one must also produce a definite outcome (a pin, a gateway route, or a + failure) rather than launching unconfigured. +- Messages, help, completion, docs and examples speak `--node` without leaving + "environment variables" wording entangled with it. + +**Non-Goals:** +- No rename of the `cloud` package's internal `Environment` vocabulary, the + on-disk registry (`clouds//cloud.json`), `cloud env`, `--print-env`, + `--token-env`, or the `ENV` instruction — those name environment variables, + where the word is correct. +- No deprecated alias for `--env` (user decision; precedent + `remote-env`'s "former `-e`/`--env` spelling is removed"). + +## Decisions + +**One flag, two resolutions, decided by fleet-in-force.** The launch keeps a +single `routeOptions` field (`node`, replacing `envName` and the old `node`). +`routeOptions.fleetFile()` already computes "fleet in force"; the launch +branches in `openLaunchCmd.RunE` become: Spinloop worn → `applyBeforeLaunch` +(unchanged); no Spinloop, `--node` set, no fleet in force → +`applyFromEnvironment`; no Spinloop, and either `--fleet` was flagged or +`--node` is set alongside a discovered fleet → `applyFromGateway`; a launch +with no Spinloop, no `--node` and no `--fleet` still applies nothing, as +today. Alternative considered: +keep two flags and alias `--env`'s behaviour behind `--node` plus a separate +pin — rejected: two flags whose validity depends on each other is the +confusion the issue names. + +**Cloud fetch keyed off the choice, not the flag.** `applyRoutedSpinloop` +currently fetches the endpoint env whenever `route.envName != ""` and enforces +one-target with `checkOneTarget`. After the merge the flag can name a fleet +node, so the fetch and the provider-keying in `applySelection` run only when +`--node` is set and routing produced no `*fleet.Choice` — i.e. the name +resolved as environment. `checkOneTarget` is removed entirely rather than +re-worded: `--fleet` names a fleet and `--node` names the node within it, +whether the fleet was flagged or discovered, so the pair is one answer, not +two (the first draft refused the pair on the fleet commands and shipped; review +caught that the launch path already accepted it, making the refusal the odd +half). A name a *named* file lacks fails naming the file and its nodes — the +registry answers only beside a discovered `fleet.yaml`, a file that states no +intent. + +**`-n` everywhere.** All renamed flags take `-n`. Verified free: only `alias` +takes `-n` (for `--name`), on a command that never took `--env`. fleet-config's +"no short form on the fleet commands" rule is dropped deliberately — the issue +asks for `-n`, and commands outside that group already had the short. + +**Launch-slot completion gains names.** `launchSlot` currently answers `--node` +with nothing. With the merge it offers registered environment names +(`compEnvs`) — the no-fleet meaning — and the fleet file's node names where +`fleet.yaml`/`--fleet` is on the line and parses; a failure to read the fleet +degrades to environments only, honouring completion's never-error rule. +`launchValueFlags` gains `-n`. The fleet commands' `--node` gains the same +feeding (`compNodes`): environments plus the fleet in force, an explicit +`--fleet` value beating the directory's file. + +**Rename mechanics.** Pure-string renames in specs use MODIFIED; five +requirements whose *scenario titles* carried `--env` (and one whose scenario +titles change because a conflict becomes a pin) go through ADDED+REMOVED, the +pattern this repo used for the `--print-env` rename — the validator pins +MODIFIED scenario titles to the current spec. + +## Risks / Trade-offs + +- [A user's muscle-memory `--env` now errors] → pflag reports "unknown flag: + --env"; the release note and docs say the replacement is `--node`. Same + sharp edge the repo took with `remote`→`cloud` and `-e`→`--print-env`. +- [Rename sweep misses a string] → tests carry most of the surface; grep + gates (`rg -- '--env|\-e\b' cmd docs examples openspec/specs`) run before + each commit; the `fleet-docker` CI example exercises real launches. +- [Bare `--node` with a discovered `fleet.yaml` changes behaviour] (was: + conflict error or silent inertness depending on Spinloop state) → now + routes or fails; covered by the fleet-routing delta scenarios. +- [`-n` collision in a future flag] → free today on every affected command; + completion and launch-slot tests pin the current spelling. + +## Migration Plan + +Single release, breaking. Ship the rename plus a CHANGELOG line naming +`--env`→`--node/-n` and the conflict-rule change. Rollback is reverting the +commit; nothing on disk changes format. diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/proposal.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/proposal.md new file mode 100644 index 00000000..b0a6bc13 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/proposal.md @@ -0,0 +1,85 @@ +# Proposal + +## Why + +`--env` reads as "environment variables" — which on this CLI genuinely means +the `ENV` instructions, `.env` files, and `cloud env` exports — but the flag +selects something else entirely: a deployment target. Our internal domain +language for a deployment target is already **node** (`fleet.Node`, and a +registered environment is modelled as a fleet of one node), so the external +surface should say the same word. Closes #236. + +## What Changes + +- **BREAKING**: `--env` and its `-e` short form are removed from every command + that took them (`apply`, `unapply`, `harness open`/`code`, `status`, + `metrics`, `logs`, `dashboard`, the `fleet` subcommands that target one + environment, and every `cloud` subcommand). They are replaced by + `--node`/`-n`. There is no deprecated alias — the rename is clean. +- On the launch path (`harness open`/`code`) the renamed flag **merges with the + existing fleet-node pin** `--node`, which keeps its name: one flag, one + meaning — "talk to this node". With a fleet in force (a `--fleet` file or a + discovered `./fleet.yaml`) the name is a node in that fleet, exactly as the + pin works today; with no fleet in force the name is a registered + environment — a fleet of one — exactly as `--env` worked today. The old + `--env`+fleet conflict error disappears: naming both is now how you pin. +- All user-facing strings that name the flag or its target follow: help text, + errors (registry, validation, conflict), completion (`--env` completions and + the launch slot move to `--node`, which also offers fleet nodes when a fleet + is in scope), and the docs site. +- Unchanged, because these are about environment variables and the name is + correct: the Spinloop `ENV` instruction, `.env` files, `spinloop cloud env`, + `--print-env`, `--token-env`. The internal `cloud` package keeps its + `Environment` vocabulary for the registry; only the CLI's external surface + speaks "node". + +## Capabilities + +### New Capabilities + +None — this renames the surface existing capabilities already govern. + +### Modified Capabilities + +- `harness-remote-env`: the launch flag becomes `--node/-n`; key-injection and + auto-configure requirements follow the rename. +- `fleet-routing`: the pin flag is the one `--node/-n`; the `--env`-vs-fleet + conflict scenario becomes pin semantics, and a bare `--node` with no fleet + resolves as the registered environment. +- `code-command`: the launch flag list and the `--env` scenario rename. +- `fleet-config`: the single-environment target flag becomes `--node`; conflict + wording, discovery, and fleet-of-one scenarios rename. +- `fleet-client`: scenarios naming `status/metrics/logs/dashboard --env` + rename. +- `remote-environments`: flag spelling, name validation and registry errors + speak `--node`. +- `remote-endpoint`: `cloud` subcommand selection flag and its error wording + rename. +- `remote-env`: scenarios rename; the `--print-env` rationale that cites the + former `--env` spelling updates. +- `remote-start-probe`: the stale `if --env is set` becomes `--print-env`. +- `environment-deployment`: `deploy` names its environment with `--node`. +- `cloud-command`: scenario rename. +- `provider-selection`: apply-time `--env` requirement renames. +- `spinloop-files`: the removed-key guidance names `--node` as the replacement. +- `remote-stats`: `metrics` selection flag renames. +- `remote-logs`: `logs` selection flag renames. +- `remote-keep`: `keep` selection flag renames. +- `remote-schedule`: `schedule` selection flag renames. +- `remote-version-reporting`: `status`/`metrics` scenarios rename. +- `remote-local-environment`: the default-environment fallback wording renames. + +## Impact + +- `cmd/spinloop/`: every `StringVar*(…, "env", "e", …)` becomes + `"node", "n"` (route.go's `routeOptions` merges `envName` into `node`; the + launch branch in `code.go` re-decides environment-vs-fleet by whether a fleet + resolves; `applyRoutedSpinloop` drops `checkOneTarget` and keys the cloud + fetch/apply off the resolved environment only when no fleet answered); + `target.go` (`fleetTarget.envName` → `nodeName`, message wording); + `complete.go` (flag completion and `launchSlot`/`launchValueFlags` for + `--node`/`-n`). +- Tests across `cmd/spinloop/` that pass `--env`/`-e` or assert their messages. +- Specs above, plus `docs/` and `examples/` prose the docs-update step covers. +- No change to config file formats, the registry layout on disk, the daemon + API, or `remote/` (TypeScript control plane). diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/cloud-command/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/cloud-command/spec.md new file mode 100644 index 00000000..67d7e942 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/cloud-command/spec.md @@ -0,0 +1,18 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Cloud environment storage names + +Environments SHALL be stored under `~/.config/spinloop/clouds//cloud.json` +and credentials in `cloud-.json` keystore files, with the same file +modes as before. The CLI SHALL NOT read or write `remotes//remote.json` +or `remote-.json`. + +#### Scenario: Writes use the new path +- **WHEN** `spinloop cloud deploy staging` finishes +- **THEN** `clouds/staging/cloud.json` exists with mode 0600 + +#### Scenario: Old environment is not found +- **WHEN** `remotes/staging/remote.json` exists and `clouds/staging/` does not +- **THEN** `spinloop status --node staging` reports that environment `staging` is not registered diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/code-command/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/code-command/spec.md new file mode 100644 index 00000000..25c0c5f3 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/code-command/spec.md @@ -0,0 +1,64 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: A shortcut for harness open with node targeting +`spinloop code` SHALL launch the active harness exactly as +`spinloop harness open` does: it SHALL accept the same flags — `-O`/`--spinloop`, +`-n`/`--node`, `--fleet`, `--prefer`, `--no-wake`, `--wake-timeout`, +`-H`/`--harness`, and `--providers` — apply a Spinloop the same way, and forward +every argument it does not consume to the harness untouched. A leading argument +naming a registered alias or a path, the `-O`/`--spinloop` flag, and the +`--node` target all drive the same Spinloop application as on `harness open`. +`spinloop code ` SHALL therefore produce the same harness config, launch +the same process, and print the same output and errors as +`spinloop harness open `. `code` SHALL NOT change what `harness open` +does; it is a new spelling of it, not a replacement. A word on the command +line that is neither a consumed Spinloop name nor a known flag forwards to the +harness as ever — except the removed `--env`/`-e`, which the command SHALL +reject naming `--node` as its replacement, since forwarding the launch's own +former flag would hand the harness a word it never owned. + +#### Scenario: the removed --env spelling names its replacement + +- **WHEN** the user runs `spinloop code --env dev-2` +- **THEN** the command fails naming `--env` and `--node` as its replacement, + and the harness is not launched with `--env` among its arguments + +#### Scenario: A bare code launches unconfigured + +- **WHEN** the user runs `spinloop code` with no arguments +- **THEN** the active harness is launched with no Spinloop applied, exactly as + `spinloop harness open` would launch it + +#### Scenario: A leading alias configures then launches + +- **WHEN** the user runs `spinloop code qwen3.6-27b -- --agent-flag` and + `qwen3.6-27b` is a registered alias +- **THEN** the aliased Spinloop is applied first and the harness is launched, + forwarding `--agent-flag`, as `spinloop harness open qwen3.6-27b -- --agent-flag` + would do + +#### Scenario: A valueless -O wears the directory's Spinloop + +- **WHEN** the user runs `spinloop code -O` in a directory holding a `Spinloop` +- **THEN** `./Spinloop` is applied and the harness launches, as + `spinloop harness open -O` would do + +#### Scenario: --node configures from a deployed environment + +- **WHEN** the user runs `spinloop code --node dev-2` with no Spinloop +- **THEN** the harness is configured from what is deployed to `dev-2` and + launched, as `spinloop harness open --node dev-2` would do + +#### Scenario: Trailing arguments are forwarded untouched + +- **WHEN** the user runs `spinloop code -- --agent-flag` +- **THEN** the harness is launched and `--agent-flag` is forwarded to it, as + `spinloop harness open -- --agent-flag` would do + +## REMOVED Requirements + +### Requirement: A shortcut for harness open +**Reason**: The requirement enumerates the launch flags and names the target flag in a scenario title; the flag is renamed `--env`/`-e` to `--node`/`-n`, and a renamed flag cannot be carried by a MODIFIED whose scenario titles the validator pins to the old spelling. +**Migration**: The requirement "A shortcut for harness open with node targeting" states the same contract with the new spelling. diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/environment-deployment/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/environment-deployment/spec.md new file mode 100644 index 00000000..7cb33751 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/environment-deployment/spec.md @@ -0,0 +1,80 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Deploy creates an environment on the control plane + +`spinloop cloud deploy` SHALL create a named environment on top of the control +plane: it SHALL discover it, then provision the +environment's own Elastic IP, EC2 instance configuration, per-environment API +key, per-environment allowed-ingress rule, and per-environment SSM state +(the deploy-config), all tagged by the environment name. It SHALL set +what the environment serves from the Spinloop and its preset, and SHALL register +the environment so the other `cloud` commands can drive it. Deploying SHALL NOT +start the instance. + +The environment name SHALL come from the command, never from the Spinloop: +`spinloop cloud deploy` SHALL take it from its required `--node ` flag, +and `spinloop fleet deploy` SHALL take it from the name of the node being +deployed, which is the registered environment that node drives. A deploy given +no name by either route SHALL fail saying the environment must be named. The +Spinloop SHALL be read only for what the environment serves and for its local +environment (`ENV` instructions and adjacent `.env`), and the same Spinloop +SHALL be deployable under more than one environment name. + +Deploy SHALL NOT provision any activity-tracking state. Engine activity is +recorded on the instance by its daemon, so there is nothing for the control +plane to seed, read or write. + +#### Scenario: Deploying stands up and registers an environment + +- **WHEN** `spinloop cloud deploy --node prod` runs against a bootstrapped + account +- **THEN** the environment's Elastic IP, instance configuration, API key, + ingress rule, and SSM state are provisioned, and the environment is + registered under the name `prod` + +#### Scenario: A deploy without a name fails + +- **WHEN** `spinloop cloud deploy` runs with no `--node` flag +- **THEN** it fails saying the environment must be named with `--node ` + +#### Scenario: One Spinloop deploys to two environments + +- **WHEN** the same Spinloop is deployed with `--node dev` and later with + `--node prod` +- **THEN** two distinct environments are created and registered, each serving + what the Spinloop describes + +#### Scenario: A fleet node deploys under its own name + +- **WHEN** `spinloop fleet deploy` targets a `kind: cloud` node named `qwen` +- **THEN** the environment created and registered is named `qwen`, from the + node's name in the fleet file, and the node's Spinloop is read only for what + it serves + +#### Scenario: Deploying provisions no activity state + +- **WHEN** a deploy succeeds +- **THEN** no idle- or activity-tracking parameter is created for the + environment + +#### Scenario: Deploying is not starting + +- **WHEN** a deploy succeeds +- **THEN** the environment is configured and registered but no instance is + running until `spinloop cloud start` + +### Requirement: Registering the environment + +Deploy SHALL register the environment in the per-user registry defined by the +Cloud Environments specification — `~/.config/spinloop/clouds//cloud.json`, +written owner-only — carrying the shared lifecycle Lambda URLs, the region, the +environment's base URL (its Elastic IP), and the environment identifier the +shared Lambdas use to select this environment's instance. + +#### Scenario: The environment is registered and resolvable + +- **WHEN** `spinloop cloud deploy --node prod` succeeds +- **THEN** `~/.config/spinloop/clouds/prod/cloud.json` exists (owner-only) + and `spinloop cloud status --node prod` resolves the environment from it diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-client/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-client/spec.md new file mode 100644 index 00000000..7a2b1082 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-client/spec.md @@ -0,0 +1,209 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Fleet status + +`spinloop status` SHALL query every node in the resolved target and render one row per node: the node name, its engine state (`idle`/`running`/`stopped`/`crashed`), what it is serving (runner and model when known), the spinloop version of the daemon on that node, and its reachability. Nodes SHALL be queried concurrently so the command's latency is that of the slowest reachable node, not their sum. + +The target SHALL resolve as it does for every other command that acts on a fleet — a named environment, a named fleet file, or the working directory's fleet file — so one environment, a fleet, and a fleet holding that same environment are all read by the one command, rendered identically however the target was named. + +A node's health SHALL be reported as the readiness mark every node carries, so a cloud environment whose endpoint is unhealthy reads the same as a daemon node whose engine has not answered its health check. A field only one node kind fills — an endpoint's address, a retention deadline — SHALL NOT be a column of a table meant to be scanned one row per node. + +A node SHALL also report how long it has been since its engine last did work, taken from the activity its daemon tracks — "which of my nodes is doing nothing?" is a question a fleet view exists to answer, and the daemon already knows. That figure SHALL NOT be labelled in a way that collides with the `idle` engine state, which means something different. A node whose daemon reports no activity yet SHALL omit the figure rather than imply an engine has sat unused since it started. + +#### Scenario: Mixed fleet renders every node + +- **WHEN** `spinloop status` runs against a fleet of several nodes +- **THEN** the output has one row per node showing its state, version, and what it serves + +#### Scenario: A named environment is read by the same command + +- **WHEN** `spinloop status --node prod` runs +- **THEN** that environment's row is rendered with no fleet file required, and + it reads the same as the row for the same environment named in a fleet file + +#### Scenario: An unhealthy endpoint reads as not ready + +- **WHEN** `spinloop status --node prod` runs and the control plane reports the + endpoint unhealthy +- **THEN** the row carries the same not-ready mark a daemon node carries when + its engine has not answered its health check + +#### Scenario: A node reports how long since it last did work + +- **WHEN** `spinloop status` runs against a node whose daemon reports a last-active time +- **THEN** that node's row shows how long ago that was, labelled so it is not confused with the `idle` engine state + +#### Scenario: A node with no recorded activity omits the figure + +- **WHEN** a node's daemon reports no last-active time, because its engine has done no work yet +- **THEN** that node's row shows no activity figure rather than a misleading one + +#### Scenario: Version is shown per node + +- **WHEN** `spinloop status` runs against a fleet of running nodes +- **THEN** each node's row includes the spinloop version string from its daemon + +#### Scenario: Version is omitted for unreachable nodes + +- **WHEN** a node's daemon is unreachable +- **THEN** that node's row shows its failure outcome without a version + +#### Scenario: The fleet-scoped spelling names its replacement + +- **WHEN** the operator runs `spinloop fleet status` +- **THEN** it fails naming `spinloop status` as the command that replaced it + +### Requirement: Fleet metrics + +`spinloop metrics` SHALL query every node in the resolved target and render +each node's engine and system metrics using the same bar, gauge, table and +json formats, selected by `--format`. +Unreachable nodes SHALL be reported as in status rather than omitted. The +command SHALL support a `--watch`/`-w` mode that refreshes on an interval, +clearing and redrawing the screen in place with no scrollback accumulation, +and exiting cleanly on interrupt. + +#### Scenario: Gauge format per node + +- **WHEN** `spinloop metrics` runs without `--format` +- **THEN** each reachable node's resource series render in gauge format under + its name + +#### Scenario: Bar format per node + +- **WHEN** `spinloop fleet metrics --format=bar` runs +- **THEN** each reachable node's metrics render in bar format under its name + +#### Scenario: JSON aggregates the fleet + +- **WHEN** `spinloop fleet metrics --format=json` runs +- **THEN** the output is valid JSON keyed or labelled by node, including + unreachable nodes with their error + +#### Scenario: Watch redraws in place + +- **WHEN** `spinloop fleet metrics --watch` runs +- **THEN** each refresh clears the screen and redraws the fleet, and Ctrl+C + exits cleanly + +The target SHALL resolve as it does for every other command that acts on a +fleet — a named environment, a named fleet file, or the working directory's +fleet file — so one environment and a fleet holding it are read by the one +command. + +#### Scenario: A named environment is read by the same command + +- **WHEN** `spinloop metrics --node prod` runs +- **THEN** that environment's metrics are rendered with no fleet file + required, in the same format a fleet file naming it would produce + +#### Scenario: The fleet-scoped spelling names its replacement + +- **WHEN** the operator runs `spinloop fleet metrics` +- **THEN** it fails naming `spinloop metrics` as the command that replaced it + +### Requirement: Fleet logs + +`spinloop logs` SHALL read the engine output of the target's nodes, so "what did that engine say?" is answerable from the same +place as "what is it doing?" — without shell access to any machine. With no node +named it SHALL read every node in the fleet; naming a node SHALL restrict it to +that one. Nodes SHALL be read concurrently, so the command's latency is that of +the slowest reachable node rather than their sum. + +The target SHALL resolve as it does for every other command that acts on a +fleet. The fleet file SHALL be named by the long form `--fleet` only: unlike +the other commands that take one, `logs` SHALL NOT accept `-f` for it, because +`-f` is that command's `--follow` short form and a flag cannot carry two +meanings on one command line. + +#### Scenario: Reading the whole fleet + +- **WHEN** the operator runs `spinloop logs` with no node named +- **THEN** every node's engine output is read and printed + +#### Scenario: Reading one node + +- **WHEN** the operator names a node +- **THEN** only that node's output is printed, and the other nodes are not + contacted + +#### Scenario: A crashed node's output is readable + +- **WHEN** a node's engine has crashed, as `spinloop status` reports +- **THEN** its output up to the crash is printed, explaining what status can + only report + +#### Scenario: The fleet file has no short flag here + +- **WHEN** the operator runs `spinloop fleet logs -f --fleet ./cluster.yaml` +- **THEN** the flag is accepted as follow mode plus the fleet file, with `-f` + not treated as a fleet-file flag + +#### Scenario: A named environment is read by the same command + +- **WHEN** `spinloop logs --node prod` runs +- **THEN** that environment's engine output is read with no fleet file + required + +#### Scenario: The fleet-scoped spelling names its replacement + +- **WHEN** the operator runs `spinloop fleet logs` +- **THEN** it fails naming `spinloop logs` as the command that replaced it + +### Requirement: Fleet dashboard + +`spinloop dashboard` SHALL open an interactive, full-screen view of the resolved target: +one panel per node, arranged in a grid and refreshed +continuously without operator input. It is the one place where what the fleet is +doing and acting on it meet: unlike `fleet metrics --watch`, it takes keyboard +input and drives the node the operator has selected. + +The target SHALL resolve as it does for every other command that acts on a +fleet, so a single named environment opens as a board of one panel. The +dashboard SHALL be openable and usable from cold — a fleet where nothing is up: +every panel SHALL show its node's outcome and reason rather than metrics, and a +node SHALL be startable from within the dashboard. + +A problem with the target itself (a missing or unparseable fleet file, an +environment that is not registered) SHALL fail the command before the view +opens, as it does for the other commands that act on a fleet; a problem +with any node SHALL NOT. + +#### Scenario: A mixed fleet renders every node + +- **WHEN** the operator opens the dashboard against a fleet with some nodes + answering and some not +- **THEN** every node has a panel: answering nodes show their metrics, the others + show their outcome and reason, and the view keeps running + +#### Scenario: A named environment opens as one panel + +- **WHEN** the operator runs `spinloop dashboard --node prod` in a directory + holding no fleet file +- **THEN** the view opens on that environment alone, and the absent file is not + an error + +#### Scenario: Opening on a fleet where nothing is up + +- **WHEN** the operator opens the dashboard and no node is reachable +- **THEN** every panel shows its node's outcome and reason +- **AND** the dashboard does not exit, and starting a node from it works + +#### Scenario: A fleet-file problem fails before the view + +- **WHEN** the named fleet file is missing or unparseable +- **THEN** the command fails naming the problem, and no interactive view opens + +#### Scenario: A non-interactive context is refused + +- **WHEN** the dashboard is run with its input or output not on a terminal, such + as through a pipe or in the background +- **THEN** it fails with a message pointing at `fleet metrics --watch`, and it + does not enter raw terminal mode or emit screen escapes + +#### Scenario: The fleet-scoped spelling names its replacement + +- **WHEN** the operator runs `spinloop fleet dashboard` +- **THEN** it fails naming `spinloop dashboard` as the command that replaced it diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-config/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-config/spec.md new file mode 100644 index 00000000..8d604f42 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-config/spec.md @@ -0,0 +1,222 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Resolution of fleet and node targets +Every command that acts on a fleet — the `spinloop fleet` group, and the +top-level verbs that read one — SHALL resolve the fleet the same way, from the +flags given: `--node ` names a single registered cloud environment, +`--fleet ` names a fleet file, and with neither the fleet file is +`./fleet.yaml` in the working directory. One rule serves every such command, so +a target means the same thing wherever it is given. + +Where no target resolves — no `--node`, no `--fleet`, and no fleet file at the +expected path — the command SHALL fail naming every way to give one: the +expected path, `--fleet `, and `--node `. It SHALL NOT look for an +engine on the machine it is running on. A local engine is already shown by the +command that runs it, and a machine whose engines are to be read this way names +them in a fleet file like any other. + +`--fleet` names the fleet and `--node` names a node within it: paired, the +command SHALL narrow the named fleet to that node. A name the *named* file +does not hold SHALL fail naming the file and its known node names — the file +is the fleet the command has stated it acts on, and no other fleet answers +for it. On the launch path the pair means the same: `--node` there pins the +selection to that fleet's node (see the `fleet-routing` specification). + +Beside a `fleet.yaml` discovered in the working directory — not named by a +flag — a `--node` that is a node of that fleet SHALL narrow the command to +that node alone; a name the fleet does not hold SHALL resolve as a registered +environment when one answers it, and fail naming the fleet and its known nodes +when nothing does. On the drive commands (`fleet start`, `fleet stop`) that +narrowing alone SHALL select the node: `-n ` stands in for the positional +node name rather than the command demanding it again. + +`--node` SHALL name a registered environment, which is a plain identifier and +never a path. A value that is not a plain identifier, and a name with no +registered configuration, SHALL each fail saying so and how to create the +environment, before any node is contacted. + +`--fleet` SHALL carry a `-f` short form on every `spinloop fleet` subcommand +except `fleet logs`, where `-f` is the short form of `--follow`; on `fleet +logs` the fleet file SHALL be named by the long form `--fleet` only. `--node` +SHALL carry the `-n` short form everywhere it appears except `fleet deploy` +and `cloud deploy`, where `-n` is the short form of `--dry-run`; on those two +the node SHALL be named by the long form `--node` only. + +#### Scenario: Default resolution + +- **WHEN** a `spinloop fleet` command runs in a directory containing + `fleet.yaml` with no `--fleet` or `--node` flag +- **THEN** that file is used + +#### Scenario: --node narrows the discovered fleet + +- **WHEN** `spinloop status -n gpu-box` runs in a directory whose + `fleet.yaml` holds a node named `gpu-box`, passing no `--fleet` +- **THEN** the command reads only that node: its row alone, its log alone, + its engine alone + +#### Scenario: A discovered fleet without the node falls back to the registry + +- **WHEN** `spinloop status --node prod` runs in a directory whose + `fleet.yaml` holds no node named `prod` and a registered environment named + `prod` exists +- **THEN** the command acts on that environment's fleet of one + +#### Scenario: A name nothing answers names the fleet it was checked against + +- **WHEN** `spinloop status --node ghost` runs in a directory whose + `fleet.yaml` holds no node named `ghost` and no environment named `ghost` + is registered +- **THEN** the failure names `ghost`, the `fleet.yaml` it was not found in, + and the fleet's known node names + +#### Scenario: A drive command takes -n as the node name + +- **WHEN** `spinloop fleet start -n gpu-box` runs in a directory whose + `fleet.yaml` holds a node named `gpu-box`, naming no node positionally +- **THEN** that node alone is started, and the command does not ask for the + name to be repeated + +#### Scenario: Explicit path + +- **WHEN** `spinloop fleet status --fleet ./cluster.yaml` runs +- **THEN** that file is used + +#### Scenario: Short form + +- **WHEN** `spinloop fleet status -f ./cluster.yaml` runs +- **THEN** `./cluster.yaml` is used, exactly as with `--fleet` + +#### Scenario: logs keeps -f for follow + +- **WHEN** the operator runs `spinloop fleet logs -f` +- **THEN** that is the command's follow flag, not a fleet-file flag, and + `logs` takes its fleet file only as `--fleet` + +#### Scenario: Missing file + +- **WHEN** a `spinloop fleet` command runs with no fleet file at the resolved + path +- **THEN** it fails, naming the expected path + +#### Scenario: A named environment is the target + +- **WHEN** `spinloop fleet status --node prod` runs, passing no `--fleet` +- **THEN** the command acts on the registered environment `prod` alone — + whether or not a `fleet.yaml` is present, so long as no discovered fleet + holds a node of that name, which would answer first + +#### Scenario: A named environment needs no fleet file + +- **WHEN** `spinloop fleet dashboard --node prod` runs in a directory holding + no `fleet.yaml` +- **THEN** the view opens on that one environment, and the absent file is not + an error + +#### Scenario: A node of a named fleet narrows it + +- **WHEN** `spinloop fleet status --node gpu-box --fleet ./cluster.yaml` + runs and `cluster.yaml` holds a node named `gpu-box` +- **THEN** the command reads only that node — the row `gpu-box` alone, the + file named by the flag supplying the fleet + +#### Scenario: A name a named fleet lacks is not resolved elsewhere + +- **WHEN** `spinloop fleet status --node prod --fleet ./cluster.yaml` runs, + `cluster.yaml` holds no node named `prod`, and a registered environment + named `prod` exists +- **THEN** it fails naming `prod`, `cluster.yaml` and its known node names, + contacts nothing, and does not fall back to the registered environment — + only a discovered `fleet.yaml` answers to the registry + +#### Scenario: The directory's fleet file is read for a node, not ignored + +- **WHEN** `spinloop fleet status --node prod` runs in a directory containing + a `fleet.yaml` that holds a node named `prod` +- **THEN** the fleet is that file and the command narrows to the node + `prod` alone, because beside a discovered fleet the one name answers one + node of it, and only a flag states a conflict + +#### Scenario: An unregistered environment is reported + +- **WHEN** `spinloop fleet status --node nope` runs and no configuration is + registered under that name +- **THEN** it fails saying the environment is not registered and how to + create it, and contacts nothing + +#### Scenario: A path is not an environment name + +- **WHEN** `spinloop fleet status --node ./cloud.json` runs +- **THEN** it fails saying an environment name is a plain identifier with no + path + +#### Scenario: No target at all names every way to give one + +- **WHEN** a command that acts on a fleet runs with no `--node`, no `--fleet`, + and no fleet file at the expected path +- **THEN** it fails naming the expected path, `--fleet ` and + `--node `, and contacts nothing + +#### Scenario: No engine on this machine is looked for + +- **WHEN** a read verb runs with no target resolvable on a machine that is + running an engine with its control API up +- **THEN** it fails as above rather than reporting that engine: a local engine + is read through the command that runs it, or by naming the machine in a fleet + file + +## REMOVED Requirements + +### Requirement: Fleet file resolution +**Reason**: The requirement's scenarios name the target flag in their titles; `--env`/`-e` is renamed `--node`/`-n`, and a MODIFIED cannot retitle scenarios. +**Migration**: Read the requirement "Resolution of fleet and node targets". + + +## MODIFIED Requirements + +### Requirement: A registered environment is a fleet of one + +A `--node ` target SHALL be a fleet holding exactly one node: a cloud +node named by the flag, whose registered configuration is the one +`clouds//cloud.json` holds. It SHALL be driven, observed and rendered +exactly as the same node listed in a fleet file is, so a command's output for +one environment does not depend on how that environment was named. + +Such a fleet has no file on disk. It SHALL therefore carry none of the +settings a fleet file supplies — no activity preference, no wake policy, no +gateway, no concurrency limits, no fleet-wide key reference — and SHALL take +each of those as its default. Where a command reports which fleet it acted +on, it SHALL name the environment rather than a path. + +A token reference SHALL NOT be resolved for such a fleet: a cloud node is +reached through its control plane with the operator's own credentials, and +there is no file for an adjacent `.env` to sit beside. An environment whose +credentials are missing or expired SHALL fail as it does on any other path. + +#### Scenario: One environment renders as one node + +- **WHEN** `spinloop fleet status --node prod` runs +- **THEN** the output is the one-row status table a single-node fleet file + produces for the same environment + +#### Scenario: An environment that cannot be reached is a row + +- **WHEN** `spinloop fleet status --node prod` runs and the environment cannot + be reached +- **THEN** the row reports why, and the command still succeeds, exactly as an + unreachable node in a fleet file does + +#### Scenario: Fleet-wide settings are absent, not inherited + +- **WHEN** a command acts on a `--node` target +- **THEN** no activity preference, wake policy, gateway or concurrency limit + is in force, and a `fleet.yaml` in the working directory supplies none of + them + +#### Scenario: No .env is consulted + +- **WHEN** a command acts on a `--node` target +- **THEN** no adjacent `.env` is read, because the target names no file to sit + beside diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-routing/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-routing/spec.md new file mode 100644 index 00000000..c2968869 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/fleet-routing/spec.md @@ -0,0 +1,239 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Choosing a node + +Selection SHALL query every candidate node concurrently, as `spinloop fleet +status` does, and SHALL prefer a node that is already running what is wanted: a +node whose state is `running` and whose served model matches the Spinloop's +`MODEL` (or its `ALIAS`, against the name the node reports serving). A Spinloop +that names no model SHALL match any running node. + +A node whose state is `running` but whose daemon reports the engine not ready is +not running what is wanted: the state turns running when the engine's process +exists, which is before the weights are fetched and loaded, and during that +window nothing is listening on the engine's port. Such a node SHALL NOT be +selected, and a failure that names it SHALL mark it not ready, so a refusal does +not read as if the node were serving the model it was asked for. A node whose +daemon reports no readiness at all — an older build, or a runner with no +health-check convention — SHALL NOT be disqualified: the absence of a reading is +not evidence of not-readiness. + +Matching nodes SHALL be ranked by the activity preference in force (see +"Preferring an idle or an active node"). Ties SHALL be broken by fleet-file +order, so the same fleet in the same state chooses the same node. + +A node that does not answer — unreachable, unauthorized, or a configuration +error — SHALL be skipped rather than aborting the selection, exactly as it is a +row rather than a failure in `spinloop fleet status`. + +`--node ` — or its `-n ` short form — SHALL pin the selection to +one node, skipping the search. It is the one name a launch gives for the node +its agent talks to: with a fleet in force the name is a node of that fleet; +with no fleet in force the same flag instead names a registered environment, +which is a fleet of one (see the cloud-environment capabilities), and this +selection never runs for it. An unknown name SHALL fail naming the known nodes, +and a pinned node that cannot be reached SHALL fail rather than falling back to +another node — a pin is an instruction, not a preference. A pinned node that is running the wanted model +but has not answered yet SHALL fail saying it is still starting it — it may be +fetching or loading weights — and naming the command for the node's log, rather +than saying that nothing serves the model or restarting the node: this node is +about to serve it. + +A running engine SHALL NEVER be stopped or restarted to make room, including a +pinned one: another person may be using it. A node running a different model is +therefore not a candidate, and pinning one SHALL fail saying what it is serving. + +#### Scenario: The preference decides between matching nodes + +- **WHEN** two nodes are running the wanted model and one reports a longer time + since it last did work +- **THEN** the one the activity preference favours is chosen, and the same fleet + in the same state chooses the same node every time + +#### Scenario: An unreachable node is skipped + +- **WHEN** one node in the fleet cannot be reached and another is running the + wanted model +- **THEN** the reachable node is chosen and the launch proceeds + +#### Scenario: A pinned node is used as given + +- **WHEN** the user runs `spinloop harness --node gpu-box` and that node is + running the wanted model +- **THEN** `gpu-box` is chosen without regard to what the other nodes are doing + +#### Scenario: A pinned node that cannot be reached fails + +- **WHEN** the user pins a node whose daemon is unreachable +- **THEN** the command fails naming that node, and no other node is selected + +#### Scenario: A not-ready engine is not a match + +- **WHEN** the only node running the wanted model reports its engine not ready, + and no other node is running it +- **THEN** the selection reports that nothing is serving the model, and the + failure names the node marked not ready + +#### Scenario: A missing readiness reading still routes + +- **WHEN** a node is running the wanted model and its daemon reports no + readiness at all +- **THEN** the node is chosen, as if its engine had answered + +#### Scenario: A pinned node that is still starting names its state + +- **WHEN** the user pins a node whose engine is running the wanted model but has + not answered yet +- **THEN** the command fails saying the node is still starting the model, that + it may be fetching or loading weights, and names the command for the node's + log, without restarting the node + +#### Scenario: A busy node is left alone + +- **WHEN** every reachable node is running a model other than the one wanted +- **THEN** no running engine is stopped, and selection falls through to waking + an idle node + +#### Scenario: Pinning a node serving something else fails + +- **WHEN** the user pins a node that is running a different model +- **THEN** the command fails saying what that node is serving, and the engine is + untouched + +## ADDED Requirements + +### Requirement: A launch routed through a fleet or pinned to a node +`spinloop harness` SHALL route a worn Spinloop through a fleet when +`--fleet ` — or its `-f ` short form — is given, and, when the +worn Spinloop was not named explicitly, through a `./fleet.yaml` in the +working directory. A worn Spinloop is named explicitly when the user gives its +path — a leading positional argument or a `--spinloop`/`-O` value — or when +`SPINLOOP_ALIAS` is set; a valueless `--spinloop` wears the default Spinloop +and is not named. An explicitly named Spinloop SHALL NOT pick up a fleet file +from the working directory on its own: the user pointed at that file +deliberately, and routing happens only when `--fleet` is also given. A launch +that wears no Spinloop applies nothing and routes nothing, in a directory +holding a `fleet.yaml` or not — unless `--node` names a target alongside a +fleet in force, which routes: it is served by the fleet's gateway where one is +declared, and otherwise fails saying the Spinloop is what decides which node +can serve. Where a Spinloop is worn and neither a flag nor a directory +`./fleet.yaml` is in force, the launch SHALL behave exactly as it does without +routing. + +Routing SHALL choose one node and give the launched agent that +node's engine as its OpenAI-compatible endpoint: the chosen base URL SHALL be +written as the applied provider's base URL, in the same place an environment's +address is written, and SHALL also be placed in the launched agent's +environment as `OPENAI_BASE_URL`. + +A variable already set in spinloop's environment SHALL win, as it does on the +cloud path — routing fills what is unset, it does not override an explicit +choice. + +A Spinloop that pins a `BASEURL` SHALL NOT be routed: the pinned address wins +and spinloop SHALL say it is not routing through the fleet, rather than +silently selecting a node whose address it then discards. + +A launch given `--node ` — or its `-n ` short form — alongside a +fleet, whether the directory's `fleet.yaml` or a `--fleet` flag, SHALL pin the +selection to that fleet's node of that name, as above: naming a node and the +fleet it belongs to is one answer, not a conflict. A `--node` whose name is not +a node of the fleet in force SHALL fail naming that fleet's known nodes rather +than resolving the name as a registered environment — with a fleet in force the +name is a fleet node's, always. Where no fleet is in force the same flag names +a registered environment instead. + +The chosen node and the reason it was chosen SHALL be reported on stderr before +the agent launches, so a launch that lands somewhere unexpected says so at the +time rather than at the first request. + +#### Scenario: A running node becomes the agent's endpoint + +- **WHEN** the user runs `spinloop harness -O` in a directory holding a + `Spinloop` and a `fleet.yaml`, and a node in that fleet is running the model + the Spinloop names +- **THEN** the launched agent's environment carries `OPENAI_BASE_URL` pointing + at that node's engine, and the applied provider's base URL is the same + address + +#### Scenario: The flag overrides the directory's fleet file + +- **WHEN** the user runs `spinloop harness -O --fleet=./cluster.yaml` in a + directory holding a `Spinloop` and a `fleet.yaml` +- **THEN** the nodes in `./cluster.yaml` are the candidates, not the ones in + the directory's `fleet.yaml` + +#### Scenario: The short form overrides the directory's fleet file + +- **WHEN** the user runs `spinloop harness -O -f ./cluster.yaml` in a directory + holding a `Spinloop` and a `fleet.yaml` +- **THEN** the nodes in `./cluster.yaml` are the candidates, not the ones in + the directory's `fleet.yaml` + +#### Scenario: No fleet file in force leaves the launch local + +- **WHEN** the user runs `spinloop harness -O` in a directory holding a + `Spinloop` and no `fleet.yaml`, passing no `--fleet` +- **THEN** no fleet file is read, no node is contacted, and the launch behaves + as it did before + +#### Scenario: An explicitly named Spinloop does not route + +- **WHEN** the user runs `spinloop harness ./elsewhere/Spinloop` in a directory + holding a `fleet.yaml`, and passes no `--fleet` +- **THEN** no fleet file is read, no node is contacted, and the launch applies + the Spinloop locally + +#### Scenario: The flag routes an explicitly named Spinloop + +- **WHEN** the user runs `spinloop harness ./elsewhere/Spinloop --fleet + ./cluster.yaml` +- **THEN** the nodes in `./cluster.yaml` are the candidates + +#### Scenario: An aliased Spinloop does not route + +- **WHEN** `SPINLOOP_ALIAS` is set and the user runs `spinloop harness -O` in + a directory holding a `fleet.yaml`, passing no `--fleet` +- **THEN** no fleet file is read, and the launch applies the aliased Spinloop + locally + +#### Scenario: A pinned BASEURL is not routed + +- **WHEN** a `Spinloop` beside a `fleet.yaml` names a `BASEURL`, and the user + runs `spinloop harness -O` +- **THEN** the `BASEURL` is used, no node is selected, and spinloop reports + that it is not routing through the fleet + +#### Scenario: An exported base URL wins + +- **WHEN** `OPENAI_BASE_URL` is already set in the user's environment and a + fleet-routed launch runs +- **THEN** the existing value reaches the agent unchanged + +#### Scenario: --node with a fleet pins that fleet's node + +- **WHEN** the user runs `spinloop harness --node dev-2` in a directory holding + a `fleet.yaml` that has no node named `dev-2` +- **THEN** the launch fails naming `dev-2` and the fleet's known nodes, and does + not fall back to a registered environment named `dev-2` + +#### Scenario: --node with no fleet names a registered environment + +- **WHEN** the user runs `spinloop harness -O --node dev-2` in a directory + holding a `Spinloop` and no `fleet.yaml`, passing no `--fleet` +- **THEN** the launch is applied against the registered environment `dev-2`, as + the same launch formerly given `--env dev-2` was + +#### Scenario: The choice is announced + +- **WHEN** a fleet-routed launch selects a node +- **THEN** the node's name, the resolved endpoint, and why it was chosen are + written to stderr before the harness is launched + +## REMOVED Requirements + +### Requirement: A launch routed through a fleet +**Reason**: The requirement's scenarios name the target flag in their titles; `--env`/`-e` is renamed `--node`/`-n`, and a MODIFIED cannot retitle scenarios. +**Migration**: The requirement "A launch routed through a fleet or pinned to a node" states the same contract with the renamed flag. diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/harness-remote-env/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/harness-remote-env/spec.md new file mode 100644 index 00000000..5954d9d7 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/harness-remote-env/spec.md @@ -0,0 +1,125 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: harness --node injects the endpoint's credentials +When `spinloop harness` is given an `--node/-n ` flag, it SHALL automatically obtain the named environment's endpoint environment variables and inject them into the child process environment, so the agent authenticates against a key that only ever existed in Secrets Manager. The failure mode this replaces is silent: a user who forgets to `eval` the exports gets an agent that cannot connect, so no way this can fail is allowed to pass unremarked — and when it leaves the agent with no key at all, it fails before the harness launches. + +The fetch SHALL happen before the Spinloop is applied, and the apply SHALL resolve the provider's API key variable against the fetched key as well as the local environment. The config the apply writes is therefore complete, and the apply SHALL NOT warn that no API key is set when the launch is about to supply one. The fetched key SHALL be used only where nothing local supplies a value, so an exported key or one in the adjacent `.env` still wins, and it SHALL satisfy only the API key variable — no other lookup. + +The key SHALL reach the agent through its environment alone, and SHALL NOT be written into any harness config. Where a harness reads the key under its own name — lucinate's `LUCINATE_OPENAI_API_KEY` — the fetched key SHALL satisfy that too. + +With no fleet in force the flag SHALL name a registered environment (see the +Cloud Environments specification), and its environment name SHALL key the +applied provider and supply the applied base URL exactly as an apply given the +flag does on its own. Alongside a fleet — a `./fleet.yaml` in force or a +`--fleet` flag — the same flag is not this capability's at all: it names a +node of that fleet, and this fetch never runs (see the `fleet-routing` +specification). + +#### Scenario: harness injects OPENAI_BASE_URL and OPENAI_API_KEY +- **WHEN** the user runs `spinloop harness --node dev-2` and the endpoint is running +- **THEN** the launched harness process receives `OPENAI_BASE_URL` and `OPENAI_API_KEY` in its environment + +#### Scenario: harness calls env Lambda to fetch key +- **WHEN** the user runs `spinloop harness --node dev-2` +- **THEN** the command calls the cloud env Lambda (not Start) for `dev-2` to obtain the `api_key` and `base_url` from the response + +#### Scenario: harness fails when endpoint is stopped +- **WHEN** the cloud instance is stopped, no API key is set locally, and the user runs `spinloop harness --node dev-2` +- **THEN** the command fails with an error telling the user to run `spinloop cloud start` first + +#### Scenario: the apply does not warn about a key the launch supplies +- **WHEN** the user runs `spinloop harness --node dev-2`, no `OPENAI_API_KEY` is set locally, and the endpoint is running +- **THEN** the apply reports that the key is read from the environment when the harness runs, and does not warn that no key was set + +#### Scenario: harness informs user it is fetching cloud env +- **WHEN** the user runs `spinloop harness --node dev-2` +- **THEN** a message is printed to stderr naming the environment, so the user knows a network call is happening + +#### Scenario: harness without --node is unaffected +- **WHEN** the user runs `spinloop harness` with no `--node` flag +- **THEN** the command behaves as before with no cloud Lambda calls + +#### Scenario: existing env vars are not overridden +- **WHEN** `OPENAI_BASE_URL` or `OPENAI_API_KEY` is already set in the user's shell environment +- **THEN** the existing value is preserved (the cloud value is only injected when the variable is not already set) + +#### Scenario: lucinate receives the fetched key under its own name +- **WHEN** the user runs `spinloop harness -H lucinate --node dev-2` and the endpoint is running +- **THEN** the launched lucinate process receives the fetched key as `LUCINATE_OPENAI_API_KEY` + +#### Scenario: --node with a fleet is a fleet node, not an environment +- **WHEN** the user runs `spinloop harness --node dev-2` in a directory holding a `fleet.yaml` (or `--fleet` is given) +- **THEN** no cloud endpoint is fetched: `dev-2` is sought among the fleet's nodes, and the launch follows the fleet-routing rules for that pin + +### Requirement: harness --node auto-configures from a deployed environment +When `spinloop harness --node ` is run with no Spinloop applied — no leading alias or path, and no `--spinloop`/`-O` — and no fleet is in force, the command SHALL fetch the named environment's live environment response (the same fetch that supplies `OPENAI_BASE_URL`/`OPENAI_API_KEY`) and, when it carries a deploy-config, synthesise a provider selection from it rather than doing nothing with the flag. + +The deploy-config's runner SHALL become the catalogue provider, by the same mapping `spinloop cloud deploy` uses in reverse (a runner is a catalogue provider's engine kind). The deploy-config's served model name SHALL become the model key. The deploy-config's context size, when present, SHALL set the context window. The harness SHALL then be configured and launched exactly as it would be for a Spinloop stating the same `PROVIDER`, `ALIAS` and `CONTEXT` with the same `--node ` — the same environment labelling, base URL, and injected credentials as the existing `--node` behaviour. + +A Spinloop applied alongside `--node` — a leading alias or path, or `--spinloop`/`-O` — SHALL continue to use its own `PROVIDER`, `ALIAS`, `MODEL` and `CONTEXT` exactly as today; the deploy-config's fields SHALL NOT override a value the Spinloop states. + +#### Scenario: bare --node configures and launches the harness +- **WHEN** the user runs `spinloop harness --node dev-3` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is configured with `dev-3` as the provider, the deployed served model name as the model, the deployed context size as the window, and is launched with the fetched base URL and API key in its environment + +#### Scenario: an applied Spinloop still wins +- **WHEN** the user runs `spinloop harness some-alias --node dev-3` (or `--spinloop= --node dev-3`) and the Spinloop states its own `PROVIDER` and `ALIAS` +- **THEN** the Spinloop's values configure the harness; the environment's deploy-config is not consulted for them + +#### Scenario: trailing args are still forwarded +- **WHEN** the user runs `spinloop harness --node dev-3 --prompt "hello"` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is auto-configured from `dev-3` and launched with `--prompt hello` forwarded to it + +## MODIFIED Requirements + +### Requirement: harness cloud error is loud +When the named environment's endpoint cannot be fetched during `spinloop harness`, the command SHALL report the failure on stderr rather than discarding it, and SHALL bound the attempt with a timeout so an unresponsive control plane cannot block the launch indefinitely. + +The failure SHALL be fatal — before the harness is launched and before its config is written — when no API key is otherwise available to the launched agent, because the endpoint refuses every request without one. The error SHALL name the environment, carry the underlying cause, and say how to resolve it: start the endpoint, or set the key. + +When an API key is already available — exported, in the `.env` beside the Spinloop, or set by an `ENV` instruction, which overrides both in the launched agent's environment — the fetch was only a convenience, so the command SHALL warn and carry on. + +#### Scenario: AWS credentials failure surfaces early +- **WHEN** AWS credentials are not available, no API key is set locally, and the user runs `spinloop harness --node dev-2` +- **THEN** the command fails with a clear error before attempting to launch the harness + +#### Scenario: cloud not deployed surfaces early +- **WHEN** the environment named by `--node` has no registered configuration (no deploy yet) and the user runs `spinloop harness --node dev-2` +- **THEN** the command fails with a clear error naming the environment and saying to deploy it + +#### Scenario: missing env_url in config surfaces early +- **WHEN** the environment's configuration lacks an `env_url` field and the user runs `spinloop harness` for it +- **THEN** the command fails with an error indicating the cloud deployment needs to be updated + +#### Scenario: a fatal fetch leaves the harness config untouched +- **WHEN** the fetch fails with no API key available and the user runs `spinloop harness --node dev-2` +- **THEN** the harness config is not written + +#### Scenario: an available key downgrades the failure to a warning +- **WHEN** the fetch fails but `OPENAI_API_KEY` is already set in the environment, in the `.env` beside the Spinloop, or by an `ENV` instruction +- **THEN** the failure is reported on stderr and the harness is launched with the key that is available + +### Requirement: harness fails clearly with nothing to auto-configure from +When `spinloop harness --node ` is run with no Spinloop applied, and the environment's fetched response carries no deploy-config — because nothing has been deployed to it, or because its `env` Lambda predates this behaviour and the reply simply omits the fields — the command SHALL fail before launching, rather than launch an unconfigured or misconfigured harness. + +The error SHALL name the environment and say what to do: deploy a model to it (`spinloop cloud deploy --node `), or, when a redeployed model is plausible but the reply still lacks the fields, run `spinloop cloud bootstrap` to update the control plane to a version whose `env` Lambda reports what is deployed. + +#### Scenario: nothing deployed to the environment +- **WHEN** the user runs `spinloop harness --node dev-3` with no Spinloop applied, and nothing has been deployed to `dev-3` +- **THEN** the command fails saying nothing is deployed to `dev-3` and how to deploy one, and the harness is not launched + +#### Scenario: an env Lambda predating this behaviour +- **WHEN** the user runs `spinloop harness --node dev-3` with no Spinloop applied, and `dev-3`'s `env` Lambda reply carries no deploy-config fields +- **THEN** the command fails the same way as when nothing is deployed, naming `spinloop cloud bootstrap` as a way to update the control plane, and the harness is not launched + +## REMOVED Requirements + +### Requirement: harness injects the endpoint's credentials +**Reason**: The requirement's scenarios name the target flag in their titles; `--env`/`-e` is renamed `--node`/`-n`, and a MODIFIED cannot retitle scenarios. +**Migration**: The requirement "harness --node injects the endpoint's credentials" states the same contract with the renamed flag. + +### Requirement: harness auto-configures from a deployed environment +**Reason**: The requirement's scenarios name the target flag in their titles; `--env`/`-e` is renamed `--node`/`-n`, and a MODIFIED cannot retitle scenarios. +**Migration**: The requirement "harness --node auto-configures from a deployed environment" states the same contract with the renamed flag. diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/provider-selection/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/provider-selection/spec.md new file mode 100644 index 00000000..2212aba5 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/provider-selection/spec.md @@ -0,0 +1,40 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Base URL resolution + +The system SHALL let the user override any provider's API base URL, resolved +with the precedence: the explicit override, then the `SPINLOOP_BASE_URL` +environment variable, then the catalogue's per-provider values. The explicit +override is the `--base-url`/`-u` flag, or the `BASEURL` of the Spinloop being +applied. + +When `apply` is given an `--node ` flag and the Spinloop states no +`BASEURL` of its own, applying it SHALL take the base URL from the named +environment's registered configuration, so the endpoint's address stays with +the deployment that owns it rather than in the hand-written Spinloop. The +system SHALL report that it did so. When the Spinloop states a `BASEURL` of its +own, that value SHALL be the applied base URL and the environment's own base +URL SHALL NOT be read; the environment still supplies the provider's name and, +for a launch, its key. + +#### Scenario: Flag beats environment and catalogue + +- **WHEN** `--base-url https://gateway/v1` is given and `SPINLOOP_BASE_URL` is + also set +- **THEN** the configured base URL is `https://gateway/v1` + +#### Scenario: Base URL from the named environment + +- **WHEN** a Spinloop with no `BASEURL` is applied with `--node dev-1`, and that + environment's registered configuration names a base URL +- **THEN** the provider is configured with the base URL from that + configuration, and the output says where it came from + +#### Scenario: A Spinloop's own BASEURL wins + +- **WHEN** a Spinloop states a `BASEURL` and is applied with `--node dev-1` + whose configuration names a different base URL +- **THEN** the provider is configured with the Spinloop's `BASEURL`, and the + provider is still keyed on the environment `dev-1` diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-endpoint/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-endpoint/spec.md new file mode 100644 index 00000000..4793777e --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-endpoint/spec.md @@ -0,0 +1,150 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Environment selection for cloud commands + +The endpoint's control URLs SHALL come from a JSON configuration naming a start +URL, a stop URL, an optional deploy URL, and a region. That configuration MAY +also name the endpoint's own base URL; it SHALL be optional, since no control +call needs it, and a configuration without it SHALL remain valid. + +A `cloud` subcommand SHALL select which environment's configuration it uses +with its `--node ` flag, and the flag SHALL be required: the value is a +registered environment's name, and the configuration is read from that +environment's `cloud.json` in the per-user registry (see the Cloud +Environments specification). A `--node` value that names an environment with no +registered configuration, and no complete configuration in the environment +variables, SHALL fail saying the environment is not registered and how to +create it. + +A subcommand given no `--node` SHALL fail naming the flag and listing the +registered environments, and SHALL act on nothing. There is no environment a +command falls back to: several of these subcommands change the state of a +cloud instance, and an instance nobody named is not one to start, stop or +terminate. The name `default` is an ordinary environment name, carrying no +special meaning. + +The Spinloop a subcommand is given as an argument SHALL NOT select an +environment; it SHALL be read only for its `ENV` instructions and the `.env` +file beside it, which the command applies before any AWS or control-plane work +(see the Cloud Local Environment specification). + +Environment variables SHALL override individual values, and the region SHALL +fall back to the standard AWS region variable and then to the region named in +the URL. A missing or incomplete configuration SHALL fail saying where to put +it. + +#### Scenario: The flag selects the environment + +- **WHEN** the user runs `spinloop cloud status --node qwen3.6-27b-prod` +- **THEN** the URLs come from that environment's `cloud.json` in the registry + +#### Scenario: An unregistered environment is named as such + +- **WHEN** a `cloud` subcommand runs with `--node missing` and no environment + `missing` is registered +- **THEN** it fails saying the environment is not registered and that + `spinloop cloud deploy --node missing` creates it + +#### Scenario: No flag uses the default environment + +- **WHEN** a `cloud` subcommand runs with no `--node` flag +- **THEN** the `default` environment is used, whether or not a `Spinloop` is + present in the working directory + +#### Scenario: An explicit Spinloop does not select an environment + +- **WHEN** a `cloud` subcommand is given a Spinloop as its argument and no + `--node` flag +- **THEN** the command uses the `default` environment and applies the + Spinloop's `ENV` instructions and adjacent `.env` to the process environment, + rather than failing for the Spinloop to name an environment + +#### Scenario: Configuration without a base URL + +- **WHEN** a cloud configuration names the control URLs and region but no base + URL, and a `cloud` subcommand runs +- **THEN** the subcommand works as it always has, since the endpoint reports its + own address in the replies to `start` and `status` + +#### Scenario: A command with no environment names the flag + +- **WHEN** the user runs a `cloud` subcommand with no `--node` +- **THEN** it fails naming `--node` and listing the registered environments, + and contacts nothing + +#### Scenario: An instance is never stopped without being named + +- **WHEN** the user runs `spinloop cloud stop` with no `--node`, with an + environment named `default` registered +- **THEN** nothing is stopped: the command fails naming the flag, and + `default` is not assumed + +#### Scenario: default is an ordinary name + +- **WHEN** the user runs a `cloud` subcommand with `--node default` and that + environment is registered +- **THEN** it acts on that environment, exactly as it would for any other name + +### Requirement: An environment reports when it last did work + +`spinloop status --node ` SHALL report how long it has been since the endpoint's +engine last did any work, alongside the instance state and health it reports +already. The figure SHALL come from the activity the on-instance daemon +tracks, not from a measurement the control plane makes itself — one answer, +derived on the box, however it is asked for. + +The figure SHALL be labelled "last active", matching the wording and duration +formatting used everywhere else this fact appears, so the same fact reads the +same way in every command. + +Collecting it SHALL NOT make `status` slower than its health check already +makes it: the daemon SHALL be asked in parallel with the health check rather +than after it. Nor SHALL it introduce a side effect — `status` SHALL remain a +read, and SHALL still perform no TCP probe. + +#### Scenario: A running endpoint reports its last activity + +- **WHEN** the user runs `spinloop status --node ` against a running endpoint + whose engine has served work +- **THEN** the output reports how long ago that work happened, labelled "last + active", beside the state and health lines + +#### Scenario: Status stays a read + +- **WHEN** the user runs `spinloop status --node ` +- **THEN** nothing is started, stopped or probed in order to obtain the + last-active figure + +### Requirement: An environment's status degrades when activity cannot be read + +`spinloop status --node ` SHALL omit the last-active figure rather than fail, +report zero, or imply inactivity, whenever the figure cannot be obtained. That +covers an endpoint whose engine has not yet done any work, a daemon that +cannot be reached or answers unrecognisably, and an instance that is not +running — reaching the daemon needs a running box, so a stopped or undeployed +environment has nothing to report about its engine. + +A failure to read the activity SHALL NOT affect the rest of the report: the +state and health lines SHALL be exactly what they are today, and the command +SHALL still succeed. + +#### Scenario: A stopped instance reports no activity figure + +- **WHEN** the user runs `spinloop status --node ` and the instance is stopped or + undeployed +- **THEN** the output reports the state as it does today and shows no + last-active figure + +#### Scenario: An unreachable daemon does not spoil the report + +- **WHEN** the endpoint is running but its daemon cannot be reached +- **THEN** the state and health lines are reported as they are today, no + last-active figure is shown, and the command succeeds + +#### Scenario: An engine that has done nothing yet + +- **WHEN** the endpoint is running and its daemon reports no last-active time +- **THEN** no last-active figure is shown, rather than one implying the engine + has been quiet since it started diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-env/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-env/spec.md new file mode 100644 index 00000000..c47b44c7 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-env/spec.md @@ -0,0 +1,74 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: cloud env subcommand +The CLI SHALL provide `spinloop cloud env` as a subcommand that returns the cloud endpoint's environment variables from an already-running instance. + +It SHALL select which environment it reads using the same rule as the other +`cloud` subcommands: the `--node ` flag naming a registered environment, +falling back to the `default` environment when the flag is absent. A Spinloop +given as an argument SHALL be read only for its `ENV` instructions and adjacent +`.env`, never to select an environment. + +Its stdout SHALL carry nothing but the `export` lines, so `eval "$(spinloop cloud env)"` is safe in a shell. Anything the command has to say for itself — notably the note reporting which alias resolved the Spinloop — SHALL go to stderr, where it is still visible in a terminal but outside what the shell evaluates. + +#### Scenario: env returns exports for a running endpoint +- **WHEN** the user runs `spinloop cloud env` and the cloud instance is running +- **THEN** stdout contains `export OPENAI_BASE_URL=` and `export OPENAI_API_KEY=` + +#### Scenario: env fails when endpoint is stopped +- **WHEN** the user runs `spinloop cloud env` and the cloud instance is stopped +- **THEN** the command fails with an error telling the user to run `spinloop cloud start` first + +#### Scenario: env names the environment with the flag +- **WHEN** the user runs `spinloop cloud env --node dev-2` +- **THEN** the command reads `dev-2`'s control configuration from the registry + and reports on that environment's endpoint + +#### Scenario: env falls back to the default environment +- **WHEN** the user runs `spinloop cloud env` with no `--node` flag +- **THEN** the command uses the `default` environment, whether or not a + `./Spinloop` is present + +#### Scenario: env outputs nothing to stderr on success +- **WHEN** the user runs `spinloop cloud env` with no alias to report, and the endpoint is running +- **THEN** stderr is empty (only export lines on stdout) + +#### Scenario: env stdout is eval-safe for an aliased Spinloop +- **WHEN** the user runs `eval "$(spinloop cloud env )"` and the endpoint is running +- **THEN** every line on stdout is an `export` line, the alias note having gone to stderr, and the shell evaluates it without error + +### Requirement: env Lambda reports what is deployed +The `env` Lambda SHALL read the named environment's deploy-config from SSM (the same state `deploy` writes and `start`/`stats` already relay) and, when one is present and parses, include it in its reply alongside `base_url` and `api_key`: a `deployed` flag, the `runner`, the `modelId`, the `servedName` (the name the engine answers to — an `ALIAS` at deploy time, falling back to the model id), and the `contextSize`. + +When no deploy-config is registered for the environment, or the stored value fails to parse, the reply SHALL omit these fields rather than fail the request: `base_url` and `api_key` remain valid and useful without them, exactly as they were before this requirement existed. + +#### Scenario: env reports the deployed model +- **WHEN** the user runs `spinloop cloud env --node dev-3` and a model has been deployed to `dev-3` +- **THEN** the reply includes `deployed: true`, the `runner`, `modelId`, `servedName` and `contextSize` the deploy recorded, alongside `base_url` and `api_key` + +#### Scenario: env degrades gracefully with nothing deployed +- **WHEN** the user runs `spinloop cloud env --node dev-3` and `dev-3` is registered but nothing has been deployed to it +- **THEN** the reply carries `base_url` and `api_key` as it always has, with no `deployed`, `runner`, `modelId`, `servedName` or `contextSize` fields, and the command does not fail because of their absence + +### Requirement: start --print-env flag prints exports + +The `spinloop cloud start` command SHALL accept a `--print-env` flag, with no +short form, that when present prints the export lines to stdout after a +successful start. The flag's former `-e`/`--env` spelling is removed, since +`--node` on `start` now selects the node like on every other `cloud` +subcommand; the eval-safe export lines remain available from +`spinloop cloud env`. + +#### Scenario: start with --print-env prints exports + +- **WHEN** the user runs `spinloop cloud start --print-env` and the instance + starts successfully +- **THEN** stdout contains the `export OPENAI_BASE_URL` and + `export OPENAI_API_KEY` lines + +#### Scenario: start without the flag suppresses exports + +- **WHEN** the user runs `spinloop cloud start` without `--print-env` +- **THEN** stdout does not contain export lines (only stderr progress) diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-environments/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-environments/spec.md new file mode 100644 index 00000000..3b1d8d88 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-environments/spec.md @@ -0,0 +1,133 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Environment name validity + +An environment name SHALL be a plain name, not a path: it SHALL NOT contain a +path separator. A `--node` value that is not a plain identifier SHALL be +rejected saying an environment name is a plain identifier. + +#### Scenario: A path-like value is not a name + +- **WHEN** a command is given `--node ./cloud.json` (or any value containing a + path separator) +- **THEN** it fails saying an environment name is a plain identifier, rather + than reading a file + +### Requirement: An environment names the harness provider + +When a Spinloop is applied with an `--node ` flag, the harness provider +SHALL be keyed on the environment name rather than the `PROVIDER` value, and +the default model SHALL read as `/`. The `PROVIDER` entry +SHALL still supply the engine configuration (its options, API-key environment +variable, and base URL). The flag SHALL name a registered environment: a value +with no registered configuration SHALL fail saying the environment is not +registered and that `spinloop cloud deploy --node ` creates it. A +Spinloop's own `BASEURL` SHALL still supply the applied base URL, as it does +when a fleet is named; the environment then provides the name and the key, not +the address. Unapplying with the same `--node` flag SHALL remove the provider +that apply wrote. + +#### Scenario: The flag becomes the provider name + +- **WHEN** a Spinloop stating `PROVIDER llamacpp` and `ALIAS qwen` is applied + with `--node dev-1` +- **THEN** the harness config holds a provider keyed `dev-1` whose default + model is `dev-1/qwen`, configured from the `llamacpp` catalogue entry + +#### Scenario: A pinned BASEURL supplies the address + +- **WHEN** a Spinloop stating a `BASEURL` is applied with `--node dev-1` whose + registered configuration names a different base URL +- **THEN** the provider is keyed `dev-1` and configured with the Spinloop's + `BASEURL` + +#### Scenario: An unregistered environment is reported + +- **WHEN** a Spinloop is applied with `--node missing` and no environment + `missing` is registered +- **THEN** the command fails saying the environment is not registered and that + `spinloop cloud deploy --node missing` creates it + +#### Scenario: Unapply removes the environment-named provider + +- **WHEN** a Spinloop is applied with `--node dev-1` and then unapplied with + `--node dev-1` +- **THEN** the provider keyed `dev-1` is removed from the harness config + +### Requirement: Cloud provider display name + +When a Spinloop is applied with an `--node` flag, the harness provider's display +name SHALL be distinct from that of the local engine of the same kind, so the +cloud environment and a local provider built from the same `PROVIDER` entry +are told apart in a harness model picker. The display name SHALL combine the +catalogue engine's display name with the environment name named by the flag +(for example `llama.cpp (dev-2)`); when the catalogue entry has no display +name, the environment name SHALL be used on its own. This labelling SHALL apply +only to the display name — the provider key, the `/` +default model, the engine options, the API-key environment variable, and the +base URL are unchanged from the existing cloud-naming behaviour. + +#### Scenario: A cloud provider is labelled with its environment + +- **WHEN** a Spinloop stating `PROVIDER llamacpp` (display name `llama.cpp`) + and `ALIAS qwen` is applied with `--node dev-2` +- **THEN** the harness config holds a provider keyed `dev-2` whose display name + is `llama.cpp (dev-2)`, distinct from a local `llamacpp` provider's + `llama.cpp` + +#### Scenario: A local and a cloud engine of the same kind are distinguishable + +- **WHEN** both a local `llamacpp` provider and a cloud `dev-2` provider built + from the same engine are present in the harness config +- **THEN** their display names differ, so the two appear as separate rows in + the harness model picker rather than two identical `llama.cpp` rows + +#### Scenario: An engine with no display name falls back to the environment name + +- **WHEN** a Spinloop's `PROVIDER` catalogue entry has no display name and the + Spinloop is applied with `--node dev-2` +- **THEN** the harness provider's display name is `dev-2` + +### Requirement: Every environment resolves by name + +An environment SHALL be resolved from its name and nothing else: the +configuration at `clouds//cloud.json` in the per-user registry. No name +SHALL resolve by a different rule from any other, and no path outside the +registry SHALL be consulted, so a command that resolves an environment never +special-cases the name it was given. + +Where a named environment has no registered file, a complete set of +`SPINLOOP_CLOUD_*` overrides SHALL configure it, so the cloud commands can +run with nothing on disk. In that case the **name given** SHALL be the +environment identifier sent with each control call — the identifier the shared +lifecycle Lambdas use to tell one instance from another, which a configuration +assembled from variables alone has no other source for. + +#### Scenario: A named environment reads its own file + +- **WHEN** a command runs with `--node prod` and `clouds/prod/cloud.json` + exists +- **THEN** its configuration is read from that file + +#### Scenario: A file at the superseded path is not read + +- **WHEN** a command runs with `--node default`, no + `clouds/default/cloud.json` exists, and a file exists at the path the + registry superseded +- **THEN** the command does not use that file's contents: no path outside the + registry is consulted for any name + +#### Scenario: Overrides configure a named environment with no file + +- **WHEN** a command runs with `--node ci`, no `clouds/ci/cloud.json` exists, + and the `SPINLOOP_CLOUD_*` variables supply a complete configuration +- **THEN** the command works, and the control calls carry `ci` as the + environment identifier + +#### Scenario: Nothing anywhere fails naming the registry + +- **WHEN** no configuration can be assembled for the named environment +- **THEN** the failure says the environment is not registered and how to + create it diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-keep/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-keep/spec.md new file mode 100644 index 00000000..c889d595 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-keep/spec.md @@ -0,0 +1,17 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The keep command selects its environment + +The `keep` subcommand SHALL select which environment's instance to tag using the same rule as the other `cloud` subcommands: the `--node ` flag naming a registered environment, or the per-user default environment. + +#### Scenario: Keep names the environment with the flag + +- **WHEN** the user runs `spinloop cloud keep 2h --node dev-2` +- **THEN** the `dev-2` environment's instance has its tag set + +#### Scenario: Keep falls back to the default environment + +- **WHEN** the user runs `spinloop cloud keep 2h` with no `--node` flag +- **THEN** the `default` environment's instance is tagged diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-local-environment/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-local-environment/spec.md new file mode 100644 index 00000000..b76d7e6d --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-local-environment/spec.md @@ -0,0 +1,48 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Cloud commands load the Spinloop's local environment + +The `cloud` control commands that resolve a Spinloop — `deploy`, `start`, +`stop`, `status`, `stats`, and the other subcommands that take one — SHALL, +before performing any AWS or control-plane work, load environment variables +into the process environment from two sources tied to the resolved Spinloop: +the `.env` file beside that Spinloop, and the Spinloop's +own `ENV` instructions. The loaded values SHALL be visible to everything the +command does afterwards — the AWS credential chain, the region resolution, and +the `SPINLOOP_CLOUD_*` overrides. The Spinloop is read for these local values +only: which environment the command acts on is selected by the command's +`--node` flag, with the `default` environment as the fallback, and the Spinloop +itself names no environment. When a command resolves no Spinloop (no path +argument and no `./Spinloop`), there is nothing adjacent to load and the command +SHALL proceed on the process environment alone. When the resolved Spinloop was +fetched from a URL, there is likewise no local `.env` beside it to load — a +URL has no local directory — and the command SHALL proceed on the Spinloop's own +`ENV` instructions and the process environment alone, exactly as the +no-Spinloop-resolved case does. + +#### Scenario: A .env beside the Spinloop reaches the control calls + +- **WHEN** `spinloop cloud status ./Spinloop` runs and a `.env` beside that Spinloop + sets `SPINLOOP_CLOUD_START_URL`, unset in the process environment +- **THEN** the command uses that value when it contacts the control endpoint + +#### Scenario: Every control command loads the local environment + +- **WHEN** any `cloud` subcommand resolves a Spinloop +- **THEN** that Spinloop's adjacent `.env` and its `ENV` instructions are loaded + before the command performs any AWS or control-plane work + +#### Scenario: No Spinloop, nothing to load + +- **WHEN** a `cloud` command runs with no Spinloop resolved and acts on the + `default` environment +- **THEN** no adjacent `.env` is read and the command proceeds on the process + environment alone + +#### Scenario: A URL-sourced Spinloop has no adjacent .env + +- **WHEN** a `cloud` command resolves a Spinloop fetched from a URL +- **THEN** no `.env` read is attempted, and only that Spinloop's own `ENV` + instructions and the process environment are applied diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-logs/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-logs/spec.md new file mode 100644 index 00000000..07ec4a47 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-logs/spec.md @@ -0,0 +1,113 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: An environment's shipped logs are readable from the CLI + +`spinloop logs --node ` SHALL print the logs an environment's instances have +shipped, without the operator needing to know the log group or stream naming, +open the AWS console, or connect to an instance. It SHALL select which +environment to read using the same `--node ` flag every other read verb +uses, so `spinloop logs --node ` and `spinloop status --node ` given +the same `--node` always speak about the same environment. + +#### Scenario: Reading an environment's logs + +- **WHEN** the operator runs `spinloop logs --node ` where `spinloop status --node ` + would report on that environment +- **THEN** the log events that environment's instances shipped are printed +- **AND** the operator is not required to name a log group, stream, or instance + +#### Scenario: Reading a named environment's logs + +- **WHEN** the operator runs `spinloop logs --node dev-2` +- **THEN** `dev-2`'s logs are printed rather than any other environment's + +#### Scenario: The cloud spelling names its replacement + +- **WHEN** the operator runs `spinloop cloud logs` +- **THEN** it fails naming `spinloop logs --node ` as the command that + replaced it + +### Requirement: An environment's logs are readable after the instance is gone + +Reading logs SHALL NOT depend on an instance being running, nor on the +environment's control endpoints answering. Logs SHALL be read from the durable +store the instances ship to, so the output of a boot that failed, or of an +instance that has since terminated, is still available. + +#### Scenario: A terminated instance's logs are still readable + +- **WHEN** an instance has produced logs and has since terminated +- **THEN** `spinloop logs --node ` still prints that instance's shipped events + +#### Scenario: A stopped environment can be diagnosed + +- **WHEN** an environment is stopped, so its status reports no running instance +- **THEN** `spinloop logs --node ` still prints the logs from its previous runs + +### Requirement: An environment's both engine and boot logs are reachable + +The command SHALL be able to read either log source an instance ships — the +inference engine's output and the boot (user-data) output — and both together. +The engine log SHALL be the default source, since it is what an operator wants +once the model is serving. Selecting the boot source SHALL be possible without +knowing which engine the environment runs, so a failure that happened before +the engine started is reachable even though the engine log is empty. + +#### Scenario: Engine output by default + +- **WHEN** the operator runs `spinloop logs --node ` with no source selected +- **THEN** the environment's engine log events are printed + +#### Scenario: Boot output on request + +- **WHEN** the operator asks for the boot source +- **THEN** the environment's start-up output is printed, including steps that + run before the engine starts + +#### Scenario: Both sources interleaved + +- **WHEN** the operator asks for all sources +- **THEN** events from both the engine and boot logs are printed together in + time order +- **AND** each line identifies which source it came from + +#### Scenario: The engine need not be named + +- **WHEN** an environment's logs are read and the operator has not stated which + inference engine it runs +- **THEN** the engine's logs are found regardless of which supported engine + produced them + +### Requirement: An environment's the volume fetched is bounded and controllable + +The command SHALL bound what it fetches by default rather than pulling an +environment's entire retained history, and SHALL let the operator widen or +narrow that: how far back to look, how many events at most to return, and +whether to restrict output to a single instance. When a bound causes older +events to be omitted, the command SHALL say so rather than presenting a +truncated view as complete. + +#### Scenario: A default window applies + +- **WHEN** the operator runs `spinloop logs --node ` with no window stated +- **THEN** only events from a bounded recent window are fetched + +#### Scenario: The window is widened + +- **WHEN** the operator states how far back to look +- **THEN** events from that whole period are fetched, subject to the retention + of the durable store + +#### Scenario: Output is capped + +- **WHEN** more events match than the stated maximum +- **THEN** the most recent events up to that maximum are printed +- **AND** the operator is told that earlier matching events were omitted + +#### Scenario: One instance is singled out + +- **WHEN** the operator names an instance +- **THEN** only that instance's events are printed, and events from the + environment's other instances are excluded diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-schedule/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-schedule/spec.md new file mode 100644 index 00000000..112b12d6 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-schedule/spec.md @@ -0,0 +1,32 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The schedule command sets, shows and clears schedules + +`spinloop cloud schedule set` SHALL accept one or more `--start CRON` and `--stop CRON` flags and an optional `--timezone ZONE`, and SHALL replace the environment's schedules with them. `spinloop cloud schedule show` SHALL print the environment's schedules, one per line, giving the action, expression and time zone, and SHALL print that there are none when the list is empty. `spinloop cloud schedule clear` SHALL remove every schedule. The commands SHALL select the environment as the other `cloud` subcommands do: `--node ` or the per-user default. `set` with neither `--start` nor `--stop` SHALL fail and say to use `clear` to remove schedules. When the deployment's control plane has no schedule endpoint, the commands SHALL fail with an error naming the fix (re-run `spinloop cloud bootstrap`). + +#### Scenario: Setting two schedules from the command line + +- **WHEN** the user runs `spinloop cloud schedule set --start "0 8 * * 1-5" --stop "0 18 * * 1-5" --timezone Europe/London` +- **THEN** the environment has those two schedules and the command prints them + +#### Scenario: Showing no schedules + +- **WHEN** the user runs `spinloop cloud schedule show` for an environment with none +- **THEN** the output says there are no schedules and the command succeeds + +#### Scenario: Clearing + +- **WHEN** the user runs `spinloop cloud schedule clear` +- **THEN** the environment has no schedules and none fires afterwards + +#### Scenario: Set with nothing to set + +- **WHEN** the user runs `spinloop cloud schedule set` with no `--start` or `--stop` +- **THEN** the command fails and tells the user to use `clear` to remove schedules + +#### Scenario: An older control plane + +- **WHEN** the deployment's configuration has no schedule endpoint +- **THEN** the command fails with an error telling the user to re-run `spinloop cloud bootstrap` diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-start-probe/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-start-probe/spec.md new file mode 100644 index 00000000..99317a6b --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-start-probe/spec.md @@ -0,0 +1,19 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Reachability warning message + +When the probe fails, the warning SHALL state that the endpoint is ready but not reachable from the current network, and SHALL provide a remediation command using `spinloop cloud deploy --overwrite --allowed-cidr`. The command SHALL include the caller's public IP as a /32 CIDR, detected by calling `checkip.amazonaws.com`. If IP detection fails, the warning SHALL use a placeholder value instead of failing the command. + +The warning SHALL NOT cause the command to exit with a non-zero status. The command SHALL still print exports (if `--print-env` is set) and exit 0. + +#### Scenario: Warning includes the caller's IP + +- **WHEN** the probe fails and the IP detection succeeds +- **THEN** the remediation command includes the caller's public IP as `/32` + +#### Scenario: IP detection failure does not break start + +- **WHEN** the probe fails and the IP detection also fails +- **THEN** the warning is still printed with a placeholder CIDR and the command exits 0 diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-stats/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-stats/spec.md new file mode 100644 index 00000000..a6671a43 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-stats/spec.md @@ -0,0 +1,183 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: An environment's metrics are reported + +The system SHALL provide a `metrics` command (`spinloop metrics --node `) that reports the current state of a cloud inference instance. It SHALL select which environment it reports on using the same `--node ` flag every other read verb uses. A Spinloop given as an argument is read only for its `ENV` instructions and adjacent `.env`, never to select the environment. When the instance is running, the report SHALL include the spinloop version from the daemon, carried by the stats Lambda reply. + +#### Scenario: Stats with a running instance + +- **WHEN** the user runs `spinloop metrics --node ` with a running instance +- **THEN** the command reports the instance state, runner, model, spinloop version, GPU info, CPU/RAM usage, token counts, and request counts + +#### Scenario: Stats with a stopped instance + +- **WHEN** the user runs `spinloop metrics --node ` and the instance is stopped +- **THEN** the command reports `state: stopped` and no metrics + +#### Scenario: Stats names the environment with the flag + +- **WHEN** the user runs `spinloop metrics --node dev-2` +- **THEN** the command reports on the `dev-2` environment's instance + +#### Scenario: Version is shown in stats output + +- **WHEN** the user runs `spinloop metrics --node ` with a running instance +- **THEN** the output includes the spinloop version + +#### Scenario: The cloud spelling names its replacement + +- **WHEN** the operator runs `spinloop cloud metrics` +- **THEN** it fails naming `spinloop metrics --node ` as the command that + replaced it + +### Requirement: An environment's cost is reported on request + +When the user passes `--cost`, the stats report SHALL include an estimated on-demand cost for the current running session. The cost SHALL be computed from the instance type's on-demand price (fetched from the AWS Price List API for the deployed region) multiplied by the elapsed time since launch. Without `--cost`, no price lookup is performed and no cost is shown. + +#### Scenario: Cost is shown with flag + +- **WHEN** the user runs `spinloop metrics --node --cost` with a running instance +- **THEN** the report includes the estimated cost for the current session + +#### Scenario: Cost is not shown by default + +- **WHEN** the user runs `spinloop metrics --node ` without `--cost` +- **THEN** the report does not include a cost line + +### Requirement: The metrics report's tabular display + +The stats output SHALL support four formats via the `--format` flag: `gauge` (default), `bar`, `table`, and `json`. The `gauge` format SHALL produce a compact display with horizontal progress gauges for the current reading, colour-coded by utilization level. The `bar` format SHALL produce a compact display drawing each resource series as a sparkline of the daemon's retained history, with each point colour-coded by its own utilization level. The `table` format SHALL produce a tab-separated key-value table, one line per metric, with the key column left-aligned and values right of it. The `json` format SHALL output the response as a JSON object to standard output. Progress and error messages SHALL go to standard error regardless of format. + +#### Scenario: Clean output + +- **WHEN** the command succeeds +- **THEN** standard output contains only the stats data with no progress or debug lines + +#### Scenario: Default format is gauge + +- **WHEN** the user runs `spinloop metrics --node ` without `--format` +- **THEN** the output is in gauge format + +#### Scenario: Table format is explicit + +- **WHEN** the user runs `spinloop metrics --node --format=table` +- **THEN** the output is in table format + +#### Scenario: Bar format is explicit + +- **WHEN** the user runs `spinloop metrics --node --format=bar` +- **THEN** the output is in bar format, drawing each resource series as a sparkline of the daemon's retained history + +#### Scenario: Gauge format is explicit + +- **WHEN** the user runs `spinloop metrics --node --format=gauge` +- **THEN** the output is in gauge format with progress gauges for the current reading + +#### Scenario: JSON format + +- **WHEN** the user runs `spinloop metrics --node --format=json` +- **THEN** the output is valid JSON containing the instance state, runner, model, GPU info, CPU/RAM usage, and token counts + +#### Scenario: JSON format with cost + +- **WHEN** the user runs `spinloop metrics --node --format=json --cost` +- **THEN** the JSON output includes a cost estimate field + +#### Scenario: Invalid format errors + +- **WHEN** the user runs `spinloop metrics --node --format=csv` +- **THEN** the command exits with an error and usage message + +### Requirement: The metrics report refreshes on request + +The system SHALL support a `--watch`/`-w` flag that repeatedly queries metrics every 60 seconds. When enabled, the command SHALL clear the screen and redraw the output in place for each refresh, producing no scrollback accumulation. Each refresh SHALL pre-render the metrics output into a buffer before clearing the screen, so the redisplay is instantaneous after the network round-trip. The command SHALL continue until the user sends `SIGINT` (Ctrl+C) or `SIGTERM`, at which point it SHALL exit cleanly. + +#### Scenario: Watch mode repeats output + +- **WHEN** the user runs `spinloop metrics --node --watch` +- **THEN** the command prints metrics, waits 60 seconds, clears the screen, and prints updated metrics + +#### Scenario: Watch redraws in place + +- **WHEN** the user runs `spinloop metrics --node -w` +- **THEN** each refresh after the first clears the screen before displaying new output, with no separator lines + +#### Scenario: Watch with JSON format + +- **WHEN** the user runs `spinloop metrics --node --watch --format=json` +- **THEN** each refresh clears the screen and outputs a JSON object + +#### Scenario: Watch with cost + +- **WHEN** the user runs `spinloop metrics --node --watch --cost` +- **THEN** each refresh includes the cost estimate + +#### Scenario: Watch stops on interrupt + +- **WHEN** the user runs `spinloop metrics --node -w` and presses Ctrl+C +- **THEN** the command exits cleanly without error + +### Requirement: An environment's metrics report when it last did work + +The metrics report SHALL include how long it has been since the endpoint's +engine last did any work, taken from the activity the on-instance daemon +tracks, in every format the command supports. The figure exists to answer "is +this endpoint doing anything?" at a glance, without the reader having to infer +it from the running-request count. + +The figure SHALL be labelled "last active" rather than "idle": `idle` is +already an engine *state* meaning nothing has been started, and one report +SHALL NOT carry two meanings of the word. The elapsed time SHALL be rendered +the same way the command's other durations are, so an uptime and a last-active +figure read alike. + +An endpoint whose daemon reports no activity — because no engine has run yet, +or because the daemon could not be reached — SHALL omit the figure rather than +show one implying the endpoint has been quiet since it started. + +#### Scenario: A working endpoint reports its last activity + +- **WHEN** the user runs `spinloop metrics --node ` against a running endpoint + whose engine has served work +- **THEN** the report shows how long ago that work happened, labelled "last + active" + +#### Scenario: Every format carries the figure + +- **WHEN** the user runs `spinloop metrics --node ` with `--format=bar`, + `--format=table`, or `--format=json` +- **THEN** each output carries the last-active figure in its own idiom + +#### Scenario: An endpoint that has done nothing omits the figure + +- **WHEN** the user runs `spinloop metrics --node ` against an endpoint whose + engine has not yet done any work +- **THEN** the report shows no last-active figure + +#### Scenario: An unreachable daemon omits the figure + +- **WHEN** the control plane cannot reach the instance's daemon to collect + metrics +- **THEN** the report shows no last-active figure, and the rest of the report + renders as it does today + +### Requirement: History in the metrics report + +When the on-instance daemon's metrics reply carries a history of system readings, the report SHALL carry it through to the command's output: the `json` format SHALL include the readings, and the `bar` format SHALL draw them. Where the daemon's reply carries no history, the report SHALL omit the field and the `bar` format SHALL fall back per the bar format specification. The control plane's relay of the daemon's reply SHALL NOT alter the readings it carries. + +#### Scenario: JSON carries the daemon's history + +- **WHEN** the instance's daemon reports a retained history and the user runs `spinloop metrics --node --format=json` +- **THEN** the JSON output includes the history's readings + +#### Scenario: Bar draws the relayed history + +- **WHEN** the instance's daemon reports a retained history and the user runs `spinloop metrics --node --format=bar` +- **THEN** each resource series is drawn as a sparkline from the readings the control plane relayed + +#### Scenario: A daemon without history degrades + +- **WHEN** the instance runs a daemon whose reply carries no history and the user runs `spinloop metrics --node --format=bar` +- **THEN** the report omits the history field and bar format draws the current reading in the gauge's filled style diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-version-reporting/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-version-reporting/spec.md new file mode 100644 index 00000000..2cf934ca --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/remote-version-reporting/spec.md @@ -0,0 +1,36 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: An environment's status shows version + +`spinloop status --node ` SHALL display the spinloop version running on the cloud instance alongside its existing state, health, and base URL fields. + +#### Scenario: Version is shown when the instance is running + +- **WHEN** the user runs `spinloop status --node ` against a running instance +- **THEN** the output includes a `version` line with the spinloop version string (e.g. `version: 1.16.0`) + +#### Scenario: Version is unavailable when the instance is stopped + +- **WHEN** the user runs `spinloop status --node ` against a stopped instance +- **THEN** the output omits the version line, since the daemon is not reachable + +### Requirement: An environment's metrics show version + +`spinloop metrics --node ` SHALL display the spinloop version in its output, as the stats Lambda already reads the daemon and can carry the version alongside its existing fields. + +#### Scenario: Version is shown in table format + +- **WHEN** the user runs `spinloop metrics --node --format=table` against a running instance +- **THEN** the table output includes a `version` line + +#### Scenario: Version is shown in JSON format + +- **WHEN** the user runs `spinloop metrics --node --format=json` against a running instance +- **THEN** the JSON output includes a `version` field + +#### Scenario: Version is omitted from bar header when unavailable + +- **WHEN** the user runs `spinloop metrics --node --format=bar` and the version is not available +- **THEN** the bar header omits the version without error diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/spinloop-files/spec.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/spinloop-files/spec.md new file mode 100644 index 00000000..a39e8d09 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/specs/spinloop-files/spec.md @@ -0,0 +1,133 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Spinloop file format + +A Spinloop SHALL be a flat, line-oriented text file of `KEYWORD value` +instructions. The keywords are `PROVIDER`, `MODEL`, `ALIAS`, `CONTEXT`, +`OUTPUT`, `PARALLEL`, `BASEURL` (also accepted as `BASE-URL`, `BASE_URL`, or +`URL`), `PRESET`, `PARAM`, and `ENV`. Keywords SHALL match +case-insensitively, with UPPERCASE as the canonical form. Blank lines, full-line +`#` comments, and trailing comments introduced by whitespace-then-`#` SHALL be +ignored. Each instruction SHALL take exactly one value; every instruction SHALL +appear at most once, except `ENV` and `PARAM`, which MAY be repeated. An `ENV` +instruction's value SHALL be a single `KEY=VALUE` token with a non-empty key and +no whitespace. A `PARAM` instruction SHALL be a key — a single token with no +whitespace — optionally followed by ` = value`, split at the first `=`: the +value MAY be empty or contain whitespace, and a key with no value names a bare +flag. A PARAM names one engine flag in the +engine's own vocabulary — what it does to the served engine's command is +defined by the `local-serving` capability — and where the same key is set by +several PARAM lines the later line's value SHALL stand. `PARALLEL`'s value SHALL +name a count of concurrent request slots; like `CONTEXT`, its numeric validity +(a positive integer) is enforced by the commands that consume it rather than by +parsing itself, and what it does to the served engine's command is defined by +the `local-serving` capability. `PROVIDER` is required. Parse errors SHALL name +the offending line. + +#### Scenario: A minimal Spinloop + +- **WHEN** a file containing only `PROVIDER openrouter` and + `MODEL deepseek/deepseek-v4-pro` is parsed +- **THEN** it yields a selection of that provider and model + +#### Scenario: Duplicate instruction + +- **WHEN** a Spinloop sets `MODEL` on two lines +- **THEN** parsing fails, citing both line numbers + +#### Scenario: Unknown keyword + +- **WHEN** a Spinloop contains `HARNESS pi` +- **THEN** parsing fails listing the accepted keywords + +#### Scenario: Naming a fleet + +- **WHEN** a Spinloop contains `FLEET ./fleet.yaml` +- **THEN** parsing fails as an unknown keyword, listing the accepted keywords, + and no special migration message is printed + +#### Scenario: Missing provider + +- **WHEN** a Spinloop has no `PROVIDER` instruction +- **THEN** parsing fails saying the PROVIDER instruction is missing + +#### Scenario: Naming a cloud endpoint + +- **WHEN** a Spinloop contains `REMOTE ./remote.json` on any line +- **THEN** parsing fails on that line, since the `REMOTE` instruction was + removed: the environment is now named with `cloud deploy --node ` at + deploy time and `--node ` at the commands that act on it + +#### Scenario: Declaring local environment variables + +- **WHEN** a Spinloop contains `ENV AWS_PROFILE=dev` and `ENV AWS_REGION=eu-west-2` + on separate lines +- **THEN** it parses, yielding both key/value pairs in the selection, and the + repetition is not treated as a duplicate-instruction error + +#### Scenario: Malformed ENV value + +- **WHEN** a Spinloop contains an `ENV` instruction whose value has no `=` or an + empty key +- **THEN** parsing fails, naming the offending line + +#### Scenario: Setting the number of parallel slots + +- **WHEN** a Spinloop contains `PARALLEL 2` +- **THEN** it parses, yielding a parallel count of 2 in the selection + +#### Scenario: A non-numeric or non-positive PARALLEL is caught on use + +- **WHEN** a Spinloop contains `PARALLEL 0`, `PARALLEL -1`, or `PARALLEL abc` +- **THEN** parsing accepts the raw value, exactly as it does for `CONTEXT`, and + the command that goes on to use it (`serve`, `cloud deploy`, a fleet wake) + fails naming the value, rather than silently treating it as a slot count + +#### Scenario: Declaring engine parameters + +- **WHEN** a Spinloop contains `PARAM jinja = 1` and + `PARAM hf = unsloth/Qwen3.8-27B-GGUF:UD-Q5_K_XL # the weights` on separate + lines +- **THEN** it parses, yielding both entries in the selection, and the repetition + is not treated as a duplicate-instruction error + +#### Scenario: A PARAM may repeat a key + +- **WHEN** a Spinloop sets `PARAM ngl = 99` on one line and `PARAM ngl = 97` on + a later line +- **THEN** it parses, and the later line's value is the one that stands + +#### Scenario: A bare PARAM names a flag without a value + +- **WHEN** a Spinloop contains `PARAM jinja` on a line by itself +- **THEN** it parses as the bare form of `PARAM jinja =`, yielding an entry + whose empty value names a bare flag + +#### Scenario: Malformed PARAM value + +- **WHEN** a Spinloop contains a `PARAM` instruction with an empty key, or a + key containing whitespace, with or without a value +- **THEN** parsing fails, naming the offending line + +### Requirement: REMOTE is a removed keyword + +A `REMOTE` instruction SHALL be rejected at parse time with an error that names +the offending line, says the `REMOTE` instruction was removed, and states the +replacement: name the environment with `cloud deploy --node ` at deploy +time, and pass `--node ` to the commands that act on the environment +(`cloud` subcommands, `apply`, `unapply`, `harness`). The error SHALL NOT be +the generic unknown-keyword message. + +#### Scenario: A REMOTE line is rejected with the migration message + +- **WHEN** a Spinloop contains `REMOTE ./remote.json` on line 3 +- **THEN** parsing fails citing line 3, saying `REMOTE` was removed, and naming + `--node` as the way to name the environment + +#### Scenario: The generic error is not used for REMOTE + +- **WHEN** a Spinloop contains a `REMOTE` line +- **THEN** the error names the removal and its replacement rather than merely + listing the accepted keywords diff --git a/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/tasks.md b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/tasks.md new file mode 100644 index 00000000..b71ed1b2 --- /dev/null +++ b/openspec/changes/archive/2026-11-02-rename-env-flag-to-node/tasks.md @@ -0,0 +1,72 @@ +# Tasks + +## 1. Rename the flag registrations + +- [x] 1.1 In `cmd/spinloop`, register `--node`/`-n` in place of `--env`/`-e` on + `apply`, `unapply` (`main.go`), the launch builder (`code.go`), `status.go`, + `logs.go`, `dashboard.go`, the fleet group (`fleet.go`), and the cloud group + (`cloud.go`, `cloud_schedule.go`), renaming the bound variables and the + `envFlagUsage`/`envFlagTargetUsage` help constants to node wording. Verify: + `go build ./...` passes and no `StringVar*` registers a flag named `"env"` + (`rg '"env"' cmd/spinloop --glob '!*_test.go'` is empty). +- [x] 1.2 Merge `routeOptions.envName` into `routeOptions.node` in + `cmd/spinloop/route.go` and adapt `code.go`'s launch branches per + design.md (environment only when no fleet is in force; `--node` with a + discovered fleet routes). Verify: `go build ./...` passes. +- [x] 1.3 Drop `checkOneTarget` from `applyRoutedSpinloop` and key the cloud + fetch (`fetchCloudEnv`) and `applySelection`'s environment slot on + "`--node` set and routing produced no choice". Verify: `go build ./...` + passes. +- [x] 1.4 Re-word the remaining user-facing strings that name the flag or its + one-target conflict: `target.go`'s conflict sentence, registry errors in + `main.go`/`cloud.go` ("run `spinloop cloud deploy --node …`"), and every + `--env` occurrence in help text. Verify: `rg -- '--env|\-e\b' cmd/spinloop + --glob '!*_test.go'` shows only `--print-env`, `--token-env`, and `.env`. + +## 2. Completion + +- [x] 2.1 Register `compEnvs` under `node` (and short `n`) everywhere it was + registered under `env`; add `-n` to `launchValueFlags` and make + `launchSlot` offer registered environment names (plus the in-force fleet's + node names where readable) for `--node`/`-n`, never erroring. Verify: + `go test ./cmd/spinloop/ -run Comp` passes and `spinloop __complete code + --node ''` lists environments. + +## 3. Tests + +- [x] 3.1 Rename `--env`/`-e` args and message assertions across + `cmd/spinloop/*_test.go`; add cases for: `--env` now rejected as unknown, + bare `--node` with no fleet configuring from the environment, `--node` + + discovered `fleet.yaml` routing (gateway served, and failing for the + Spinloop when the fleet has none), `--node` naming a non-fleet name with a + fleet in force failing without registry fallback. Verify: `go test ./...` + passes. +- [x] 3.2 Run `go vet ./...`, `gofmt -l .` (empty), and + `go test ./... -cover` with total ≥ 80%. Verify: all three clean. + +## 4. Docs and examples + +- [x] 4.1 Update `docs/` pages and `examples/` READMEs/Spinloops that show + `--env` or `-e` for targeting to `--node`/`-n`, leaving `--print-env`, + `--token-env`, `ENV`, and `.env` alone. Verify: `rg -- '--env|-e\b' docs + examples` shows only environment-variable spellings. + +## 5. Integration + +- [x] 5.1 Exercise the renamed surface end-to-end: build the binary, run a + fleet-docker-example-style flow by hand or via the example's script + (`examples/fleet-docker/`), confirming launch, status and pin all work with + `--node`/`-n` and that `--env` errors everywhere. Verify: the flow + completes and prints the announced node. (Done against stub daemons — + Docker unavailable locally; CI's fleet-integration job covers the Docker + flow.) + +## 6. Review revision (draft PR) + +- [x] 6.1 Let `--fleet` and `--node` pair instead of conflict: `--fleet` + names the fleet, `--node` narrows to the node within it, named or + discovered. A name a *named* file lacks fails naming the file and its + nodes — registry fallback stays only beside a discovered `fleet.yaml`. + Delete `checkOneTarget`; feed `--node` completion from the fleet in force + (`compNodes`). Verify: `go test ./cmd/spinloop`, spec strict-valid, and + the stub-daemon driver's `--fleet … -n …` pair and ghost-name checks. diff --git a/openspec/specs/cloud-command/spec.md b/openspec/specs/cloud-command/spec.md index 9a802573..58d5f0cd 100644 --- a/openspec/specs/cloud-command/spec.md +++ b/openspec/specs/cloud-command/spec.md @@ -80,7 +80,7 @@ or `remote-.json`. #### Scenario: Old environment is not found - **WHEN** `remotes/staging/remote.json` exists and `clouds/staging/` does not -- **THEN** `spinloop status --env staging` reports that environment `staging` is not registered +- **THEN** `spinloop status --node staging` reports that environment `staging` is not registered ### Requirement: Unrelated uses of remote are unchanged diff --git a/openspec/specs/code-command/spec.md b/openspec/specs/code-command/spec.md index 372ad914..428e0edf 100644 --- a/openspec/specs/code-command/spec.md +++ b/openspec/specs/code-command/spec.md @@ -10,19 +10,48 @@ dispatching to `harness open` rather than reimplementing it. ## Requirements -### Requirement: A shortcut for harness open +### Requirement: Completion offers the Spinloop slot + +`code`'s tab completion SHALL offer the Spinloop slot — registered alias names +plus paths — exactly as `spinloop harness open`'s first positional slot does. +An unreadable config, an unloadable catalogue, or nonsense input SHALL leave it +silent: no candidates, no error, and nothing written to stderr. +#### Scenario: The Spinloop slot completes + +- **WHEN** the user completes `spinloop code ` +- **THEN** registered alias names and paths are offered, as on + `spinloop harness open ` + +#### Scenario: A broken config stays quiet + +- **WHEN** spinloop's config file is unreadable and completion is attempted on + `spinloop code` +- **THEN** no candidates are offered, the command exits zero, and nothing is + written to stderr + +### Requirement: A shortcut for harness open with node targeting `spinloop code` SHALL launch the active harness exactly as `spinloop harness open` does: it SHALL accept the same flags — `-O`/`--spinloop`, -`--env`, `--fleet`, `--node`, `--prefer`, `--no-wake`, `--wake-timeout`, +`-n`/`--node`, `--fleet`, `--prefer`, `--no-wake`, `--wake-timeout`, `-H`/`--harness`, and `--providers` — apply a Spinloop the same way, and forward every argument it does not consume to the harness untouched. A leading argument -naming a registered alias or a path, the `-O`/`--spinloop` flag, and the `--env` -environment all drive the same Spinloop application as on `harness open`. +naming a registered alias or a path, the `-O`/`--spinloop` flag, and the +`--node` target all drive the same Spinloop application as on `harness open`. `spinloop code ` SHALL therefore produce the same harness config, launch the same process, and print the same output and errors as `spinloop harness open `. `code` SHALL NOT change what `harness open` -does; it is a new spelling of it, not a replacement. +does; it is a new spelling of it, not a replacement. A word on the command +line that is neither a consumed Spinloop name nor a known flag forwards to the +harness as ever — except the removed `--env`/`-e`, which the command SHALL +reject naming `--node` as its replacement, since forwarding the launch's own +former flag would hand the harness a word it never owned. + +#### Scenario: the removed --env spelling names its replacement + +- **WHEN** the user runs `spinloop code --env dev-2` +- **THEN** the command fails naming `--env` and `--node` as its replacement, + and the harness is not launched with `--env` among its arguments #### Scenario: A bare code launches unconfigured @@ -44,34 +73,14 @@ does; it is a new spelling of it, not a replacement. - **THEN** `./Spinloop` is applied and the harness launches, as `spinloop harness open -O` would do -#### Scenario: --env configures from a deployed environment +#### Scenario: --node configures from a deployed environment -- **WHEN** the user runs `spinloop code --env dev-2` with no Spinloop +- **WHEN** the user runs `spinloop code --node dev-2` with no Spinloop - **THEN** the harness is configured from what is deployed to `dev-2` and - launched, as `spinloop harness open --env dev-2` would do + launched, as `spinloop harness open --node dev-2` would do #### Scenario: Trailing arguments are forwarded untouched - **WHEN** the user runs `spinloop code -- --agent-flag` - **THEN** the harness is launched and `--agent-flag` is forwarded to it, as `spinloop harness open -- --agent-flag` would do - -### Requirement: Completion offers the Spinloop slot - -`code`'s tab completion SHALL offer the Spinloop slot — registered alias names -plus paths — exactly as `spinloop harness open`'s first positional slot does. -An unreadable config, an unloadable catalogue, or nonsense input SHALL leave it -silent: no candidates, no error, and nothing written to stderr. - -#### Scenario: The Spinloop slot completes - -- **WHEN** the user completes `spinloop code ` -- **THEN** registered alias names and paths are offered, as on - `spinloop harness open ` - -#### Scenario: A broken config stays quiet - -- **WHEN** spinloop's config file is unreadable and completion is attempted on - `spinloop code` -- **THEN** no candidates are offered, the command exits zero, and nothing is - written to stderr diff --git a/openspec/specs/environment-deployment/spec.md b/openspec/specs/environment-deployment/spec.md index de248772..5b723700 100644 --- a/openspec/specs/environment-deployment/spec.md +++ b/openspec/specs/environment-deployment/spec.md @@ -19,7 +19,7 @@ the environment so the other `cloud` commands can drive it. Deploying SHALL NOT start the instance. The environment name SHALL come from the command, never from the Spinloop: -`spinloop cloud deploy` SHALL take it from its required `--env ` flag, +`spinloop cloud deploy` SHALL take it from its required `--node ` flag, and `spinloop fleet deploy` SHALL take it from the name of the node being deployed, which is the registered environment that node drives. A deploy given no name by either route SHALL fail saying the environment must be named. The @@ -33,7 +33,7 @@ plane to seed, read or write. #### Scenario: Deploying stands up and registers an environment -- **WHEN** `spinloop cloud deploy --env prod` runs against a bootstrapped +- **WHEN** `spinloop cloud deploy --node prod` runs against a bootstrapped account - **THEN** the environment's Elastic IP, instance configuration, API key, ingress rule, and SSM state are provisioned, and the environment is @@ -41,13 +41,13 @@ plane to seed, read or write. #### Scenario: A deploy without a name fails -- **WHEN** `spinloop cloud deploy` runs with no `--env` flag -- **THEN** it fails saying the environment must be named with `--env ` +- **WHEN** `spinloop cloud deploy` runs with no `--node` flag +- **THEN** it fails saying the environment must be named with `--node ` #### Scenario: One Spinloop deploys to two environments -- **WHEN** the same Spinloop is deployed with `--env dev` and later with - `--env prod` +- **WHEN** the same Spinloop is deployed with `--node dev` and later with + `--node prod` - **THEN** two distinct environments are created and registered, each serving what the Spinloop describes @@ -69,6 +69,7 @@ plane to seed, read or write. - **WHEN** a deploy succeeds - **THEN** the environment is configured and registered but no instance is running until `spinloop cloud start` + ### Requirement: Discovering the control plane Deploy SHALL discover the control plane from the bootstrap stack's @@ -116,9 +117,9 @@ shared Lambdas use to select this environment's instance. #### Scenario: The environment is registered and resolvable -- **WHEN** `spinloop cloud deploy --env prod` succeeds +- **WHEN** `spinloop cloud deploy --node prod` succeeds - **THEN** `~/.config/spinloop/clouds/prod/cloud.json` exists (owner-only) - and `spinloop cloud status --env prod` resolves the environment from it + and `spinloop cloud status --node prod` resolves the environment from it ### Requirement: Refuse to overwrite a live environment diff --git a/openspec/specs/fleet-client/spec.md b/openspec/specs/fleet-client/spec.md index af8f2eac..5d5f89b1 100644 --- a/openspec/specs/fleet-client/spec.md +++ b/openspec/specs/fleet-client/spec.md @@ -25,13 +25,13 @@ A node SHALL also report how long it has been since its engine last did work, ta #### Scenario: A named environment is read by the same command -- **WHEN** `spinloop status --env prod` runs +- **WHEN** `spinloop status --node prod` runs - **THEN** that environment's row is rendered with no fleet file required, and it reads the same as the row for the same environment named in a fleet file #### Scenario: An unhealthy endpoint reads as not ready -- **WHEN** `spinloop status --env prod` runs and the control plane reports the +- **WHEN** `spinloop status --node prod` runs and the control plane reports the endpoint unhealthy - **THEN** the row carries the same not-ready mark a daemon node carries when its engine has not answered its health check @@ -135,7 +135,7 @@ command. #### Scenario: A named environment is read by the same command -- **WHEN** `spinloop metrics --env prod` runs +- **WHEN** `spinloop metrics --node prod` runs - **THEN** that environment's metrics are rendered with no fleet file required, in the same format a fleet file naming it would produce @@ -502,7 +502,7 @@ meanings on one command line. #### Scenario: A named environment is read by the same command -- **WHEN** `spinloop logs --env prod` runs +- **WHEN** `spinloop logs --node prod` runs - **THEN** that environment's engine output is read with no fleet file required @@ -667,7 +667,7 @@ with any node SHALL NOT. #### Scenario: A named environment opens as one panel -- **WHEN** the operator runs `spinloop dashboard --env prod` in a directory +- **WHEN** the operator runs `spinloop dashboard --node prod` in a directory holding no fleet file - **THEN** the view opens on that environment alone, and the absent file is not an error diff --git a/openspec/specs/fleet-config/spec.md b/openspec/specs/fleet-config/spec.md index 81582055..134be726 100644 --- a/openspec/specs/fleet-config/spec.md +++ b/openspec/specs/fleet-config/spec.md @@ -47,120 +47,6 @@ loopback-only daemon). - **WHEN** a `fleet.yaml` is parsed - **THEN** it carries only a token reference, never a literal token value -### Requirement: Fleet file resolution - -Every command that acts on a fleet — the `spinloop fleet` group, and the -top-level verbs that read one — SHALL resolve the fleet the same way, from the -flags given: `--env ` names a single registered cloud environment, -`--fleet ` names a fleet file, and with neither the fleet file is -`./fleet.yaml` in the working directory. One rule serves every such command, so -a target means the same thing wherever it is given. - -Where no target resolves — no `--env`, no `--fleet`, and no fleet file at the -expected path — the command SHALL fail naming every way to give one: the -expected path, `--fleet `, and `--env `. It SHALL NOT look for an -engine on the machine it is running on. A local engine is already shown by the -command that runs it, and a machine whose engines are to be read this way names -them in a fleet file like any other. - -`--env` and `--fleet` SHALL NOT both be given: each names where the model is -served from, so a command stating both SHALL fail naming both rather than -resolving one by precedence. The same rule governs a launch, so an operator -meets it once. - -`--env` SHALL name a registered environment, which is a plain identifier and -never a path. A value that is not a plain identifier, and a name with no -registered configuration, SHALL each fail saying so and how to create the -environment, before any node is contacted. - -`--fleet` SHALL carry a `-f` short form on every `spinloop fleet` subcommand -except `fleet logs`, where `-f` is the short form of `--follow`; on `fleet -logs` the fleet file SHALL be named by the long form `--fleet` only. `--env` -SHALL carry no short form on the fleet commands, so that one spelling means -one thing across the group. - -#### Scenario: Default resolution - -- **WHEN** a `spinloop fleet` command runs in a directory containing - `fleet.yaml` with no `--fleet` or `--env` flag -- **THEN** that file is used - -#### Scenario: Explicit path - -- **WHEN** `spinloop fleet status --fleet ./cluster.yaml` runs -- **THEN** that file is used - -#### Scenario: Short form - -- **WHEN** `spinloop fleet status -f ./cluster.yaml` runs -- **THEN** `./cluster.yaml` is used, exactly as with `--fleet` - -#### Scenario: logs keeps -f for follow - -- **WHEN** the operator runs `spinloop fleet logs -f` -- **THEN** that is the command's follow flag, not a fleet-file flag, and - `logs` takes its fleet file only as `--fleet` - -#### Scenario: Missing file - -- **WHEN** a `spinloop fleet` command runs with no fleet file at the resolved - path -- **THEN** it fails, naming the expected path - -#### Scenario: A named environment is the target - -- **WHEN** `spinloop fleet status --env prod` runs -- **THEN** the command acts on the registered environment `prod` alone, - whether or not a `fleet.yaml` is present - -#### Scenario: A named environment needs no fleet file - -- **WHEN** `spinloop fleet dashboard --env prod` runs in a directory holding - no `fleet.yaml` -- **THEN** the view opens on that one environment, and the absent file is not - an error - -#### Scenario: Naming both a fleet and an environment fails - -- **WHEN** `spinloop fleet status --env prod --fleet ./cluster.yaml` runs -- **THEN** it fails naming both the environment and the fleet file, and - contacts nothing - -#### Scenario: The directory's fleet file does not conflict with --env - -- **WHEN** `spinloop fleet status --env prod` runs in a directory containing - `fleet.yaml` -- **THEN** the environment is the target and the directory's file is ignored, - because only a flag states a conflict - -#### Scenario: An unregistered environment is reported - -- **WHEN** `spinloop fleet status --env nope` runs and no configuration is - registered under that name -- **THEN** it fails saying the environment is not registered and how to - create it, and contacts nothing - -#### Scenario: A path is not an environment name - -- **WHEN** `spinloop fleet status --env ./cloud.json` runs -- **THEN** it fails saying an environment name is a plain identifier with no - path - -#### Scenario: No target at all names every way to give one - -- **WHEN** a command that acts on a fleet runs with no `--env`, no `--fleet`, - and no fleet file at the expected path -- **THEN** it fails naming the expected path, `--fleet ` and - `--env `, and contacts nothing - -#### Scenario: No engine on this machine is looked for - -- **WHEN** a read verb runs with no target resolvable on a machine that is - running an engine with its control API up -- **THEN** it fails as above rather than reporting that engine: a local engine - is read through the command that runs it, or by naming the machine in a fleet - file - ### Requirement: Per-node engine endpoint A node MAY declare where its engine serves, overriding what its daemon reports: @@ -583,7 +469,7 @@ section SHALL behave exactly as it does today. ### Requirement: A registered environment is a fleet of one -A `--env ` target SHALL be a fleet holding exactly one node: a cloud +A `--node ` target SHALL be a fleet holding exactly one node: a cloud node named by the flag, whose registered configuration is the one `clouds//cloud.json` holds. It SHALL be driven, observed and rendered exactly as the same node listed in a fleet file is, so a command's output for @@ -602,26 +488,191 @@ credentials are missing or expired SHALL fail as it does on any other path. #### Scenario: One environment renders as one node -- **WHEN** `spinloop fleet status --env prod` runs +- **WHEN** `spinloop fleet status --node prod` runs - **THEN** the output is the one-row status table a single-node fleet file produces for the same environment #### Scenario: An environment that cannot be reached is a row -- **WHEN** `spinloop fleet status --env prod` runs and the environment cannot +- **WHEN** `spinloop fleet status --node prod` runs and the environment cannot be reached - **THEN** the row reports why, and the command still succeeds, exactly as an unreachable node in a fleet file does #### Scenario: Fleet-wide settings are absent, not inherited -- **WHEN** a command acts on a `--env` target +- **WHEN** a command acts on a `--node` target - **THEN** no activity preference, wake policy, gateway or concurrency limit is in force, and a `fleet.yaml` in the working directory supplies none of them #### Scenario: No .env is consulted -- **WHEN** a command acts on a `--env` target +- **WHEN** a command acts on a `--node` target - **THEN** no adjacent `.env` is read, because the target names no file to sit beside + +### Requirement: Resolution of fleet and node targets +Every command that acts on a fleet — the `spinloop fleet` group, and the +top-level verbs that read one — SHALL resolve the fleet the same way, from the +flags given: `--node ` names a single registered cloud environment, +`--fleet ` names a fleet file, and with neither the fleet file is +`./fleet.yaml` in the working directory. One rule serves every such command, so +a target means the same thing wherever it is given. + +Where no target resolves — no `--node`, no `--fleet`, and no fleet file at the +expected path — the command SHALL fail naming every way to give one: the +expected path, `--fleet `, and `--node `. It SHALL NOT look for an +engine on the machine it is running on. A local engine is already shown by the +command that runs it, and a machine whose engines are to be read this way names +them in a fleet file like any other. + +`--fleet` names the fleet and `--node` names a node within it: paired, the +command SHALL narrow the named fleet to that node. A name the *named* file +does not hold SHALL fail naming the file and its known node names — the file +is the fleet the command has stated it acts on, and no other fleet answers +for it. On the launch path the pair means the same: `--node` there pins the +selection to that fleet's node (see the `fleet-routing` specification). + +Beside a `fleet.yaml` discovered in the working directory — not named by a +flag — a `--node` that is a node of that fleet SHALL narrow the command to +that node alone; a name the fleet does not hold SHALL resolve as a registered +environment when one answers it, and fail naming the fleet and its known nodes +when nothing does. On the drive commands (`fleet start`, `fleet stop`) that +narrowing alone SHALL select the node: `-n ` stands in for the positional +node name rather than the command demanding it again. + +`--node` SHALL name a registered environment, which is a plain identifier and +never a path. A value that is not a plain identifier, and a name with no +registered configuration, SHALL each fail saying so and how to create the +environment, before any node is contacted. + +`--fleet` SHALL carry a `-f` short form on every `spinloop fleet` subcommand +except `fleet logs`, where `-f` is the short form of `--follow`; on `fleet +logs` the fleet file SHALL be named by the long form `--fleet` only. `--node` +SHALL carry the `-n` short form everywhere it appears except `fleet deploy` +and `cloud deploy`, where `-n` is the short form of `--dry-run`; on those two +the node SHALL be named by the long form `--node` only. + +#### Scenario: Default resolution + +- **WHEN** a `spinloop fleet` command runs in a directory containing + `fleet.yaml` with no `--fleet` or `--node` flag +- **THEN** that file is used + +#### Scenario: --node narrows the discovered fleet + +- **WHEN** `spinloop status -n gpu-box` runs in a directory whose + `fleet.yaml` holds a node named `gpu-box`, passing no `--fleet` +- **THEN** the command reads only that node: its row alone, its log alone, + its engine alone + +#### Scenario: A discovered fleet without the node falls back to the registry + +- **WHEN** `spinloop status --node prod` runs in a directory whose + `fleet.yaml` holds no node named `prod` and a registered environment named + `prod` exists +- **THEN** the command acts on that environment's fleet of one + +#### Scenario: A name nothing answers names the fleet it was checked against + +- **WHEN** `spinloop status --node ghost` runs in a directory whose + `fleet.yaml` holds no node named `ghost` and no environment named `ghost` + is registered +- **THEN** the failure names `ghost`, the `fleet.yaml` it was not found in, + and the fleet's known node names + +#### Scenario: A drive command takes -n as the node name + +- **WHEN** `spinloop fleet start -n gpu-box` runs in a directory whose + `fleet.yaml` holds a node named `gpu-box`, naming no node positionally +- **THEN** that node alone is started, and the command does not ask for the + name to be repeated + +#### Scenario: Explicit path + +- **WHEN** `spinloop fleet status --fleet ./cluster.yaml` runs +- **THEN** that file is used + +#### Scenario: Short form + +- **WHEN** `spinloop fleet status -f ./cluster.yaml` runs +- **THEN** `./cluster.yaml` is used, exactly as with `--fleet` + +#### Scenario: logs keeps -f for follow + +- **WHEN** the operator runs `spinloop fleet logs -f` +- **THEN** that is the command's follow flag, not a fleet-file flag, and + `logs` takes its fleet file only as `--fleet` + +#### Scenario: Missing file + +- **WHEN** a `spinloop fleet` command runs with no fleet file at the resolved + path +- **THEN** it fails, naming the expected path + +#### Scenario: A named environment is the target + +- **WHEN** `spinloop fleet status --node prod` runs, passing no `--fleet` +- **THEN** the command acts on the registered environment `prod` alone — + whether or not a `fleet.yaml` is present, so long as no discovered fleet + holds a node of that name, which would answer first + +#### Scenario: A named environment needs no fleet file + +- **WHEN** `spinloop fleet dashboard --node prod` runs in a directory holding + no `fleet.yaml` +- **THEN** the view opens on that one environment, and the absent file is not + an error + +#### Scenario: A node of a named fleet narrows it + +- **WHEN** `spinloop fleet status --node gpu-box --fleet ./cluster.yaml` + runs and `cluster.yaml` holds a node named `gpu-box` +- **THEN** the command reads only that node — the row `gpu-box` alone, the + file named by the flag supplying the fleet + +#### Scenario: A name a named fleet lacks is not resolved elsewhere + +- **WHEN** `spinloop fleet status --node prod --fleet ./cluster.yaml` runs, + `cluster.yaml` holds no node named `prod`, and a registered environment + named `prod` exists +- **THEN** it fails naming `prod`, `cluster.yaml` and its known node names, + contacts nothing, and does not fall back to the registered environment — + only a discovered `fleet.yaml` answers to the registry + +#### Scenario: The directory's fleet file is read for a node, not ignored + +- **WHEN** `spinloop fleet status --node prod` runs in a directory containing + a `fleet.yaml` that holds a node named `prod` +- **THEN** the fleet is that file and the command narrows to the node + `prod` alone, because beside a discovered fleet the one name answers one + node of it, and only a flag states a conflict + +#### Scenario: An unregistered environment is reported + +- **WHEN** `spinloop fleet status --node nope` runs and no configuration is + registered under that name +- **THEN** it fails saying the environment is not registered and how to + create it, and contacts nothing + +#### Scenario: A path is not an environment name + +- **WHEN** `spinloop fleet status --node ./cloud.json` runs +- **THEN** it fails saying an environment name is a plain identifier with no + path + +#### Scenario: No target at all names every way to give one + +- **WHEN** a command that acts on a fleet runs with no `--node`, no `--fleet`, + and no fleet file at the expected path +- **THEN** it fails naming the expected path, `--fleet ` and + `--node `, and contacts nothing + +#### Scenario: No engine on this machine is looked for + +- **WHEN** a read verb runs with no target resolvable on a machine that is + running an engine with its control API up +- **THEN** it fails as above rather than reporting that engine: a local engine + is read through the command that runs it, or by naming the machine in a fleet + file diff --git a/openspec/specs/fleet-routing/spec.md b/openspec/specs/fleet-routing/spec.md index b374348c..a4167d78 100644 --- a/openspec/specs/fleet-routing/spec.md +++ b/openspec/specs/fleet-routing/spec.md @@ -34,10 +34,14 @@ A node that does not answer — unreachable, unauthorized, or a configuration error — SHALL be skipped rather than aborting the selection, exactly as it is a row rather than a failure in `spinloop fleet status`. -`--node ` SHALL pin the selection to one node, skipping the search. An -unknown name SHALL fail naming the known nodes, and a pinned node that cannot be -reached SHALL fail rather than falling back to another node — a pin is an -instruction, not a preference. A pinned node that is running the wanted model +`--node ` — or its `-n ` short form — SHALL pin the selection to +one node, skipping the search. It is the one name a launch gives for the node +its agent talks to: with a fleet in force the name is a node of that fleet; +with no fleet in force the same flag instead names a registered environment, +which is a fleet of one (see the cloud-environment capabilities), and this +selection never runs for it. An unknown name SHALL fail naming the known nodes, +and a pinned node that cannot be reached SHALL fail rather than falling back to +another node — a pin is an instruction, not a preference. A pinned node that is running the wanted model but has not answered yet SHALL fail saying it is still starting it — it may be fetching or loading weights — and naming the command for the node's log, rather than saying that nothing serves the model or restarting the node: this node is @@ -471,8 +475,92 @@ one. - **THEN** the output names the section's address as the one the launch will use, no node is queried, and nothing is started -### Requirement: A launch routed through a fleet +### Requirement: A launch through a fleet's gateway needs no Spinloop + +A launch routed through a fleet SHALL NOT require a Spinloop when the +effective fleet file declares a `gateway` section. A gateway resolves the model +per request rather than by matching a node against one, so there is no model +for a Spinloop to name; requiring one would ask for a file whose contents the +launch then ignores. + +Where the fleet file names no gateway, a launch with no Spinloop SHALL fail +before launching, saying that `--fleet` needs a Spinloop because it is the +Spinloop's model that decides which node can serve you: a node can only be +matched by the model a Spinloop names. Where the named fleet file cannot be +read at all, the failure SHALL name that file rather than the missing +Spinloop — the file is the thing the launch was pointed at. + +The same applies to a Spinloop that is given but names neither a model nor an +alias, when routed at a gateway: the launch SHALL NOT fail on that account +either, for the same reason. + +Whenever the harness is configured this way — routed at a gateway, with no +model or alias to apply — the launch SHALL query the gateway's `GET /v1/models` +and use the result as the harness's model list, so the harness has something to +choose from instead of an empty one. A failure to complete that query (the +gateway unreachable, timed out, or answering something unusable) SHALL NOT fail +the launch: it SHALL warn and configure the harness with an empty model list, +on the same terms a launch already warns and carries on when it cannot refresh +a cloud endpoint's key. This model-list population is a capability of +harnesses whose config format holds more than one model per provider; a harness +with no such concept is configured with no model or alias. + +The provider such a launch configures SHALL be named and keyed by the gateway, +not by the catalogue's shared generic id: its display name SHALL lead with +"Gateway" — not the catalogue engine's own generic label — followed by the +gateway's `name` where the fleet file's `gateway` section gives one, or its +address otherwise (e.g. "Gateway (dev-2)" or "Gateway (localhost:4000)"), the +same "