diff --git a/cmd/task/main.go b/cmd/task/main.go index dfb28208..0c73469d 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -1089,6 +1089,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..4903c84f --- /dev/null +++ b/cmd/task/sprite.go @@ -0,0 +1,463 @@ +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")) +) + +// 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 tmux session + 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 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)) +} + +// 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 with task daemon + 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 hooks on a new sprite. +func setupSprite(client *sdk.Client, sprite *sdk.Sprite) error { + ctx := context.Background() + + fmt.Println("Setting up sprite...") + + // Install essential packages + steps := []struct { + desc string + cmd string + }{ + {"Installing packages", "apt-get update && apt-get install -y tmux git curl"}, + {"Creating workspace", "mkdir -p /workspace"}, + {"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))) + } + } + + // Install the hook script that writes to a file for streaming back + fmt.Println(" Installing hook script...") + hookScript := `#!/bin/bash +# task-sprite-hook: Writes Claude hook events to a file for streaming +# The task executor on the local machine tails this file +input=$(cat) +printf '{"task_id":%s,"event":"%s","data":%s}\n' "$TASK_ID" "$1" "$input" >> /tmp/task-hooks.jsonl +` + installHookCmd := fmt.Sprintf(`cat > /usr/local/bin/task-sprite-hook << 'HOOKEOF' +%s +HOOKEOF +chmod +x /usr/local/bin/task-sprite-hook`, hookScript) + + cmd := sprite.CommandContext(ctx, "sh", "-c", installHookCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("install hook script: %w\n%s", err, string(output)) + } + + // Configure Claude to use our hook script + fmt.Println(" Configuring Claude hooks...") + claudeSettings := `{ + "permissions": { + "allow": ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)", "Grep(*)", "Glob(*)", "WebFetch(*)", "Task(*)", "TodoWrite(*)"], + "deny": [] + }, + "hooks": { + "PreToolUse": ["/usr/local/bin/task-sprite-hook PreToolUse"], + "PostToolUse": ["/usr/local/bin/task-sprite-hook PostToolUse"], + "Notification": ["/usr/local/bin/task-sprite-hook Notification"], + "Stop": ["/usr/local/bin/task-sprite-hook Stop"] + } +}` + configureClaudeCmd := fmt.Sprintf(`mkdir -p ~/.claude && cat > ~/.claude/settings.json << 'SETTINGSEOF' +%s +SETTINGSEOF`, claudeSettings) + + cmd = sprite.CommandContext(ctx, "sh", "-c", configureClaudeCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("configure claude: %w\n%s", err, string(output)) + } + + // Initialize the hooks file + cmd = sprite.CommandContext(ctx, "sh", "-c", "touch /tmp/task-hooks.jsonl") + cmd.Run() + + // 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() +} + +// 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..efa97dbf 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) 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 @@ -44,6 +45,7 @@ require ( 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 @@ -60,6 +62,7 @@ require ( 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/superfly/sprites-go v0.0.0-20260109202230-abba9310f931 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/go.sum b/go.sum index 56d0eb24..d0b5c5c8 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,6 +85,8 @@ 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= @@ -129,6 +133,8 @@ 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..527c13db 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,9 @@ 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 +560,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 +573,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 +588,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 +601,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 +613,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 9c69d873..b6e63e3b 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -18,6 +18,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" ) @@ -52,6 +53,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 } // New creates a new executor. @@ -98,6 +102,21 @@ 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.spriteRunner.StartHookListener() + e.logger.Info("Sprite runner initialized - Claude will execute on sprite") + } + } + e.logger.Info("Background executor started") go e.worker(ctx) @@ -114,6 +133,11 @@ func (e *Executor) Stop() { close(e.stopCh) e.mu.Unlock() + // Stop sprite runner if running + if e.spriteRunner != nil { + e.spriteRunner.StopHookListener() + } + e.logger.Info("Background executor stopped") } @@ -857,6 +881,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..e5c3da41 --- /dev/null +++ b/internal/executor/executor_sprite.go @@ -0,0 +1,277 @@ +package executor + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" + sdk "github.com/superfly/sprites-go" +) + +// spriteHookEvent represents a hook event received from a sprite. +type spriteHookEvent struct { + TaskID int64 `json:"task_id"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +// spriteHookData is the data field from Claude's hook input. +type spriteHookData struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + NotificationType string `json:"notification_type,omitempty"` + Message string `json:"message,omitempty"` + StopReason string `json:"stop_reason,omitempty"` +} + +// SpriteRunner manages Claude execution on sprites. +type SpriteRunner struct { + db *db.DB + client *sdk.Client + sprite *sdk.Sprite + + // Hook streaming + hookCtx context.Context + hookCancel context.CancelFunc + hookWg sync.WaitGroup + + 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) + } + + // 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) + } + + // Save sprite name + database.SetSetting(sprites.SettingName, spriteName) + + // Setup will be done by the caller (setupSprite in cmd/task/sprite.go) + // For now, we'll just note that it needs setup + logger("Sprite created but may need setup. Run 'task sprite up' to initialize.") + } + + return &SpriteRunner{ + db: database, + client: client, + sprite: sprite, + logger: logger, + }, nil +} + +// StartHookListener starts the background hook event listener. +func (r *SpriteRunner) StartHookListener() { + r.hookCtx, r.hookCancel = context.WithCancel(context.Background()) + + r.hookWg.Add(1) + go r.streamHooks() +} + +// StopHookListener stops the hook listener. +func (r *SpriteRunner) StopHookListener() { + if r.hookCancel != nil { + r.hookCancel() + } + r.hookWg.Wait() +} + +// streamHooks tails the hooks file on the sprite and processes events. +func (r *SpriteRunner) streamHooks() { + defer r.hookWg.Done() + + for { + select { + case <-r.hookCtx.Done(): + return + default: + } + + // Start tailing the hooks file + cmd := r.sprite.CommandContext(r.hookCtx, "tail", "-n", "0", "-f", "/tmp/task-hooks.jsonl") + stdout, err := cmd.StdoutPipe() + if err != nil { + r.logger("Failed to get stdout pipe: %v", err) + time.Sleep(time.Second) + continue + } + + if err := cmd.Start(); err != nil { + r.logger("Failed to start tail: %v", err) + time.Sleep(time.Second) + continue + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var event spriteHookEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + r.logger("Failed to parse hook event: %v", err) + continue + } + + r.handleHookEvent(&event) + } + + // If we get here, tail exited - wait and retry + cmd.Wait() + select { + case <-r.hookCtx.Done(): + return + case <-time.After(time.Second): + // Retry + } + } +} + +// handleHookEvent processes a hook event from the sprite. +func (r *SpriteRunner) handleHookEvent(event *spriteHookEvent) { + // Parse the hook data + var data spriteHookData + if err := json.Unmarshal(event.Data, &data); err != nil { + r.logger("Failed to parse hook data: %v", err) + return + } + + // Get current task + task, err := r.db.GetTask(event.TaskID) + if err != nil || task == nil { + return + } + + // Only manage status if the task has started + if task.StartedAt == nil { + return + } + + switch event.Event { + case "Stop": + if data.StopReason == "end_turn" && task.Status == db.StatusProcessing { + r.db.UpdateTaskStatus(event.TaskID, db.StatusBlocked) + r.db.AppendTaskLog(event.TaskID, "system", "Waiting for user input") + } + + case "Notification": + if (data.NotificationType == "idle_prompt" || data.NotificationType == "permission_prompt") && + task.Status == db.StatusProcessing { + r.db.UpdateTaskStatus(event.TaskID, db.StatusBlocked) + msg := "Waiting for user input" + if data.NotificationType == "permission_prompt" { + msg = "Waiting for permission" + } + r.db.AppendTaskLog(event.TaskID, "system", msg) + } + + case "PreToolUse", "PostToolUse": + if task.Status == db.StatusBlocked { + r.db.UpdateTaskStatus(event.TaskID, db.StatusProcessing) + r.db.AppendTaskLog(event.TaskID, "system", "Claude resumed working") + } + } +} + +// 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 for task %d", task.ID) + + // Create a tmux session on the sprite for this task + sessionName := fmt.Sprintf("task-%d", task.ID) + + // Kill any existing session with this name + killCmd := r.sprite.CommandContext(ctx, "tmux", "kill-session", "-t", sessionName) + killCmd.Run() // Ignore errors - session may not exist + + // Build the claude command + // Set TASK_ID env var so hooks know which task this is + claudeCmd := fmt.Sprintf(`cd %s && TASK_ID=%d claude --dangerously-skip-permissions -p %q`, + shellEscape(workDir), task.ID, prompt) + + // Create new tmux session running claude + cmd := r.sprite.CommandContext(ctx, "tmux", "new-session", "-d", "-s", sessionName, "-c", workDir, "sh", "-c", claudeCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("start claude session: %w: %s", err, string(output)) + } + + r.logger("Claude started in tmux session %s on sprite", sessionName) + + // Wait for the session to complete (poll for session existence) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + // Check if session still exists + checkCmd := r.sprite.CommandContext(ctx, "tmux", "has-session", "-t", sessionName) + if err := checkCmd.Run(); err != nil { + // Session ended + r.logger("Claude session %s completed", sessionName) + return nil + } + } + } +} + +// shellEscape escapes a string for use in a shell command. +func shellEscape(s string) string { + // Simple escape - wrap in single quotes and escape any single quotes + return "'" + escapeQuotes(s) + "'" +} + +func escapeQuotes(s string) string { + result := "" + for _, c := range s { + if c == '\'' { + result += "'\\''" + } else { + result += string(c) + } + } + return result +} + +// SyncWorkdir syncs the local workdir to the sprite. +// This copies the task's git worktree to the sprite. +func (r *SpriteRunner) SyncWorkdir(ctx context.Context, localPath string, remotePath string) error { + // For now, we'll just create the directory on the sprite + // In the future, we could use rsync or git clone + cmd := r.sprite.CommandContext(ctx, "mkdir", "-p", remotePath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("create remote dir: %w: %s", err, string(output)) + } + + // TODO: Implement actual sync (rsync, git clone, or file transfer) + 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 +} diff --git a/internal/sprites/sprites.go b/internal/sprites/sprites.go new file mode 100644 index 00000000..71197c1a --- /dev/null +++ b/internal/sprites/sprites.go @@ -0,0 +1,106 @@ +// Package sprites provides shared functionality for Fly.io Sprites integration. +// Sprites are isolated VMs used as execution environments for Claude. +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) != "" +}