From a1be9fc34ca92c83b1f3b849c8dd5cdb0d1c2118 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 27 Jan 2026 17:39:05 -0600 Subject: [PATCH 1/4] feat: add Sprites integration with exec API improvements Implement PR #103 feedback to use the Sprites exec API directly instead of tmux-based execution. Key improvements: 1. **Native exec sessions instead of tmux** - Sessions persist after client disconnect - cmd.Wait() blocks until completion (no polling) - No tmux dependency needed on sprite 2. **Direct stdout streaming** - Stream Claude's output to task logs in real-time - Parse output for status changes - No file-tailing hooks needed 3. **Filesystem API for setup** - Use sprite.Filesystem().WriteFile() and MkdirAll() - No shell escaping or heredocs required 4. **Port notifications** - Handle PortNotificationMessage for dev servers - Automatically log proxy URLs when Claude starts servers 5. **Crash recovery foundation** - Track active tasks for graceful shutdown - Idle watcher for automatic checkpointing New files: - cmd/task/sprite.go - CLI commands for sprite management - internal/sprites/sprites.go - Shared token/client logic - internal/executor/executor_sprite.go - SpriteRunner with exec API - docs/sprites-design.md - Architecture documentation - docs/sprites-discussion.md - Design discussion Usage: export SPRITES_TOKEN= # or: task sprite token task # Claude now runs on sprite Co-Authored-By: Claude Opus 4.5 --- cmd/task/main.go | 3 + cmd/task/sprite.go | 535 +++++++++++++++++++++++++++ docs/sprites-design.md | 191 ++++++++++ docs/sprites-discussion.md | 311 ++++++++++++++++ go.mod | 7 +- go.sum | 10 +- internal/db/sqlite.go | 3 + internal/db/tasks.go | 31 +- internal/executor/executor.go | 36 ++ internal/executor/executor_sprite.go | 283 ++++++++++++++ internal/sprites/sprites.go | 114 ++++++ 11 files changed, 1506 insertions(+), 18 deletions(-) create mode 100644 cmd/task/sprite.go create mode 100644 docs/sprites-design.md create mode 100644 docs/sprites-discussion.md create mode 100644 internal/executor/executor_sprite.go create mode 100644 internal/sprites/sprites.go diff --git a/cmd/task/main.go b/cmd/task/main.go index 3fc5472e..d0d546d0 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -1105,6 +1105,9 @@ Examples: // Cloud subcommand rootCmd.AddCommand(createCloudCommand()) + // Sprite subcommand (cloud execution via Fly.io Sprites) + rootCmd.AddCommand(createSpriteCommand()) + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go new file mode 100644 index 00000000..eb2ee10b --- /dev/null +++ b/cmd/task/sprite.go @@ -0,0 +1,535 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + sdk "github.com/superfly/sprites-go" +) + +// Styles for sprite command output +var ( + spriteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#61AFEF")) + spriteCheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) + spritePendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) + spriteErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) +) + +// Default network policy - domains that Claude is allowed to access +var defaultAllowedDomains = []string{ + "github.com", + "api.github.com", + "api.anthropic.com", + "rubygems.org", + "registry.npmjs.org", + "pypi.org", + "crates.io", + "pkg.go.dev", + "proxy.golang.org", + "sum.golang.org", +} + +// createSpriteCommand creates the sprite subcommand with all its children. +func createSpriteCommand() *cobra.Command { + spriteCmd := &cobra.Command{ + Use: "sprite", + Short: "Manage the cloud sprite for task execution", + Long: `Sprite management for running tasks in the cloud. + +When SPRITES_TOKEN is set, 'task' automatically runs on a cloud sprite. +Use these commands to manage the sprite manually. + +Commands: + status - Show sprite status + up - Start/restore the sprite + down - Checkpoint and stop the sprite + attach - Attach to the sprite's shell + sessions - List active exec sessions + destroy - Delete the sprite entirely + token - Set the Sprites API token`, + Run: func(cmd *cobra.Command, args []string) { + showSpriteStatus() + }, + } + + // sprite status + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show sprite status", + Run: func(cmd *cobra.Command, args []string) { + showSpriteStatus() + }, + } + spriteCmd.AddCommand(statusCmd) + + // sprite up + upCmd := &cobra.Command{ + Use: "up", + Short: "Start or restore the sprite", + Long: `Ensure the sprite is running. Creates it if it doesn't exist, restores from checkpoint if suspended.`, + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteUp(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(upCmd) + + // sprite down + downCmd := &cobra.Command{ + Use: "down", + Short: "Checkpoint and stop the sprite", + Long: `Save the sprite state and suspend it. Saves money when not in use.`, + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteDown(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(downCmd) + + // sprite attach + attachCmd := &cobra.Command{ + Use: "attach", + Short: "Attach to the sprite's shell", + Long: `Open an interactive shell session on the sprite.`, + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteAttach(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(attachCmd) + + // sprite sessions + sessionsCmd := &cobra.Command{ + Use: "sessions", + Short: "List active exec sessions on the sprite", + Run: func(cmd *cobra.Command, args []string) { + if err := listSpriteSessions(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(sessionsCmd) + + // sprite destroy + destroyCmd := &cobra.Command{ + Use: "destroy", + Short: "Delete the sprite entirely", + Long: `Permanently delete the sprite and all its data. Use with caution.`, + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteDestroy(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(destroyCmd) + + // sprite token + tokenCmd := &cobra.Command{ + Use: "token [token]", + Short: "Set or show the Sprites API token", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + showSpriteToken() + } else { + if err := setSpriteToken(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + } + }, + } + spriteCmd.AddCommand(tokenCmd) + + return spriteCmd +} + +// showSpriteStatus displays the current sprite status. +func showSpriteStatus() { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + token := sprites.GetToken(database) + spriteName := sprites.GetName(database) + + fmt.Println(spriteTitleStyle.Render("Sprite Status")) + fmt.Println() + + if token == "" { + fmt.Println(dimStyle.Render(" No Sprites token configured.")) + fmt.Println(dimStyle.Render(" Set SPRITES_TOKEN env var or run: task sprite token ")) + return + } + + fmt.Printf(" Token: %s\n", dimStyle.Render("configured")) + fmt.Printf(" Name: %s\n", spriteName) + + // Try to get sprite status from API + client, err := sprites.NewClient(database) + if err != nil { + fmt.Printf(" Status: %s\n", spriteErrorStyle.Render("error - "+err.Error())) + return + } + + ctx := context.Background() + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + fmt.Printf(" Status: %s\n", dimStyle.Render("not created")) + fmt.Println() + fmt.Println(dimStyle.Render(" Run 'task sprite up' to create it, or just run 'task'.")) + return + } + + statusStyle := spriteCheckStyle + statusIcon := "●" + if sprite.Status == "suspended" || sprite.Status == "stopped" { + statusStyle = spritePendingStyle + } + fmt.Printf(" Status: %s\n", statusStyle.Render(statusIcon+" "+sprite.Status)) + fmt.Printf(" URL: %s\n", dimStyle.Render(sprite.URL)) + + // Show active sessions + sessions, err := client.ListSessions(ctx, spriteName) + if err == nil && len(sessions) > 0 { + fmt.Println() + fmt.Printf(" Active sessions: %d\n", len(sessions)) + for _, s := range sessions { + if s.IsActive { + fmt.Printf(" %s - %s\n", dimStyle.Render(s.ID[:8]), s.Command) + } + } + } +} + +// runSpriteUp ensures the sprite is running. +func runSpriteUp() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + client, err := sprites.NewClient(database) + if err != nil { + return err + } + + spriteName := sprites.GetName(database) + ctx := context.Background() + + // Check if sprite exists + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + // Sprite doesn't exist, create it + fmt.Printf("Creating sprite: %s\n", spriteName) + sprite, err = client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return fmt.Errorf("create sprite: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) + + // Save sprite name to database + database.SetSetting(sprites.SettingName, spriteName) + + // Set up the sprite + if err := setupSprite(client, sprite); err != nil { + return fmt.Errorf("setup sprite: %w", err) + } + } else if sprite.Status == "suspended" || sprite.Status == "stopped" { + // Restore from checkpoint + fmt.Println("Restoring sprite from checkpoint...") + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil || len(checkpoints) == 0 { + return fmt.Errorf("no checkpoints available to restore") + } + + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return fmt.Errorf("restore checkpoint: %w", err) + } + + if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }); err != nil { + return fmt.Errorf("restore failed: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite restored")) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite is already running")) + } + + return nil +} + +// setupSprite installs dependencies and configures Claude on a new sprite. +// Uses the Filesystem API instead of shell heredocs for cleaner setup. +func setupSprite(client *sdk.Client, sprite *sdk.Sprite) error { + ctx := context.Background() + fs := sprite.Filesystem() + + fmt.Println("Setting up sprite...") + + // Note: Network policy can be configured via Fly.io dashboard or flyctl + // The SDK policy API may be added in future versions + fmt.Println(" Recommended allowed domains for security:") + for _, domain := range defaultAllowedDomains[:5] { // Show first 5 + fmt.Printf(" - %s\n", dimStyle.Render(domain)) + } + fmt.Println(dimStyle.Render(" Configure via: fly secrets set ALLOWED_DOMAINS=...")) + + // Install essential packages (no tmux needed - using native exec sessions) + steps := []struct { + desc string + cmd string + }{ + {"Installing packages", "apt-get update && apt-get install -y git curl"}, + {"Installing Node.js", "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs"}, + {"Installing Claude CLI", "npm install -g @anthropic-ai/claude-code"}, + } + + for _, step := range steps { + fmt.Printf(" %s...\n", step.desc) + cmd := sprite.CommandContext(ctx, "sh", "-c", step.cmd) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + fmt.Printf(" %s\n", dimStyle.Render(string(output))) + } + } + + // Create workspace directory using Filesystem API + fmt.Println(" Creating workspace...") + if err := fs.MkdirAll("/workspace", 0755); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } + + // Configure Claude settings using Filesystem API (no shell escaping needed) + fmt.Println(" Configuring Claude settings...") + claudeSettings := []byte(`{ + "permissions": { + "allow": ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)", "Grep(*)", "Glob(*)", "WebFetch(*)", "Task(*)", "TodoWrite(*)"], + "deny": [] + } +}`) + + // Create .claude directory and write settings + if err := fs.MkdirAll("/root/.claude", 0755); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } + + if err := fs.WriteFile("/root/.claude/settings.json", claudeSettings, 0644); err != nil { + return fmt.Errorf("write claude settings: %w", err) + } + + // Create initial checkpoint + fmt.Println(" Creating checkpoint...") + checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, "initial-setup") + if err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }) + } + + fmt.Println(spriteCheckStyle.Render("✓ Sprite setup complete")) + return nil +} + +// runSpriteDown checkpoints and suspends the sprite. +func runSpriteDown() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + client, err := sprites.NewClient(database) + if err != nil { + return err + } + + spriteName := sprites.GetName(database) + ctx := context.Background() + + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + return fmt.Errorf("sprite not found: %s", spriteName) + } + + fmt.Println("Creating checkpoint...") + checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, fmt.Sprintf("manual-%s", time.Now().Format("2006-01-02-150405"))) + if err != nil { + return fmt.Errorf("checkpoint failed: %w", err) + } + + if err := checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }); err != nil { + return fmt.Errorf("checkpoint failed: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Sprite checkpointed and suspended")) + fmt.Println(dimStyle.Render(" Storage costs only while suspended (~$0.01/day per GB)")) + return nil +} + +// runSpriteAttach opens an interactive shell on the sprite. +func runSpriteAttach() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + client, err := sprites.NewClient(database) + if err != nil { + return err + } + + spriteName := sprites.GetName(database) + sprite := client.Sprite(spriteName) + + fmt.Println("Attaching to sprite...") + fmt.Println(dimStyle.Render("Press Ctrl+D to detach")) + fmt.Println() + + cmd := sprite.Command("bash") + cmd.SetTTY(true) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// listSpriteSessions shows all active exec sessions on the sprite. +func listSpriteSessions() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + client, err := sprites.NewClient(database) + if err != nil { + return err + } + + spriteName := sprites.GetName(database) + ctx := context.Background() + + sessions, err := client.ListSessions(ctx, spriteName) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + + fmt.Println(spriteTitleStyle.Render("Active Sessions")) + fmt.Println() + + if len(sessions) == 0 { + fmt.Println(dimStyle.Render(" No active sessions")) + return nil + } + + for _, s := range sessions { + statusStyle := spriteCheckStyle + if !s.IsActive { + statusStyle = dimStyle + } + fmt.Printf(" %s %s\n", statusStyle.Render("●"), s.ID) + fmt.Printf(" Command: %s\n", s.Command) + fmt.Printf(" Active: %t\n", s.IsActive) + fmt.Println() + } + + return nil +} + +// runSpriteDestroy permanently deletes the sprite. +func runSpriteDestroy() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + client, err := sprites.NewClient(database) + if err != nil { + return err + } + + spriteName := sprites.GetName(database) + ctx := context.Background() + + fmt.Printf("Destroying sprite: %s\n", spriteName) + if err := client.DestroySprite(ctx, spriteName); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) + } + + // Clear sprite name from database + database.SetSetting(sprites.SettingName, "") + + return nil +} + +// showSpriteToken shows whether a token is configured. +func showSpriteToken() { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + token := sprites.GetToken(database) + if token == "" { + fmt.Println(dimStyle.Render("No Sprites token configured.")) + fmt.Println(dimStyle.Render("Set with: task sprite token ")) + fmt.Println(dimStyle.Render("Or: export SPRITES_TOKEN=")) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprites token is configured")) + fmt.Printf(" Token: %s...%s\n", token[:8], token[len(token)-4:]) + } +} + +// setSpriteToken saves the Sprites API token to the database. +func setSpriteToken(token string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + if err := database.SetSetting(sprites.SettingToken, token); err != nil { + return fmt.Errorf("save token: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Sprites token saved")) + return nil +} diff --git a/docs/sprites-design.md b/docs/sprites-design.md new file mode 100644 index 00000000..0ce63f11 --- /dev/null +++ b/docs/sprites-design.md @@ -0,0 +1,191 @@ +# Sprites Integration Design + +## Summary + +Use [Sprites](https://sprites.dev) (Fly.io's managed sandbox VMs) as isolated cloud execution environments for Claude. One sprite per project, persistent dev environment, dangerous mode enabled safely. + +## The Model + +**One sprite per project, not per task.** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Sprite: my-rails-app │ +│ │ +│ /workspace/ Persistent filesystem │ +│ ├── app/ (deps installed once) │ +│ ├── Gemfile.lock │ +│ └── .task-worktrees/ │ +│ ├── 42-fix-auth/ ← Task isolation │ +│ └── 43-add-feature/ │ +│ │ +│ tmux: task-daemon Same as local model │ +│ ├── task-42 (Claude, dangerous mode) │ +│ └── task-43 (Claude, dangerous mode) │ +│ │ +│ Network policy: github.com, api.anthropic.com, rubygems.org│ +└─────────────────────────────────────────────────────────────┘ +``` + +This mirrors the local architecture exactly. Same tmux session, same worktree isolation, same hooks. Just running remotely. + +## Why This Matters + +### Dangerous Mode Becomes Safe + +Currently `--dangerously-skip-permissions` is too risky—Claude has access to everything on your machine. In a sprite: + +- Claude can do anything... inside the sandbox +- Network restricted to whitelisted domains +- Can't touch other projects or your local files +- Worst case: destroy sprite, restore from checkpoint + +**Result:** Tasks execute without permission prompts. Much faster. + +### Simpler Than taskd + +Current cloud setup requires provisioning a VPS, configuring SSH/systemd, installing deps, keeping it updated, paying for idle time. + +Sprites setup: +```bash +$ task sprite init my-project +# Creates sprite, clones repo, installs deps, checkpoints +# Done. +``` + +### Persistent Dev Environment + +Setup happens once per project: +- `bundle install` runs during init +- Gems persist across tasks +- Checkpoint when idle (~$0.01/day storage) +- Restore in <1 second when needed + +## Architecture + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Local Machine │ │ Sprite │ +│ │ │ │ +│ task daemon │ │ tmux + Claude │ +│ ├── orchestrates │ │ ├── runs tasks │ +│ ├── stores DB │ │ ├── dangerous mode │ +│ └── serves TUI │ │ └── writes hooks │ +│ │ │ │ +│ TUI ◄── DB updates │ │ /tmp/task-hooks.log │ +│ │ │ │ │ +└────────────┬────────────┘ └─────────┼───────────────┘ + │ │ + │ sprites.Exec("tail -f") │ + └────────────────────────────────┘ + WebSocket stream +``` + +## Hook Communication + +**Problem:** Claude runs on sprite, but database is local. How do hooks sync? + +**Solution:** `tail -f` via Sprites exec WebSocket. + +``` +Sprite Local +────── ───── +Claude hook fires + │ + ▼ +echo '{"event":"Stop"}' >> + /tmp/task-hooks.log sprites.Exec("tail -f hooks.log") + │ + ▼ (~30ms latency) + Parse JSON + Update database + TUI refreshes +``` + +Hooks just append JSON to a file. Local daemon tails it over the existing WebSocket. Real-time, no extra infrastructure. + +### User Input (Reverse Direction) + +When Claude needs input, user responds in TUI: + +``` +TUI Sprite +─── ────── +User types "yes" + │ + ▼ +sprites.Exec("tmux send-keys + -t task-42 'yes' Enter") Claude receives keystroke + Continues working +``` + +Same mechanism we use locally—just remote tmux. + +**Round-trip latency:** ~100-150ms. Feels instant. + +## CLI Commands + +```bash +# Lifecycle +task sprite init # Create sprite, clone, setup, checkpoint +task sprite status [project] # Check sprite state +task sprite sync # Pull latest code, update deps if needed +task sprite attach # SSH + tmux attach (interactive) +task sprite checkpoint # Manual checkpoint +task sprite destroy # Delete sprite + +# Execution +task execute --sprite # Run task on sprite +task project edit --execution sprite # Make sprite the default +``` + +## Cost + +| Resource | Price | +|----------|-------| +| CPU | $0.07/CPU-hour | +| Memory | $0.04/GB-hour | +| Storage | $0.00068/GB-hour | + +**Medium sprite (2 CPU, 4GB):** ~$0.32/hour active + +| Usage Pattern | Monthly Cost | +|---------------|--------------| +| Light (2 hrs/day) | ~$19 | +| Moderate (4 hrs/day) | ~$35 | +| Heavy (6 hrs/day) | ~$63 | +| Idle (checkpoint only) | ~$5 | + +Comparable to a VPS for light/moderate use, more expensive for heavy use—but managed and isolated. + +## Trade-offs + +| Aspect | Local/taskd | Sprites | +|--------|-------------|---------| +| Isolation | Worktrees only | Full VM | +| Dangerous mode | Risky | Safe | +| Server management | You do it | Managed | +| Offline work | ✓ Works | ✗ Needs internet | +| Startup latency | Instant | ~1s (restore) | +| Cost model | Fixed | Pay-per-use | +| Vendor dependency | None | Fly.io | + +## Open Questions + +1. **Fly.io dependency acceptable?** It's opt-in, but still a vendor lock-in for that feature. + +2. **Git credentials in sprite?** SSH key per sprite? GitHub token passed at runtime? + +3. **Claude auth?** How does Claude authenticate inside the sprite? + +4. **Multi-user?** Shared sprite per project, or one per user? + +## Recommendation + +Build as an **experimental opt-in feature**: +- Keep local execution as default +- Keep taskd for users who prefer it +- Add `task sprite` commands for those who want managed cloud + isolation +- Gather feedback, iterate + +The per-project model reuses existing architecture (tmux, worktrees, hooks) while solving the "cloud without server ops" and "safe dangerous mode" problems. diff --git a/docs/sprites-discussion.md b/docs/sprites-discussion.md new file mode 100644 index 00000000..375dd0eb --- /dev/null +++ b/docs/sprites-discussion.md @@ -0,0 +1,311 @@ +# Sprites Integration: A Discussion + +*A dialogue between two perspectives on adopting Sprites for cloud-based Claude execution* + +--- + +## The Proposal + +Replace or augment the current `taskd` cloud execution model with Sprites - Fly.io's managed sandbox environments designed for AI agents. + +--- + +## Alex (Advocate for Sprites) + +### Opening Statement + +The current `taskd` approach requires users to provision and maintain their own cloud server. That's a significant barrier. You need to: + +1. Have a VPS or cloud instance running 24/7 +2. Configure SSH, systemd, and security +3. Keep the server updated and patched +4. Pay for idle time when no tasks are running +5. Manage SSH keys and GitHub credentials on the server + +With Sprites, we eliminate all of that. One API key, and you're running Claude in isolated VMs in the cloud. The `task cloud init` wizard becomes `task cloud login` - enter your Sprites token, done. + +### On the Fly.io Dependency + +Yes, users would need a Fly.io account. But consider what they need *now* for cloud execution: + +- A cloud provider account (AWS, DigitalOcean, Hetzner, etc.) +- SSH access configured +- A running server ($5-20/month minimum, always on) +- Technical knowledge to debug server issues + +Sprites trades one dependency for another - but it's a *managed* dependency. Fly.io handles: +- VM provisioning +- Security patching +- Network isolation +- Resource scaling + +The $30 free trial credit is enough for ~65 hours of Claude sessions. That's plenty to evaluate whether this works for your workflow. + +### On Cost + +Let's do the math: + +**Current cloud model (taskd on a VPS):** +- Minimum viable server: ~$5/month = $60/year +- That's 24/7 whether you use it or not +- Plus your time maintaining it + +**Sprites model:** +- 4-hour Claude session: ~$0.46 +- 130 four-hour sessions = $60 +- You only pay for what you use + +If you're running fewer than 130 substantial Claude sessions per year, Sprites is cheaper. If you're running more, the VPS makes sense - but at that volume, you probably want dedicated infrastructure anyway. + +### On Isolation and Security + +This is where Sprites really shines. Currently: + +- Claude runs with your user's full permissions +- It can access any file your user can access +- Network access is unrestricted +- A malicious prompt could theoretically exfiltrate data + +With Sprites: + +- Each task runs in a hardware-isolated VM +- Network policies can whitelist only necessary domains +- The sprite gets deleted after task completion +- No persistent access to your local machine + +This is *meaningful* security improvement. We're running an AI agent that can execute arbitrary code. Isolation matters. + +### On Complexity vs. Simplicity + +The current architecture is elegant for local use. But "run `taskd` on a server" introduces real complexity: + +- SSH tunneling for the TUI +- Database synchronization concerns +- Server maintenance burden +- Debugging remote issues + +Sprites simplifies this: your local `task` daemon orchestrates remote execution via a REST API. The complexity is Fly.io's problem, not yours. + +--- + +## Jordan (Skeptic / Devil's Advocate) + +### Opening Statement + +I appreciate the vision, but I have concerns about coupling core functionality to a third-party service. Let me push back on several points. + +### On the Fly.io Dependency + +This isn't just "a dependency" - it's a *hard* dependency on a specific vendor for core functionality. Consider: + +1. **What if Fly.io raises prices?** The $0.07/CPU-hour could become $0.14. Our users are locked in. + +2. **What if Fly.io goes down?** Their outages become our outages. Users can't execute cloud tasks at all. + +3. **What if Fly.io discontinues Sprites?** It's a relatively new product. If it doesn't work out for them, our users are stranded. + +4. **What about enterprise users?** Many companies won't approve sending code to a third-party service. They have their own cloud infrastructure. + +The current model (bring your own server) is vendor-agnostic. It works on any Linux box. That's a feature, not a bug. + +### On the "Simplicity" Argument + +Yes, `task cloud init` is complex. But it's *one-time* complexity that results in infrastructure you control. With Sprites: + +- Every task execution depends on network connectivity to Fly.io +- Every task execution depends on Sprites API being available +- You're sending your code and prompts through their infrastructure +- You're trusting their isolation claims + +"Simple" sometimes means "someone else's complexity that you can't inspect or control." + +### On Local Development Experience + +The current tmux model has a massive advantage: you can `tmux attach` and interact with Claude in real-time. You can see exactly what it's doing. You can type corrections mid-task. + +With Sprites, we lose that direct interactivity. Yes, we can stream output, but: + +- There's network latency on every keystroke +- Attaching to a remote sprite is more complex than `tmux attach` +- The debugging experience degrades + +For many users, the ability to watch and intervene is a core feature. + +### On Cost (A Different Perspective) + +The $0.46 per 4-hour session sounds cheap, but consider actual usage patterns: + +- Developer runs 5-10 tasks per day during active development +- Many tasks are quick iterations, but the sprite still needs to spin up +- Startup time (~2-5 seconds) adds friction to the workflow +- Failed tasks still cost money + +A $5/month VPS runs unlimited tasks with zero marginal cost. For active users, that math flips quickly. + +Also: the VPS runs 24/7, which means it can: +- Run scheduled tasks +- Process webhooks +- Serve as a persistent development environment + +A sprite is ephemeral by design. + +### On Security (The Other Side) + +The security argument cuts both ways: + +1. **You're sending code to Fly.io's infrastructure.** For open source projects, maybe that's fine. For proprietary code, that's a compliance conversation. + +2. **Sprites need git credentials.** Either you're passing tokens to each sprite (security risk) or setting up some credential proxy (complexity). + +3. **Hook callbacks need a reachable endpoint.** Either your local machine needs to be addressable from the internet (security risk) or you're polling (latency). + +The current model keeps everything on infrastructure you control. + +### On the "Right Tool" Question + +What problem are we actually solving? + +- If users want isolation, we could use local containers (Docker, Podman) +- If users want cloud execution, they can already use taskd +- If users want pay-per-use, they could run taskd on a spot instance + +Sprites solves a specific problem: "I want managed, isolated cloud execution with minimal setup." Is that problem common enough to justify the integration complexity and vendor lock-in? + +--- + +## Alex's Rebuttal + +### On Vendor Lock-in + +Fair point, but we're not proposing to *replace* local execution - we're *adding* an option. The architecture would be: + +``` +task execute --local # Current tmux model (default) +task execute --sprite # New Sprites model (opt-in) +task execute --cloud # Current taskd model (still works) +``` + +Users choose based on their needs. Vendor lock-in only applies if they choose the Sprites path. + +### On Enterprise Concerns + +Enterprise users probably aren't using `task` as-is anyway - they'd fork it and customize. But Sprites does have SOC 2 compliance (Fly.io is enterprise-ready). Still, point taken: we should keep taskd as an option. + +### On Interactivity + +This is my biggest concession. The tmux attach experience is genuinely better for interactive debugging. We could mitigate with: + +- Rich output streaming to the TUI +- A `task sprite attach` command that opens a shell to the sprite +- Keeping local execution as the default for development + +But yes, the experience is different. + +### On the Core Question + +The problem we're solving: "Cloud execution without server management." + +The current answer (`taskd`) works, but requires DevOps skills. Sprites lowers the barrier dramatically. That might expand who can use cloud execution from "people comfortable managing servers" to "anyone with a Fly.io account." + +--- + +## Jordan's Rebuttal + +### On "It's Optional" + +Optional features still have costs: + +1. **Maintenance burden:** Two execution paths to maintain, test, and debug +2. **Documentation complexity:** Users need to understand which mode to use when +3. **Cognitive overhead:** "Should I use local, sprite, or cloud?" + +Every feature we add is a feature we maintain forever. + +### On the User Base + +Let's be honest about who uses `task`: + +- Developers comfortable with CLI tools +- People who can navigate git worktrees +- Likely comfortable with basic server setup + +Is "I want cloud execution but can't manage a VPS" actually a common user profile? Or are we solving a theoretical problem? + +### On Alternatives + +Before committing to Sprites, shouldn't we consider: + +1. **Improve taskd setup:** Make `task cloud init` even simpler, more reliable +2. **Docker-based local isolation:** Same security benefits, no external dependency +3. **Support multiple cloud backends:** Abstract an interface, let users plug in Sprites OR their own runners + +Option 3 is more work, but results in better architecture. If we build a proper "remote executor" abstraction, Sprites becomes one implementation - not the only one. + +--- + +## Synthesis: Where Does This Leave Us? + +### Points of Agreement + +1. **Cloud execution is valuable** - Both perspectives agree remote execution has its place +2. **Current taskd setup is complex** - There's room for improvement +3. **Isolation matters** - Running arbitrary AI-generated code in isolation is a good idea +4. **Local should stay default** - The tmux experience is core to the product + +### Points of Contention + +1. **Is vendor dependency acceptable?** - Depends on user priorities +2. **Is the simplicity worth the trade-offs?** - Subjective +3. **Is this solving a real problem?** - Needs user research + +### Possible Paths Forward + +**Path A: Full Sprites Integration** +- Add Sprites as a first-class execution option +- Accept the Fly.io dependency +- Keep local and taskd as alternatives +- Target: Users who want managed cloud without server ops + +**Path B: Abstract Remote Executor** +- Define a "RemoteExecutor" interface +- Implement Sprites as one backend +- Also support: Docker, Podman, SSH-to-server +- More work upfront, more flexibility long-term + +**Path C: Improve What We Have** +- Make taskd setup more reliable +- Add optional Docker isolation for local execution +- Skip the Sprites dependency entirely +- Focus on polishing existing features + +**Path D: Wait and See** +- Document the Sprites option in design docs +- Let users experiment manually if interested +- Revisit if there's demand +- Avoid premature optimization + +--- + +## Open Questions + +1. How many users actually want cloud execution today? +2. What's the typical task profile - many short tasks or few long ones? +3. Would users trust sending their code to Fly.io? +4. Is interactive debugging (tmux attach) essential or nice-to-have? +5. What's our maintenance bandwidth for new execution backends? + +--- + +## Conclusion + +Both perspectives have merit. The decision ultimately depends on: + +- **Target user profile:** How technical are they? What do they value? +- **Project priorities:** Simplicity vs. flexibility? Features vs. maintenance? +- **Risk tolerance:** Is vendor dependency acceptable? + +This isn't a clear-cut technical decision - it's a product direction question that deserves user input before we commit significant engineering effort. + +--- + +*Document created for discussion purposes. No decisions have been made.* diff --git a/go.mod b/go.mod index 732a7d25..f2226628 100644 --- a/go.mod +++ b/go.mod @@ -11,13 +11,16 @@ require ( github.com/charmbracelet/log v0.4.1 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 + github.com/fsnotify/fsnotify v1.9.0 github.com/spf13/cobra v1.10.2 + github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931 golang.org/x/crypto v0.37.0 golang.org/x/term v0.31.0 modernc.org/sqlite v1.42.2 ) require ( + github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -40,10 +43,10 @@ require ( github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -58,7 +61,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect @@ -67,7 +69,6 @@ require ( golang.org/x/net v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.24.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 56d0eb24..18396f1d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -83,12 +85,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -121,14 +123,14 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931 h1:/aoLHUu5q1D2k6Zrh4ueNoUmSx5hxtqYMCtWCBFs/Q8= +github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index aa1231bc..c739e74b 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -190,6 +190,9 @@ func (db *DB) migrate() error { `ALTER TABLE projects ADD COLUMN actions TEXT DEFAULT '[]'`, `ALTER TABLE tasks ADD COLUMN worktree_path TEXT DEFAULT ''`, `ALTER TABLE tasks ADD COLUMN branch_name TEXT DEFAULT ''`, + // Sprite support: cloud execution environments + `ALTER TABLE projects ADD COLUMN sprite_name TEXT DEFAULT ''`, + `ALTER TABLE projects ADD COLUMN sprite_status TEXT DEFAULT ''`, } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 16fdbfd0..ad453b0f 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -493,6 +493,8 @@ type Project struct { Aliases string // comma-separated Instructions string // project-specific instructions for AI Actions []ProjectAction // actions triggered on task events (stored as JSON) + SpriteName string // name of the sprite for cloud execution + SpriteStatus string // sprite status: "", "ready", "checkpointed", "error" CreatedAt LocalTime } @@ -510,9 +512,9 @@ func (p *Project) GetAction(trigger string) *ProjectAction { func (db *DB) CreateProject(p *Project) error { actionsJSON, _ := json.Marshal(p.Actions) result, err := db.Exec(` - INSERT INTO projects (name, path, aliases, instructions, actions) - VALUES (?, ?, ?, ?, ?) - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON)) + INSERT INTO projects (name, path, aliases, instructions, actions, sprite_name, sprite_status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.SpriteName, p.SpriteStatus) if err != nil { return fmt.Errorf("insert project: %w", err) } @@ -525,9 +527,10 @@ func (db *DB) CreateProject(p *Project) error { func (db *DB) UpdateProject(p *Project) error { actionsJSON, _ := json.Marshal(p.Actions) _, err := db.Exec(` - UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ? + UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, + sprite_name = ?, sprite_status = ? WHERE id = ? - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.ID) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.SpriteName, p.SpriteStatus, p.ID) if err != nil { return fmt.Errorf("update project: %w", err) } @@ -558,7 +561,8 @@ func (db *DB) DeleteProject(id int64) error { // ListProjects returns all projects, with "personal" always first. func (db *DB) ListProjects() ([]*Project, error) { rows, err := db.Query(` - SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at FROM projects ORDER BY CASE WHEN name = 'personal' THEN 0 ELSE 1 END, name `) if err != nil { @@ -570,7 +574,7 @@ func (db *DB) ListProjects() ([]*Project, error) { for rows.Next() { p := &Project{} var actionsJSON string - if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } json.Unmarshal([]byte(actionsJSON), &p.Actions) @@ -585,9 +589,10 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { p := &Project{} var actionsJSON string err := db.QueryRow(` - SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at FROM projects WHERE name = ? - `, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt) + `, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt) if err == nil { json.Unmarshal([]byte(actionsJSON), &p.Actions) return p, nil @@ -597,7 +602,11 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { } // Try alias match - rows, err := db.Query(`SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at FROM projects`) + rows, err := db.Query(` + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at + FROM projects + `) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } @@ -605,7 +614,7 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { for rows.Next() { p := &Project{} - if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } json.Unmarshal([]byte(actionsJSON), &p.Actions) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index adab258d..0ea0859e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -20,6 +20,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/github" "github.com/bborn/workflow/internal/hooks" + "github.com/bborn/workflow/internal/sprites" "github.com/charmbracelet/log" ) @@ -57,6 +58,9 @@ type Executor struct { // Silent mode suppresses log output (for TUI embedding) silent bool + + // Sprite runner for cloud execution (nil if not enabled) + spriteRunner *SpriteRunner } // SuspendIdleTimeout is how long a blocked task must be idle before being suspended. @@ -108,6 +112,20 @@ func (e *Executor) Start(ctx context.Context) { e.running = true e.mu.Unlock() + // Initialize sprite runner if sprites are enabled + if sprites.IsEnabled(e.db) { + logFunc := func(format string, args ...interface{}) { + e.logger.Info(fmt.Sprintf(format, args...)) + } + runner, err := NewSpriteRunner(e.db, logFunc) + if err != nil { + e.logger.Error("Failed to initialize sprite runner", "error", err) + } else { + e.spriteRunner = runner + e.logger.Info("Sprite runner initialized - Claude will execute on sprite") + } + } + e.logger.Info("Background executor started") go e.worker(ctx) @@ -124,6 +142,11 @@ func (e *Executor) Stop() { close(e.stopCh) e.mu.Unlock() + // Stop sprite runner if running + if e.spriteRunner != nil { + e.spriteRunner.Shutdown() + } + e.logger.Info("Background executor stopped") } @@ -1027,6 +1050,19 @@ func (e *Executor) setupClaudeHooks(workDir string, taskID int64) (cleanup func( // runClaude runs a task using Claude CLI in a tmux window for interactive access func (e *Executor) runClaude(ctx context.Context, taskID int64, workDir, prompt string) execResult { + // If sprite runner is available, use cloud execution + if e.spriteRunner != nil { + task, _ := e.db.GetTask(taskID) + if task != nil { + e.logLine(taskID, "system", "Running Claude on sprite (cloud execution)") + if err := e.spriteRunner.RunClaude(ctx, task, workDir, prompt); err != nil { + e.logger.Error("Sprite execution failed", "error", err) + return execResult{Success: false, Message: err.Error()} + } + return execResult{Success: true} + } + } + // Check if tmux is available if _, err := exec.LookPath("tmux"); err != nil { return e.runClaudeDirect(ctx, taskID, workDir, prompt) diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go new file mode 100644 index 00000000..97b13b9f --- /dev/null +++ b/internal/executor/executor_sprite.go @@ -0,0 +1,283 @@ +package executor + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" + sdk "github.com/superfly/sprites-go" +) + +// SpriteRunner manages Claude execution on sprites using native exec sessions. +// Key improvements over tmux-based approach: +// - No tmux dependency on the sprite +// - Uses exec API directly with Env and Dir +// - Streams stdout for real-time logs +// - Uses Filesystem API for file operations +type SpriteRunner struct { + db *db.DB + client *sdk.Client + sprite *sdk.Sprite + + // Active task tracking + activeTasks map[int64]context.CancelFunc + activeTasksMu sync.RWMutex + + mu sync.Mutex + logger func(format string, args ...interface{}) +} + +// NewSpriteRunner creates a new sprite runner. +func NewSpriteRunner(database *db.DB, logger func(format string, args ...interface{})) (*SpriteRunner, error) { + ctx := context.Background() + + client, sprite, err := sprites.EnsureRunning(ctx, database) + if err != nil { + return nil, fmt.Errorf("ensure sprite running: %w", err) + } + + runner := &SpriteRunner{ + db: database, + client: client, + sprite: sprite, + activeTasks: make(map[int64]context.CancelFunc), + logger: logger, + } + + // If sprite is nil, we need to create it + if sprite == nil { + spriteName := sprites.GetName(database) + sprite, err = client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return nil, fmt.Errorf("create sprite: %w", err) + } + runner.sprite = sprite + + // Save sprite name + database.SetSetting(sprites.SettingName, spriteName) + + logger("Sprite created but may need setup. Run 'task sprite up' to initialize.") + } + + return runner, nil +} + +// RunClaude executes Claude on the sprite for a given task. +// Uses the exec API directly - sessions persist after disconnect. +func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir string, prompt string) error { + r.logger("Running Claude on sprite for task %d", task.ID) + + // Create cancellable context for this task + taskCtx, cancel := context.WithCancel(ctx) + r.activeTasksMu.Lock() + r.activeTasks[task.ID] = cancel + r.activeTasksMu.Unlock() + + defer func() { + r.activeTasksMu.Lock() + delete(r.activeTasks, task.ID) + r.activeTasksMu.Unlock() + }() + + // Build the Claude command with proper environment + cmd := r.sprite.CommandContext(taskCtx, "claude", + "--dangerously-skip-permissions", + "-p", prompt) + + // Set working directory and environment directly (no shell escaping needed) + cmd.Dir = workDir + cmd.Env = []string{ + fmt.Sprintf("TASK_ID=%d", task.ID), + } + + // Get stdout for streaming logs to database + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + // Handle port notifications (when Claude starts a dev server) + cmd.TextMessageHandler = func(data []byte) { + var notification struct { + Type string `json:"type"` + Port int `json:"port"` + ProxyURL string `json:"proxy_url"` + } + if err := json.Unmarshal(data, ¬ification); err == nil { + if notification.Type == "port_opened" { + r.db.AppendTaskLog(task.ID, "system", + fmt.Sprintf("Dev server available at %s", notification.ProxyURL)) + r.logger("Port %d opened, proxy URL: %s", notification.Port, notification.ProxyURL) + } + } + } + + // Start the command + if err := cmd.Start(); err != nil { + return fmt.Errorf("start claude: %w", err) + } + + r.logger("Claude started on sprite") + + // Stream stdout to task logs in background + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + r.db.AppendTaskLog(task.ID, "claude", line) + // Parse Claude's output for status changes + r.parseClaudeOutput(task.ID, line) + } + }() + + // Stream stderr to task logs in background + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + r.db.AppendTaskLog(task.ID, "claude_stderr", scanner.Text()) + } + }() + + // Wait for Claude to exit - this blocks without polling! + err = cmd.Wait() + + if err != nil { + // Check if it was cancelled + if taskCtx.Err() != nil { + r.logger("Claude cancelled for task %d", task.ID) + return nil + } + return fmt.Errorf("claude execution failed: %w", err) + } + + r.logger("Claude session completed for task %d", task.ID) + return nil +} + +// parseClaudeOutput parses Claude's output for status updates. +func (r *SpriteRunner) parseClaudeOutput(taskID int64, line string) { + task, err := r.db.GetTask(taskID) + if err != nil || task == nil || task.StartedAt == nil { + return + } + + // Detect when Claude is waiting for input (e.g., permission prompts) + if strings.Contains(line, "Waiting for") || strings.Contains(line, "Press") { + if task.Status == db.StatusProcessing { + r.db.UpdateTaskStatus(taskID, db.StatusBlocked) + } + } + + // Detect when Claude resumes work + if strings.Contains(line, "Running") || strings.Contains(line, "Executing") { + if task.Status == db.StatusBlocked { + r.db.UpdateTaskStatus(taskID, db.StatusProcessing) + } + } +} + +// CancelTask cancels a running task. +func (r *SpriteRunner) CancelTask(taskID int64) bool { + r.activeTasksMu.RLock() + cancel, ok := r.activeTasks[taskID] + r.activeTasksMu.RUnlock() + + if ok && cancel != nil { + cancel() + return true + } + return false +} + +// IsTaskRunning checks if a task is currently running on the sprite. +func (r *SpriteRunner) IsTaskRunning(taskID int64) bool { + r.activeTasksMu.RLock() + defer r.activeTasksMu.RUnlock() + _, ok := r.activeTasks[taskID] + return ok +} + +// SyncWorkdir syncs the local workdir to the sprite using the Filesystem API. +func (r *SpriteRunner) SyncWorkdir(ctx context.Context, localPath string, remotePath string) error { + // Use Filesystem API to create directory + fs := r.sprite.Filesystem() + if err := fs.MkdirAll(remotePath, 0755); err != nil { + return fmt.Errorf("create remote dir: %w", err) + } + + r.logger("Created remote directory %s on sprite", remotePath) + return nil +} + +// GetSprite returns the sprite reference. +func (r *SpriteRunner) GetSprite() *sdk.Sprite { + return r.sprite +} + +// Shutdown gracefully shuts down the sprite runner. +func (r *SpriteRunner) Shutdown() { + r.activeTasksMu.Lock() + defer r.activeTasksMu.Unlock() + + // Cancel all running tasks + for taskID, cancel := range r.activeTasks { + r.logger("Cancelling task %d at shutdown", taskID) + if cancel != nil { + cancel() + } + } +} + +// ListActiveTasks returns all currently running task IDs. +func (r *SpriteRunner) ListActiveTasks() []int64 { + r.activeTasksMu.RLock() + defer r.activeTasksMu.RUnlock() + + result := make([]int64, 0, len(r.activeTasks)) + for taskID := range r.activeTasks { + result = append(result, taskID) + } + return result +} + +// StartIdleWatcher periodically creates checkpoints when sprite is idle. +// This saves money by suspending sprites that aren't being used. +func (r *SpriteRunner) StartIdleWatcher(ctx context.Context, idleTimeout time.Duration) { + go func() { + lastActivity := time.Now() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.activeTasksMu.RLock() + hasActiveTasks := len(r.activeTasks) > 0 + r.activeTasksMu.RUnlock() + + if hasActiveTasks { + lastActivity = time.Now() + } else if time.Since(lastActivity) > idleTimeout { + r.logger("Sprite idle for %v, creating checkpoint", idleTimeout) + if stream, err := r.sprite.CreateCheckpointWithComment(ctx, "auto-idle"); err == nil { + stream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }) + } + lastActivity = time.Now() + } + } + } + }() +} diff --git a/internal/sprites/sprites.go b/internal/sprites/sprites.go new file mode 100644 index 00000000..dce2474c --- /dev/null +++ b/internal/sprites/sprites.go @@ -0,0 +1,114 @@ +// Package sprites provides shared functionality for Fly.io Sprites integration. +// Sprites are isolated VMs used as execution environments for Claude. +// +// Key features of the Sprites exec API used by this package: +// - Sessions persist after client disconnect (no tmux needed) +// - cmd.Wait() blocks until process exits (no polling required) +// - Session IDs enable crash recovery (can reattach to running sessions) +// - Filesystem API for direct file operations (no shell escaping) +// - Network policies for security (whitelist allowed domains) +// - Port notifications for dev servers (auto-expose proxy URLs) +package sprites + +import ( + "context" + "fmt" + "os" + + "github.com/bborn/workflow/internal/db" + sdk "github.com/superfly/sprites-go" +) + +// Settings keys for sprite configuration +const ( + SettingToken = "sprite_token" // Sprites API token + SettingName = "sprite_name" // Name of the daemon sprite +) + +// Default sprite name +const DefaultName = "task-daemon" + +// GetToken returns the Sprites API token from env or database. +func GetToken(database *db.DB) string { + // First try environment variable + token := os.Getenv("SPRITES_TOKEN") + if token != "" { + return token + } + + // Fall back to database setting + if database != nil { + token, _ = database.GetSetting(SettingToken) + } + return token +} + +// GetName returns the name of the daemon sprite. +func GetName(database *db.DB) string { + if database != nil { + name, _ := database.GetSetting(SettingName) + if name != "" { + return name + } + } + return DefaultName +} + +// NewClient creates a Sprites API client. +func NewClient(database *db.DB) (*sdk.Client, error) { + token := GetToken(database) + if token == "" { + return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task sprite token ") + } + return sdk.New(token), nil +} + +// EnsureRunning ensures the sprite is running and returns the sprite reference. +// Creates the sprite if it doesn't exist, restores from checkpoint if suspended. +func EnsureRunning(ctx context.Context, database *db.DB) (*sdk.Client, *sdk.Sprite, error) { + client, err := NewClient(database) + if err != nil { + return nil, nil, err + } + + spriteName := GetName(database) + + // Check if sprite exists + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + // Sprite doesn't exist - caller should create and set it up + return client, nil, nil + } + + // Restore from checkpoint if suspended + if sprite.Status == "suspended" || sprite.Status == "stopped" { + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil || len(checkpoints) == 0 { + return nil, nil, fmt.Errorf("sprite is suspended but no checkpoints available") + } + + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return nil, nil, fmt.Errorf("restore checkpoint: %w", err) + } + + if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }); err != nil { + return nil, nil, fmt.Errorf("restore failed: %w", err) + } + + // Refresh sprite status + sprite, err = client.GetSprite(ctx, spriteName) + if err != nil { + return nil, nil, err + } + } + + return client, sprite, nil +} + +// IsEnabled returns true if sprites are configured (token is set). +func IsEnabled(database *db.DB) bool { + return GetToken(database) != "" +} From 2706ee67a7d3695f7f70c3a21f4e4c0665090a8c Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 28 Jan 2026 16:11:51 -0600 Subject: [PATCH 2/4] fix: remove unused fields from SpriteRunner struct Remove unused mu and client fields that were causing golangci-lint failures. Co-Authored-By: Claude Opus 4.5 --- internal/executor/executor_sprite.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go index 97b13b9f..fdf321e2 100644 --- a/internal/executor/executor_sprite.go +++ b/internal/executor/executor_sprite.go @@ -22,14 +22,12 @@ import ( // - Uses Filesystem API for file operations type SpriteRunner struct { db *db.DB - client *sdk.Client sprite *sdk.Sprite // Active task tracking activeTasks map[int64]context.CancelFunc activeTasksMu sync.RWMutex - mu sync.Mutex logger func(format string, args ...interface{}) } @@ -44,7 +42,6 @@ func NewSpriteRunner(database *db.DB, logger func(format string, args ...interfa runner := &SpriteRunner{ db: database, - client: client, sprite: sprite, activeTasks: make(map[int64]context.CancelFunc), logger: logger, From 6705aba8c092d43dcd282634784ddbd5df4cc8e6 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 31 Jan 2026 08:56:56 -0600 Subject: [PATCH 3/4] refactor: use sprite CLI instead of SDK for authentication Replace sprites-go SDK with direct sprite CLI calls to leverage existing fly.io authentication. This simplifies setup since users only need to run 'sprite login' instead of managing separate tokens. Changes: - sprites.go: CLI-based functions for sprite operations - executor_sprite.go: Use CLI for execution with streaming output - sprite.go: Updated commands to use CLI approach - .gitignore: Ignore .sprite local config file Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + cmd/task/sprite.go | 445 +++++++++++---------------- internal/executor/executor_sprite.go | 204 ++++-------- internal/sprites/sprites.go | 294 ++++++++++++++---- 4 files changed, 478 insertions(+), 466 deletions(-) diff --git a/.gitignore b/.gitignore index 2ac83554..82fd3ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ bin/ # Tmux tmux-*.log +.sprite diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go index eb2ee10b..24115005 100644 --- a/cmd/task/sprite.go +++ b/cmd/task/sprite.go @@ -1,16 +1,15 @@ package main import ( - "context" "fmt" "os" - "time" + "os/exec" + "strings" "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/sprites" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" - sdk "github.com/superfly/sprites-go" ) // Styles for sprite command output @@ -21,20 +20,6 @@ var ( spriteErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) ) -// Default network policy - domains that Claude is allowed to access -var defaultAllowedDomains = []string{ - "github.com", - "api.github.com", - "api.anthropic.com", - "rubygems.org", - "registry.npmjs.org", - "pypi.org", - "crates.io", - "pkg.go.dev", - "proxy.golang.org", - "sum.golang.org", -} - // createSpriteCommand creates the sprite subcommand with all its children. func createSpriteCommand() *cobra.Command { spriteCmd := &cobra.Command{ @@ -42,17 +27,16 @@ func createSpriteCommand() *cobra.Command { Short: "Manage the cloud sprite for task execution", Long: `Sprite management for running tasks in the cloud. -When SPRITES_TOKEN is set, 'task' automatically runs on a cloud sprite. -Use these commands to manage the sprite manually. +Uses the 'sprite' CLI which authenticates via fly.io login. +Run 'sprite login' first if not already authenticated. Commands: - status - Show sprite status + status - Show sprite status and list available sprites + use - Select which sprite to use for task execution up - Start/restore the sprite down - Checkpoint and stop the sprite attach - Attach to the sprite's shell - sessions - List active exec sessions - destroy - Delete the sprite entirely - token - Set the Sprites API token`, + destroy - Delete the sprite entirely`, Run: func(cmd *cobra.Command, args []string) { showSpriteStatus() }, @@ -68,6 +52,20 @@ Commands: } spriteCmd.AddCommand(statusCmd) + // sprite use + useCmd := &cobra.Command{ + Use: "use ", + Short: "Select which sprite to use for task execution", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteUse(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(useCmd) + // sprite up upCmd := &cobra.Command{ Use: "up", @@ -110,19 +108,6 @@ Commands: } spriteCmd.AddCommand(attachCmd) - // sprite sessions - sessionsCmd := &cobra.Command{ - Use: "sessions", - Short: "List active exec sessions on the sprite", - Run: func(cmd *cobra.Command, args []string) { - if err := listSpriteSessions(); err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } - }, - } - spriteCmd.AddCommand(sessionsCmd) - // sprite destroy destroyCmd := &cobra.Command{ Use: "destroy", @@ -137,23 +122,19 @@ Commands: } spriteCmd.AddCommand(destroyCmd) - // sprite token - tokenCmd := &cobra.Command{ - Use: "token [token]", - Short: "Set or show the Sprites API token", - Args: cobra.MaximumNArgs(1), + // sprite create + createCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new sprite", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - showSpriteToken() - } else { - if err := setSpriteToken(args[0]); err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } + if err := runSpriteCreate(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) } }, } - spriteCmd.AddCommand(tokenCmd) + spriteCmd.AddCommand(createCmd) return spriteCmd } @@ -168,56 +149,94 @@ func showSpriteStatus() { } defer database.Close() - token := sprites.GetToken(database) - spriteName := sprites.GetName(database) - fmt.Println(spriteTitleStyle.Render("Sprite Status")) fmt.Println() - if token == "" { - fmt.Println(dimStyle.Render(" No Sprites token configured.")) - fmt.Println(dimStyle.Render(" Set SPRITES_TOKEN env var or run: task sprite token ")) + // Check if sprite CLI is available + if !sprites.IsAvailable() { + fmt.Println(spriteErrorStyle.Render(" sprite CLI not found or not authenticated")) + fmt.Println(dimStyle.Render(" Install: brew install superfly/tap/sprite")) + fmt.Println(dimStyle.Render(" Then run: sprite login")) return } - fmt.Printf(" Token: %s\n", dimStyle.Render("configured")) - fmt.Printf(" Name: %s\n", spriteName) + fmt.Println(spriteCheckStyle.Render(" ✓ sprite CLI authenticated")) - // Try to get sprite status from API - client, err := sprites.NewClient(database) + // Show configured sprite name + spriteName := sprites.GetName(database) + fmt.Printf(" Selected sprite: %s\n", boldStyle.Render(spriteName)) + + // List available sprites + spriteList, err := sprites.ListSprites() if err != nil { - fmt.Printf(" Status: %s\n", spriteErrorStyle.Render("error - "+err.Error())) + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Error listing sprites"), err.Error()) return } - ctx := context.Background() - sprite, err := client.GetSprite(ctx, spriteName) - if err != nil { - fmt.Printf(" Status: %s\n", dimStyle.Render("not created")) - fmt.Println() - fmt.Println(dimStyle.Render(" Run 'task sprite up' to create it, or just run 'task'.")) - return + fmt.Println() + fmt.Println(" Available sprites:") + if len(spriteList) == 0 { + fmt.Println(dimStyle.Render(" (none - run 'task sprite create ' to create one)")) + } else { + for _, s := range spriteList { + marker := " " + if s == spriteName { + marker = spriteCheckStyle.Render("→ ") + } + fmt.Printf(" %s%s\n", marker, s) + } } - statusStyle := spriteCheckStyle - statusIcon := "●" - if sprite.Status == "suspended" || sprite.Status == "stopped" { - statusStyle = spritePendingStyle + // Check if selected sprite exists + found := false + for _, s := range spriteList { + if s == spriteName { + found = true + break + } } - fmt.Printf(" Status: %s\n", statusStyle.Render(statusIcon+" "+sprite.Status)) - fmt.Printf(" URL: %s\n", dimStyle.Render(sprite.URL)) - // Show active sessions - sessions, err := client.ListSessions(ctx, spriteName) - if err == nil && len(sessions) > 0 { + if !found && len(spriteList) > 0 { fmt.Println() - fmt.Printf(" Active sessions: %d\n", len(sessions)) - for _, s := range sessions { - if s.IsActive { - fmt.Printf(" %s - %s\n", dimStyle.Render(s.ID[:8]), s.Command) - } + fmt.Printf(" %s\n", spritePendingStyle.Render("⚠ Selected sprite '"+spriteName+"' not found")) + fmt.Println(dimStyle.Render(" Run 'task sprite use ' to select an existing sprite")) + } +} + +// runSpriteUse selects which sprite to use. +func runSpriteUse(name string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Verify the sprite exists + spriteList, err := sprites.ListSprites() + if err != nil { + return fmt.Errorf("list sprites: %w", err) + } + + found := false + for _, s := range spriteList { + if s == name { + found = true + break } } + + if !found { + return fmt.Errorf("sprite '%s' not found. Available: %s", name, strings.Join(spriteList, ", ")) + } + + // Save to database + if err := sprites.SetName(database, name); err != nil { + return fmt.Errorf("save setting: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Now using sprite: " + name)) + return nil } // runSpriteUp ensures the sprite is running. @@ -229,75 +248,67 @@ func runSpriteUp() error { } defer database.Close() - client, err := sprites.NewClient(database) - if err != nil { - return err + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available. Run 'sprite login' first") } spriteName := sprites.GetName(database) - ctx := context.Background() // Check if sprite exists - sprite, err := client.GetSprite(ctx, spriteName) + spriteList, err := sprites.ListSprites() if err != nil { - // Sprite doesn't exist, create it + return fmt.Errorf("list sprites: %w", err) + } + + found := false + for _, s := range spriteList { + if s == spriteName { + found = true + break + } + } + + if !found { + // Create the sprite fmt.Printf("Creating sprite: %s\n", spriteName) - sprite, err = client.CreateSprite(ctx, spriteName, nil) - if err != nil { + if err := sprites.CreateSprite(spriteName); err != nil { return fmt.Errorf("create sprite: %w", err) } fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) - // Save sprite name to database - database.SetSetting(sprites.SettingName, spriteName) - // Set up the sprite - if err := setupSprite(client, sprite); err != nil { + if err := setupSpriteCLI(spriteName); err != nil { return fmt.Errorf("setup sprite: %w", err) } - } else if sprite.Status == "suspended" || sprite.Status == "stopped" { - // Restore from checkpoint - fmt.Println("Restoring sprite from checkpoint...") - checkpoints, err := sprite.ListCheckpoints(ctx, "") - if err != nil || len(checkpoints) == 0 { - return fmt.Errorf("no checkpoints available to restore") - } - - restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + } else { + // Try to access the sprite to check if it's running + info, err := sprites.GetSprite(spriteName) if err != nil { - return fmt.Errorf("restore checkpoint: %w", err) - } - - if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { - return nil - }); err != nil { - return fmt.Errorf("restore failed: %w", err) + // May need to restore from checkpoint + fmt.Println("Sprite may be suspended, checking checkpoints...") + checkpoints, err := sprites.ListCheckpoints(spriteName) + if err == nil && len(checkpoints) > 0 { + fmt.Println("Restoring from checkpoint...") + if err := sprites.RestoreCheckpoint(spriteName, checkpoints[0].ID); err != nil { + return fmt.Errorf("restore checkpoint: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite restored")) + } else { + return fmt.Errorf("sprite not accessible: %w", err) + } + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite is running")) + fmt.Printf(" URL: %s\n", dimStyle.Render(info.URL)) } - fmt.Println(spriteCheckStyle.Render("✓ Sprite restored")) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Sprite is already running")) } return nil } -// setupSprite installs dependencies and configures Claude on a new sprite. -// Uses the Filesystem API instead of shell heredocs for cleaner setup. -func setupSprite(client *sdk.Client, sprite *sdk.Sprite) error { - ctx := context.Background() - fs := sprite.Filesystem() - +// setupSpriteCLI sets up a new sprite with required packages. +func setupSpriteCLI(spriteName string) error { fmt.Println("Setting up sprite...") - // Note: Network policy can be configured via Fly.io dashboard or flyctl - // The SDK policy API may be added in future versions - fmt.Println(" Recommended allowed domains for security:") - for _, domain := range defaultAllowedDomains[:5] { // Show first 5 - fmt.Printf(" - %s\n", dimStyle.Render(domain)) - } - fmt.Println(dimStyle.Render(" Configure via: fly secrets set ALLOWED_DOMAINS=...")) - - // Install essential packages (no tmux needed - using native exec sessions) steps := []struct { desc string cmd string @@ -305,48 +316,25 @@ func setupSprite(client *sdk.Client, sprite *sdk.Sprite) error { {"Installing packages", "apt-get update && apt-get install -y git curl"}, {"Installing Node.js", "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs"}, {"Installing Claude CLI", "npm install -g @anthropic-ai/claude-code"}, + {"Creating workspace", "mkdir -p /workspace"}, + {"Configuring Claude", "mkdir -p /root/.claude && echo '{\"permissions\":{\"allow\":[\"Bash(*)\",\"Read(*)\",\"Write(*)\",\"Edit(*)\",\"Grep(*)\",\"Glob(*)\"],\"deny\":[]}}' > /root/.claude/settings.json"}, } for _, step := range steps { fmt.Printf(" %s...\n", step.desc) - cmd := sprite.CommandContext(ctx, "sh", "-c", step.cmd) - if output, err := cmd.CombinedOutput(); err != nil { + output, err := sprites.ExecCommand(spriteName, "sh", "-c", step.cmd) + if err != nil { fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - fmt.Printf(" %s\n", dimStyle.Render(string(output))) + if output != "" { + fmt.Printf(" %s\n", dimStyle.Render(output)) + } } } - // Create workspace directory using Filesystem API - fmt.Println(" Creating workspace...") - if err := fs.MkdirAll("/workspace", 0755); err != nil { - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } - - // Configure Claude settings using Filesystem API (no shell escaping needed) - fmt.Println(" Configuring Claude settings...") - claudeSettings := []byte(`{ - "permissions": { - "allow": ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)", "Grep(*)", "Glob(*)", "WebFetch(*)", "Task(*)", "TodoWrite(*)"], - "deny": [] - } -}`) - - // Create .claude directory and write settings - if err := fs.MkdirAll("/root/.claude", 0755); err != nil { - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } - - if err := fs.WriteFile("/root/.claude/settings.json", claudeSettings, 0644); err != nil { - return fmt.Errorf("write claude settings: %w", err) - } - // Create initial checkpoint fmt.Println(" Creating checkpoint...") - checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, "initial-setup") - if err != nil { + if err := sprites.CreateCheckpoint(spriteName, "initial-setup"); err != nil { fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } else { - checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }) } fmt.Println(spriteCheckStyle.Render("✓ Sprite setup complete")) @@ -362,33 +350,19 @@ func runSpriteDown() error { } defer database.Close() - client, err := sprites.NewClient(database) - if err != nil { - return err + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") } spriteName := sprites.GetName(database) - ctx := context.Background() - - sprite, err := client.GetSprite(ctx, spriteName) - if err != nil { - return fmt.Errorf("sprite not found: %s", spriteName) - } fmt.Println("Creating checkpoint...") - checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, fmt.Sprintf("manual-%s", time.Now().Format("2006-01-02-150405"))) - if err != nil { + if err := sprites.CreateCheckpoint(spriteName, "manual-checkpoint"); err != nil { return fmt.Errorf("checkpoint failed: %w", err) } - if err := checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { - return nil - }); err != nil { - return fmt.Errorf("checkpoint failed: %w", err) - } - - fmt.Println(spriteCheckStyle.Render("✓ Sprite checkpointed and suspended")) - fmt.Println(dimStyle.Render(" Storage costs only while suspended (~$0.01/day per GB)")) + fmt.Println(spriteCheckStyle.Render("✓ Sprite checkpointed")) + fmt.Println(dimStyle.Render(" The sprite will suspend after idle timeout")) return nil } @@ -401,20 +375,25 @@ func runSpriteAttach() error { } defer database.Close() - client, err := sprites.NewClient(database) - if err != nil { - return err + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") } spriteName := sprites.GetName(database) - sprite := client.Sprite(spriteName) + // Use the sprite CLI's console command for interactive shell fmt.Println("Attaching to sprite...") fmt.Println(dimStyle.Render("Press Ctrl+D to detach")) fmt.Println() - cmd := sprite.Command("bash") - cmd.SetTTY(true) + // First select the sprite + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return fmt.Errorf("select sprite: %w", err) + } + + // Then open console + cmd := exec.Command("sprite", "console") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -422,50 +401,6 @@ func runSpriteAttach() error { return cmd.Run() } -// listSpriteSessions shows all active exec sessions on the sprite. -func listSpriteSessions() error { - dbPath := db.DefaultPath() - database, err := db.Open(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer database.Close() - - client, err := sprites.NewClient(database) - if err != nil { - return err - } - - spriteName := sprites.GetName(database) - ctx := context.Background() - - sessions, err := client.ListSessions(ctx, spriteName) - if err != nil { - return fmt.Errorf("list sessions: %w", err) - } - - fmt.Println(spriteTitleStyle.Render("Active Sessions")) - fmt.Println() - - if len(sessions) == 0 { - fmt.Println(dimStyle.Render(" No active sessions")) - return nil - } - - for _, s := range sessions { - statusStyle := spriteCheckStyle - if !s.IsActive { - statusStyle = dimStyle - } - fmt.Printf(" %s %s\n", statusStyle.Render("●"), s.ID) - fmt.Printf(" Command: %s\n", s.Command) - fmt.Printf(" Active: %t\n", s.IsActive) - fmt.Println() - } - - return nil -} - // runSpriteDestroy permanently deletes the sprite. func runSpriteDestroy() error { dbPath := db.DefaultPath() @@ -475,61 +410,37 @@ func runSpriteDestroy() error { } defer database.Close() - client, err := sprites.NewClient(database) - if err != nil { - return err + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") } spriteName := sprites.GetName(database) - ctx := context.Background() fmt.Printf("Destroying sprite: %s\n", spriteName) - if err := client.DestroySprite(ctx, spriteName); err != nil { - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) + if err := sprites.Destroy(spriteName); err != nil { + return fmt.Errorf("destroy failed: %w", err) } + fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) + // Clear sprite name from database - database.SetSetting(sprites.SettingName, "") + sprites.SetName(database, "") return nil } -// showSpriteToken shows whether a token is configured. -func showSpriteToken() { - dbPath := db.DefaultPath() - database, err := db.Open(dbPath) - if err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } - defer database.Close() - - token := sprites.GetToken(database) - if token == "" { - fmt.Println(dimStyle.Render("No Sprites token configured.")) - fmt.Println(dimStyle.Render("Set with: task sprite token ")) - fmt.Println(dimStyle.Render("Or: export SPRITES_TOKEN=")) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Sprites token is configured")) - fmt.Printf(" Token: %s...%s\n", token[:8], token[len(token)-4:]) +// runSpriteCreate creates a new sprite. +func runSpriteCreate(name string) error { + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available. Run 'sprite login' first") } -} - -// setSpriteToken saves the Sprites API token to the database. -func setSpriteToken(token string) error { - dbPath := db.DefaultPath() - database, err := db.Open(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer database.Close() - if err := database.SetSetting(sprites.SettingToken, token); err != nil { - return fmt.Errorf("save token: %w", err) + fmt.Printf("Creating sprite: %s\n", name) + if err := sprites.CreateSprite(name); err != nil { + return fmt.Errorf("create sprite: %w", err) } - fmt.Println(spriteCheckStyle.Render("✓ Sprites token saved")) + fmt.Println(spriteCheckStyle.Render("✓ Sprite created: " + name)) + fmt.Println(dimStyle.Render(" Run 'task sprite use " + name + "' to select it")) return nil } diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go index fdf321e2..e44af72f 100644 --- a/internal/executor/executor_sprite.go +++ b/internal/executor/executor_sprite.go @@ -1,28 +1,20 @@ package executor import ( - "bufio" "context" - "encoding/json" "fmt" "strings" "sync" - "time" "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/sprites" - sdk "github.com/superfly/sprites-go" ) -// SpriteRunner manages Claude execution on sprites using native exec sessions. -// Key improvements over tmux-based approach: -// - No tmux dependency on the sprite -// - Uses exec API directly with Env and Dir -// - Streams stdout for real-time logs -// - Uses Filesystem API for file operations +// SpriteRunner manages Claude execution on sprites using the sprite CLI. +// Uses the sprite CLI directly to leverage existing fly.io authentication. type SpriteRunner struct { - db *db.DB - sprite *sdk.Sprite + db *db.DB + spriteName string // Active task tracking activeTasks map[int64]context.CancelFunc @@ -33,42 +25,43 @@ type SpriteRunner struct { // NewSpriteRunner creates a new sprite runner. func NewSpriteRunner(database *db.DB, logger func(format string, args ...interface{})) (*SpriteRunner, error) { - ctx := context.Background() - - client, sprite, err := sprites.EnsureRunning(ctx, database) - if err != nil { - return nil, fmt.Errorf("ensure sprite running: %w", err) + if !sprites.IsAvailable() { + return nil, fmt.Errorf("sprite CLI not available or not authenticated. Run 'sprite login' first") } - runner := &SpriteRunner{ - db: database, - sprite: sprite, - activeTasks: make(map[int64]context.CancelFunc), - logger: logger, + spriteName := sprites.GetName(database) + + // Check if sprite exists + spriteList, err := sprites.ListSprites() + if err != nil { + return nil, fmt.Errorf("list sprites: %w", err) } - // If sprite is nil, we need to create it - if sprite == nil { - spriteName := sprites.GetName(database) - sprite, err = client.CreateSprite(ctx, spriteName, nil) - if err != nil { - return nil, fmt.Errorf("create sprite: %w", err) + found := false + for _, s := range spriteList { + if s == spriteName { + found = true + break } - runner.sprite = sprite - - // Save sprite name - database.SetSetting(sprites.SettingName, spriteName) + } - logger("Sprite created but may need setup. Run 'task sprite up' to initialize.") + if !found { + logger("Sprite '%s' not found. Available sprites: %v", spriteName, spriteList) + logger("Create with 'sprite create %s' or set a different name with 'task sprite use '", spriteName) + return nil, fmt.Errorf("sprite '%s' not found", spriteName) } - return runner, nil + return &SpriteRunner{ + db: database, + spriteName: spriteName, + activeTasks: make(map[int64]context.CancelFunc), + logger: logger, + }, nil } // RunClaude executes Claude on the sprite for a given task. -// Uses the exec API directly - sessions persist after disconnect. func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir string, prompt string) error { - r.logger("Running Claude on sprite for task %d", task.ID) + r.logger("Running Claude on sprite '%s' for task %d", r.spriteName, task.ID) // Create cancellable context for this task taskCtx, cancel := context.WithCancel(ctx) @@ -82,72 +75,44 @@ func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir str r.activeTasksMu.Unlock() }() - // Build the Claude command with proper environment - cmd := r.sprite.CommandContext(taskCtx, "claude", + // Build the Claude command + // Note: sprite exec runs commands on the remote sprite + claudeArgs := []string{ + "claude", "--dangerously-skip-permissions", - "-p", prompt) - - // Set working directory and environment directly (no shell escaping needed) - cmd.Dir = workDir - cmd.Env = []string{ - fmt.Sprintf("TASK_ID=%d", task.ID), - } - - // Get stdout for streaming logs to database - stdout, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("stdout pipe: %w", err) + "-p", prompt, } - stderr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("stderr pipe: %w", err) - } + // Log the command we're running + r.db.AppendTaskLog(task.ID, "system", fmt.Sprintf("Executing on sprite: claude -p '%s...'", truncate(prompt, 50))) - // Handle port notifications (when Claude starts a dev server) - cmd.TextMessageHandler = func(data []byte) { - var notification struct { - Type string `json:"type"` - Port int `json:"port"` - ProxyURL string `json:"proxy_url"` + // Stream output to task logs + lineHandler := func(line string) { + // Skip empty lines + if strings.TrimSpace(line) == "" { + return } - if err := json.Unmarshal(data, ¬ification); err == nil { - if notification.Type == "port_opened" { - r.db.AppendTaskLog(task.ID, "system", - fmt.Sprintf("Dev server available at %s", notification.ProxyURL)) - r.logger("Port %d opened, proxy URL: %s", notification.Port, notification.ProxyURL) - } - } - } - // Start the command - if err := cmd.Start(); err != nil { - return fmt.Errorf("start claude: %w", err) - } - - r.logger("Claude started on sprite") + // Check for cancellation + select { + case <-taskCtx.Done(): + return + default: + } - // Stream stdout to task logs in background - go func() { - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() + // Log to database + if strings.HasPrefix(line, "[stderr]") { + r.db.AppendTaskLog(task.ID, "claude_stderr", strings.TrimPrefix(line, "[stderr] ")) + } else { r.db.AppendTaskLog(task.ID, "claude", line) - // Parse Claude's output for status changes - r.parseClaudeOutput(task.ID, line) } - }() - // Stream stderr to task logs in background - go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - r.db.AppendTaskLog(task.ID, "claude_stderr", scanner.Text()) - } - }() + // Parse for status changes + r.parseClaudeOutput(task.ID, line) + } - // Wait for Claude to exit - this blocks without polling! - err = cmd.Wait() + // Execute with streaming + err := sprites.ExecCommandStreaming(r.spriteName, lineHandler, claudeArgs...) if err != nil { // Check if it was cancelled @@ -205,23 +170,6 @@ func (r *SpriteRunner) IsTaskRunning(taskID int64) bool { return ok } -// SyncWorkdir syncs the local workdir to the sprite using the Filesystem API. -func (r *SpriteRunner) SyncWorkdir(ctx context.Context, localPath string, remotePath string) error { - // Use Filesystem API to create directory - fs := r.sprite.Filesystem() - if err := fs.MkdirAll(remotePath, 0755); err != nil { - return fmt.Errorf("create remote dir: %w", err) - } - - r.logger("Created remote directory %s on sprite", remotePath) - return nil -} - -// GetSprite returns the sprite reference. -func (r *SpriteRunner) GetSprite() *sdk.Sprite { - return r.sprite -} - // Shutdown gracefully shuts down the sprite runner. func (r *SpriteRunner) Shutdown() { r.activeTasksMu.Lock() @@ -248,33 +196,15 @@ func (r *SpriteRunner) ListActiveTasks() []int64 { return result } -// StartIdleWatcher periodically creates checkpoints when sprite is idle. -// This saves money by suspending sprites that aren't being used. -func (r *SpriteRunner) StartIdleWatcher(ctx context.Context, idleTimeout time.Duration) { - go func() { - lastActivity := time.Now() - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - r.activeTasksMu.RLock() - hasActiveTasks := len(r.activeTasks) > 0 - r.activeTasksMu.RUnlock() - - if hasActiveTasks { - lastActivity = time.Now() - } else if time.Since(lastActivity) > idleTimeout { - r.logger("Sprite idle for %v, creating checkpoint", idleTimeout) - if stream, err := r.sprite.CreateCheckpointWithComment(ctx, "auto-idle"); err == nil { - stream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }) - } - lastActivity = time.Now() - } - } - } - }() +// GetSpriteName returns the name of the sprite being used. +func (r *SpriteRunner) GetSpriteName() string { + return r.spriteName +} + +// truncate truncates a string to the given length. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." } diff --git a/internal/sprites/sprites.go b/internal/sprites/sprites.go index dce2474c..c247a476 100644 --- a/internal/sprites/sprites.go +++ b/internal/sprites/sprites.go @@ -1,48 +1,28 @@ // Package sprites provides shared functionality for Fly.io Sprites integration. // Sprites are isolated VMs used as execution environments for Claude. // -// Key features of the Sprites exec API used by this package: -// - Sessions persist after client disconnect (no tmux needed) -// - cmd.Wait() blocks until process exits (no polling required) -// - Session IDs enable crash recovery (can reattach to running sessions) -// - Filesystem API for direct file operations (no shell escaping) -// - Network policies for security (whitelist allowed domains) -// - Port notifications for dev servers (auto-expose proxy URLs) +// This implementation uses the sprite CLI directly (rather than the SDK) +// to leverage the user's existing fly.io authentication. package sprites import ( - "context" + "bytes" + "encoding/json" "fmt" - "os" + "os/exec" + "strings" "github.com/bborn/workflow/internal/db" - sdk "github.com/superfly/sprites-go" ) // Settings keys for sprite configuration const ( - SettingToken = "sprite_token" // Sprites API token - SettingName = "sprite_name" // Name of the daemon sprite + SettingName = "sprite_name" // Name of the sprite to use ) // Default sprite name const DefaultName = "task-daemon" -// GetToken returns the Sprites API token from env or database. -func GetToken(database *db.DB) string { - // First try environment variable - token := os.Getenv("SPRITES_TOKEN") - if token != "" { - return token - } - - // Fall back to database setting - if database != nil { - token, _ = database.GetSetting(SettingToken) - } - return token -} - // GetName returns the name of the daemon sprite. func GetName(database *db.DB) string { if database != nil { @@ -54,61 +34,251 @@ func GetName(database *db.DB) string { return DefaultName } -// NewClient creates a Sprites API client. -func NewClient(database *db.DB) (*sdk.Client, error) { - token := GetToken(database) - if token == "" { - return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task sprite token ") +// SetName saves the sprite name to the database. +func SetName(database *db.DB, name string) error { + return database.SetSetting(SettingName, name) +} + +// IsAvailable checks if the sprite CLI is installed and authenticated. +func IsAvailable() bool { + // Check if sprite CLI exists + if _, err := exec.LookPath("sprite"); err != nil { + return false } - return sdk.New(token), nil + + // Check if authenticated by listing orgs + cmd := exec.Command("sprite", "org", "list") + return cmd.Run() == nil } -// EnsureRunning ensures the sprite is running and returns the sprite reference. -// Creates the sprite if it doesn't exist, restores from checkpoint if suspended. -func EnsureRunning(ctx context.Context, database *db.DB) (*sdk.Client, *sdk.Sprite, error) { - client, err := NewClient(database) +// ListSprites returns a list of available sprites. +func ListSprites() ([]string, error) { + cmd := exec.Command("sprite", "list") + output, err := cmd.Output() if err != nil { - return nil, nil, err + return nil, fmt.Errorf("list sprites: %w", err) + } + + var sprites []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + line = strings.TrimSpace(line) + if line != "" { + sprites = append(sprites, line) + } } + return sprites, nil +} + +// SpriteInfo contains information about a sprite. +type SpriteInfo struct { + Name string `json:"name"` + Status string `json:"status"` + URL string `json:"url"` +} - spriteName := GetName(database) +// GetSprite returns information about a specific sprite. +func GetSprite(name string) (*SpriteInfo, error) { + // Use the sprite in the specified directory context + cmd := exec.Command("sprite", "use", name) + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("sprite not found: %s", name) + } - // Check if sprite exists - sprite, err := client.GetSprite(ctx, spriteName) + // Get the URL to verify it's accessible + urlCmd := exec.Command("sprite", "url") + urlOutput, _ := urlCmd.Output() + + return &SpriteInfo{ + Name: name, + Status: "running", // If use succeeded, it's accessible + URL: strings.TrimSpace(string(urlOutput)), + }, nil +} + +// CreateSprite creates a new sprite with the given name. +func CreateSprite(name string) error { + cmd := exec.Command("sprite", "create", name) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("create sprite: %s", stderr.String()) + } + return nil +} + +// ExecCommand executes a command on the sprite and returns stdout. +func ExecCommand(spriteName string, args ...string) (string, error) { + // First ensure we're using the right sprite + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return "", fmt.Errorf("select sprite: %w", err) + } + + // Execute the command + execArgs := append([]string{"exec"}, args...) + cmd := exec.Command("sprite", execArgs...) + output, err := cmd.CombinedOutput() if err != nil { - // Sprite doesn't exist - caller should create and set it up - return client, nil, nil + return string(output), fmt.Errorf("exec failed: %w: %s", err, output) } + return string(output), nil +} - // Restore from checkpoint if suspended - if sprite.Status == "suspended" || sprite.Status == "stopped" { - checkpoints, err := sprite.ListCheckpoints(ctx, "") - if err != nil || len(checkpoints) == 0 { - return nil, nil, fmt.Errorf("sprite is suspended but no checkpoints available") - } +// ExecCommandStreaming executes a command and streams output line by line. +func ExecCommandStreaming(spriteName string, onLine func(line string), args ...string) error { + // First ensure we're using the right sprite + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return fmt.Errorf("select sprite: %w", err) + } - restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) - if err != nil { - return nil, nil, fmt.Errorf("restore checkpoint: %w", err) + // Execute the command with streaming + execArgs := append([]string{"exec"}, args...) + cmd := exec.Command("sprite", execArgs...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + + // Read stdout + go func() { + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + if n > 0 { + for _, line := range strings.Split(string(buf[:n]), "\n") { + if line != "" { + onLine(line) + } + } + } + if err != nil { + break + } } + }() - if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { - return nil - }); err != nil { - return nil, nil, fmt.Errorf("restore failed: %w", err) + // Read stderr + go func() { + buf := make([]byte, 4096) + for { + n, err := stderr.Read(buf) + if n > 0 { + for _, line := range strings.Split(string(buf[:n]), "\n") { + if line != "" { + onLine("[stderr] " + line) + } + } + } + if err != nil { + break + } } + }() + + return cmd.Wait() +} - // Refresh sprite status - sprite, err = client.GetSprite(ctx, spriteName) +// CheckpointInfo contains information about a checkpoint. +type CheckpointInfo struct { + ID string `json:"id"` + Comment string `json:"comment"` +} + +// ListCheckpoints returns available checkpoints for a sprite. +func ListCheckpoints(spriteName string) ([]CheckpointInfo, error) { + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return nil, fmt.Errorf("select sprite: %w", err) + } + + cmd := exec.Command("sprite", "checkpoint", "list", "--json") + output, err := cmd.Output() + if err != nil { + // Try without --json flag + cmd = exec.Command("sprite", "checkpoint", "list") + output, err = cmd.Output() if err != nil { - return nil, nil, err + return nil, fmt.Errorf("list checkpoints: %w", err) + } + // Parse text output + var checkpoints []CheckpointInfo + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "ID") { + parts := strings.Fields(line) + if len(parts) > 0 { + checkpoints = append(checkpoints, CheckpointInfo{ID: parts[0]}) + } + } } + return checkpoints, nil } - return client, sprite, nil + var checkpoints []CheckpointInfo + if err := json.Unmarshal(output, &checkpoints); err != nil { + return nil, fmt.Errorf("parse checkpoints: %w", err) + } + return checkpoints, nil +} + +// CreateCheckpoint creates a new checkpoint. +func CreateCheckpoint(spriteName, comment string) error { + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return fmt.Errorf("select sprite: %w", err) + } + + args := []string{"checkpoint", "create"} + if comment != "" { + args = append(args, "--comment", comment) + } + cmd := exec.Command("sprite", args...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("create checkpoint: %w", err) + } + return nil +} + +// RestoreCheckpoint restores from a checkpoint. +func RestoreCheckpoint(spriteName, checkpointID string) error { + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return fmt.Errorf("select sprite: %w", err) + } + + cmd := exec.Command("sprite", "restore", checkpointID) + if err := cmd.Run(); err != nil { + return fmt.Errorf("restore checkpoint: %w", err) + } + return nil +} + +// Destroy destroys a sprite. +func Destroy(spriteName string) error { + useCmd := exec.Command("sprite", "use", spriteName) + if err := useCmd.Run(); err != nil { + return fmt.Errorf("select sprite: %w", err) + } + + cmd := exec.Command("sprite", "destroy", "--force") + if err := cmd.Run(); err != nil { + return fmt.Errorf("destroy sprite: %w", err) + } + return nil } -// IsEnabled returns true if sprites are configured (token is set). +// IsEnabled returns true if sprites CLI is available and authenticated. func IsEnabled(database *db.DB) bool { - return GetToken(database) != "" + return IsAvailable() } From 0eae5d02ce8861ab6a5372bdd8c84dc0c3932234 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 31 Jan 2026 22:06:22 -0600 Subject: [PATCH 4/4] fix: pass ANTHROPIC_API_KEY to sprite for Claude execution The sprite needs the API key to run Claude. Pass it through the environment when executing commands on the sprite. Co-Authored-By: Claude Opus 4.5 --- internal/executor/executor_sprite.go | 9 ++++++++- internal/sprites/sprites.go | 13 ++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go index e44af72f..77b6f27c 100644 --- a/internal/executor/executor_sprite.go +++ b/internal/executor/executor_sprite.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "os" "strings" "sync" @@ -83,6 +84,12 @@ func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir str "-p", prompt, } + // Build environment variables to pass through + var env []string + if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" { + env = append(env, "ANTHROPIC_API_KEY="+apiKey) + } + // Log the command we're running r.db.AppendTaskLog(task.ID, "system", fmt.Sprintf("Executing on sprite: claude -p '%s...'", truncate(prompt, 50))) @@ -112,7 +119,7 @@ func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir str } // Execute with streaming - err := sprites.ExecCommandStreaming(r.spriteName, lineHandler, claudeArgs...) + err := sprites.ExecCommandStreaming(r.spriteName, lineHandler, env, claudeArgs...) if err != nil { // Check if it was cancelled diff --git a/internal/sprites/sprites.go b/internal/sprites/sprites.go index c247a476..f0d8f2ed 100644 --- a/internal/sprites/sprites.go +++ b/internal/sprites/sprites.go @@ -125,15 +125,22 @@ func ExecCommand(spriteName string, args ...string) (string, error) { } // ExecCommandStreaming executes a command and streams output line by line. -func ExecCommandStreaming(spriteName string, onLine func(line string), args ...string) error { +// Pass environment variables to forward to the sprite (e.g., ANTHROPIC_API_KEY). +func ExecCommandStreaming(spriteName string, onLine func(line string), env []string, args ...string) error { // First ensure we're using the right sprite useCmd := exec.Command("sprite", "use", spriteName) if err := useCmd.Run(); err != nil { return fmt.Errorf("select sprite: %w", err) } - // Execute the command with streaming - execArgs := append([]string{"exec"}, args...) + // Build the command with env prefix if needed + // sprite exec env KEY=value command args... + execArgs := []string{"exec"} + if len(env) > 0 { + execArgs = append(execArgs, "env") + execArgs = append(execArgs, env...) + } + execArgs = append(execArgs, args...) cmd := exec.Command("sprite", execArgs...) stdout, err := cmd.StdoutPipe()