From a129e36054a78d597b3a93a7d64a9ccff2d13b84 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 13:45:52 -0800 Subject: [PATCH] Add daemon RPC support for gate commands (bd-likt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add gate operation constants (OpGateCreate, OpGateList, OpGateShow, OpGateClose, OpGateWait) to protocol.go - Add Gate*Args and Gate*Result types to protocol.go - Add gate handler methods (handleGateCreate, handleGateList, handleGateShow, handleGateClose, handleGateWait) to server_issues_epics.go - Register gate handlers in handleRequest switch - Add gate client methods (GateCreate, GateList, GateShow, GateClose, GateWait) to client.go - Update cmd/bd/gate.go to use daemon client when available, falling back to direct store access Gate commands now work with the daemon, eliminating the need for --no-daemon flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/gate.go | 439 ++++++++++-------- internal/rpc/client.go | 27 ++ internal/rpc/protocol.go | 51 ++ internal/rpc/server_issues_epics.go | 338 ++++++++++++++ .../server_routing_validation_diagnostics.go | 11 + 5 files changed, 684 insertions(+), 182 deletions(-) diff --git a/cmd/bd/gate.go b/cmd/bd/gate.go index 6c2667af..40f7937a 100644 --- a/cmd/bd/gate.go +++ b/cmd/bd/gate.go @@ -8,6 +8,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" @@ -105,42 +106,65 @@ Examples: 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") + var gate *types.Issue + + // Try daemon first, fall back to direct store access + if daemonClient != nil { + resp, err := daemonClient.GateCreate(&rpc.GateCreateArgs{ + Title: title, + AwaitType: awaitType, + AwaitID: awaitID, + Timeout: timeout, + Waiters: notifyAddrs, + }) + if err != nil { + FatalError("gate create: %v", err) } + + // Parse the gate ID from response and fetch full gate + var result rpc.GateCreateResult + if err := json.Unmarshal(resp.Data, &result); err != nil { + FatalError("failed to parse gate create result: %v", err) + } + + // Get the full gate for output + showResp, err := daemonClient.GateShow(&rpc.GateShowArgs{ID: result.ID}) + if err != nil { + FatalError("failed to fetch created gate: %v", err) + } + if err := json.Unmarshal(showResp.Data, &gate); err != nil { + FatalError("failed to parse gate: %v", err) + } + } else if store != nil { + 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() + } 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 @@ -197,34 +221,39 @@ var gateShowCmd = &cobra.Command{ 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") + var gate *types.Issue + + // Try daemon first, fall back to direct store access + if daemonClient != nil { + resp, err := daemonClient.GateShow(&rpc.GateShowArgs{ID: args[0]}) + if err != nil { + FatalError("gate show: %v", err) + } + if err := json.Unmarshal(resp.Data, &gate); err != nil { + FatalError("failed to parse gate: %v", err) + } + } else if store != nil { + gateID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - 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) + 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) + } + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") os.Exit(1) } @@ -263,30 +292,36 @@ var gateListCmd = &cobra.Command{ 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") + var issues []*types.Issue + + // Try daemon first, fall back to direct store access + if daemonClient != nil { + resp, err := daemonClient.GateList(&rpc.GateListArgs{All: showAll}) + if err != nil { + FatalError("gate list: %v", err) + } + if err := json.Unmarshal(resp.Data, &issues); err != nil { + FatalError("failed to parse gates: %v", err) + } + } else if store != nil { + // Build filter for gates + gateType := types.TypeGate + filter := types.IssueFilter{ + IssueType: &gateType, + } + if !showAll { + openStatus := types.StatusOpen + filter.Status = &openStatus } - 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) + var err error + issues, err = store.SearchIssues(ctx, "", filter) + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing gates: %v\n", err) + os.Exit(1) + } + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") os.Exit(1) } @@ -338,47 +373,58 @@ var gateCloseCmd = &cobra.Command{ 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") + var closedGate *types.Issue + var gateID string + + // Try daemon first, fall back to direct store access + if daemonClient != nil { + resp, err := daemonClient.GateClose(&rpc.GateCloseArgs{ + ID: args[0], + Reason: reason, + }) + if err != nil { + FatalError("gate close: %v", err) + } + if err := json.Unmarshal(resp.Data, &closedGate); err != nil { + FatalError("failed to parse gate: %v", err) + } + gateID = closedGate.ID + } else if store != nil { + var err error + gateID, err = utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - 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) + } - // 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) + } - if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err) + markDirtyAndScheduleFlush() + closedGate, _ = store.GetIssue(ctx, gateID) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") os.Exit(1) } - markDirtyAndScheduleFlush() - if jsonOutput { - closedGate, _ := store.GetIssue(ctx, gateID) outputJSON(closedGate) return } @@ -402,87 +448,116 @@ var gateWaitCmd = &cobra.Command{ 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") + var addedCount int + var gateID string + var newWaiters []string + + // Try daemon first, fall back to direct store access + if daemonClient != nil { + resp, err := daemonClient.GateWait(&rpc.GateWaitArgs{ + ID: args[0], + Waiters: notifyAddrs, + }) + if err != nil { + FatalError("gate wait: %v", err) } - 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 + var result rpc.GateWaitResult + if err := json.Unmarshal(resp.Data, &result); err != nil { + FatalError("failed to parse gate wait result: %v", err) } + addedCount = result.AddedCount + gateID = args[0] // Use the input ID for display + // For daemon mode, we don't know exactly which waiters were added + // Just report the count + newWaiters = nil + } else if store != nil { + var err error + 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 + } + for _, addr := range notifyAddrs { + if !waiterSet[addr] { + newWaiters = append(newWaiters, addr) + waiterSet[addr] = true + } + } + + addedCount = len(newWaiters) + + if addedCount == 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 + } + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + os.Exit(1) } - if len(newWaiters) == 0 { + if addedCount == 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) + // For daemon mode, output the result + outputJSON(map[string]interface{}{"added_count": addedCount, "gate_id": gateID}) return } - fmt.Printf("%s Added waiter(s) to gate %s:\n", ui.RenderPass("✓"), gateID) + fmt.Printf("%s Added %d waiter(s) to gate %s\n", ui.RenderPass("✓"), addedCount, gateID) for _, addr := range newWaiters { fmt.Printf(" + %s\n", addr) } diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 0c70f0cd..a5a000d0 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -395,6 +395,33 @@ func (c *Client) EpicStatus(args *EpicStatusArgs) (*Response, error) { return c.Execute(OpEpicStatus, args) } +// Gate operations (bd-likt) + +// GateCreate creates a gate via the daemon +func (c *Client) GateCreate(args *GateCreateArgs) (*Response, error) { + return c.Execute(OpGateCreate, args) +} + +// GateList lists gates via the daemon +func (c *Client) GateList(args *GateListArgs) (*Response, error) { + return c.Execute(OpGateList, args) +} + +// GateShow shows a gate via the daemon +func (c *Client) GateShow(args *GateShowArgs) (*Response, error) { + return c.Execute(OpGateShow, args) +} + +// GateClose closes a gate via the daemon +func (c *Client) GateClose(args *GateCloseArgs) (*Response, error) { + return c.Execute(OpGateClose, args) +} + +// GateWait adds waiters to a gate via the daemon +func (c *Client) GateWait(args *GateWaitArgs) (*Response, error) { + return c.Execute(OpGateWait, args) +} + // cleanupStaleDaemonArtifacts removes stale daemon.pid file when socket is missing and lock is free. // This prevents stale artifacts from accumulating after daemon crashes. // Only removes pid file - lock file is managed by OS (released on process exit). diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index c92d92de..ccc25ae4 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -2,6 +2,7 @@ package rpc import ( "encoding/json" + "time" ) // Operation constants for all bd commands @@ -37,6 +38,13 @@ const ( OpGetMutations = "get_mutations" OpShutdown = "shutdown" OpDelete = "delete" + + // Gate operations (bd-likt) + OpGateCreate = "gate_create" + OpGateList = "gate_list" + OpGateShow = "gate_show" + OpGateClose = "gate_close" + OpGateWait = "gate_wait" ) // Request represents an RPC request from client to daemon @@ -413,3 +421,46 @@ type ImportArgs struct { type GetMutationsArgs struct { Since int64 `json:"since"` // Unix timestamp in milliseconds (0 for all recent) } + +// Gate operations (bd-likt) + +// GateCreateArgs represents arguments for creating a gate +type GateCreateArgs struct { + Title string `json:"title"` + AwaitType string `json:"await_type"` // gh:run, gh:pr, timer, human, mail + AwaitID string `json:"await_id"` // ID/value for the await type + Timeout time.Duration `json:"timeout"` // Timeout duration + Waiters []string `json:"waiters"` // Mail addresses to notify when gate clears +} + +// GateCreateResult represents the result of creating a gate +type GateCreateResult struct { + ID string `json:"id"` // Created gate ID +} + +// GateListArgs represents arguments for listing gates +type GateListArgs struct { + All bool `json:"all"` // Include closed gates +} + +// GateShowArgs represents arguments for showing a gate +type GateShowArgs struct { + ID string `json:"id"` // Gate ID (partial or full) +} + +// GateCloseArgs represents arguments for closing a gate +type GateCloseArgs struct { + ID string `json:"id"` // Gate ID (partial or full) + Reason string `json:"reason,omitempty"` // Close reason +} + +// GateWaitArgs represents arguments for adding waiters to a gate +type GateWaitArgs struct { + ID string `json:"id"` // Gate ID (partial or full) + Waiters []string `json:"waiters"` // Additional waiters to add +} + +// GateWaitResult represents the result of adding waiters +type GateWaitResult struct { + AddedCount int `json:"added_count"` // Number of new waiters added +} diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 22c2471a..a7100bbc 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -1373,3 +1373,341 @@ func (s *Server) handleEpicStatus(req *Request) Response { Data: data, } } + +// Gate handlers (bd-likt) + +func (s *Server) handleGateCreate(req *Request) Response { + var args GateCreateArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid gate create args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + now := time.Now() + + // Create gate issue + gate := &types.Issue{ + Title: args.Title, + IssueType: types.TypeGate, + Status: types.StatusOpen, + Priority: 1, // Gates are typically high priority + Assignee: "deacon/", + Wisp: true, // Gates are wisps (ephemeral) + AwaitType: args.AwaitType, + AwaitID: args.AwaitID, + Timeout: args.Timeout, + Waiters: args.Waiters, + CreatedAt: now, + UpdatedAt: now, + } + gate.ContentHash = gate.ComputeContentHash() + + if err := store.CreateIssue(ctx, gate, s.reqActor(req)); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to create gate: %v", err), + } + } + + // Emit mutation event + s.emitMutation(MutationCreate, gate.ID) + + data, _ := json.Marshal(GateCreateResult{ID: gate.ID}) + return Response{ + Success: true, + Data: data, + } +} + +func (s *Server) handleGateList(req *Request) Response { + var args GateListArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid gate list args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Build filter for gates + gateType := types.TypeGate + filter := types.IssueFilter{ + IssueType: &gateType, + } + if !args.All { + openStatus := types.StatusOpen + filter.Status = &openStatus + } + + gates, err := store.SearchIssues(ctx, "", filter) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to list gates: %v", err), + } + } + + data, _ := json.Marshal(gates) + return Response{ + Success: true, + Data: data, + } +} + +func (s *Server) handleGateShow(req *Request) Response { + var args GateShowArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid gate show args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Resolve partial ID + gateID, err := utils.ResolvePartialID(ctx, store, args.ID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to resolve gate ID: %v", err), + } + } + + gate, err := store.GetIssue(ctx, gateID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get gate: %v", err), + } + } + if gate == nil { + return Response{ + Success: false, + Error: fmt.Sprintf("gate %s not found", gateID), + } + } + if gate.IssueType != types.TypeGate { + return Response{ + Success: false, + Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), + } + } + + data, _ := json.Marshal(gate) + return Response{ + Success: true, + Data: data, + } +} + +func (s *Server) handleGateClose(req *Request) Response { + var args GateCloseArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid gate close args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Resolve partial ID + gateID, err := utils.ResolvePartialID(ctx, store, args.ID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to resolve gate ID: %v", err), + } + } + + // Verify it's a gate + gate, err := store.GetIssue(ctx, gateID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get gate: %v", err), + } + } + if gate == nil { + return Response{ + Success: false, + Error: fmt.Sprintf("gate %s not found", gateID), + } + } + if gate.IssueType != types.TypeGate { + return Response{ + Success: false, + Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), + } + } + + reason := args.Reason + if reason == "" { + reason = "Gate closed" + } + + oldStatus := string(gate.Status) + + if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req)); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to close gate: %v", err), + } + } + + // Emit rich status change event + s.emitRichMutation(MutationEvent{ + Type: MutationStatus, + IssueID: gateID, + OldStatus: oldStatus, + NewStatus: "closed", + }) + + closedGate, _ := store.GetIssue(ctx, gateID) + data, _ := json.Marshal(closedGate) + return Response{ + Success: true, + Data: data, + } +} + +func (s *Server) handleGateWait(req *Request) Response { + var args GateWaitArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid gate wait args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Resolve partial ID + gateID, err := utils.ResolvePartialID(ctx, store, args.ID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to resolve gate ID: %v", err), + } + } + + // Get existing gate + gate, err := store.GetIssue(ctx, gateID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get gate: %v", err), + } + } + if gate == nil { + return Response{ + Success: false, + Error: fmt.Sprintf("gate %s not found", gateID), + } + } + if gate.IssueType != types.TypeGate { + return Response{ + Success: false, + Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), + } + } + if gate.Status == types.StatusClosed { + return Response{ + Success: false, + Error: fmt.Sprintf("gate %s is already closed", gateID), + } + } + + // Add new waiters (avoiding duplicates) + waiterSet := make(map[string]bool) + for _, w := range gate.Waiters { + waiterSet[w] = true + } + newWaiters := []string{} + for _, addr := range args.Waiters { + if !waiterSet[addr] { + newWaiters = append(newWaiters, addr) + waiterSet[addr] = true + } + } + + addedCount := len(newWaiters) + + if addedCount > 0 { + // Update waiters using SQLite directly + sqliteStore, ok := store.(*sqlite.SQLiteStorage) + if !ok { + return Response{ + Success: false, + Error: "gate wait requires SQLite storage", + } + } + + 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 { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add waiters: %v", err), + } + } + + // Emit mutation event + s.emitMutation(MutationUpdate, gateID) + } + + data, _ := json.Marshal(GateWaitResult{AddedCount: addedCount}) + return Response{ + Success: true, + Data: data, + } +} diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index d8965100..9a8fcd41 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -221,6 +221,17 @@ func (s *Server) handleRequest(req *Request) Response { resp = s.handleGetMutations(req) case OpShutdown: resp = s.handleShutdown(req) + // Gate operations (bd-likt) + case OpGateCreate: + resp = s.handleGateCreate(req) + case OpGateList: + resp = s.handleGateList(req) + case OpGateShow: + resp = s.handleGateShow(req) + case OpGateClose: + resp = s.handleGateClose(req) + case OpGateWait: + resp = s.handleGateWait(req) default: s.metrics.RecordError(req.Operation) return Response{