diff --git a/internal/model/types.go b/internal/model/types.go index 9d688fd..c0aa75a 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -88,6 +88,7 @@ type LiveSession struct { type RetryEntry struct { IssueID string Identifier string + IssueTitle string Attempt int // 1-based DueAtMS int64 Error string diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index c599216..2976378 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -642,7 +642,7 @@ func (o *Orchestrator) onWorkerExit(issueID string, entry *model.RunningEntry, e nextAttempt := entry.RetryAttempt + 1 delay := o.calculateBackoff(nextAttempt) - o.scheduleRetry(issueID, entry.Identifier, nextAttempt, err.Error(), delay) + o.scheduleRetry(issueID, entry.Identifier, entry.Issue.Title, nextAttempt, err.Error(), delay) o.logger.Warn("worker failed", "issue_id", issueID, @@ -665,7 +665,7 @@ func (o *Orchestrator) calculateBackoff(attempt int) int64 { return delay } -func (o *Orchestrator) scheduleRetry(issueID, identifier string, attempt int, errMsg string, delayMS int64) { +func (o *Orchestrator) scheduleRetry(issueID, identifier, issueTitle string, attempt int, errMsg string, delayMS int64) { // Cancel existing retry timer delete(o.retryAttempts, issueID) @@ -673,6 +673,7 @@ func (o *Orchestrator) scheduleRetry(issueID, identifier string, attempt int, er entry := &model.RetryEntry{ IssueID: issueID, Identifier: identifier, + IssueTitle: issueTitle, Attempt: attempt, DueAtMS: dueAt, Error: errMsg, @@ -701,7 +702,7 @@ func (o *Orchestrator) onRetryTimer(issueID string) { issues, err := o.linear.FetchCandidateIssues(ctx, o.cfg.TrackerActiveStates) if err != nil { o.mu.Lock() - o.scheduleRetry(issueID, entry.Identifier, entry.Attempt+1, "retry poll failed", o.calculateBackoff(entry.Attempt+1)) + o.scheduleRetry(issueID, entry.Identifier, entry.IssueTitle, entry.Attempt+1, "retry poll failed", o.calculateBackoff(entry.Attempt+1)) o.mu.Unlock() return } @@ -738,7 +739,7 @@ func (o *Orchestrator) onRetryTimer(issueID string) { if slots <= 0 { o.mu.Lock() - o.scheduleRetry(issueID, entry.Identifier, entry.Attempt+1, "no available orchestrator slots", o.calculateBackoff(entry.Attempt+1)) + o.scheduleRetry(issueID, entry.Identifier, entry.IssueTitle, entry.Attempt+1, "no available orchestrator slots", o.calculateBackoff(entry.Attempt+1)) o.mu.Unlock() return } @@ -885,6 +886,7 @@ func (o *Orchestrator) Snapshot() StateSnapshot { snap.Running = append(snap.Running, RunningSnapshot{ IssueID: entry.IssueID, IssueIdentifier: entry.Identifier, + IssueTitle: entry.Issue.Title, State: entry.Issue.State, SessionID: entry.Session.SessionID, TurnCount: entry.Session.TurnCount, @@ -904,6 +906,7 @@ func (o *Orchestrator) Snapshot() StateSnapshot { snap.Retrying = append(snap.Retrying, RetrySnapshot{ IssueID: entry.IssueID, IssueIdentifier: entry.Identifier, + IssueTitle: entry.IssueTitle, Attempt: entry.Attempt, DueAt: time.UnixMilli(entry.DueAtMS).UTC(), Error: entry.Error, @@ -925,6 +928,7 @@ type StateSnapshot struct { type RunningSnapshot struct { IssueID string `json:"issue_id"` IssueIdentifier string `json:"issue_identifier"` + IssueTitle string `json:"issue_title"` State string `json:"state"` SessionID string `json:"session_id"` TurnCount int `json:"turn_count"` @@ -944,6 +948,7 @@ type TokenSnapshot struct { type RetrySnapshot struct { IssueID string `json:"issue_id"` IssueIdentifier string `json:"issue_identifier"` + IssueTitle string `json:"issue_title"` Attempt int `json:"attempt"` DueAt time.Time `json:"due_at"` Error string `json:"error"` diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 33a1383..6b844f4 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -53,6 +53,56 @@ func TestSortForDispatch_SamePrioritySameTime(t *testing.T) { } } +func TestSnapshotIncludesIssueTitle(t *testing.T) { + o := &Orchestrator{ + running: make(map[string]*model.RunningEntry), + retryAttempts: make(map[string]*model.RetryEntry), + } + + o.running["issue-1"] = &model.RunningEntry{ + IssueID: "issue-1", + Identifier: "ABC-1", + Issue: model.Issue{ + ID: "issue-1", + Identifier: "ABC-1", + Title: "Fix the login bug", + State: "In Progress", + }, + StartedAt: time.Now(), + } + + o.retryAttempts["issue-2"] = &model.RetryEntry{ + IssueID: "issue-2", + Identifier: "ABC-2", + IssueTitle: "Add dark mode", + Attempt: 1, + DueAtMS: time.Now().Add(10 * time.Second).UnixMilli(), + Error: "timeout", + } + + snap := o.Snapshot() + + if len(snap.Running) != 1 { + t.Fatalf("expected 1 running entry, got %d", len(snap.Running)) + } + if snap.Running[0].IssueTitle != "Fix the login bug" { + t.Errorf("running snapshot: expected title %q, got %q", "Fix the login bug", snap.Running[0].IssueTitle) + } + if snap.Running[0].IssueIdentifier != "ABC-1" { + t.Errorf("running snapshot: expected identifier %q, got %q", "ABC-1", snap.Running[0].IssueIdentifier) + } + + if len(snap.Retrying) != 1 { + t.Fatalf("expected 1 retrying entry, got %d", len(snap.Retrying)) + } + if snap.Retrying[0].IssueTitle != "Add dark mode" { + t.Errorf("retry snapshot: expected title %q, got %q", "Add dark mode", snap.Retrying[0].IssueTitle) + } + if snap.Retrying[0].IssueIdentifier != "ABC-2" { + t.Errorf("retry snapshot: expected identifier %q, got %q", "ABC-2", snap.Retrying[0].IssueIdentifier) + } +} + func TestSortForDispatch_NilPriorityLast(t *testing.T) { now := time.Now() issues := []model.Issue{ diff --git a/internal/server/server.go b/internal/server/server.go index 5bc030c..067015e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,9 +13,15 @@ import ( "github.com/jordan/go-symphony/internal/orchestrator" ) +// orchClient is the subset of orchestrator.Orchestrator used by the server. +type orchClient interface { + Snapshot() orchestrator.StateSnapshot + TriggerPoll(ctx context.Context) +} + // Server is the optional HTTP observability server. type Server struct { - orch *orchestrator.Orchestrator + orch orchClient logger *slog.Logger srv *http.Server addr string @@ -109,11 +115,11 @@ Runtime: %.1fs if len(snap.Running) > 0 { fmt.Fprintf(w, `

Running (%d)

- + `, len(snap.Running)) for _, r := range snap.Running { - fmt.Fprintf(w, "\n", - r.IssueIdentifier, r.State, r.SessionID, r.TurnCount, r.LastEvent, + fmt.Fprintf(w, "\n", + r.IssueIdentifier, r.IssueTitle, r.State, r.SessionID, r.TurnCount, r.LastEvent, r.StartedAt.Format("15:04:05"), r.Tokens.InputTokens, r.Tokens.OutputTokens, r.Tokens.TotalTokens, ) @@ -124,11 +130,11 @@ Runtime: %.1fs if len(snap.Retrying) > 0 { fmt.Fprintf(w, `

Retrying (%d)

IssueStateSessionTurnsLast EventStartedTokens
IssueTitleStateSessionTurnsLast EventStartedTokens
%s%s%s%d%s%s%d/%d/%d
%s%s%s%s%d%s%s%d/%d/%d
- + `, len(snap.Retrying)) for _, r := range snap.Retrying { - fmt.Fprintf(w, "\n", - r.IssueIdentifier, r.Attempt, r.DueAt.Format("15:04:05"), r.Error, + fmt.Fprintf(w, "\n", + r.IssueIdentifier, r.IssueTitle, r.Attempt, r.DueAt.Format("15:04:05"), r.Error, ) } fmt.Fprintf(w, "
IssueAttemptDue AtError
IssueTitleAttemptDue AtError
%s%d%s%s
%s%s%d%s%s
\n") diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..6e1ffe2 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jordan/go-symphony/internal/orchestrator" +) + +type fakeOrch struct { + snap orchestrator.StateSnapshot +} + +func (f *fakeOrch) Snapshot() orchestrator.StateSnapshot { return f.snap } +func (f *fakeOrch) TriggerPoll(_ context.Context) {} + +func newTestServer(snap orchestrator.StateSnapshot) *httptest.Server { + s := &Server{orch: &fakeOrch{snap: snap}} + mux := http.NewServeMux() + mux.HandleFunc("GET /", s.handleDashboard) + return httptest.NewServer(mux) +} + +func TestDashboardShowsIssueTitle(t *testing.T) { + now := time.Now() + snap := orchestrator.StateSnapshot{ + GeneratedAt: now, + Running: []orchestrator.RunningSnapshot{ + { + IssueID: "id-1", + IssueIdentifier: "ZYX-99", + IssueTitle: "Fix the login bug", + State: "In Progress", + StartedAt: now, + }, + }, + Retrying: []orchestrator.RetrySnapshot{ + { + IssueID: "id-2", + IssueIdentifier: "ZYX-100", + IssueTitle: "Add dark mode", + Attempt: 1, + DueAt: now.Add(10 * time.Second), + Error: "timeout", + }, + }, + } + + ts := newTestServer(snap) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/") //nolint:noctx // test-only HTTP call + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + html := string(body) + + for _, want := range []string{"Fix the login bug", "ZYX-99", "Add dark mode", "ZYX-100"} { + if !strings.Contains(html, want) { + t.Errorf("dashboard missing %q", want) + } + } +}