diff --git a/.gitignore b/.gitignore index 2ac8355..82fd3ee 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ bin/ # Tmux tmux-*.log +.sprite diff --git a/cmd/task/main.go b/cmd/task/main.go index 297a259..1f27d20 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -1355,6 +1355,9 @@ Examples: // Cloud subcommand rootCmd.AddCommand(createCloudCommand()) + // Sprite subcommand (cloud execution via Fly.io Sprites) + rootCmd.AddCommand(createSpriteCommand()) + // Settings command settingsCmd := &cobra.Command{ Use: "settings", diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go new file mode 100644 index 0000000..2411500 --- /dev/null +++ b/cmd/task/sprite.go @@ -0,0 +1,446 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +// 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")) +) + +// 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. + +Uses the 'sprite' CLI which authenticates via fly.io login. +Run 'sprite login' first if not already authenticated. + +Commands: + 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 + destroy - Delete the sprite entirely`, + 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 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", + 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 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 create + createCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new sprite", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteCreate(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(createCmd) + + 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() + + fmt.Println(spriteTitleStyle.Render("Sprite Status")) + fmt.Println() + + // 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.Println(spriteCheckStyle.Render(" ✓ sprite CLI authenticated")) + + // 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(" %s: %s\n", spriteErrorStyle.Render("Error listing sprites"), err.Error()) + 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) + } + } + + // Check if selected sprite exists + found := false + for _, s := range spriteList { + if s == spriteName { + found = true + break + } + } + + if !found && len(spriteList) > 0 { + fmt.Println() + 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. +func runSpriteUp() error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available. Run 'sprite login' first") + } + + spriteName := sprites.GetName(database) + + // Check if sprite exists + spriteList, err := sprites.ListSprites() + if err != nil { + 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) + if err := sprites.CreateSprite(spriteName); err != nil { + return fmt.Errorf("create sprite: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) + + // Set up the sprite + if err := setupSpriteCLI(spriteName); err != nil { + return fmt.Errorf("setup sprite: %w", err) + } + } else { + // Try to access the sprite to check if it's running + info, err := sprites.GetSprite(spriteName) + if err != nil { + // 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)) + } + } + + return nil +} + +// setupSpriteCLI sets up a new sprite with required packages. +func setupSpriteCLI(spriteName string) error { + fmt.Println("Setting up sprite...") + + 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"}, + {"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) + output, err := sprites.ExecCommand(spriteName, "sh", "-c", step.cmd) + if err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + if output != "" { + fmt.Printf(" %s\n", dimStyle.Render(output)) + } + } + } + + // Create initial checkpoint + fmt.Println(" Creating checkpoint...") + if err := sprites.CreateCheckpoint(spriteName, "initial-setup"); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } + + 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() + + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") + } + + spriteName := sprites.GetName(database) + + fmt.Println("Creating checkpoint...") + if err := sprites.CreateCheckpoint(spriteName, "manual-checkpoint"); err != nil { + return fmt.Errorf("checkpoint failed: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Sprite checkpointed")) + fmt.Println(dimStyle.Render(" The sprite will suspend after idle timeout")) + 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() + + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") + } + + spriteName := sprites.GetName(database) + + // 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() + + // 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 + + return cmd.Run() +} + +// 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() + + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available") + } + + spriteName := sprites.GetName(database) + + fmt.Printf("Destroying sprite: %s\n", spriteName) + 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 + sprites.SetName(database, "") + + return nil +} + +// runSpriteCreate creates a new sprite. +func runSpriteCreate(name string) error { + if !sprites.IsAvailable() { + return fmt.Errorf("sprite CLI not available. Run 'sprite login' first") + } + + 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("✓ Sprite created: " + name)) + fmt.Println(dimStyle.Render(" Run 'task sprite use " + name + "' to select it")) + return nil +} diff --git a/docs/sprites-design.md b/docs/sprites-design.md new file mode 100644 index 0000000..0ce63f1 --- /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 0000000..375dd0e --- /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 732a7d2..f6e0356 100644 --- a/go.mod +++ b/go.mod @@ -11,13 +11,17 @@ 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 + gopkg.in/yaml.v3 v3.0.1 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 +44,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 +62,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 +70,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 56d0eb2..89de677 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= @@ -157,6 +159,7 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index d288370..bc53708 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -236,6 +236,9 @@ func (db *DB) migrate() error { // Tmux pane IDs for deterministic pane identification (avoids index-based guessing) `ALTER TABLE tasks ADD COLUMN claude_pane_id TEXT DEFAULT ''`, // tmux pane ID for Claude/executor pane (e.g., "%1234") `ALTER TABLE tasks ADD COLUMN shell_pane_id TEXT DEFAULT ''`, // tmux pane ID for shell pane (e.g., "%1235") + // 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 ad920fc..18310fc 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -977,6 +977,8 @@ type Project struct { Actions []ProjectAction // actions triggered on task events (stored as JSON) Color string // hex color for display (e.g., "#61AFEF") ClaudeConfigDir string // override CLAUDE_CONFIG_DIR for this project + SpriteName string // name of the sprite for cloud execution + SpriteStatus string // sprite status: "", "ready", "checkpointed", "error" CreatedAt LocalTime } @@ -994,9 +996,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, color, claude_config_dir) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir) + INSERT INTO projects (name, path, aliases, instructions, actions, color, claude_config_dir, sprite_name, sprite_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, p.SpriteName, p.SpriteStatus) if err != nil { return fmt.Errorf("insert project: %w", err) } @@ -1009,9 +1011,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 = ?, color = ?, claude_config_dir = ? + UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, + color = ?, claude_config_dir = ?, sprite_name = ?, sprite_status = ? WHERE id = ? - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, p.ID) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, p.SpriteName, p.SpriteStatus, p.ID) if err != nil { return fmt.Errorf("update project: %w", err) } @@ -1052,7 +1055,9 @@ func (db *DB) CountTasksByProject(projectName string) (int, 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, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(color, ''), COALESCE(claude_config_dir, ''), + 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 { @@ -1064,7 +1069,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.Color, &p.ClaudeConfigDir, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } json.Unmarshal([]byte(actionsJSON), &p.Actions) @@ -1079,9 +1084,11 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { p := &Project{} var actionsJSON string err := db.QueryRow(` - SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(color, ''), COALESCE(claude_config_dir, ''), + 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.Color, &p.ClaudeConfigDir, &p.CreatedAt) + `, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt) if err == nil { json.Unmarshal([]byte(actionsJSON), &p.Actions) return p, nil @@ -1091,7 +1098,12 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { } // Try alias match - rows, err := db.Query(`SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at FROM projects`) + rows, err := db.Query(` + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(color, ''), COALESCE(claude_config_dir, ''), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at + FROM projects + `) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } @@ -1099,7 +1111,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.Color, &p.ClaudeConfigDir, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &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 e876e3c..5f38307 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -22,6 +22,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" ) @@ -63,6 +64,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 + executorSlug string executorName string } @@ -207,6 +211,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") // Check for overdue scheduled tasks immediately on startup @@ -227,6 +245,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") } @@ -1864,6 +1887,16 @@ 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, task *db.Task, workDir, prompt string) execResult { + // If sprite runner is available, use cloud execution + if e.spriteRunner != nil { + e.logLine(task.ID, "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 { e.logLine(task.ID, "error", "tmux is not installed - required for task execution") diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go new file mode 100644 index 0000000..77b6f27 --- /dev/null +++ b/internal/executor/executor_sprite.go @@ -0,0 +1,217 @@ +package executor + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" +) + +// 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 + spriteName string + + // Active task tracking + activeTasks map[int64]context.CancelFunc + activeTasksMu sync.RWMutex + + 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) { + if !sprites.IsAvailable() { + return nil, fmt.Errorf("sprite CLI not available or not authenticated. Run 'sprite login' first") + } + + spriteName := sprites.GetName(database) + + // Check if sprite exists + spriteList, err := sprites.ListSprites() + if err != nil { + return nil, fmt.Errorf("list sprites: %w", err) + } + + found := false + for _, s := range spriteList { + if s == spriteName { + found = true + break + } + } + + 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 &SpriteRunner{ + db: database, + spriteName: spriteName, + activeTasks: make(map[int64]context.CancelFunc), + logger: logger, + }, nil +} + +// RunClaude executes Claude on the sprite for a given task. +func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir string, prompt string) error { + 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) + 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 + // Note: sprite exec runs commands on the remote sprite + claudeArgs := []string{ + "claude", + "--dangerously-skip-permissions", + "-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))) + + // Stream output to task logs + lineHandler := func(line string) { + // Skip empty lines + if strings.TrimSpace(line) == "" { + return + } + + // Check for cancellation + select { + case <-taskCtx.Done(): + return + default: + } + + // 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 for status changes + r.parseClaudeOutput(task.ID, line) + } + + // Execute with streaming + err := sprites.ExecCommandStreaming(r.spriteName, lineHandler, env, claudeArgs...) + + 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 +} + +// 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 +} + +// 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 new file mode 100644 index 0000000..f0d8f2e --- /dev/null +++ b/internal/sprites/sprites.go @@ -0,0 +1,291 @@ +// Package sprites provides shared functionality for Fly.io Sprites integration. +// Sprites are isolated VMs used as execution environments for Claude. +// +// This implementation uses the sprite CLI directly (rather than the SDK) +// to leverage the user's existing fly.io authentication. +package sprites + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/bborn/workflow/internal/db" +) + +// Settings keys for sprite configuration +const ( + SettingName = "sprite_name" // Name of the sprite to use +) + +// Default sprite name +const DefaultName = "task-daemon" + +// 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 +} + +// 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 + } + + // Check if authenticated by listing orgs + cmd := exec.Command("sprite", "org", "list") + return cmd.Run() == nil +} + +// 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, 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"` +} + +// 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) + } + + // 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 { + return string(output), fmt.Errorf("exec failed: %w: %s", err, output) + } + return string(output), nil +} + +// ExecCommandStreaming executes a command and streams output line by line. +// 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) + } + + // 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() + 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 + } + } + }() + + // 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() +} + +// 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, 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 + } + + 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 CLI is available and authenticated. +func IsEnabled(database *db.DB) bool { + return IsAvailable() +}