Files
synapbus/cmd/docgardener/report.go
T
Algis DumbrisandClaude Opus 4.6 ff5d0c49f4 feat(018): dynamic agent spawning — primitives + doc-gardener demo
Ships the MVP slice of spec 018 (dynamic agent spawning):

- 5 new SQLite migrations (021-025): goals + goal_tasks + agent_proposals
  + reputation_evidence + secrets + harness_runs.task_id. The legacy
  `tasks` table (channel auctions) and `agent_trust` table (reactions
  workflow) are left untouched — the new schema coexists.

- 4 new internal packages, fully tested:
  - internal/goals: Goal struct + store + service, slug collision dedup,
    backing-channel auto-create via ChannelCreator adapter
  - internal/goaltasks: goal_tasks table with denormalized 16 KB
    ancestry snapshots, single-statement optimistic-lock atomic claim,
    recursive-CTE cost rollup, state machine, per-billing-code rollup
  - internal/secrets: NaCl-secretbox encrypted blobs, user/agent/task
    scope precedence, sanitized env injection, master-key bootstrap
  - internal/trust additions: ConfigHash (deterministic SHA-256 of
    model + prompt + tools + skills + mcp + subagents, sorted),
    DelegationCap (tier + tool-scope + budget + depth enforcement),
    append-only Ledger with exponential time-decay rolling score and
    70%-of-parent child seeding. Existing trust package unchanged.

- Critical invariants under test:
  - 50-goroutine concurrent claim race → exactly one winner per round
  - ConfigHash stable under shuffled array inputs, sensitive to
    capability changes
  - DelegationCap full tier × tool-scope matrix
  - Ledger time-decay + parent seed at 70 % ± 1 %
  - Secret name sanitization, scope precedence, plaintext never
    returned via MCP-equivalent paths

- internal/agents/types.go extended with dynamic-spawning columns
  (config_hash, parent_agent_id, spawn_depth, system_prompt,
  autonomy_tier, tool_scope_json, quarantined_at). Existing tests
  still pass.

- cmd/docgardener: self-contained demo binary driving the end-to-end
  flow. `docgardener run` creates a goal, builds a task tree with
  denormalized ancestry, spawns 3 specialists (each going through
  real delegation-cap validation and config-hash computation and
  70 %-of-parent reputation seeding), claims tasks atomically, runs
  them through the state machine, records reputation evidence.
  `docgardener report` queries all of that back out and renders a
  rich dark-mode HTML report (header, spend metrics, task tree,
  spawned-agent cards with reputation bars, cost breakdown, artifacts,
  timeline).

- examples/doc-gardener: start.sh / run_task.sh / report.sh / stop.sh
  mirroring the cold-topic-explainer pattern. Launches an isolated
  synapbus instance on port 18089, drives the demo, renders
  report.html, cleans up. Full README documenting what's real vs
  deferred, plus examples/README.md listing both examples.

- specs/018: tasks.md updated with MVP completion status; legacy tasks
  naming collision noted.

Deferred (marked explicitly in example README):
- Real LLM-driven coordinator (needs MCP tool wiring + prompt
  iteration)
- Real subprocess runs (needs reactor integration with task_id on
  ExecRequest)
- Full MCP tool surface (contracts are written at
  specs/018-dynamic-agent-spawning/contracts/mcp-tools.md)
- Svelte /goals UI (REST endpoints remain a follow-up)
- Full budget race + quarantine auto-trigger wiring
- Full resource-request → secrets fulfill reaction-workflow path

Cross-compiles clean for linux/amd64 and darwin/arm64 with no CGO
(SC-010). All new package tests pass (SC-004, SC-005, SC-007).

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

