* Add LastActivity calculation for convoy dashboard (hq-x2xy) Adds internal/activity package with color-coded activity tracking: - Green: <2 minutes (active) - Yellow: 2-5 minutes (stale) - Red: >5 minutes (stuck) Features: - Calculate() function returns Info with formatted age and color class - Helper methods: IsActive(), IsStale(), IsStuck() - Handles edge cases: zero time, future time (clock skew) Tests: 8 test functions with 25 sub-tests covering all thresholds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add convoy dashboard HTML template with Last Activity (hq-fq1g) Adds internal/web package with convoy dashboard template: - convoy.html with Last Activity column and color coding - Green (<2min), Yellow (2-5min), Red (>5min) activity indicators - htmx auto-refresh every 30 seconds - Progress bars for convoy completion - Status indicators for open/closed convoys - Empty state when no convoys Also includes internal/activity package (dependency from hq-x2xy): - Calculate() returns Info with formatted age and color class - Helper methods: IsActive(), IsStale(), IsStuck() Tests: 6 template tests + 8 activity tests, all passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add convoy list handler with activity data (hq-3edt) Adds HTTP handler that wires convoy dashboard template to real data: - ConvoyHandler: HTTP handler for GET / rendering convoy dashboard - LiveConvoyFetcher: Fetches convoys from beads with activity data - ConvoyFetcher interface: Enables mocking for tests Features: - Fetches open convoys from town beads - Calculates progress (completed/total) from tracked issues - Gets Last Activity from worker agent beads - Color codes activity: Green (<2min), Yellow (2-5min), Red (>5min) Includes dependencies (not yet merged): - internal/activity: Activity calculation (hq-x2xy) - internal/web/templates: HTML template (hq-fq1g) Tests: 5 handler tests + 6 template tests + 8 activity tests = 19 total 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add 'gt dashboard' CLI command (hq-s1bg) Add dashboard command to start the convoy tracking web server. Usage: gt dashboard [--port=8080] [--open] Features: - --port: Configurable HTTP port (default 8080) - --open: Auto-open browser on start - Cross-platform browser launch (darwin/linux/windows) - Graceful workspace detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
298 lines
7.2 KiB
Go
298 lines
7.2 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/activity"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// LiveConvoyFetcher fetches convoy data from beads.
|
|
type LiveConvoyFetcher struct {
|
|
townBeads string
|
|
}
|
|
|
|
// NewLiveConvoyFetcher creates a fetcher for the current workspace.
|
|
func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
return &LiveConvoyFetcher{
|
|
townBeads: filepath.Join(townRoot, ".beads"),
|
|
}, nil
|
|
}
|
|
|
|
// FetchConvoys fetches all open convoys with their activity data.
|
|
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
|
// List all open convoy-type issues
|
|
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
|
|
listCmd := exec.Command("bd", listArgs...)
|
|
listCmd.Dir = f.townBeads
|
|
|
|
var stdout bytes.Buffer
|
|
listCmd.Stdout = &stdout
|
|
|
|
if err := listCmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("listing convoys: %w", err)
|
|
}
|
|
|
|
var convoys []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
|
|
return nil, fmt.Errorf("parsing convoy list: %w", err)
|
|
}
|
|
|
|
// Build convoy rows with activity data
|
|
rows := make([]ConvoyRow, 0, len(convoys))
|
|
for _, c := range convoys {
|
|
row := ConvoyRow{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
Status: c.Status,
|
|
}
|
|
|
|
// Get tracked issues for progress and activity calculation
|
|
tracked := f.getTrackedIssues(c.ID)
|
|
row.Total = len(tracked)
|
|
|
|
var mostRecentActivity time.Time
|
|
for _, t := range tracked {
|
|
if t.Status == "closed" {
|
|
row.Completed++
|
|
}
|
|
// Track most recent activity from workers
|
|
if t.LastActivity.After(mostRecentActivity) {
|
|
mostRecentActivity = t.LastActivity
|
|
}
|
|
}
|
|
|
|
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
|
|
|
|
// Calculate activity info from most recent worker activity
|
|
if !mostRecentActivity.IsZero() {
|
|
row.LastActivity = activity.Calculate(mostRecentActivity)
|
|
} else {
|
|
row.LastActivity = activity.Info{
|
|
FormattedAge: "no activity",
|
|
ColorClass: activity.ColorUnknown,
|
|
}
|
|
}
|
|
|
|
// Get tracked issues for expandable view
|
|
row.TrackedIssues = make([]TrackedIssue, len(tracked))
|
|
for i, t := range tracked {
|
|
row.TrackedIssues[i] = TrackedIssue{
|
|
ID: t.ID,
|
|
Title: t.Title,
|
|
Status: t.Status,
|
|
Assignee: t.Assignee,
|
|
}
|
|
}
|
|
|
|
rows = append(rows, row)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
// trackedIssueInfo holds info about an issue being tracked by a convoy.
|
|
type trackedIssueInfo struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
Assignee string
|
|
LastActivity time.Time
|
|
}
|
|
|
|
// getTrackedIssues fetches tracked issues for a convoy.
|
|
func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo {
|
|
dbPath := filepath.Join(f.townBeads, "beads.db")
|
|
|
|
// Query tracked dependencies from SQLite
|
|
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
|
|
queryCmd := exec.Command("sqlite3", "-json", dbPath,
|
|
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
|
|
|
|
var stdout bytes.Buffer
|
|
queryCmd.Stdout = &stdout
|
|
if err := queryCmd.Run(); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var deps []struct {
|
|
DependsOnID string `json:"depends_on_id"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Collect issue IDs (normalize external refs)
|
|
issueIDs := make([]string, 0, len(deps))
|
|
for _, dep := range deps {
|
|
issueID := dep.DependsOnID
|
|
if strings.HasPrefix(issueID, "external:") {
|
|
parts := strings.SplitN(issueID, ":", 3)
|
|
if len(parts) == 3 {
|
|
issueID = parts[2]
|
|
}
|
|
}
|
|
issueIDs = append(issueIDs, issueID)
|
|
}
|
|
|
|
// Batch fetch issue details
|
|
details := f.getIssueDetailsBatch(issueIDs)
|
|
|
|
// Get worker info for activity timestamps
|
|
workers := f.getWorkersForIssues(issueIDs)
|
|
|
|
// Build result
|
|
result := make([]trackedIssueInfo, 0, len(issueIDs))
|
|
for _, id := range issueIDs {
|
|
info := trackedIssueInfo{ID: id}
|
|
|
|
if d, ok := details[id]; ok {
|
|
info.Title = d.Title
|
|
info.Status = d.Status
|
|
info.Assignee = d.Assignee
|
|
} else {
|
|
info.Title = "(external)"
|
|
info.Status = "unknown"
|
|
}
|
|
|
|
if w, ok := workers[id]; ok && w.LastActivity != nil {
|
|
info.LastActivity = *w.LastActivity
|
|
}
|
|
|
|
result = append(result, info)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// issueDetail holds basic issue info.
|
|
type issueDetail struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
Assignee string
|
|
}
|
|
|
|
// getIssueDetailsBatch fetches details for multiple issues.
|
|
func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*issueDetail {
|
|
result := make(map[string]*issueDetail)
|
|
if len(issueIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
args := append([]string{"show"}, issueIDs...)
|
|
args = append(args, "--json")
|
|
|
|
showCmd := exec.Command("bd", args...)
|
|
var stdout bytes.Buffer
|
|
showCmd.Stdout = &stdout
|
|
|
|
if err := showCmd.Run(); err != nil {
|
|
return result
|
|
}
|
|
|
|
var issues []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Assignee string `json:"assignee"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return result
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
result[issue.ID] = &issueDetail{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Status: issue.Status,
|
|
Assignee: issue.Assignee,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// workerDetail holds worker info including last activity.
|
|
type workerDetail struct {
|
|
Worker string
|
|
LastActivity *time.Time
|
|
}
|
|
|
|
// getWorkersForIssues finds workers and their last activity for issues.
|
|
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
|
|
result := make(map[string]*workerDetail)
|
|
if len(issueIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
townRoot, _ := workspace.FindFromCwd()
|
|
if townRoot == "" {
|
|
return result
|
|
}
|
|
|
|
// Find all rig beads databases
|
|
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
|
|
|
|
for _, dbPath := range rigDirs {
|
|
for _, issueID := range issueIDs {
|
|
if _, ok := result[issueID]; ok {
|
|
continue
|
|
}
|
|
|
|
safeID := strings.ReplaceAll(issueID, "'", "''")
|
|
query := fmt.Sprintf(
|
|
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`,
|
|
safeID)
|
|
|
|
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
|
|
var stdout bytes.Buffer
|
|
queryCmd.Stdout = &stdout
|
|
if err := queryCmd.Run(); err != nil {
|
|
continue
|
|
}
|
|
|
|
var agents []struct {
|
|
ID string `json:"id"`
|
|
HookBead string `json:"hook_bead"`
|
|
LastActivity string `json:"last_activity"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 {
|
|
continue
|
|
}
|
|
|
|
agent := agents[0]
|
|
detail := &workerDetail{
|
|
Worker: agent.ID,
|
|
}
|
|
|
|
if agent.LastActivity != "" {
|
|
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
|
|
detail.LastActivity = &t
|
|
}
|
|
}
|
|
|
|
result[issueID] = detail
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|