From 45e88dcbcf14a7d2ca73a1c14bb0430cb76d1193 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Tue, 9 Jun 2026 14:21:41 +0200 Subject: [PATCH 1/2] feat: added auto pause --- cmd/sandbox/create.go | 43 +++++++++++++++++ cmd/sandbox/edit.go | 87 +++++++++++++++++++++++++++++++---- internal/api/sandbox.go | 25 ++++++++++ internal/api/sandbox_types.go | 47 ++++++++++--------- 4 files changed, 171 insertions(+), 31 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 413c884..cab99fc 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/pterm/pterm" "github.com/urfave/cli/v2" @@ -75,6 +76,10 @@ Examples: Name: "disk", Usage: "S3 disk to mount at creation (repeatable): :/mount/path", }, + &cli.StringFlag{ + Name: "auto-pause", + Usage: "Pause the sandbox automatically after this long with no activity — e.g. 10m, 1h, 30m. Leave empty to keep it running until you stop it.", + }, }, Action: runCreate, } @@ -172,6 +177,14 @@ func runCreate(c *cli.Context) error { req.Disks = disks } + if raw := strings.TrimSpace(c.String("auto-pause")); raw != "" { + secs, parseErr := parseDurationToSeconds(raw) + if parseErr != nil { + return fmt.Errorf("--auto-pause %q: %w", raw, parseErr) + } + req.AutoPauseAfterSeconds = &secs + } + spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") //nolint:errcheck resp, err := client.CreateSandbox(c.Context, req) if err != nil { @@ -280,4 +293,34 @@ func printCreateResult(resp *api.SandboxCreateResp) { fmt.Printf(" %s\n", resp.IngressURLTemplate) pterm.Println(pterm.Gray(" Replace with the port your service is listening on.")) } + + if resp.AutoPauseAfterSeconds != nil { + d := time.Duration(*resp.AutoPauseAfterSeconds) * time.Second + pterm.Println(pterm.Gray(fmt.Sprintf(" Will pause automatically after %s with no activity.", formatDuration(d)))) + } +} + +// parseDurationToSeconds parses human durations like "10m", "1h", "30m" into +// seconds. Returns an error for values outside 60–86400. +func parseDurationToSeconds(s string) (int, error) { + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("use a duration like 10m, 1h, or 30m") + } + secs := int(d.Seconds()) + if secs < 60 || secs > 86400 { + return 0, fmt.Errorf("must be between 1 minute (1m) and 24 hours (24h)") + } + return secs, nil +} + +// formatDuration renders a duration in the most readable unit (e.g. "10m", "1h"). +func formatDuration(d time.Duration) string { + if d >= time.Hour && d%time.Hour == 0 { + return fmt.Sprintf("%dh", int(d.Hours())) + } + if d >= time.Minute && d%time.Minute == 0 { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + return d.String() } diff --git a/cmd/sandbox/edit.go b/cmd/sandbox/edit.go index 330365a..4090d09 100644 --- a/cmd/sandbox/edit.go +++ b/cmd/sandbox/edit.go @@ -3,6 +3,7 @@ package sandbox import ( "fmt" "strings" + "time" "github.com/pterm/pterm" "github.com/urfave/cli/v2" @@ -37,6 +38,10 @@ func newEditCommand() *cli.Command { Name: "add-ssh-key", Usage: "Path to a public-key file to add (repeatable)", }, + &cli.StringFlag{ + Name: "auto-pause", + Usage: "Pause automatically after this long with no activity (e.g. 10m, 1h). Use `off` to disable.", + }, }, Action: runEdit, } @@ -51,8 +56,8 @@ func runEdit(c *cli.Context) error { // urfave/cli v2 stops flag parsing at the first positional, so // `edit my-sb --ingress on` loses `--ingress`. Re-scan args by hand // so users can put flags anywhere. - ref, ingressFlag, sshFiles := parseEditArgs(c) - hasFlagChanges := ingressFlag != "" || len(sshFiles) > 0 + ref, ingressFlag, autoPauseFlag, sshFiles := parseEditArgs(c) + hasFlagChanges := ingressFlag != "" || autoPauseFlag != "" || len(sshFiles) > 0 // Resolve the sandbox first — either from positional or via picker. id, label, err := resolveTarget(c, client, ref) @@ -73,6 +78,11 @@ func runEdit(c *cli.Context) error { return err } } + if autoPauseFlag != "" { + if err := applyAutoPauseFlag(c, client, label, id, autoPauseFlag); err != nil { + return err + } + } if len(sshFiles) > 0 { if err := applyAddSSHKeys(c, client, label, id, sshFiles); err != nil { return err @@ -83,7 +93,7 @@ func runEdit(c *cli.Context) error { // No flags — interactive only. if !terminal.IsInteractive() { - return fmt.Errorf("nothing to do — pass --ingress or --add-ssh-key, or run again on a terminal for an interactive menu") + return fmt.Errorf("nothing to do — pass --ingress, --auto-pause, or --add-ssh-key, or run again on a terminal for an interactive menu") } return runEditMenu(c, client, label, id) } @@ -93,10 +103,9 @@ func runEdit(c *cli.Context) error { // as the sandbox ref, and recognises `--ingress `, // `--ingress=`, `--add-ssh-key `, `--add-ssh-key=` // in any position. -func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) { - // Start with whatever urfave/cli already parsed (covers - // flags-before-positional). Use as defaults. +func parseEditArgs(c *cli.Context) (ref, ingressVal, autoPauseVal string, sshPaths []string) { ingressVal = strings.ToLower(strings.TrimSpace(c.String("ingress"))) + autoPauseVal = strings.TrimSpace(c.String("auto-pause")) sshPaths = append([]string{}, c.StringSlice("add-ssh-key")...) args := c.Args().Slice() @@ -110,6 +119,13 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) { } case strings.HasPrefix(a, "--ingress="): ingressVal = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--ingress="))) + case a == "--auto-pause": + if i+1 < len(args) { + autoPauseVal = strings.TrimSpace(args[i+1]) + i++ + } + case strings.HasPrefix(a, "--auto-pause="): + autoPauseVal = strings.TrimSpace(strings.TrimPrefix(a, "--auto-pause=")) case a == "--add-ssh-key": if i+1 < len(args) { sshPaths = append(sshPaths, strings.TrimSpace(args[i+1])) @@ -123,7 +139,7 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) { } } } - return ref, ingressVal, sshPaths + return ref, ingressVal, autoPauseVal, sshPaths } // resolveTarget figures out which sandbox the user wants to edit. With @@ -156,7 +172,11 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er fmt.Println() pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" Editing %s", refLabel(label, id)) - header := fmt.Sprintf(" Public URL: %s SSH keys: %d", onOff(sb.IngressEnabled), len(sb.SSHPubkeys)) + autoPauseLabel := "off" + if sb.AutoPauseAfterSeconds != nil { + autoPauseLabel = "pauses after " + formatDuration(time.Duration(*sb.AutoPauseAfterSeconds)*time.Second) + " idle" + } + header := fmt.Sprintf(" Public URL: %s SSH keys: %d Auto-pause: %s", onOff(sb.IngressEnabled), len(sb.SSHPubkeys), autoPauseLabel) if bw != nil { bwLine := fmt.Sprintf("%s used of %s", humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes)) if bw.Capped { @@ -171,11 +191,12 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er optIngress = "Toggle public URL" optSSH = "Add an SSH key" optBandwidth = "Top up bandwidth" + optAutoPause = "Auto-pause when idle" optDone = "Done" ) for { choice, err := pterm.DefaultInteractiveSelect. - WithOptions([]string{optIngress, optSSH, optBandwidth, optDone}). + WithOptions([]string{optIngress, optSSH, optBandwidth, optAutoPause, optDone}). WithDefaultText("What would you like to change?"). Show() if err != nil { @@ -258,6 +279,28 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er humanBytes(bytes), humanBytes(updated.UsedBytes), humanBytes(updated.QuotaBytes), humanBytes(updated.RemainingBytes)) + case optAutoPause: + current := "off" + if sb.AutoPauseAfterSeconds != nil { + current = formatDuration(time.Duration(*sb.AutoPauseAfterSeconds) * time.Second) + } + input, err := pterm.DefaultInteractiveTextInput. + WithDefaultText(fmt.Sprintf("Pause after how long with no activity? (current: %s — e.g. 10m, 1h, or 'off')", current)). + Show() + if err != nil { + return fmt.Errorf("could not read your input: %w", err) + } + input = strings.TrimSpace(input) + if input == "" { + continue + } + if err := applyAutoPauseFlag(c, client, label, id, input); err != nil { + pterm.Error.Printfln("%v", err) + continue + } + if refreshed, err := client.GetSandbox(c.Context, id); err == nil { + sb = refreshed + } case optDone: return nil } @@ -308,6 +351,32 @@ func applyAddSSHKeys(c *cli.Context, client *api.SandboxClient, label, id string return nil } +// applyAutoPauseFlag handles --auto-pause : "off" disables, a duration enables. +func applyAutoPauseFlag(c *cli.Context, client *api.SandboxClient, label, id, value string) error { + var seconds *int + switch strings.ToLower(value) { + case "off", "disable", "false", "no": + // leave seconds nil → disable + default: + secs, err := parseDurationToSeconds(value) + if err != nil { + return fmt.Errorf("--auto-pause %q: %w", value, err) + } + seconds = &secs + } + updated, err := client.SetAutoPause(c.Context, id, seconds) + if err != nil { + return err + } + if updated.AutoPauseAfterSeconds != nil { + d := time.Duration(*updated.AutoPauseAfterSeconds) * time.Second + pterm.Success.Printfln("Auto-pause set to %s for %s", formatDuration(d), refLabel(label, id)) + } else { + pterm.Success.Printfln("Auto-pause turned off for %s", refLabel(label, id)) + } + return nil +} + // onOff renders true/false as the verb the user typed mentally. func onOff(v bool) string { if v { diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 8ea63e3..b627efe 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -220,6 +220,31 @@ func (c *SandboxClient) SetSandboxIngress(ctx context.Context, id string, enable return &envelope.Data, nil } +// SetAutoPause sets or clears the idle-pause timeout on a sandbox. +// Pass nil to turn auto-pause off; pass a pointer to seconds (60–86400) to enable. +func (c *SandboxClient) SetAutoPause(ctx context.Context, id string, seconds *int) (*SandboxView, error) { + var body map[string]any + if seconds == nil { + body = map[string]any{"disable_auto_pause": true} + } else { + body = map[string]any{"auto_pause_after_seconds": *seconds} + } + var envelope Response[SandboxView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(body). + SetResult(&envelope). + Patch("/v1/sandboxes/{id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + // ── Disks ───────────────────────────────────────────────────────── // CreateDisk registers an S3 bucket as a named disk the caller can diff --git a/internal/api/sandbox_types.go b/internal/api/sandbox_types.go index cde491f..483563e 100644 --- a/internal/api/sandbox_types.go +++ b/internal/api/sandbox_types.go @@ -12,16 +12,17 @@ import "time" // SandboxCreateReq is the body of POST /v1/sandboxes. // `host_id` is deliberately absent — pinning was removed from the API. type SandboxCreateReq struct { - Name string `json:"name,omitempty"` - Shape string `json:"shape"` - Rootfs string `json:"rootfs,omitempty"` - DiskMib int64 `json:"disk_mib,omitempty"` - SSHPubkeys []string `json:"ssh_pubkeys,omitempty"` - Egress []string `json:"egress,omitempty"` - Envs map[string]string `json:"envs,omitempty"` - IngressEnabled bool `json:"ingress_enabled,omitempty"` - Networks []SandboxNetworkAttach `json:"networks,omitempty"` - Disks []SandboxDiskAttach `json:"disks,omitempty"` + Name string `json:"name,omitempty"` + Shape string `json:"shape"` + Rootfs string `json:"rootfs,omitempty"` + DiskMib int64 `json:"disk_mib,omitempty"` + SSHPubkeys []string `json:"ssh_pubkeys,omitempty"` + Egress []string `json:"egress,omitempty"` + Envs map[string]string `json:"envs,omitempty"` + IngressEnabled bool `json:"ingress_enabled,omitempty"` + Networks []SandboxNetworkAttach `json:"networks,omitempty"` + Disks []SandboxDiskAttach `json:"disks,omitempty"` + AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"` } // SandboxNetworkAttach binds a sandbox to a private network at create time. @@ -85,18 +86,19 @@ type SandboxForkReq struct { // SandboxCreateResp is the response body for POST /v1/sandboxes. // `mode` is deliberately absent — it's an internal boot-path detail. type SandboxCreateResp struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - IP string `json:"ip"` - Shape string `json:"shape"` - Rootfs *string `json:"rootfs,omitempty"` - VCPU int `json:"vcpu"` - MemMib int `json:"mem_mib"` - DiskMib int64 `json:"disk_mib"` - SpawnMs float64 `json:"spawn_ms,omitempty"` - Egress []string `json:"egress,omitempty"` - BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"` - IngressURLTemplate string `json:"ingress_url_template,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + IP string `json:"ip"` + Shape string `json:"shape"` + Rootfs *string `json:"rootfs,omitempty"` + VCPU int `json:"vcpu"` + MemMib int `json:"mem_mib"` + DiskMib int64 `json:"disk_mib"` + SpawnMs float64 `json:"spawn_ms,omitempty"` + Egress []string `json:"egress,omitempty"` + BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"` + IngressURLTemplate string `json:"ingress_url_template,omitempty"` + AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"` } // SandboxView is the projection returned by GET /v1/sandboxes and @@ -126,6 +128,7 @@ type SandboxView struct { PausedAt *time.Time `json:"paused_at,omitempty"` LastResumedAt *time.Time `json:"last_resumed_at,omitempty"` ForkedFrom *string `json:"forked_from,omitempty"` + AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"` } // ── List shape ──────────────────────────────────────────────────── From 174e99282fc4c8055dea7c686dbe6f0ecefb6ac2 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Tue, 9 Jun 2026 14:24:07 +0200 Subject: [PATCH 2/2] chore: fix types --- internal/api/sandbox.go | 27 +++++++-------------------- internal/api/sandbox_types.go | 8 ++++++++ 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index b627efe..e129b75 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -203,37 +203,24 @@ func (c *SandboxClient) AddSSHPubkeys(ctx context.Context, id string, keys []str // the updated SandboxView (with ingress_url_template when enabled and // the cluster knows its domain suffix). func (c *SandboxClient) SetSandboxIngress(ctx context.Context, id string, enabled bool) (*SandboxView, error) { - body := map[string]bool{"ingress_enabled": enabled} - var envelope Response[SandboxView] - resp, err := c.Client.R(). - SetContext(ctx). - SetPathParam("id", id). - SetBody(body). - SetResult(&envelope). - Patch("/v1/sandboxes/{id}") - if err != nil { - return nil, err - } - if resp.IsError() { - return nil, ParseAPIError(resp.StatusCode(), resp.Body()) - } - return &envelope.Data, nil + return c.patchSandbox(ctx, id, SandboxPatchReq{IngressEnabled: &enabled}) } // SetAutoPause sets or clears the idle-pause timeout on a sandbox. // Pass nil to turn auto-pause off; pass a pointer to seconds (60–86400) to enable. func (c *SandboxClient) SetAutoPause(ctx context.Context, id string, seconds *int) (*SandboxView, error) { - var body map[string]any if seconds == nil { - body = map[string]any{"disable_auto_pause": true} - } else { - body = map[string]any{"auto_pause_after_seconds": *seconds} + return c.patchSandbox(ctx, id, SandboxPatchReq{DisableAutoPause: true}) } + return c.patchSandbox(ctx, id, SandboxPatchReq{AutoPauseAfterSeconds: seconds}) +} + +func (c *SandboxClient) patchSandbox(ctx context.Context, id string, req SandboxPatchReq) (*SandboxView, error) { var envelope Response[SandboxView] resp, err := c.Client.R(). SetContext(ctx). SetPathParam("id", id). - SetBody(body). + SetBody(req). SetResult(&envelope). Patch("/v1/sandboxes/{id}") if err != nil { diff --git a/internal/api/sandbox_types.go b/internal/api/sandbox_types.go index 483563e..cfdac6c 100644 --- a/internal/api/sandbox_types.go +++ b/internal/api/sandbox_types.go @@ -25,6 +25,14 @@ type SandboxCreateReq struct { AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"` } +// SandboxPatchReq is the body of PATCH /v1/sandboxes/:id. +// Only non-nil / non-zero fields are sent; omitempty keeps unset ones out of the wire. +type SandboxPatchReq struct { + IngressEnabled *bool `json:"ingress_enabled,omitempty"` + AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"` + DisableAutoPause bool `json:"disable_auto_pause,omitempty"` +} + // SandboxNetworkAttach binds a sandbox to a private network at create time. type SandboxNetworkAttach struct { ID string `json:"id"`