237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
)
|
|
|
|
// Park command parks work on a gate, allowing agent to exit safely.
|
|
// When the gate closes, waiters are notified and can resume.
|
|
|
|
var parkCmd = &cobra.Command{
|
|
Use: "park <gate-id>",
|
|
GroupID: GroupWork,
|
|
Short: "Park work on a gate for async resumption",
|
|
Long: `Park current work on a gate, allowing the agent to exit safely.
|
|
|
|
When you need to wait for an external condition (timer, CI, human approval),
|
|
park your work on a gate. When the gate closes, you'll receive wake mail.
|
|
|
|
The park command:
|
|
1. Saves your current hook state (molecule/bead you're working on)
|
|
2. Adds you as a waiter on the gate
|
|
3. Stores context notes in the parked state
|
|
|
|
After parking, you can exit the session safely. Use 'gt resume' to check
|
|
for cleared gates and continue work.
|
|
|
|
Examples:
|
|
# Create a timer gate and park work on it
|
|
bd gate create --await timer:30m --title "Coffee break"
|
|
gt park <gate-id> -m "Taking a break, will resume auth work"
|
|
|
|
# Park on a human approval gate
|
|
bd gate create --await human:deploy-approval --notify ops/
|
|
gt park <gate-id> -m "Deploy staged, awaiting approval"
|
|
|
|
# Park on a GitHub Actions gate
|
|
bd gate create --await gh:run:123456789
|
|
gt park <gate-id> -m "Waiting for CI to complete"`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runPark,
|
|
}
|
|
|
|
var (
|
|
parkMessage string
|
|
parkDryRun bool
|
|
)
|
|
|
|
func init() {
|
|
parkCmd.Flags().StringVarP(&parkMessage, "message", "m", "", "Context notes for resumption")
|
|
parkCmd.Flags().BoolVarP(&parkDryRun, "dry-run", "n", false, "Show what would be done without executing")
|
|
rootCmd.AddCommand(parkCmd)
|
|
}
|
|
|
|
// ParkedWork represents work state saved when parking on a gate.
|
|
type ParkedWork struct {
|
|
// AgentID is the agent that parked (e.g., "gastown/crew/max")
|
|
AgentID string `json:"agent_id"`
|
|
|
|
// GateID is the gate we're parked on
|
|
GateID string `json:"gate_id"`
|
|
|
|
// BeadID is the bead/molecule we were working on
|
|
BeadID string `json:"bead_id,omitempty"`
|
|
|
|
// Formula is the formula attached to the work (if any)
|
|
Formula string `json:"formula,omitempty"`
|
|
|
|
// Context is additional context notes from the agent
|
|
Context string `json:"context,omitempty"`
|
|
|
|
// ParkedAt is when the work was parked
|
|
ParkedAt time.Time `json:"parked_at"`
|
|
}
|
|
|
|
func runPark(cmd *cobra.Command, args []string) error {
|
|
gateID := args[0]
|
|
|
|
// Verify gate exists and is open
|
|
gateCheck := exec.Command("bd", "gate", "show", gateID, "--json")
|
|
gateOutput, err := gateCheck.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("gate '%s' not found or not accessible", gateID)
|
|
}
|
|
|
|
// Parse gate info to verify it's open
|
|
var gateInfo struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(gateOutput, &gateInfo); err != nil {
|
|
return fmt.Errorf("parsing gate info: %w", err)
|
|
}
|
|
if gateInfo.Status == "closed" {
|
|
return fmt.Errorf("gate '%s' is already closed - nothing to park on", gateID)
|
|
}
|
|
|
|
// Detect agent identity
|
|
agentID, _, cloneRoot, err := resolveSelfTarget()
|
|
if err != nil {
|
|
return fmt.Errorf("detecting agent identity: %w", err)
|
|
}
|
|
|
|
// Read current pinned bead (if any)
|
|
var beadID, formula, hookContext string
|
|
workDir, err := findLocalBeadsDir()
|
|
if err == nil {
|
|
b := beads.New(workDir)
|
|
pinnedBeads, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusPinned,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err == nil && len(pinnedBeads) > 0 {
|
|
beadID = pinnedBeads[0].ID
|
|
// Extract molecule from attachment fields
|
|
if attachment := beads.ParseAttachmentFields(pinnedBeads[0]); attachment != nil {
|
|
formula = attachment.AttachedMolecule
|
|
}
|
|
// Context is part of the bead description, not stored separately
|
|
hookContext = pinnedBeads[0].Description
|
|
}
|
|
}
|
|
|
|
// Build context combining hook context and new message
|
|
context := ""
|
|
if hookContext != "" && parkMessage != "" {
|
|
context = hookContext + "\n---\n" + parkMessage
|
|
} else if hookContext != "" {
|
|
context = hookContext
|
|
} else if parkMessage != "" {
|
|
context = parkMessage
|
|
}
|
|
|
|
// Create parked work record
|
|
parked := &ParkedWork{
|
|
AgentID: agentID,
|
|
GateID: gateID,
|
|
BeadID: beadID,
|
|
Formula: formula,
|
|
Context: context,
|
|
ParkedAt: time.Now(),
|
|
}
|
|
|
|
if parkDryRun {
|
|
fmt.Printf("Would park on gate %s\n", gateID)
|
|
fmt.Printf(" Agent: %s\n", agentID)
|
|
if beadID != "" {
|
|
fmt.Printf(" Bead: %s\n", beadID)
|
|
}
|
|
if formula != "" {
|
|
fmt.Printf(" Formula: %s\n", formula)
|
|
}
|
|
if context != "" {
|
|
fmt.Printf(" Context: %s\n", context)
|
|
}
|
|
fmt.Printf("Would add %s as waiter on gate\n", agentID)
|
|
return nil
|
|
}
|
|
|
|
// Add agent as waiter on the gate
|
|
waitCmd := exec.Command("bd", "gate", "wait", gateID, "--notify", agentID)
|
|
if err := waitCmd.Run(); err != nil {
|
|
// Not fatal - might already be a waiter
|
|
fmt.Printf("%s Note: could not add as waiter (may already be registered)\n", style.Dim.Render("⚠"))
|
|
}
|
|
|
|
// Store parked work in a file (alongside hook files)
|
|
parkedPath := parkedWorkPath(cloneRoot, agentID)
|
|
parkedJSON, err := json.MarshalIndent(parked, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling parked work: %w", err)
|
|
}
|
|
if err := os.WriteFile(parkedPath, parkedJSON, 0644); err != nil {
|
|
return fmt.Errorf("writing parked state: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Parked work on gate %s\n", style.Bold.Render("🅿️"), gateID)
|
|
if beadID != "" {
|
|
fmt.Printf(" Working on: %s\n", beadID)
|
|
}
|
|
if context != "" {
|
|
// Truncate for display
|
|
displayContext := context
|
|
if len(displayContext) > 80 {
|
|
displayContext = displayContext[:77] + "..."
|
|
}
|
|
fmt.Printf(" Context: %s\n", displayContext)
|
|
}
|
|
fmt.Printf("\n%s You can now safely exit. Run 'gt resume' to check for cleared gates.\n",
|
|
style.Dim.Render("→"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// parkedWorkPath returns the file path for an agent's parked work state.
|
|
func parkedWorkPath(cloneRoot, agentID string) string {
|
|
return filepath.Join(cloneRoot, ".beads", fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_")))
|
|
}
|
|
|
|
// readParkedWork reads the parked work state for an agent.
|
|
func readParkedWork(cloneRoot, agentID string) (*ParkedWork, error) {
|
|
parkedPath := parkedWorkPath(cloneRoot, agentID)
|
|
data, err := os.ReadFile(parkedPath)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var parked ParkedWork
|
|
if err := json.Unmarshal(data, &parked); err != nil {
|
|
return nil, err
|
|
}
|
|
return &parked, nil
|
|
}
|
|
|
|
// clearParkedWork removes the parked work state for an agent.
|
|
func clearParkedWork(cloneRoot, agentID string) error {
|
|
parkedPath := parkedWorkPath(cloneRoot, agentID)
|
|
err := os.Remove(parkedPath)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|