feat: add gate issue type and CLI commands for async coordination (bd-udsi)
Add async gates - coordination primitives for agents to wait on external events like CI completion, PR merges, timers, or human approval. Changes: - Add 'gate' issue type to types.go with gate-specific fields: - AwaitType: condition type (gh:run, gh:pr, timer, human, mail) - AwaitID: condition identifier - Timeout: max wait duration - Waiters: mail addresses to notify when gate clears - Add SQLite migration 027_gate_columns for new fields - Update all SQLite storage queries to handle gate fields - Add bd gate commands: create, show, list, close, wait - All commands support --json output and --no-daemon mode Closes: bd-2v0f, bd-lz49, bd-u66e 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -428,7 +428,7 @@ func init() {
|
||||
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
|
||||
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
|
||||
countCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
|
||||
countCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
|
||||
countCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")
|
||||
|
||||
@@ -430,7 +430,7 @@ func init() {
|
||||
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
|
||||
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
|
||||
registerPriorityFlag(createCmd, "2")
|
||||
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule)")
|
||||
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate)")
|
||||
registerCommonIssueFlags(createCmd)
|
||||
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
|
||||
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")
|
||||
|
||||
@@ -537,7 +537,7 @@ func init() {
|
||||
|
||||
// Filter flags
|
||||
exportCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
exportCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
exportCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
|
||||
exportCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
|
||||
exportCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
|
||||
|
||||
|
||||
524
cmd/bd/gate.go
Normal file
524
cmd/bd/gate.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// Gate commands - async coordination primitives for agent workflows (bd-udsi)
|
||||
//
|
||||
// Gates are wisp issues that block until external conditions are met.
|
||||
// They are managed by the Deacon patrol and enable agents to wait on:
|
||||
// - GitHub Actions run completion (gh:run:<id>)
|
||||
// - Pull request merge/close (gh:pr:<id>)
|
||||
// - Simple timer delay (timer:<duration>)
|
||||
// - Human approval (human:<prompt>)
|
||||
// - Mail matching a pattern (mail:<pattern>)
|
||||
//
|
||||
// Usage:
|
||||
// bd gate create --await gh:run:123 --timeout 30m --notify beads/dave
|
||||
// bd gate show <id>
|
||||
// bd gate list
|
||||
// bd gate close <id> --reason "completed"
|
||||
// bd gate wait <id> --notify beads/alice
|
||||
|
||||
var gateCmd = &cobra.Command{
|
||||
Use: "gate",
|
||||
Short: "Gate commands (async coordination)",
|
||||
Long: `Manage gates - async coordination primitives for agent workflows.
|
||||
|
||||
Gates are ephemeral (wisp) issues that block until external conditions are met.
|
||||
They are typically managed by the Deacon patrol.
|
||||
|
||||
Await types:
|
||||
gh:run:<id> Wait for GitHub Actions run to complete
|
||||
gh:pr:<id> Wait for pull request to be merged/closed
|
||||
timer:<duration> Simple delay (e.g., timer:30m, timer:1h)
|
||||
human:<prompt> Wait for human approval
|
||||
mail:<pattern> Wait for mail matching pattern
|
||||
|
||||
Commands:
|
||||
create Create a new gate
|
||||
show Show gate details
|
||||
list List open gates
|
||||
close Close a gate
|
||||
wait Add a waiter to an existing gate`,
|
||||
}
|
||||
|
||||
var gateCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new gate",
|
||||
Long: `Create a new gate to wait on an external condition.
|
||||
|
||||
The gate will be created as a wisp issue (ephemeral) and assigned to the
|
||||
Deacon patrol for monitoring.
|
||||
|
||||
Examples:
|
||||
bd gate create --await gh:run:123456789 --timeout 30m --notify beads/dave
|
||||
bd gate create --await gh:pr:42 --timeout 1h --notify beads/dave beads/alice
|
||||
bd gate create --await timer:15m --notify beads/dave
|
||||
bd gate create --await human:approve-deploy --timeout 4h --notify beads/dave`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("gate create")
|
||||
ctx := rootCtx
|
||||
|
||||
awaitSpec, _ := cmd.Flags().GetString("await")
|
||||
timeoutStr, _ := cmd.Flags().GetString("timeout")
|
||||
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
|
||||
if awaitSpec == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --await is required\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse await spec into type and ID
|
||||
awaitType, awaitID := parseAwaitSpec(awaitSpec)
|
||||
if awaitType == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid await spec %q, expected format: type:id\n", awaitSpec)
|
||||
fmt.Fprintf(os.Stderr, "Valid types: gh:run, gh:pr, timer, human, mail\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse timeout
|
||||
var timeout time.Duration
|
||||
if timeoutStr != "" {
|
||||
var err error
|
||||
timeout, err = time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid timeout %q: %v\n", timeoutStr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate title if not provided
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Gate: %s:%s", awaitType, awaitID)
|
||||
}
|
||||
|
||||
// Gate creation requires direct store access
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate create requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate create ...\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
gate := &types.Issue{
|
||||
// ID will be generated by CreateIssue
|
||||
Title: title,
|
||||
IssueType: types.TypeGate,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1, // Gates are typically high priority
|
||||
Assignee: "deacon/",
|
||||
Wisp: true, // Gates are wisps (ephemeral)
|
||||
AwaitType: awaitType,
|
||||
AwaitID: awaitID,
|
||||
Timeout: timeout,
|
||||
Waiters: notifyAddrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
gate.ContentHash = gate.ComputeContentHash()
|
||||
|
||||
if err := store.CreateIssue(ctx, gate, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating gate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(gate)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created gate: %s\n", ui.RenderPass("✓"), gate.ID)
|
||||
fmt.Printf(" Await: %s:%s\n", awaitType, awaitID)
|
||||
if timeout > 0 {
|
||||
fmt.Printf(" Timeout: %v\n", timeout)
|
||||
}
|
||||
if len(notifyAddrs) > 0 {
|
||||
fmt.Printf(" Notify: %s\n", strings.Join(notifyAddrs, ", "))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// parseAwaitSpec parses an await specification like "gh:run:123" or "timer:30m"
|
||||
// Returns (awaitType, awaitID) or ("", "") if invalid
|
||||
func parseAwaitSpec(spec string) (string, string) {
|
||||
// Handle compound types like gh:run:123 and gh:pr:42
|
||||
if strings.HasPrefix(spec, "gh:run:") {
|
||||
return "gh:run", strings.TrimPrefix(spec, "gh:run:")
|
||||
}
|
||||
if strings.HasPrefix(spec, "gh:pr:") {
|
||||
return "gh:pr", strings.TrimPrefix(spec, "gh:pr:")
|
||||
}
|
||||
|
||||
// Handle simple types like timer:30m, human:approve, mail:pattern
|
||||
parts := strings.SplitN(spec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
awaitType := parts[0]
|
||||
awaitID := parts[1]
|
||||
|
||||
// Validate type
|
||||
validTypes := map[string]bool{
|
||||
"timer": true,
|
||||
"human": true,
|
||||
"mail": true,
|
||||
}
|
||||
if !validTypes[awaitType] {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return awaitType, awaitID
|
||||
}
|
||||
|
||||
var gateShowCmd = &cobra.Command{
|
||||
Use: "show <gate-id>",
|
||||
Short: "Show gate details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
|
||||
// Gate show requires direct store access
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate show requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate show %s\n", args[0])
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gate, err := store.GetIssue(ctx, gateID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate.IssueType != types.TypeGate {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(gate)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Gate: %s\n", ui.RenderAccent("🚧"), gate.ID)
|
||||
fmt.Printf(" Title: %s\n", gate.Title)
|
||||
fmt.Printf(" Status: %s\n", gate.Status)
|
||||
fmt.Printf(" Await: %s:%s\n", gate.AwaitType, gate.AwaitID)
|
||||
if gate.Timeout > 0 {
|
||||
elapsed := time.Since(gate.CreatedAt)
|
||||
remaining := gate.Timeout - elapsed
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
fmt.Printf(" Timeout: %v (remaining: %v)\n", gate.Timeout, remaining.Round(time.Second))
|
||||
}
|
||||
if len(gate.Waiters) > 0 {
|
||||
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", gate.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
if gate.CloseReason != "" {
|
||||
fmt.Printf(" Close reason: %s\n", gate.CloseReason)
|
||||
}
|
||||
fmt.Println()
|
||||
},
|
||||
}
|
||||
|
||||
var gateListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List open gates",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
showAll, _ := cmd.Flags().GetBool("all")
|
||||
|
||||
// Gate list requires direct store access
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate list requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate list\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build filter for gates
|
||||
gateType := types.TypeGate
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &gateType,
|
||||
}
|
||||
if !showAll {
|
||||
openStatus := types.StatusOpen
|
||||
filter.Status = &openStatus
|
||||
}
|
||||
|
||||
issues, err := store.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error listing gates: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(issues)
|
||||
return
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
fmt.Println("No gates found")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Gates (%d):\n\n", ui.RenderAccent("🚧"), len(issues))
|
||||
for _, gate := range issues {
|
||||
statusIcon := "⏳"
|
||||
if gate.Status == types.StatusClosed {
|
||||
statusIcon = "✓"
|
||||
}
|
||||
awaitSpec := fmt.Sprintf("%s:%s", gate.AwaitType, gate.AwaitID)
|
||||
timeoutInfo := ""
|
||||
if gate.Timeout > 0 {
|
||||
elapsed := time.Since(gate.CreatedAt)
|
||||
remaining := gate.Timeout - elapsed
|
||||
if remaining < 0 {
|
||||
timeoutInfo = " (timed out)"
|
||||
} else {
|
||||
timeoutInfo = fmt.Sprintf(" (%v left)", remaining.Round(time.Second))
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s %s: %s%s\n", statusIcon, gate.ID, awaitSpec, timeoutInfo)
|
||||
if len(gate.Waiters) > 0 {
|
||||
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
},
|
||||
}
|
||||
|
||||
var gateCloseCmd = &cobra.Command{
|
||||
Use: "close <gate-id>",
|
||||
Short: "Close a gate",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("gate close")
|
||||
ctx := rootCtx
|
||||
reason, _ := cmd.Flags().GetString("reason")
|
||||
if reason == "" {
|
||||
reason = "Gate closed"
|
||||
}
|
||||
|
||||
// Gate close requires direct store access
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate close requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate close %s\n", args[0])
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Verify it's a gate
|
||||
gate, err := store.GetIssue(ctx, gateID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate.IssueType != types.TypeGate {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
if jsonOutput {
|
||||
closedGate, _ := store.GetIssue(ctx, gateID)
|
||||
outputJSON(closedGate)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Closed gate: %s\n", ui.RenderPass("✓"), gateID)
|
||||
fmt.Printf(" Reason: %s\n", reason)
|
||||
},
|
||||
}
|
||||
|
||||
var gateWaitCmd = &cobra.Command{
|
||||
Use: "wait <gate-id>",
|
||||
Short: "Add a waiter to an existing gate",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("gate wait")
|
||||
ctx := rootCtx
|
||||
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
|
||||
|
||||
if len(notifyAddrs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: --notify is required\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Gate wait requires direct store access for now
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate wait requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate wait %s --notify ...\n", args[0])
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get existing gate
|
||||
gate, err := store.GetIssue(ctx, gateID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate.IssueType != types.TypeGate {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gate.Status == types.StatusClosed {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate %s is already closed\n", gateID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Add new waiters (avoiding duplicates)
|
||||
waiterSet := make(map[string]bool)
|
||||
for _, w := range gate.Waiters {
|
||||
waiterSet[w] = true
|
||||
}
|
||||
newWaiters := []string{}
|
||||
for _, addr := range notifyAddrs {
|
||||
if !waiterSet[addr] {
|
||||
newWaiters = append(newWaiters, addr)
|
||||
waiterSet[addr] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(newWaiters) == 0 {
|
||||
fmt.Println("All specified waiters are already registered on this gate")
|
||||
return
|
||||
}
|
||||
|
||||
// Update waiters - need to use SQLite directly for Waiters field
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "Error: gate wait requires SQLite storage\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
allWaiters := append(gate.Waiters, newWaiters...)
|
||||
waitersJSON, _ := json.Marshal(allWaiters)
|
||||
|
||||
// Use raw SQL to update the waiters field
|
||||
_, err = sqliteStore.UnderlyingDB().ExecContext(ctx, `UPDATE issues SET waiters = ?, updated_at = ? WHERE id = ?`,
|
||||
string(waitersJSON), time.Now(), gateID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error adding waiters: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
if jsonOutput {
|
||||
updatedGate, _ := store.GetIssue(ctx, gateID)
|
||||
outputJSON(updatedGate)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Added waiter(s) to gate %s:\n", ui.RenderPass("✓"), gateID)
|
||||
for _, addr := range newWaiters {
|
||||
fmt.Printf(" + %s\n", addr)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Gate create flags
|
||||
gateCreateCmd.Flags().String("await", "", "Await spec: gh:run:<id>, gh:pr:<id>, timer:<duration>, human:<prompt>, mail:<pattern> (required)")
|
||||
gateCreateCmd.Flags().String("timeout", "", "Timeout duration (e.g., 30m, 1h)")
|
||||
gateCreateCmd.Flags().StringSlice("notify", nil, "Mail addresses to notify when gate clears (repeatable)")
|
||||
gateCreateCmd.Flags().String("title", "", "Custom title for the gate")
|
||||
gateCreateCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
|
||||
// Gate show flags
|
||||
gateShowCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
|
||||
// Gate list flags
|
||||
gateListCmd.Flags().Bool("all", false, "Show all gates including closed")
|
||||
gateListCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
|
||||
// Gate close flags
|
||||
gateCloseCmd.Flags().StringP("reason", "r", "", "Reason for closing")
|
||||
gateCloseCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
|
||||
// Gate wait flags
|
||||
gateWaitCmd.Flags().StringSlice("notify", nil, "Mail addresses to add as waiters (repeatable, required)")
|
||||
gateWaitCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
|
||||
// Add subcommands to gate command
|
||||
gateCmd.AddCommand(gateCreateCmd)
|
||||
gateCmd.AddCommand(gateShowCmd)
|
||||
gateCmd.AddCommand(gateListCmd)
|
||||
gateCmd.AddCommand(gateCloseCmd)
|
||||
gateCmd.AddCommand(gateWaitCmd)
|
||||
|
||||
// Add gate command to root
|
||||
rootCmd.AddCommand(gateCmd)
|
||||
}
|
||||
@@ -636,7 +636,7 @@ func init() {
|
||||
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
registerPriorityFlag(listCmd, "")
|
||||
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")
|
||||
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
|
||||
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
|
||||
listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
|
||||
listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")
|
||||
|
||||
@@ -373,7 +373,7 @@ func init() {
|
||||
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
|
||||
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
|
||||
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
|
||||
searchCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
|
||||
searchCmd.Flags().IntP("limit", "n", 50, "Limit results (default: 50)")
|
||||
|
||||
@@ -527,7 +527,7 @@ var updateCmd = &cobra.Command{
|
||||
issueType, _ := cmd.Flags().GetString("type")
|
||||
// Validate issue type
|
||||
if !types.IssueType(issueType).IsValid() {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid issue type %q. Valid types: bug, feature, task, epic, chore\n", issueType)
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid issue type %q. Valid types: bug, feature, task, epic, chore, merge-request, molecule, gate\n", issueType)
|
||||
os.Exit(1)
|
||||
}
|
||||
updates["issue_type"] = issueType
|
||||
@@ -1390,7 +1390,7 @@ func init() {
|
||||
updateCmd.Flags().StringP("status", "s", "", "New status")
|
||||
registerPriorityFlag(updateCmd, "")
|
||||
updateCmd.Flags().String("title", "", "New title")
|
||||
updateCmd.Flags().StringP("type", "t", "", "New type (bug|feature|task|epic|chore|merge-request|molecule)")
|
||||
updateCmd.Flags().StringP("type", "t", "", "New type (bug|feature|task|epic|chore|merge-request|molecule|gate)")
|
||||
registerCommonIssueFlags(updateCmd)
|
||||
updateCmd.Flags().String("notes", "", "Additional notes")
|
||||
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
|
||||
|
||||
@@ -250,6 +250,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID
|
||||
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
|
||||
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template,
|
||||
i.await_type, i.await_id, i.timeout_ns, i.waiters,
|
||||
d.type
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.depends_on_id
|
||||
@@ -272,6 +273,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s
|
||||
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
|
||||
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template,
|
||||
i.await_type, i.await_id, i.timeout_ns, i.waiters,
|
||||
d.type
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
@@ -827,6 +829,11 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||
@@ -835,6 +842,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan issue: %w", err)
|
||||
@@ -887,6 +895,19 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
issueIDs = append(issueIDs, issue.ID)
|
||||
@@ -930,6 +951,11 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
var depType types.DependencyType
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -939,6 +965,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
&depType,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -989,6 +1016,19 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
// Fetch labels for this issue
|
||||
labels, err := s.GetLabels(ctx, issue.ID)
|
||||
|
||||
@@ -46,8 +46,9 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
|
||||
issue.AcceptanceCriteria, issue.Notes, issue.Status,
|
||||
@@ -56,6 +57,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
||||
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
)
|
||||
if err != nil {
|
||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||
@@ -76,8 +78,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
@@ -111,6 +114,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
||||
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
)
|
||||
if err != nil {
|
||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||
|
||||
@@ -159,7 +159,8 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
|
||||
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
|
||||
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
|
||||
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template,
|
||||
i.await_type, i.await_id, i.timeout_ns, i.waiters
|
||||
FROM issues i
|
||||
JOIN labels l ON i.id = l.issue_id
|
||||
WHERE l.label = ?
|
||||
|
||||
@@ -43,6 +43,7 @@ var migrationsList = []Migration{
|
||||
{"is_template_column", migrations.MigrateIsTemplateColumn},
|
||||
{"remove_depends_on_fk", migrations.MigrateRemoveDependsOnFK},
|
||||
{"additional_indexes", migrations.MigrateAdditionalIndexes},
|
||||
{"gate_columns", migrations.MigrateGateColumns},
|
||||
}
|
||||
|
||||
// MigrationInfo contains metadata about a migration for inspection
|
||||
@@ -93,6 +94,7 @@ func getMigrationDescription(name string) string {
|
||||
"is_template_column": "Adds is_template column for template molecules (beads-1ra)",
|
||||
"remove_depends_on_fk": "Removes FK constraint on depends_on_id to allow external references (bd-zmmy)",
|
||||
"additional_indexes": "Adds performance optimization indexes for common query patterns (bd-h0we)",
|
||||
"gate_columns": "Adds gate columns (await_type, await_id, timeout_ns, waiters) for async coordination (bd-udsi)",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
|
||||
51
internal/storage/sqlite/migrations/027_gate_columns.go
Normal file
51
internal/storage/sqlite/migrations/027_gate_columns.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MigrateGateColumns adds gate-related columns to the issues table for async coordination (bd-udsi).
|
||||
// Gate fields enable agents to wait on external conditions (CI completion, human approval, etc.)
|
||||
func MigrateGateColumns(db *sql.DB) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
sqlType string
|
||||
}{
|
||||
{"await_type", "TEXT"}, // Condition type: gh:run, gh:pr, timer, human, mail
|
||||
{"await_id", "TEXT"}, // Condition identifier (e.g., run ID, PR number)
|
||||
{"timeout_ns", "INTEGER"}, // Timeout in nanoseconds (Go's time.Duration)
|
||||
{"waiters", "TEXT"}, // JSON array of mail addresses to notify
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
// Check if column already exists
|
||||
var columnExists bool
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM pragma_table_info('issues')
|
||||
WHERE name = ?
|
||||
`, col.name).Scan(&columnExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check %s column: %w", col.name, err)
|
||||
}
|
||||
|
||||
if columnExists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the column
|
||||
_, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.sqlType))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add %s column: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add index for gate type issues (for efficient gate queries)
|
||||
_, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_gate ON issues(issue_type) WHERE issue_type = 'gate'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gate index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -491,9 +491,13 @@ func TestMigrateContentHashColumn(t *testing.T) {
|
||||
relates_to TEXT DEFAULT '',
|
||||
duplicate_of TEXT DEFAULT '',
|
||||
superseded_by TEXT DEFAULT '',
|
||||
await_type TEXT DEFAULT '',
|
||||
await_id TEXT DEFAULT '',
|
||||
timeout_ns INTEGER DEFAULT 0,
|
||||
waiters TEXT DEFAULT '',
|
||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
|
||||
);
|
||||
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '' FROM issues_backup;
|
||||
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '' FROM issues_backup;
|
||||
DROP TABLE issues_backup;
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
@@ -302,8 +302,9 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
|
||||
issue.AcceptanceCriteria, issue.Notes, issue.Status,
|
||||
@@ -312,6 +313,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
||||
issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason,
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert issue: %w", err)
|
||||
@@ -335,7 +337,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
||||
issue_type = ?, assignee = ?, estimated_minutes = ?,
|
||||
updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?,
|
||||
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
|
||||
sender = ?, ephemeral = ?, pinned = ?, is_template = ?
|
||||
sender = ?, ephemeral = ?, pinned = ?, is_template = ?,
|
||||
await_type = ?, await_id = ?, timeout_ns = ?, waiters = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
issue.ContentHash, issue.Title, issue.Description, issue.Design,
|
||||
@@ -344,6 +347,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
||||
issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo,
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
issue.ID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -252,6 +252,11 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
|
||||
var contentHash sql.NullString
|
||||
var compactedAtCommit sql.NullString
|
||||
@@ -261,7 +266,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
created_at, updated_at, closed_at, external_ref,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
WHERE id = ?
|
||||
`, id).Scan(
|
||||
@@ -272,6 +278,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -337,6 +344,19 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
// Fetch labels for this issue
|
||||
labels, err := s.GetLabels(ctx, issue.ID)
|
||||
@@ -447,6 +467,11 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
|
||||
@@ -454,7 +479,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
||||
created_at, updated_at, closed_at, external_ref,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
WHERE external_ref = ?
|
||||
`, externalRef).Scan(
|
||||
@@ -465,6 +491,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -530,6 +557,19 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
// Fetch labels for this issue
|
||||
labels, err := s.GetLabels(ctx, issue.ID)
|
||||
@@ -1646,7 +1686,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -111,7 +112,8 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
|
||||
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
|
||||
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
|
||||
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template
|
||||
i.sender, i.ephemeral, i.pinned, i.is_template,
|
||||
i.await_type, i.await_id, i.timeout_ns, i.waiters
|
||||
FROM issues i
|
||||
WHERE %s
|
||||
AND NOT EXISTS (
|
||||
@@ -247,7 +249,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
||||
created_at, updated_at, closed_at, external_ref, source_repo,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
WHERE status != 'closed'
|
||||
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
|
||||
@@ -300,6 +303,11 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||
@@ -309,6 +317,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
||||
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &ephemeral, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
|
||||
@@ -373,6 +382,19 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,8 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
|
||||
created_at, updated_at, closed_at, external_ref,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
WHERE id = ?
|
||||
`, id)
|
||||
@@ -1122,7 +1123,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
|
||||
created_at, updated_at, closed_at, external_ref,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters
|
||||
FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
@@ -1169,6 +1171,11 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
||||
var pinned sql.NullInt64
|
||||
// Template field (beads-1ra)
|
||||
var isTemplate sql.NullInt64
|
||||
// Gate fields (bd-udsi)
|
||||
var awaitType sql.NullString
|
||||
var awaitID sql.NullString
|
||||
var timeoutNs sql.NullInt64
|
||||
var waiters sql.NullString
|
||||
|
||||
err := row.Scan(
|
||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||
@@ -1178,6 +1185,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan issue: %w", err)
|
||||
@@ -1239,6 +1247,19 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
||||
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
||||
issue.IsTemplate = true
|
||||
}
|
||||
// Gate fields (bd-udsi)
|
||||
if awaitType.Valid {
|
||||
issue.AwaitType = awaitType.String
|
||||
}
|
||||
if awaitID.Valid {
|
||||
issue.AwaitID = awaitID.String
|
||||
}
|
||||
if timeoutNs.Valid {
|
||||
issue.Timeout = time.Duration(timeoutNs.Int64)
|
||||
}
|
||||
if waiters.Valid && waiters.String != "" {
|
||||
issue.Waiters = parseJSONStringArray(waiters.String)
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ type Issue struct {
|
||||
// HOP fields (bd-7pwh): entity tracking for CV chains
|
||||
Creator *EntityRef `json:"creator,omitempty"` // Who created this issue (human, agent, or org)
|
||||
Validations []Validation `json:"validations,omitempty"` // Who validated/approved this work
|
||||
|
||||
// Gate fields (bd-udsi): async coordination primitives
|
||||
AwaitType string `json:"await_type,omitempty"` // Condition type: gh:run, gh:pr, timer, human, mail
|
||||
AwaitID string `json:"await_id,omitempty"` // Condition identifier (e.g., run ID, PR number)
|
||||
Timeout time.Duration `json:"timeout,omitempty"` // Max wait time before escalation
|
||||
Waiters []string `json:"waiters,omitempty"` // Mail addresses to notify when gate clears
|
||||
}
|
||||
|
||||
// ComputeContentHash creates a deterministic hash of the issue's content.
|
||||
@@ -140,6 +146,17 @@ func (i *Issue) ComputeContentHash() string {
|
||||
}
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
// Hash gate fields for async coordination (bd-udsi)
|
||||
h.Write([]byte(i.AwaitType))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(i.AwaitID))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(fmt.Sprintf("%d", i.Timeout)))
|
||||
h.Write([]byte{0})
|
||||
for _, waiter := range i.Waiters {
|
||||
h.Write([]byte(waiter))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
@@ -313,12 +330,13 @@ const (
|
||||
TypeMessage IssueType = "message" // Ephemeral communication between workers
|
||||
TypeMergeRequest IssueType = "merge-request" // Merge queue entry for refinery processing
|
||||
TypeMolecule IssueType = "molecule" // Template molecule for issue hierarchies (beads-1ra)
|
||||
TypeGate IssueType = "gate" // Async coordination gate (bd-udsi)
|
||||
)
|
||||
|
||||
// IsValid checks if the issue type value is valid
|
||||
func (t IssueType) IsValid() bool {
|
||||
switch t {
|
||||
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule:
|
||||
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user