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>
371 lines
10 KiB
Go
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
|
|
}
|