Files
synapbus/internal/api/goals_handler.go
T
Algis DumbrisandClaude Opus 4.6 42f8256df6 feat(goals): complete_goal MCP tool + draft→active auto-transition
Three improvements that turn the doc-gardener demo from "runs but
stays in 'draft' forever" into a goal that properly transitions
through its lifecycle and renders a completion summary on /goals/<id>.

### 1. complete_goal MCP tool (#59, #62)

New tool surface: complete_goal(goal_id, status, summary, completion_message_id?)

The critic calls this from inside the sandbox after it sends its FINAL:
DM. Records the one-paragraph human-readable summary on the goal row
plus a pointer to the message that carried the FINAL text, so the Web
UI /goals/<id> page has both the verdict and a deep link to the full
findings JSON.

Status parameter accepts completed | stuck | cancelled. Idempotent
when called with the current status. Rejects callers owned by a
different human than the goal owner.

Plumbing:
- New migration 026_goals_completion_summary.sql adds two columns
  to goals: completion_summary TEXT, completion_message_id INTEGER
  (FK messages.id, ON DELETE SET NULL).
- internal/goals/types.go: new CompletionSummary + CompletionMessageID
  fields on Goal struct.
- internal/goals/store.go: Get/List Scan both new columns;
  SetCompletion(goalID, status, summary, messageID) helper that
  updates status+summary+message_id atomically and populates
  completed_at for terminal states.
- internal/goals/service.go: Complete(ctx, goalID, status, summary,
  messageID) wraps the store method with legalTransition gating.
  legalTransition expanded so draft can jump straight to completed
  (no mandatory "active" hop required).
- internal/mcp/goals_tools.go: completeGoalTool definition +
  handleCompleteGoal handler. Tool count 6 → 7.
- internal/api/goals_handler.go: surfaces completion_summary,
  completion_message_id, and completed_at on both list and detail
  endpoints so the Svelte /goals UI can render them.

### 2. Draft → active auto-transition in propose_task_tree (#60)

handleProposeTaskTree now flips the goal from draft to active at the
end. Previously the coordinator would call create_goal +
propose_task_tree and dispatch inspector, but the goal stayed in
draft forever because nothing transitioned it. Now the mere fact
of having a task tree means the goal is active.

Safe: the transition is best-effort and ignores the legal-transition
error when the goal is already beyond draft.

### 3. REVISE round cap (#61)

Two-layer enforcement:

- Server-side: examples/doc-gardener/start.sh drops max_trigger_depth
  from 8 to 4. Each REVISE round costs 2 hops (critic→inspector +
  inspector→critic), so depth=4 caps the loop at roughly 2 rounds
  before the reactor refuses further dispatches.

- Prompt-side: inspector now includes revision_round (starting at
  0, incremented when it sees a REVISE: input) in its findings JSON.
  Critic reads revision_round and force-FINALs when >= 1. Prompt
  explicitly tells the critic to call complete_goal after sending
  FINAL, so the goal row gets a proper completion_summary.

### 4. run_task.sh terminal-state detection

Rewrote the poll loop to watch goals.status/completion_summary as
the definitive "done" signal rather than parsing DM bodies. Keeps
a message-based fallback for TRIVIAL/CANNOT paths that don't create
a goal. Treats "Received system trigger..." and "Coalesced
trigger..." as informational (they're `__coalesced__` reactor
synthetic events leaking through the coordinator reply, not real
user-facing output). Bare coordinator replies are terminal only
when no goal was created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:52:44 +03:00

336 lines
11 KiB
Go

