Files
gastown/internal/convoy/observer.go
nux b14835b140 fix(refinery): pass convoy ID to convoy check command
The runConvoyCheck function was running `gt convoy check` without
the convoy ID, which checked all open convoys. Now it passes the
specific convoy ID to check only the relevant convoy, as specified
in the requirements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:51:21 -08:00

137 lines
4.1 KiB
Go

// Package convoy provides shared convoy operations for redundant observers.
package convoy
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// CheckConvoysForIssue finds any convoys tracking the given issue and triggers
// convoy completion checks. This enables redundant convoy observation from
// multiple agents (Witness, Refinery, Daemon).
//
// The check is idempotent - running it multiple times for the same issue is safe.
// The underlying `gt convoy check` handles already-closed convoys gracefully.
//
// Parameters:
// - townRoot: path to the town root directory
// - issueID: the issue ID that was just closed
// - observer: identifier for logging (e.g., "witness", "refinery")
// - logger: optional logger function (can be nil)
//
// Returns the convoy IDs that were checked (may be empty if issue isn't tracked).
func CheckConvoysForIssue(townRoot, issueID, observer string, logger func(format string, args ...interface{})) []string {
if logger == nil {
logger = func(format string, args ...interface{}) {} // no-op
}
// Find convoys tracking this issue
convoyIDs := getTrackingConvoys(townRoot, issueID)
if len(convoyIDs) == 0 {
return nil
}
logger("%s: issue %s is tracked by %d convoy(s): %v", observer, issueID, len(convoyIDs), convoyIDs)
// Run convoy check for each tracking convoy
// Note: gt convoy check is idempotent and handles already-closed convoys
for _, convoyID := range convoyIDs {
if isConvoyClosed(townRoot, convoyID) {
logger("%s: convoy %s already closed, skipping", observer, convoyID)
continue
}
logger("%s: running convoy check for %s", observer, convoyID)
if err := runConvoyCheck(townRoot, convoyID); err != nil {
logger("%s: convoy check failed: %v", observer, err)
}
}
return convoyIDs
}
// getTrackingConvoys returns convoy IDs that track the given issue.
// Uses direct SQLite query for efficiency (same approach as daemon/convoy_watcher).
func getTrackingConvoys(townRoot, issueID string) []string {
townBeads := filepath.Join(townRoot, ".beads")
dbPath := filepath.Join(townBeads, "beads.db")
// Query for convoys that track this issue
// Handle both direct ID and external reference format
safeIssueID := strings.ReplaceAll(issueID, "'", "''")
// Query for dependencies where this issue is the target
// Convoys use "tracks" type: convoy -> tracked issue (depends_on_id)
query := fmt.Sprintf(`
SELECT DISTINCT issue_id FROM dependencies
WHERE type = 'tracks'
AND (depends_on_id = '%s' OR depends_on_id LIKE '%%:%s')
`, safeIssueID, safeIssueID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return nil
}
var results []struct {
IssueID string `json:"issue_id"`
}
if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
return nil
}
convoyIDs := make([]string, 0, len(results))
for _, r := range results {
convoyIDs = append(convoyIDs, r.IssueID)
}
return convoyIDs
}
// isConvoyClosed checks if a convoy is already closed.
func isConvoyClosed(townRoot, convoyID string) bool {
townBeads := filepath.Join(townRoot, ".beads")
dbPath := filepath.Join(townBeads, "beads.db")
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
query := fmt.Sprintf(`SELECT status FROM issues WHERE id = '%s'`, safeConvoyID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return false
}
var results []struct {
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &results); err != nil || len(results) == 0 {
return false
}
return results[0].Status == "closed"
}
// runConvoyCheck runs `gt convoy check <convoy-id>` to check a specific convoy.
// This is idempotent and handles already-closed convoys gracefully.
func runConvoyCheck(townRoot, convoyID string) error {
cmd := exec.Command("gt", "convoy", "check", convoyID)
cmd.Dir = townRoot
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%v: %s", err, stderr.String())
}
return nil
}