feat(018): /goals page, budget cascade, quarantine, secrets loop

- New /api/goals + /api/goals/{id} endpoints serving list + task tree
  + cost rollup + billing breakdown + spawned agents + timeline.
- New Svelte /goals and /goals/[id] pages with sidebar link.
- goals.Service.EvaluateBudget returns a soft/hard verdict; agent
  runner posts the 80% warning once and auto-pauses at 100%.
- Auto-quarantine: after each reputation append the agent runner
  checks rolling score < 0.3 and writes quarantined_at; reactor
  refuses new reactive dispatches to quarantined agents.
- Reactor exposes SetSecretProvider; main.go wires secrets.Store
  so reactive subprocess runs inherit user/agent-scoped env vars.
- cli-verifier demonstrates the resource-request protocol: checks
  MCPPROXY_API_KEY, posts to #requests + resource_requests row if
  missing. New `synapbus secrets set/list` CLI (direct-DB) closes
  the loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-04-14 20:27:25 +03:00
co-authored by Claude Opus 4.6
parent 3b94fab226
commit f9d8f1a1c9
15 changed files with 1136 additions and 12 deletions
+1
View File
@@ -45,6 +45,7 @@ __pycache__/
benchmark/data/
benchmark/results/
.venv-bench/
.venv-kimi/
.venv/
# Debug
+96
View File
@@ -393,6 +393,29 @@ func (a *agentRunner) handleSpecialist(ctx context.Context) (string, error) {
tokensOut := int64(400 + 100*(taskID%3))
costCents := int64(25 + 10*(taskID%3))
_ = a.tasks.AddSpend(ctx, taskID, tokensIn+tokensOut, costCents)
// 4a. Budget cascade — re-roll up the goal's total cents and
// check the thresholds. On first crossing of 80% we post a
// warning; at 100% the goal is auto-paused and new claims
// would bounce.
if g.RootTaskID != nil {
_, rollupCents, _, _ := a.tasks.RollupCosts(ctx, *g.RootTaskID)
verdict, err := a.goals.EvaluateBudget(ctx, g.ID, rollupCents)
if err == nil && verdict != nil {
if verdict.TriggerSoftAlert {
a.postSystemMessage(ctx, g.ChannelID,
fmt.Sprintf("⚠️ Budget soft alert: goal has consumed %.0f%% of its dollar budget.",
verdict.PercentBudget))
_ = a.goals.MarkSoftAlertPosted(ctx, g.ID)
}
if verdict.TriggerHardPause {
a.postSystemMessage(ctx, g.ChannelID,
fmt.Sprintf("🛑 Budget hard cap: goal at %.0f%% → auto-paused.", verdict.PercentBudget))
_ = a.goals.TransitionStatus(ctx, g.ID, goals.StatusPaused)
}
}
}
_ = a.tasks.Transition(ctx, taskID, goaltasks.StatusAwaitingVerification, goaltasks.Extras{CompletionMessageID: &artifactMsgID})
extras := goaltasks.Extras{}
@@ -412,12 +435,32 @@ func (a *agentRunner) handleSpecialist(ctx context.Context) (string, error) {
a.logger.Warn("append evidence failed", "err", err)
}
// 5a. Quarantine check — if the rolling reputation dropped below
// 0.3 after this evidence, flag the agent as quarantined so
// future reactive runs refuse to spawn.
if score, _, err := a.ledger.RollingScore(ctx, hash, "default", 30); err == nil && score < 0.3 {
_, _ = a.db.ExecContext(ctx,
`UPDATE agents SET quarantined_at = ?, quarantine_reason = ? WHERE id = ? AND quarantined_at IS NULL`,
time.Now().UTC(), fmt.Sprintf("reputation=%.2f", score), specialistID)
a.postSystemMessage(ctx, g.ChannelID,
fmt.Sprintf("⛔ Agent %s quarantined — reputation %.2f below 0.3.", a.agentName, score))
}
// 6. Post a system summary line with real telemetry.
a.postSystemMessage(ctx, g.ChannelID,
fmt.Sprintf("Task %d %q %s by %s — tokens_in=%d tokens_out=%d cost=$%.2f duration=%dms Δrep=%+.2f",
taskID, t.Title, verdict, role, tokensIn, tokensOut,
float64(costCents)/100, duration.Milliseconds(), scoreDelta))
// 6a. Resource-request protocol demo: the cli-verifier needs
// MCPPROXY_API_KEY. If the injected env doesn't carry it, it
// posts a structured request to the #requests channel so a
// human can set it via `synapbus secrets set`.
if role == "cli-verifier" && os.Getenv("MCPPROXY_API_KEY") == "" {
a.postResourceRequest(ctx, role, taskID, "MCPPROXY_API_KEY",
"Need the mcpproxy admin API key to re-run live CLI verification against a remote proxy; set it with `synapbus secrets set MCPPROXY_API_KEY <value> --scope agent:cli-verifier`.")
}
// 7. DM coordinator with DONE or FAIL.
reply := fmt.Sprintf("%s task=%d role=%s tokens_in=%d tokens_out=%d cost_cents=%d duration_ms=%d",
strings.ToUpper(string(verdict)), taskID, role, tokensIn, tokensOut, costCents, duration.Milliseconds())
@@ -562,6 +605,59 @@ func (a *agentRunner) spawnSpecialist(ctx context.Context, ownerID, parentID int
return id, nil
}
// postResourceRequest writes a structured resource_requests row AND
// posts a #requests channel message describing the missing secret.
// The human reads it, runs `synapbus secrets set` to provision it,
// and the next reactive run picks up the injected env var.
func (a *agentRunner) postResourceRequest(ctx context.Context, role string, taskID int64, resourceName, reason string) {
// Ensure #requests channel exists. Admin CLI creates it in
// start.sh; we re-check defensively here and create if missing.
var reqChannelID int64
err := a.db.QueryRowContext(ctx,
`SELECT id FROM channels WHERE name='requests' LIMIT 1`).Scan(&reqChannelID)
if err != nil {
// Channel missing — create it inline (no CreatedBy enforcement in the demo).
res, cerr := a.db.ExecContext(ctx,
`INSERT INTO channels (name, description, type, created_by, is_private, is_system)
VALUES ('requests','Resource requests','blackboard', ?, 0, 1)`,
a.agentName)
if cerr != nil {
a.logger.Warn("could not create #requests channel", "err", cerr)
return
}
reqChannelID, _ = res.LastInsertId()
}
body := fmt.Sprintf("#resource-request agent=%s task=%d resource=%s type=env_var\nreason: %s",
a.agentName, taskID, resourceName, reason)
// Insert the message directly (the #requests channel is not
// reactive so bypassing the reactor dispatcher is fine here).
convID, cerr := a.ensureConversation(ctx, reqChannelID)
if cerr != nil {
a.logger.Warn("could not ensure #requests conversation", "err", cerr)
return
}
now := time.Now().UTC()
_, err = a.db.ExecContext(ctx, `
INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, 7, 'done', '{"kind":"resource-request"}', ?, ?)`,
convID, a.agentName, reqChannelID, body, now, now)
if err != nil {
a.logger.Warn("could not post resource-request message", "err", err)
return
}
// Also write a resource_requests row (feature 018) so the /goals
// page + /api could display it later.
_, _ = a.db.ExecContext(ctx, `
INSERT INTO resource_requests (requester_agent_id, task_id, resource_name, resource_type, reason, status)
SELECT id, ?, ?, 'env_var', ?, 'pending' FROM agents WHERE name=?`,
taskID, resourceName, reason, a.agentName)
a.logger.Info("resource request posted", "resource", resourceName, "task_id", taskID)
}
// postRealArtifactDirect is a copy of flow.go's postRealArtifact that
// does not rely on a shared conversation — it creates a fresh
// conversation scoped to this single message write if one doesn't
+11 -1
View File
@@ -493,11 +493,21 @@ flags=--port --config --socket --data-dir --log-format --log-level --otel-endpoi
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF`
case "cli-verifier":
return `cat <<EOF
// The cli-verifier participates in the resource-request
// protocol: it checks for a scoped secret (MCPPROXY_API_KEY)
// in its injected env. If the secret is missing the verifier
// still produces a finding but flags that it's running in
// "unauthenticated" mode; the agent itself DMs #requests from
// Go code (see agent.go::handleSpecialist).
return `
KEY_STATE="missing"
if [ -n "${MCPPROXY_API_KEY:-}" ]; then KEY_STATE="present"; fi
cat <<EOF
#verified task=` + fmt.Sprint(t.ID) + `
source=mcpproxy --help
matched=10
missing=--otel-endpoint --retention
mcpproxy_api_key=$KEY_STATE
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF`
case "drift-reporter":
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"context"
"fmt"
"github.com/synapbus/synapbus/internal/channels"
)
// svcGoalChannelCreator adapts channels.Service to the
// goals.ChannelCreator interface — wrapping it here avoids the
// internal/goals package importing internal/channels (which would
// create a cycle via messaging).
type svcGoalChannelCreator struct {
channels *channels.Service
}
func (c *svcGoalChannelCreator) CreateGoalChannel(
ctx context.Context,
slug, title, description, ownerUsername string,
) (int64, error) {
name := "goal-" + slug
ch, err := c.channels.CreateChannel(ctx, channels.CreateChannelRequest{
Name: name,
Description: fmt.Sprintf("Goal: %s", title),
Topic: title,
Type: channels.TypeBlackboard,
IsPrivate: true,
IsSystem: true,
CreatedBy: ownerUsername,
})
if err != nil {
return 0, err
}
return ch.ID, nil
}
+26
View File
@@ -34,6 +34,9 @@ import (
"github.com/synapbus/synapbus/internal/auth/idp"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/goals"
"github.com/synapbus/synapbus/internal/goaltasks"
"github.com/synapbus/synapbus/internal/secrets"
"github.com/synapbus/synapbus/internal/dispatcher"
"github.com/synapbus/synapbus/internal/health"
"github.com/synapbus/synapbus/internal/jsruntime"
@@ -109,6 +112,9 @@ func main() {
// Add wiki export/import subcommands.
addWikiCommands(rootCmd)
// Add secrets CLI (resource-request protocol, feature 018).
rootCmd.AddCommand(registerSecretsCLI())
if err := rootCmd.Execute(); err != nil {
slog.Error("command failed", "error", err)
os.Exit(1)
@@ -514,6 +520,17 @@ func runServe(cmd *cobra.Command, args []string) error {
harnessRegistry.Observer = harnessRunsStore
reactorEngine.SetHarnessRegistry(harnessRegistry)
reactorEngine.SetReactionNotifier(&reactorReactionAdapter{svc: reactionService})
// Secrets store — feature 018. Encrypted secrets scoped to
// user/agent/task, injected by the reactor as env vars on each
// reactive subprocess run.
secretsStore, err := secrets.NewStore(db.DB, dataDir, slog.Default())
if err != nil {
slog.Warn("secrets store unavailable — reactive runs will not receive injected secrets", "error", err)
} else {
reactorEngine.SetSecretProvider(secretsStore)
slog.Info("secrets store bootstrapped and wired to reactor")
}
slog.Info("harness registry configured",
"backends", harnessRegistry.Names(),
)
@@ -537,6 +554,13 @@ func runServe(cmd *cobra.Command, args []string) error {
// Create MCP server (4 hybrid tools: my_status, send_message, search, execute)
wikiService := wiki.NewService(db.DB)
// Goals + goal_tasks (feature 018 — dynamic agent spawning).
goalsStore := goals.NewStore(db.DB)
goalTasksStore := goaltasks.NewStore(db.DB)
goalChannelCreator := &svcGoalChannelCreator{channels: channelService}
goalsService := goals.NewService(goalsStore, goalChannelCreator, slog.Default())
goalTasksService := goaltasks.NewService(goalTasksStore, slog.Default())
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, wikiService, con, jsPool, actionRegistry, actionIndex, db.DB)
// Wire the agent marketplace (spec 016 MVP).
@@ -731,6 +755,8 @@ func runServe(cmd *cobra.Command, args []string) error {
ReactorStore: reactorStore,
ReactorEngine: reactorEngine,
HarnessRunsStore: harnessRunsStore,
GoalsService: goalsService,
GoalTasksService: goalTasksService,
BaseURL: baseURL,
WikiService: wikiService,
})
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/secrets"
)
// registerSecretsCLI returns the `secrets` cobra command tree for the
// resource-request protocol. Unlike most admin commands it does NOT go
// through the admin socket — it opens the SQLite DB directly. This
// keeps the demo simple, avoids adding socket handlers, and works
// equally well when the server is not running.
func registerSecretsCLI() *cobra.Command {
var (
dbPath string
scope string
)
resolveDB := func() (string, error) {
if dbPath != "" {
return dbPath, nil
}
if env := os.Getenv("SYNAPBUS_DATA_DIR"); env != "" {
return filepath.Join(env, "synapbus.db"), nil
}
return "./data/synapbus.db", nil
}
openDirect := func() (*sql.DB, string, error) {
path, err := resolveDB()
if err != nil {
return nil, "", err
}
if _, err := os.Stat(path); err != nil {
return nil, "", fmt.Errorf("db not found at %s — set --db or SYNAPBUS_DATA_DIR", path)
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, "", err
}
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)", abs)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, "", err
}
db.SetMaxOpenConns(1)
return db, filepath.Dir(abs), nil
}
parseScope := func(s string) (string, int64, error) {
// forms: user:<name>, agent:<name>, task:<id>
if !strings.Contains(s, ":") {
return "", 0, fmt.Errorf("scope must be user:NAME, agent:NAME, or task:ID")
}
typ, ident, _ := strings.Cut(s, ":")
db, _, err := openDirect()
if err != nil {
return "", 0, err
}
defer db.Close()
switch typ {
case "user":
var id int64
err := db.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username=?`, ident).Scan(&id)
if err != nil {
return "", 0, fmt.Errorf("user %q not found: %w", ident, err)
}
return secrets.ScopeUser, id, nil
case "agent":
var id int64
err := db.QueryRowContext(context.Background(), `SELECT id FROM agents WHERE name=?`, ident).Scan(&id)
if err != nil {
return "", 0, fmt.Errorf("agent %q not found: %w", ident, err)
}
return secrets.ScopeAgent, id, nil
case "task":
id, err := strconv.ParseInt(ident, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("task scope id must be an integer")
}
return secrets.ScopeTask, id, nil
}
return "", 0, fmt.Errorf("unknown scope type %q", typ)
}
root := &cobra.Command{
Use: "secrets",
Short: "Manage encrypted scoped secrets (resource-request protocol)",
}
root.PersistentFlags().StringVar(&dbPath, "db", "", "Path to synapbus.db (defaults to ./data or SYNAPBUS_DATA_DIR)")
setCmd := &cobra.Command{
Use: "set NAME VALUE",
Short: "Store a secret under a scope",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
name, value := args[0], args[1]
scopeType, scopeID, err := parseScope(scope)
if err != nil {
return err
}
db, dataDir, err := openDirect()
if err != nil {
return err
}
defer db.Close()
store, err := secrets.NewStore(db, dataDir, slog.Default())
if err != nil {
return err
}
s, err := store.Set(cmd.Context(), name, scopeType, scopeID, 0, value)
if err != nil {
return err
}
fmt.Printf("stored secret id=%d name=%s scope=%s:%d\n", s.ID, s.Name, scopeType, scopeID)
return nil
},
}
setCmd.Flags().StringVar(&scope, "scope", "", "Scope (user:NAME, agent:NAME, task:ID)")
_ = setCmd.MarkFlagRequired("scope")
listCmd := &cobra.Command{
Use: "list",
Short: "List secrets visible to a scope (names only — never values)",
RunE: func(cmd *cobra.Command, args []string) error {
scopeType, scopeID, err := parseScope(scope)
if err != nil {
return err
}
db, dataDir, err := openDirect()
if err != nil {
return err
}
defer db.Close()
store, err := secrets.NewStore(db, dataDir, slog.Default())
if err != nil {
return err
}
infos, err := store.List(cmd.Context(), []secrets.Scope{{Type: scopeType, ID: scopeID}})
if err != nil {
return err
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tSCOPE\tLAST USED")
for _, i := range infos {
last := "—"
if i.LastUsedAt != nil {
last = i.LastUsedAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(tw, "%s\t%s:%d\t%s\n", i.Name, i.ScopeType, i.ScopeID, last)
}
return tw.Flush()
},
}
listCmd.Flags().StringVar(&scope, "scope", "", "Scope (user:NAME, agent:NAME, task:ID)")
_ = listCmd.MarkFlagRequired("scope")
root.AddCommand(setCmd, listCmd)
return root
}
+317
View File
@@ -0,0 +1,317 @@
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"`
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,
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
_ = 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()
}
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,
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
},
"tasks": out,
"rollup": map[string]any{
"tokens": rollupTokens,
"dollars_cents": rollupCents,
"task_count": rollupCount,
},
"billing_breakdown": billingBreakdown,
"spawned_agents": agentList,
"timeline": timeline,
})
}
+15
View File
@@ -10,6 +10,8 @@ import (
"github.com/synapbus/synapbus/internal/apikeys"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/goals"
"github.com/synapbus/synapbus/internal/goaltasks"
"github.com/synapbus/synapbus/internal/harness/runs"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
@@ -43,6 +45,8 @@ type RouterConfig struct {
ReactorStore *reactor.Store
ReactorEngine *reactor.Reactor
HarnessRunsStore *runs.Store
GoalsService *goals.Service
GoalTasksService *goaltasks.Service
WikiService *wiki.Service
SSEHub *SSEHub
Broadcaster *SSEBroadcaster
@@ -266,6 +270,17 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
})
}
// Goals
if cfg.GoalsService != nil && cfg.GoalTasksService != nil && cfg.DB != nil {
goalsHandler := NewGoalsHandler(cfg.GoalsService, cfg.GoalTasksService, cfg.DB)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/goals", goalsHandler.ListGoals)
r.Get("/api/goals/{id}", goalsHandler.GetGoal)
})
}
// Trust Scores
if cfg.TrustService != nil {
trustHandler := NewTrustHandler(cfg.TrustService)
+38
View File
@@ -104,6 +104,44 @@ func (s *Service) TransitionStatus(ctx context.Context, goalID int64, newStatus
return s.store.SetStatus(ctx, goalID, newStatus)
}
// BudgetVerdict describes what the budget enforcer wants the caller to do.
type BudgetVerdict struct {
PercentBudget float64 // 0..100+
TriggerSoftAlert bool // first time we cross 80%
TriggerHardPause bool // crossed 100% and goal is still active
}
// EvaluateBudget computes current spend-vs-budget for a goal and returns
// the enforcement verdict. It does NOT mutate state on its own — the
// caller uses MarkSoftAlertPosted / TransitionStatus to apply the
// verdict once it has posted the corresponding system messages.
//
// Only dollar-cents budget is enforced in MVP (tokens are tracked but
// don't trip the cascade).
func (s *Service) EvaluateBudget(ctx context.Context, goalID int64, spentCents int64) (*BudgetVerdict, error) {
g, err := s.store.Get(ctx, goalID)
if err != nil {
return nil, err
}
v := &BudgetVerdict{}
if g.BudgetDollarsCents == nil || *g.BudgetDollarsCents <= 0 {
return v, nil
}
v.PercentBudget = float64(spentCents) / float64(*g.BudgetDollarsCents) * 100.0
if v.PercentBudget >= 80 && !g.Alert80PctPosted {
v.TriggerSoftAlert = true
}
if v.PercentBudget >= 100 && g.Status == StatusActive {
v.TriggerHardPause = true
}
return v, nil
}
// MarkSoftAlertPosted records that the 80% soft-alert was emitted.
func (s *Service) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
return s.store.MarkSoftAlertPosted(ctx, goalID)
}
func legalTransition(from, to string) bool {
switch from {
case StatusDraft:
+41
View File
@@ -32,6 +32,13 @@ const (
)
// Reactor is the reactive agent triggering engine.
// SecretProvider builds the env map of scoped secrets to inject into a
// reactive subprocess run. Returning an error is non-fatal — the run
// proceeds without any injected secrets and the error is logged.
type SecretProvider interface {
BuildEnvMap(ctx context.Context, userID, agentID, taskID int64) (map[string]string, error)
}
type Reactor struct {
store *Store
agentStore agents.AgentStore
@@ -39,6 +46,7 @@ type Reactor struct {
registry *harness.Registry
notifier FailureNotifier
reactions ReactionNotifier
secrets SecretProvider
logger *slog.Logger
}
@@ -78,6 +86,12 @@ func (r *Reactor) SetHarnessRegistry(reg *harness.Registry) {
r.registry = reg
}
// SetSecretProvider wires the component that reads scoped secrets for
// the reactor to inject into subprocess runs as env vars.
func (r *Reactor) SetSecretProvider(p SecretProvider) {
r.secrets = p
}
// SetReactionNotifier wires the component that marks triggering DMs
// with in_progress / done / reject reactions. Optional — a nil
// notifier simply skips the reaction step.
@@ -148,6 +162,20 @@ func (r *Reactor) evaluateTrigger(ctx context.Context, agentName string, event d
return nil // Not reactive, skip
}
// 2a. Refuse to dispatch to a quarantined agent. Quarantine is set
// when the agent's rolling reputation drops below the threshold
// (0.3 by default). Existing in-flight runs are allowed to finish
// but no new reactive runs will spawn.
if agent.QuarantinedAt != nil {
r.logger.Info("reactive dispatch to quarantined agent refused",
"agent", agentName,
"quarantined_at", agent.QuarantinedAt,
"reason", agent.QuarantineReason,
)
r.recordSkippedRun(ctx, agentName, event, StatusFailed, "agent quarantined: "+agent.QuarantineReason)
return nil
}
// 3. Pick a backend. K8s agents (k8s_image set) keep the existing
// createJob + async poller path for restart safety. Everything else
// goes through the harness registry in a goroutine.
@@ -337,6 +365,19 @@ func (r *Reactor) dispatchHarness(ctx context.Context, agent *agents.Agent, even
},
}
// Inject scoped secrets as env vars (user + agent scope; task scope
// is added when a task ID becomes available in the trigger path).
if r.secrets != nil {
if secretEnv, serr := r.secrets.BuildEnvMap(ctx, agent.OwnerID, agent.ID, 0); serr == nil {
for k, v := range secretEnv {
req.Env[k] = v
}
} else {
r.logger.Warn("secret provider failed — continuing without injection",
"agent", agent.Name, "error", serr)
}
}
// Block on a detached context so a cancelled incoming request
// does not kill in-flight work. Callers get a fast return above.
go r.runHarness(runID, agent, event, req)
+11 -11
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.C512GHr7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dmlz6bHA.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DV85bOb-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/bToYWyxM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B7OLp0b1.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CzUB9ngU.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B5iOtbKp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CtXjcZd5.js" rel="modulepreload">
<link href="/_app/immutable/chunks/17s2OMzH.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.SOdOn-UG.js" rel="modulepreload">
<link href="/_app/immutable/chunks/D7RUu5Hq.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C86Hpm02.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C-WCSejw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BndK8xE3.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C_UL06IJ.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.Wya5_mWU.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1402urg = {
__sveltekit_8az38d = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.C512GHr7.js"),
import("/_app/immutable/entry/app.SOdOn-UG.js")
import("/_app/immutable/entry/start.DV85bOb-.js"),
import("/_app/immutable/entry/app.Wya5_mWU.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+10
View File
@@ -347,4 +347,14 @@ export const runs = {
reactiveAgents: () => request<{ agents: any[] }>('GET', '/api/agents/reactive')
};
export const goals = {
list: (params?: { limit?: number }) => {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', String(params.limit));
const q = qs.toString();
return request<{ goals: any[] }>('GET', `/api/goals${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/goals/${id}`)
};
export { ApiError };
+1
View File
@@ -64,6 +64,7 @@
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/runs', label: 'Agent Runs' },
{ href: '/goals', label: 'Goals' },
{ href: '/skills', label: 'Skills' },
{ href: '/settings', label: 'Settings' }
];
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { goals } from '$lib/api/client';
import { user } from '$lib/stores/auth';
let list = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let _intervalId: ReturnType<typeof setInterval> | null = null;
let _initialized = $state(false);
$effect(() => {
if (!_initialized && $user) {
_initialized = true;
void load();
_intervalId = setInterval(load, 10_000);
}
return () => {
if (_intervalId) clearInterval(_intervalId);
_intervalId = null;
};
});
async function load() {
try {
const r = await goals.list({ limit: 50 });
list = r.goals ?? [];
error = null;
} catch (e: any) {
error = e?.message ?? String(e);
} finally {
loading = false;
}
}
function formatDollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
function statusColor(s: string): string {
switch (s) {
case 'active':
return 'bg-blue-500/20 text-blue-300 border-blue-500/40';
case 'completed':
return 'bg-green-500/20 text-green-300 border-green-500/40';
case 'paused':
return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/40';
case 'stuck':
return 'bg-orange-500/20 text-orange-300 border-orange-500/40';
case 'cancelled':
return 'bg-red-500/20 text-red-300 border-red-500/40';
default:
return 'bg-bg-tertiary text-text-secondary border-border';
}
}
</script>
<svelte:head>
<title>Goals — SynapBus</title>
</svelte:head>
<div class="p-6 max-w-6xl mx-auto">
<header class="mb-6">
<h1 class="text-2xl font-semibold text-text-primary">Goals</h1>
<p class="text-sm text-text-secondary mt-1">
Top-level objectives owned by a human, decomposed into task trees by a coordinator, and
executed by dynamically spawned specialist agents.
</p>
</header>
{#if loading}
<p class="text-text-secondary">Loading…</p>
{:else if error}
<div class="rounded border border-red-500/40 bg-red-500/10 p-3 text-red-300 text-sm">
{error}
</div>
{:else if list.length === 0}
<div class="rounded border border-border bg-bg-secondary p-6 text-center text-text-secondary">
No goals yet. Goals are created by the coordinator agent when a human DMs it with a brief.
</div>
{:else}
<div class="space-y-3">
{#each list as g (g.id)}
<a
href={`/goals/${g.id}`}
class="block rounded-lg border border-border bg-bg-secondary p-4 hover:border-text-link transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-mono text-text-tertiary">#{g.id}</span>
<span
class="text-xs rounded px-2 py-0.5 border uppercase tracking-wide {statusColor(
g.status
)}"
>
{g.status}
</span>
<span class="text-xs text-text-secondary">by {g.owner_username || '—'}</span>
</div>
<h2 class="font-medium text-text-primary truncate">{g.title}</h2>
<div class="text-xs font-mono text-text-tertiary mt-1">#goal-{g.slug}</div>
</div>
<div class="text-right shrink-0">
<div class="text-xs text-text-secondary">Spend</div>
<div class="text-sm font-semibold text-text-primary">
{formatDollars(g.spent_dollars_cents)}
</div>
<div class="text-xs text-text-tertiary">{g.spent_tokens.toLocaleString()} tok</div>
{#if g.budget_dollars_cents}
<div class="mt-1 text-xs text-text-secondary">
{g.percent_budget.toFixed(0)}% of {formatDollars(g.budget_dollars_cents)}
</div>
<div class="mt-1 h-1 w-24 rounded bg-bg-tertiary overflow-hidden">
<div
class="h-full {g.percent_budget >= 100
? 'bg-red-500'
: g.percent_budget >= 80
? 'bg-yellow-500'
: 'bg-green-500'}"
style={`width: ${Math.min(100, g.percent_budget).toFixed(1)}%`}
></div>
</div>
{/if}
</div>
</div>
<div class="mt-3 flex items-center gap-4 text-xs text-text-secondary">
<span>{g.task_count} tasks</span>
<span class="text-text-tertiary">·</span>
<span>created {g.created_at.replace('T', ' ').replace('Z', ' UTC')}</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
+225
View File
@@ -0,0 +1,225 @@
<script lang="ts">
import { page } from '$app/stores';
import { goals } from '$lib/api/client';
import { user } from '$lib/stores/auth';
let data = $state<any>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let _intervalId: ReturnType<typeof setInterval> | null = null;
let _initialized = $state(false);
const goalId = $derived(Number($page.params.id));
$effect(() => {
if (!_initialized && $user && goalId) {
_initialized = true;
void load();
_intervalId = setInterval(load, 10_000);
}
return () => {
if (_intervalId) clearInterval(_intervalId);
_intervalId = null;
};
});
async function load() {
try {
data = await goals.get(goalId);
error = null;
} catch (e: any) {
error = e?.message ?? String(e);
} finally {
loading = false;
}
}
function formatDollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
function statusPill(s: string): string {
switch (s) {
case 'done':
return 'bg-green-500/20 text-green-300 border-green-500/40';
case 'failed':
return 'bg-red-500/20 text-red-300 border-red-500/40';
case 'in_progress':
return 'bg-blue-500/20 text-blue-300 border-blue-500/40';
case 'awaiting_verification':
return 'bg-purple-500/20 text-purple-300 border-purple-500/40';
case 'claimed':
return 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40';
case 'approved':
return 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40';
default:
return 'bg-bg-tertiary text-text-secondary border-border';
}
}
// Build a parent→children lookup for tree rendering.
const tree = $derived.by(() => {
if (!data?.tasks) return { root: null, byParent: new Map() };
const byParent = new Map<number | null, any[]>();
let root: any = null;
for (const t of data.tasks) {
const key = t.parent_task_id ?? null;
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key)!.push(t);
if (t.parent_task_id === null || t.parent_task_id === undefined) root = t;
}
return { root, byParent };
});
</script>
<svelte:head>
<title>{data?.goal?.title ?? 'Goal'} — SynapBus</title>
</svelte:head>
<div class="p-6 max-w-6xl mx-auto">
<a href="/goals" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
← All goals
</a>
{#if loading && !data}
<p class="text-text-secondary">Loading…</p>
{:else if error}
<div class="rounded border border-red-500/40 bg-red-500/10 p-3 text-red-300 text-sm">
{error}
</div>
{:else if data}
<header class="mb-6">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-mono text-text-tertiary">#{data.goal.id}</span>
<span class="text-xs rounded px-2 py-0.5 border uppercase tracking-wide {statusPill(
data.goal.status
)}">
{data.goal.status}
</span>
<span class="text-xs text-text-secondary">by {data.goal.owner_username}</span>
</div>
<h1 class="text-2xl font-semibold text-text-primary">{data.goal.title}</h1>
<div class="text-xs font-mono text-text-tertiary mt-1">#goal-{data.goal.slug}</div>
<p class="text-sm text-text-secondary mt-3 whitespace-pre-wrap">{data.goal.description}</p>
</header>
<!-- Cost rollup + billing breakdown + spawned agents -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="rounded-lg border border-border bg-bg-secondary p-4">
<div class="text-xs uppercase text-text-tertiary tracking-wide">Total spend</div>
<div class="text-xl font-semibold text-text-primary mt-1">
{formatDollars(data.rollup.dollars_cents)}
</div>
<div class="text-xs text-text-secondary">
{data.rollup.tokens.toLocaleString()} tokens · {data.rollup.task_count} tasks
</div>
{#if data.goal.budget_dollars_cents}
<div class="mt-2 text-xs text-text-secondary">
budget {formatDollars(data.goal.budget_dollars_cents)} · max depth
{data.goal.max_spawn_depth}
</div>
{/if}
</div>
<div class="rounded-lg border border-border bg-bg-secondary p-4">
<div class="text-xs uppercase text-text-tertiary tracking-wide">Billing breakdown</div>
<ul class="mt-2 space-y-1 text-xs">
{#each Object.entries(data.billing_breakdown) as [code, s] (code)}
<li class="flex items-center justify-between">
<span class="font-mono text-text-secondary">{code || '—'}</span>
<span class="text-text-primary">{formatDollars((s as any).cents)}</span>
</li>
{/each}
</ul>
</div>
<div class="rounded-lg border border-border bg-bg-secondary p-4">
<div class="text-xs uppercase text-text-tertiary tracking-wide">Spawned agents</div>
<ul class="mt-2 space-y-1 text-xs">
{#each data.spawned_agents as a (a.id)}
<li class="flex items-center justify-between gap-2">
<a class="text-text-link hover:underline font-mono" href={`/agents/${a.name}`}>
{a.name}
</a>
<span class="text-text-tertiary">depth {a.spawn_depth}</span>
</li>
{/each}
</ul>
</div>
</section>
<!-- Task tree -->
<section class="mb-6">
<h2 class="text-sm font-semibold text-text-primary mb-2">Task tree</h2>
<div class="space-y-2">
{#each data.tasks as t (t.id)}
<div
class="rounded border border-border bg-bg-secondary p-3"
style={`margin-left: ${t.depth * 20}px`}
>
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-text-tertiary">#{t.id}</span>
<span class="text-xs rounded px-2 py-0.5 border uppercase tracking-wide {statusPill(
t.status
)}">
{t.status.replace('_', ' ')}
</span>
{#if t.billing_code}
<span class="text-xs font-mono text-text-tertiary">{t.billing_code}</span>
{/if}
{#if t.assignee_agent_name}
<span class="text-xs text-text-secondary">
→
<a href={`/agents/${t.assignee_agent_name}`} class="text-text-link hover:underline"
>{t.assignee_agent_name}</a
>
</span>
{/if}
</div>
<div class="mt-1 font-medium text-text-primary text-sm">{t.title}</div>
{#if t.description}
<div class="mt-1 text-xs text-text-secondary">{t.description}</div>
{/if}
{#if t.acceptance_criteria}
<div class="mt-1 text-xs text-text-tertiary italic">
acceptance: {t.acceptance_criteria}
</div>
{/if}
<div class="mt-2 flex items-center gap-3 text-xs text-text-secondary">
<span>{t.spent_tokens.toLocaleString()} tok</span>
<span class="text-text-tertiary">·</span>
<span>{formatDollars(t.spent_dollars_cents)}</span>
{#if t.failure_reason}
<span class="text-red-300">· {t.failure_reason}</span>
{/if}
</div>
</div>
{/each}
</div>
</section>
<!-- Timeline -->
{#if data.timeline?.length > 0}
<section>
<h2 class="text-sm font-semibold text-text-primary mb-2">Recent activity</h2>
<div class="rounded-lg border border-border bg-bg-secondary p-4 max-h-96 overflow-y-auto">
<ul class="space-y-2 text-xs">
{#each data.timeline as ev (ev.id)}
<li class="flex gap-2">
<span class="font-mono text-text-tertiary shrink-0">
{ev.created_at.substring(11, 19)}
</span>
<span class="font-mono text-text-secondary shrink-0">{ev.from}</span>
{#if ev.kind}
<span
class="text-[10px] rounded px-1 bg-bg-tertiary text-text-tertiary uppercase shrink-0"
>{ev.kind}</span
>
{/if}
<span class="text-text-primary whitespace-pre-wrap break-words">{ev.body}</span>
</li>
{/each}
</ul>
</div>
</section>
{/if}
{/if}
</div>