package api
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/goals"
"github.com/synapbus/synapbus/internal/goaltasks"
)
// GoalsHandler serves the /api/goals endpoints used by the Web UI /goals
// page: list goals, show a single goal's full task tree with cost
// rollup, billing-code breakdown, and the spawned agents attached.
type GoalsHandler struct {
goals *goals.Service
tasks *goaltasks.Service
db *sql.DB
}
func NewGoalsHandler(g *goals.Service, t *goaltasks.Service, db *sql.DB) *GoalsHandler {
return &GoalsHandler{goals: g, tasks: t, db: db}
}
// ListGoals returns recent goals with basic metadata + total spend.
func (h *GoalsHandler) ListGoals(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 200 {
limit = v
}
}
gs, err := h.goals.ListGoals(r.Context(), nil, limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
return
}
type goalSummary struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
ChannelID int64 `json:"channel_id"`
OwnerUsername string `json:"owner_username"`
RootTaskID *int64 `json:"root_task_id"`
SpentTokens int64 `json:"spent_tokens"`
SpentDollarsCents int64 `json:"spent_dollars_cents"`
TaskCount int `json:"task_count"`
BudgetTokens *int64 `json:"budget_tokens"`
BudgetDollarsCents *int64 `json:"budget_dollars_cents"`
PercentBudget float64 `json:"percent_budget"`
CompletionSummary *string `json:"completion_summary,omitempty"`
CompletionMessageID *int64 `json:"completion_message_id,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"`
CreatedAt string `json:"created_at"`
}
out := make([]goalSummary, 0, len(gs))
for _, g := range gs {
s := goalSummary{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
Status: g.Status,
ChannelID: g.ChannelID,
RootTaskID: g.RootTaskID,
BudgetTokens: g.BudgetTokens,
BudgetDollarsCents: g.BudgetDollarsCents,
CompletionSummary: g.CompletionSummary,
CompletionMessageID: g.CompletionMessageID,
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if g.CompletedAt != nil {
ca := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
s.CompletedAt = &ca
}
_ = h.db.QueryRowContext(r.Context(),
`SELECT username FROM users WHERE id=?`, g.OwnerUserID).Scan(&s.OwnerUsername)
if g.RootTaskID != nil {
tokens, cents, count, err := h.tasks.RollupCosts(r.Context(), *g.RootTaskID)
if err == nil {
s.SpentTokens = tokens
s.SpentDollarsCents = cents
s.TaskCount = count
}
}
if g.BudgetDollarsCents != nil && *g.BudgetDollarsCents > 0 {
s.PercentBudget = float64(s.SpentDollarsCents) / float64(*g.BudgetDollarsCents) * 100.0
}
out = append(out, s)
}
writeJSON(w, http.StatusOK, map[string]any{"goals": out})
}
// GetGoal returns a single goal with its full task tree, cost rollup,
// billing-code breakdown, and spawned-agent snapshot.
func (h *GoalsHandler) GetGoal(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "invalid goal id"))
return
}
g, err := h.goals.GetGoal(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", err.Error()))
return
}
tasks, err := h.tasks.ListByGoal(r.Context(), g.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
return
}
var rollupTokens, rollupCents int64
var rollupCount int
if g.RootTaskID != nil {
rollupTokens, rollupCents, rollupCount, _ = h.tasks.RollupCosts(r.Context(), *g.RootTaskID)
}
type taskOut struct {
ID int64 `json:"id"`
ParentTaskID *int64 `json:"parent_task_id"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria string `json:"acceptance_criteria"`
Status string `json:"status"`
Depth int `json:"depth"`
BillingCode string `json:"billing_code"`
AssigneeAgentID *int64 `json:"assignee_agent_id"`
AssigneeAgentName string `json:"assignee_agent_name,omitempty"`
SpentTokens int64 `json:"spent_tokens"`
SpentDollarsCents int64 `json:"spent_dollars_cents"`
VerifierConfig *goaltasks.VerifierConfig `json:"verifier_config,omitempty"`
HeartbeatConfig *goaltasks.HeartbeatConfig `json:"heartbeat_config,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt *string `json:"completed_at,omitempty"`
}
agentNameByID := map[int64]string{}
out := make([]taskOut, 0, len(tasks))
for _, t := range tasks {
tt := taskOut{
ID: t.ID,
ParentTaskID: t.ParentTaskID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Status: t.Status,
Depth: t.Depth,
BillingCode: t.BillingCode,
AssigneeAgentID: t.AssigneeAgentID,
SpentTokens: t.SpentTokens,
SpentDollarsCents: t.SpentDollarsCents,
VerifierConfig: t.VerifierConfig,
HeartbeatConfig: t.HeartbeatConfig,
FailureReason: t.FailureReason,
CreatedAt: t.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if t.CompletedAt != nil {
s := t.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
tt.CompletedAt = &s
}
if t.AssigneeAgentID != nil {
name, ok := agentNameByID[*t.AssigneeAgentID]
if !ok {
_ = h.db.QueryRowContext(r.Context(),
`SELECT name FROM agents WHERE id=?`, *t.AssigneeAgentID).Scan(&name)
agentNameByID[*t.AssigneeAgentID] = name
}
tt.AssigneeAgentName = name
}
out = append(out, tt)
}
// Spawned agents attached to this goal (any agent whose config_hash
// appears as an assignee on one of the goal's tasks, plus the
// coordinator itself).
type spawnedAgent struct {
ID int64 `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ConfigHash string `json:"config_hash"`
SpawnDepth int `json:"spawn_depth"`
AutonomyTier string `json:"autonomy_tier"`
ParentAgent string `json:"parent_agent_name,omitempty"`
}
agentSeen := map[int64]bool{}
agentList := []spawnedAgent{}
collectAgent := func(id int64) {
if id == 0 || agentSeen[id] {
return
}
agentSeen[id] = true
var sa spawnedAgent
var parentID sql.NullInt64
err := h.db.QueryRowContext(r.Context(), `
SELECT id, name, display_name,
COALESCE(config_hash,''), COALESCE(spawn_depth,0),
COALESCE(autonomy_tier,''), parent_agent_id
FROM agents WHERE id=?`, id).
Scan(&sa.ID, &sa.Name, &sa.DisplayName, &sa.ConfigHash, &sa.SpawnDepth, &sa.AutonomyTier, &parentID)
if err != nil {
return
}
if parentID.Valid {
var pname string
_ = h.db.QueryRowContext(r.Context(),
`SELECT name FROM agents WHERE id=?`, parentID.Int64).Scan(&pname)
sa.ParentAgent = pname
}
agentList = append(agentList, sa)
}
if g.CoordinatorAgentID != nil {
collectAgent(*g.CoordinatorAgentID)
}
for _, t := range tasks {
if t.AssigneeAgentID != nil {
collectAgent(*t.AssigneeAgentID)
}
}
// Billing-code rollup via raw query (service wrapper not needed).
billingBreakdown := map[string]map[string]int64{}
if g.RootTaskID != nil {
rows, err := h.db.QueryContext(r.Context(), `
WITH RECURSIVE subtree(id) AS (
SELECT id FROM goal_tasks WHERE id = ?
UNION ALL
SELECT t.id FROM goal_tasks t
JOIN subtree s ON t.parent_task_id = s.id
)
SELECT COALESCE(billing_code,''), SUM(spent_tokens), SUM(spent_dollars_cents)
FROM goal_tasks WHERE id IN subtree
GROUP BY billing_code`, *g.RootTaskID)
if err == nil {
for rows.Next() {
var code string
var tokens, cents int64
if err := rows.Scan(&code, &tokens, &cents); err == nil {
billingBreakdown[code] = map[string]int64{
"tokens": tokens,
"cents": cents,
}
}
}
rows.Close()
}
}
var ownerUsername string
_ = h.db.QueryRowContext(r.Context(),
`SELECT username FROM users WHERE id=?`, g.OwnerUserID).Scan(&ownerUsername)
// Recent system/artifact messages on the goal channel for a small timeline.
type timelineEvent struct {
ID int64 `json:"id"`
From string `json:"from"`
Body string `json:"body"`
Kind string `json:"kind"`
CreatedAt string `json:"created_at"`
}
timeline := []timelineEvent{}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, from_agent, body, COALESCE(metadata,''), created_at
FROM messages
WHERE channel_id = ?
ORDER BY id DESC
LIMIT 50`, g.ChannelID)
if err == nil {
for rows.Next() {
var ev timelineEvent
var meta string
if err := rows.Scan(&ev.ID, &ev.From, &ev.Body, &meta, &ev.CreatedAt); err == nil {
if meta != "" {
var m map[string]any
if json.Unmarshal([]byte(meta), &m) == nil {
if k, ok := m["kind"].(string); ok {
ev.Kind = k
}
}
}
timeline = append(timeline, ev)
}
}
rows.Close()
}
var completedAt *string
if g.CompletedAt != nil {
s := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
completedAt = &s
}
writeJSON(w, http.StatusOK, map[string]any{
"goal": map[string]any{
"id": g.ID,
"slug": g.Slug,
"title": g.Title,
"description": g.Description,
"status": g.Status,
"channel_id": g.ChannelID,
"coordinator_agent_id": g.CoordinatorAgentID,
"root_task_id": g.RootTaskID,
"owner_user_id": g.OwnerUserID,
"owner_username": ownerUsername,
"budget_tokens": g.BudgetTokens,
"budget_dollars_cents": g.BudgetDollarsCents,
"max_spawn_depth": g.MaxSpawnDepth,
"alert_80pct_posted": g.Alert80PctPosted,
"completion_summary": g.CompletionSummary,
"completion_message_id": g.CompletionMessageID,
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
"completed_at": completedAt,
},
"tasks": out,
"rollup": map[string]any{
"tokens": rollupTokens,
"dollars_cents": rollupCents,
"task_count": rollupCount,
},
"billing_breakdown": billingBreakdown,
"spawned_agents": agentList,
"timeline": timeline,
})
}