371 lines
10 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"os"
"time"
"github.com/spf13/cobra"
"github.com/synapbus/synapbus/internal/trust"
)
func renderReport(_ *cobra.Command, _ []string) error {
db, err := openDB(flagDBPath)
if err != nil {
return err
}
defer db.Close()
ctx := context.Background()
goalID := flagGoalID
if goalID == 0 {
// Try .last_goal_id marker first, then fall back to most recent goal.
if data, err := os.ReadFile(".last_goal_id"); err == nil {
fmt.Sscanf(string(data), "%d", &goalID)
}
}
if goalID == 0 {
if err := db.QueryRowContext(ctx, `SELECT id FROM goals ORDER BY id DESC LIMIT 1`).Scan(&goalID); err != nil {
return fmt.Errorf("no goals found — did you run ./run_task.sh?")
}
}
snap, err := buildSnapshot(ctx, db, goalID)
if err != nil {
return err
}
tmpl := template.Must(template.New("report").Funcs(template.FuncMap{
"dollars": func(cents int64) string { return fmt.Sprintf("$%.2f", float64(cents)/100) },
"cents": func(cents int64) string { return fmt.Sprintf("¢%d", cents) },
"shortHash": func(s string) string { if len(s) > 12 { return s[:12] }; return s },
"pct": func(x float64) string { return fmt.Sprintf("%.1f", x*100) },
"nonZero": func(n int64) bool { return n != 0 },
"formatTime": func(t time.Time) string { return t.Format("15:04:05") },
"mul": func(a, b int) int { return a * b },
}).Parse(reportTemplate))
f, err := os.Create(flagOutputPath)
if err != nil {
return err
}
defer f.Close()
if err := tmpl.Execute(f, snap); err != nil {
return fmt.Errorf("render template: %w", err)
}
fmt.Printf("✓ Report written to %s\n", flagOutputPath)
return nil
}
// --- snapshot types ---------------------------------------------------
type reportSnapshot struct {
Goal goalView
Tree []taskView
Agents []agentView
BillingBreakdown []billingRow
TotalTokens int64
TotalDollarsC int64
BudgetTokens int64
BudgetDollarsC int64
SpendPctDollar float64
Timeline []timelineEvent
Artifacts []artifactView
GeneratedAt time.Time
}
type goalView struct {
ID int64
Slug string
Title string
Description string
Status string
Owner string
ChannelName string
CreatedAt time.Time
CompletedAt *time.Time
}
type taskView struct {
ID int64
ParentID *int64
Depth int
Title string
Description string
Status string
Assignee string
BillingCode string
SpentTokens int64
SpentDollarsC int64
CreatedAt time.Time
CompletedAt *time.Time
VerifierKind string
Children []taskView
}
type agentView struct {
ID int64
Name string
DisplayName string
ParentAgentName string
SpawnDepth int
ConfigHash string
AutonomyTier string
ToolScope []string
RollingRep float64
EvidenceCount int
SystemPromptFirst string
}
type billingRow struct {
Code string
Tokens int64
DollarsCents int64
TaskCount int
}
type timelineEvent struct {
When time.Time
Kind string
Actor string
Message string
Priority int
}
type artifactView struct {
From string
Body string
When time.Time
Kind string
}
// --- snapshot builder -------------------------------------------------
func buildSnapshot(ctx context.Context, db *sql.DB, goalID int64) (*reportSnapshot, error) {
snap := &reportSnapshot{GeneratedAt: time.Now().UTC()}
// Goal row.
var g goalView
var ownerID, channelID int64
var budgetTokens, budgetDollars sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT id, slug, title, description, status, owner_user_id, channel_id, created_at, completed_at, budget_tokens, budget_dollars_cents
FROM goals WHERE id=?`, goalID).Scan(
&g.ID, &g.Slug, &g.Title, &g.Description, &g.Status, &ownerID, &channelID, &g.CreatedAt, &g.CompletedAt, &budgetTokens, &budgetDollars)
if err != nil {
return nil, fmt.Errorf("goal %d: %w", goalID, err)
}
_ = db.QueryRowContext(ctx, `SELECT username FROM users WHERE id=?`, ownerID).Scan(&g.Owner)
_ = db.QueryRowContext(ctx, `SELECT name FROM channels WHERE id=?`, channelID).Scan(&g.ChannelName)
snap.Goal = g
if budgetTokens.Valid {
snap.BudgetTokens = budgetTokens.Int64
}
if budgetDollars.Valid {
snap.BudgetDollarsC = budgetDollars.Int64
}
// Tasks — load all rows into memory first, then resolve the
// assignee agent names with separate queries. With MaxOpenConns=1
// we cannot issue nested queries while the outer rows iterator is
// still open.
type rawTask struct {
view *taskView
assignee sql.NullInt64
}
rows, err := db.QueryContext(ctx, `
SELECT id, parent_task_id, depth, title, description, status, assignee_agent_id,
COALESCE(billing_code, ''), spent_tokens, spent_dollars_cents,
created_at, completed_at, verifier_config_json
FROM goal_tasks WHERE goal_id=? ORDER BY id`, goalID)
if err != nil {
return nil, err
}
var raws []rawTask
for rows.Next() {
t := &taskView{}
var parentID sql.NullInt64
var verifierJSON sql.NullString
var assignee sql.NullInt64
if err := rows.Scan(&t.ID, &parentID, &t.Depth, &t.Title, &t.Description, &t.Status, &assignee,
&t.BillingCode, &t.SpentTokens, &t.SpentDollarsC, &t.CreatedAt, &t.CompletedAt, &verifierJSON); err != nil {
_ = rows.Close()
return nil, err
}
if parentID.Valid {
p := parentID.Int64
t.ParentID = &p
}
if verifierJSON.Valid && verifierJSON.String != "" {
var v struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal([]byte(verifierJSON.String), &v)
t.VerifierKind = v.Kind
}
raws = append(raws, rawTask{view: t, assignee: assignee})
}
_ = rows.Close()
flatByID := map[int64]*taskView{}
var rootID int64
for _, raw := range raws {
t := raw.view
if t.ParentID == nil {
rootID = t.ID
}
if raw.assignee.Valid {
var name string
_ = db.QueryRowContext(ctx, `SELECT name FROM agents WHERE id=?`, raw.assignee.Int64).Scan(&name)
t.Assignee = name
}
snap.TotalTokens += t.SpentTokens
snap.TotalDollarsC += t.SpentDollarsC
flatByID[t.ID] = t
}
// Build recursive tree.
for _, t := range flatByID {
if t.ParentID != nil {
if parent, ok := flatByID[*t.ParentID]; ok {
parent.Children = append(parent.Children, *t)
}
}
}
if root, ok := flatByID[rootID]; ok {
snap.Tree = []taskView{*root}
// Re-resolve children so the root's children have their own children populated (one pass isn't enough in map iteration order).
var resolve func(tv *taskView)
resolve = func(tv *taskView) {
tv.Children = nil
for _, t := range flatByID {
if t.ParentID != nil && *t.ParentID == tv.ID {
child := *t
resolve(&child)
tv.Children = append(tv.Children, child)
}
}
}
resolve(&snap.Tree[0])
}
// Budget percentage.
if snap.BudgetDollarsC > 0 {
snap.SpendPctDollar = float64(snap.TotalDollarsC) / float64(snap.BudgetDollarsC)
}
// Billing breakdown.
brows, err := db.QueryContext(ctx, `
SELECT COALESCE(billing_code, ''), SUM(spent_tokens), SUM(spent_dollars_cents), COUNT(*)
FROM goal_tasks WHERE goal_id=? GROUP BY billing_code ORDER BY billing_code`, goalID)
if err != nil {
return nil, err
}
for brows.Next() {
var b billingRow
if err := brows.Scan(&b.Code, &b.Tokens, &b.DollarsCents, &b.TaskCount); err != nil {
_ = brows.Close()
return nil, err
}
snap.BillingBreakdown = append(snap.BillingBreakdown, b)
}
_ = brows.Close()
// Agents: everyone who appears in goal_tasks.assignee_agent_id plus the coordinator.
var coordinatorID sql.NullInt64
_ = db.QueryRowContext(ctx, `SELECT coordinator_agent_id FROM goals WHERE id=?`, goalID).Scan(&coordinatorID)
agentIDSet := map[int64]bool{}
if coordinatorID.Valid {
agentIDSet[coordinatorID.Int64] = true
}
aRows, err := db.QueryContext(ctx, `
SELECT DISTINCT assignee_agent_id FROM goal_tasks
WHERE goal_id=? AND assignee_agent_id IS NOT NULL`, goalID)
if err != nil {
return nil, err
}
var aIDs []int64
for aRows.Next() {
var id int64
if err := aRows.Scan(&id); err != nil {
_ = aRows.Close()
return nil, err
}
aIDs = append(aIDs, id)
}
_ = aRows.Close()
for _, id := range aIDs {
agentIDSet[id] = true
}
ledger := trust.NewLedger(db)
for id := range agentIDSet {
var av agentView
var parentID sql.NullInt64
var toolScopeJSON string
if err := db.QueryRowContext(ctx, `
SELECT id, name, display_name, config_hash, parent_agent_id, spawn_depth, autonomy_tier,
tool_scope_json, system_prompt
FROM agents WHERE id=?`, id).Scan(
&av.ID, &av.Name, &av.DisplayName, &av.ConfigHash, &parentID, &av.SpawnDepth, &av.AutonomyTier,
&toolScopeJSON, &av.SystemPromptFirst); err != nil {
continue
}
if parentID.Valid {
_ = db.QueryRowContext(ctx, `SELECT name FROM agents WHERE id=?`, parentID.Int64).Scan(&av.ParentAgentName)
}
if toolScopeJSON != "" {
_ = json.Unmarshal([]byte(toolScopeJSON), &av.ToolScope)
}
if len(av.SystemPromptFirst) > 160 {
av.SystemPromptFirst = av.SystemPromptFirst[:160] + "…"
}
av.RollingRep, av.EvidenceCount, _ = ledger.RollingScore(ctx, av.ConfigHash, "default", 30)
snap.Agents = append(snap.Agents, av)
}
// Timeline: every message posted to the goal's backing channel, broken
// into "system" vs "artifact" by the metadata.kind field we set at write.
mRows, err := db.QueryContext(ctx, `
SELECT from_agent, metadata, body, priority, created_at
FROM messages
WHERE channel_id=?
ORDER BY created_at, id`, channelID)
if err != nil {
return nil, err
}
for mRows.Next() {
var e timelineEvent
var metaStr string
if err := mRows.Scan(&e.Actor, &metaStr, &e.Message, &e.Priority, &e.When); err != nil {
_ = mRows.Close()
return nil, err
}
var meta struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal([]byte(metaStr), &meta)
e.Kind = meta.Kind
if e.Kind == "" {
e.Kind = "message"
}
snap.Timeline = append(snap.Timeline, e)
if e.Kind == "artifact" {
snap.Artifacts = append(snap.Artifacts, artifactView{
From: e.Actor,
Body: e.Message,
When: e.When,
Kind: e.Kind,
})
}
}
_ = mRows.Close()
return snap, nil
}