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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d9d1b7fee2
commit
ff5d0c49f4
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// dbChannelCreator implements goals.ChannelCreator without taking a
|
||||
// dependency on the internal/channels service (which would drag in
|
||||
// half the server). It talks to the channels and channel_members
|
||||
// tables directly. This is only safe because the demo driver runs
|
||||
// against the same process's DB and the channels schema is stable.
|
||||
type dbChannelCreator struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// CreateGoalChannel satisfies goals.ChannelCreator.
|
||||
func (c *dbChannelCreator) CreateGoalChannel(ctx context.Context, slug, title, description, ownerUsername string) (int64, error) {
|
||||
name := "goal-" + slug
|
||||
id, err := c.ensureChannel(ctx, name, description, "blackboard", ownerUsername)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ownerUsername != "" {
|
||||
if err := c.addMember(ctx, id, ownerUsername); err != nil {
|
||||
return 0, fmt.Errorf("add owner %q to goal channel: %w", ownerUsername, err)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ensureChannel upserts a channel row by name. Returns the id.
|
||||
func (c *dbChannelCreator) ensureChannel(ctx context.Context, name, description, channelType, createdBy string) (int64, error) {
|
||||
var id int64
|
||||
err := c.db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name = ?`, name).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
res, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO channels (name, description, type, is_private, is_system, created_by)
|
||||
VALUES (?, ?, ?, 0, 0, ?)`, name, description, channelType, createdBy)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create channel %q: %w", name, err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// getByName resolves a channel id by name.
|
||||
func (c *dbChannelCreator) getByName(ctx context.Context, name string) (int64, error) {
|
||||
var id int64
|
||||
err := c.db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name = ?`, name).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// addMember is idempotent — does nothing if the member is already present.
|
||||
func (c *dbChannelCreator) addMember(ctx context.Context, channelID int64, agentName string) error {
|
||||
var exists int
|
||||
_ = c.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(1) FROM channel_members WHERE channel_id=? AND agent_name=?`,
|
||||
channelID, agentName).Scan(&exists)
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO channel_members (channel_id, agent_name, role)
|
||||
VALUES (?, ?, 'member')`, channelID, agentName)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/goals"
|
||||
"github.com/synapbus/synapbus/internal/goaltasks"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
)
|
||||
|
||||
// flow wires the primitives for the demo. It reaches into the DB
|
||||
// directly for bootstrap (users, channels, agents) because these are
|
||||
// one-shot operations that would otherwise require embedding the full
|
||||
// service wiring. For the domain logic (goals, tasks, trust) it uses
|
||||
// the real services.
|
||||
type flow struct {
|
||||
db *sql.DB
|
||||
goals *goals.Service
|
||||
tasks *goaltasks.Service
|
||||
ledger *trust.Ledger
|
||||
logger *slog.Logger
|
||||
channels *dbChannelCreator
|
||||
// bootstrap state
|
||||
ownerUserID int64
|
||||
ownerUsername string
|
||||
coordinatorAgentID int64
|
||||
coordinatorHash string
|
||||
approvalsChannelID int64
|
||||
conversationID int64 // shared conversation for all system/artifact messages
|
||||
}
|
||||
|
||||
func newFlow(db *sql.DB, logger *slog.Logger) *flow {
|
||||
cc := &dbChannelCreator{db: db}
|
||||
return &flow{
|
||||
db: db,
|
||||
goals: goals.NewService(goals.NewStore(db), cc, logger),
|
||||
tasks: goaltasks.NewService(goaltasks.NewStore(db), logger),
|
||||
ledger: trust.NewLedger(db),
|
||||
logger: logger,
|
||||
channels: cc,
|
||||
}
|
||||
}
|
||||
|
||||
// --- bootstrap --------------------------------------------------------
|
||||
|
||||
func (f *flow) bootstrap(ctx context.Context) error {
|
||||
// owner user
|
||||
if err := f.db.QueryRowContext(ctx,
|
||||
`SELECT id, username FROM users WHERE username='algis'`).Scan(&f.ownerUserID, &f.ownerUsername); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("lookup user: %w", err)
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("algis-demo-pw"), bcrypt.DefaultCost)
|
||||
res, err := f.db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('algis', ?)`, string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user algis: %w", err)
|
||||
}
|
||||
f.ownerUserID, _ = res.LastInsertId()
|
||||
f.ownerUsername = "algis"
|
||||
f.logger.Info("user created", "username", f.ownerUsername, "id", f.ownerUserID)
|
||||
}
|
||||
|
||||
// approvals and requests channels (idempotent).
|
||||
for _, name := range []string{"approvals", "requests"} {
|
||||
if _, err := f.channels.ensureChannel(ctx, name, "Auto-approved "+name+" queue", "blackboard", f.ownerUsername); err != nil {
|
||||
return fmt.Errorf("ensure channel %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
f.approvalsChannelID, err = f.channels.getByName(ctx, "approvals")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Coordinator agent — always exists before a run starts.
|
||||
coord := coordinatorConfig()
|
||||
f.coordinatorHash = trust.ConfigHash(coord.ToTrustConfig())
|
||||
coordID, err := f.ensureAgent(ctx, ensureAgentInput{
|
||||
Name: coord.Name,
|
||||
DisplayName: coord.DisplayName,
|
||||
OwnerID: f.ownerUserID,
|
||||
SystemPrompt: coord.SystemPrompt,
|
||||
ConfigHash: f.coordinatorHash,
|
||||
AutonomyTier: trust.TierAssisted,
|
||||
ToolScope: coord.ToolScope,
|
||||
SpawnDepth: 0,
|
||||
ParentAgentID: nil,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure coordinator: %w", err)
|
||||
}
|
||||
f.coordinatorAgentID = coordID
|
||||
|
||||
// Seed the coordinator with some neutral evidence so spawned children
|
||||
// are seeded at 70 % of a sensible baseline instead of the 0.5 neutral
|
||||
// default.
|
||||
if _, err := f.ledger.Append(ctx, trust.Evidence{
|
||||
ConfigHash: f.coordinatorHash,
|
||||
OwnerUserID: f.ownerUserID,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: 0.8, // strong baseline for a pre-built meta agent
|
||||
EvidenceRef: "bootstrap:coordinator-baseline",
|
||||
Weight: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed coordinator reputation: %w", err)
|
||||
}
|
||||
|
||||
f.logger.Info("bootstrap complete",
|
||||
"user_id", f.ownerUserID,
|
||||
"coordinator_id", coordID,
|
||||
"coordinator_hash", f.coordinatorHash[:12],
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- demo flow --------------------------------------------------------
|
||||
|
||||
func (f *flow) run(ctx context.Context) (int64, error) {
|
||||
f.logger.Info("=== Phase 1: goal creation ===")
|
||||
budget := int64(5000) // $50.00 in cents
|
||||
goalTokens := int64(200000)
|
||||
g, err := f.goals.CreateGoal(ctx, goals.CreateGoalInput{
|
||||
Title: "Keep docs.mcpproxy.app accurate against source",
|
||||
Description: `Verify every CLI flag and config option mentioned in docs.mcpproxy.app actually exists in the mcpproxy binary, flag any drift, and propose doc patches.`,
|
||||
OwnerUserID: f.ownerUserID,
|
||||
OwnerUsername: f.ownerUsername,
|
||||
CoordinatorAgentID: &f.coordinatorAgentID,
|
||||
BudgetTokens: &goalTokens,
|
||||
BudgetDollarsCents: &budget,
|
||||
MaxSpawnDepth: 3,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Activate the goal.
|
||||
if err := f.goals.TransitionStatus(ctx, g.ID, goals.StatusActive); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Create the conversation used for all subsequent messages in this channel.
|
||||
convRes, err := f.db.ExecContext(ctx,
|
||||
`INSERT INTO conversations (subject, created_by, channel_id) VALUES (?, 'system', ?)`,
|
||||
"Doc-gardener demo run", g.ChannelID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create conversation: %w", err)
|
||||
}
|
||||
f.conversationID, _ = convRes.LastInsertId()
|
||||
f.postSystemMessage(ctx, g.ChannelID, fmt.Sprintf("Goal %q created (id=%d, budget=$%.2f).", g.Title, g.ID, float64(budget)/100))
|
||||
|
||||
// Coordinator is expected to be a member of its goal's channel.
|
||||
_ = f.channels.addMember(ctx, g.ChannelID, "doc-gardener-coordinator")
|
||||
_ = f.channels.addMember(ctx, g.ChannelID, f.ownerUsername)
|
||||
|
||||
f.logger.Info("=== Phase 2: task tree decomposition ===")
|
||||
tree := buildTaskTree()
|
||||
rootTaskID, allTaskIDs, err := f.tasks.CreateTree(ctx, goaltasks.CreateTreeInput{
|
||||
GoalID: g.ID,
|
||||
CreatedByAgent: &f.coordinatorAgentID,
|
||||
Root: tree,
|
||||
InitialStatus: goaltasks.StatusApproved,
|
||||
DefaultBilling: "doc-gardener",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE goals SET root_task_id=? WHERE id=?`, rootTaskID, g.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f.postSystemMessage(ctx, g.ChannelID, fmt.Sprintf("Coordinator proposed a tree of %d tasks rooted at task %d. Auto-approved.", len(allTaskIDs), rootTaskID))
|
||||
|
||||
f.logger.Info("=== Phase 3: specialist agent spawning ===")
|
||||
// Spawn three specialists. Each goes through delegation-cap validation
|
||||
// against the coordinator's grant before being materialized.
|
||||
coordGrant := trust.Grant{
|
||||
AutonomyTier: trust.TierAssisted,
|
||||
ToolScope: []string{"messages:read", "messages:send", "channels:read", "reactions:add"},
|
||||
BudgetTokens: goalTokens / 2,
|
||||
BudgetDollarsCents: budget / 2,
|
||||
SpawnDepth: 0,
|
||||
}
|
||||
|
||||
type specialistSpec struct {
|
||||
name string
|
||||
display string
|
||||
role string
|
||||
tier string
|
||||
toolScope []string
|
||||
model string
|
||||
systemPrompt string
|
||||
billingCode string
|
||||
}
|
||||
specialists := []specialistSpec{
|
||||
{
|
||||
name: "docs-scanner", display: "Docs Scanner", role: "docs-scanner",
|
||||
tier: trust.TierAssisted,
|
||||
toolScope: []string{"messages:read", "messages:send", "channels:read"},
|
||||
model: "gemini-2.5-flash",
|
||||
systemPrompt: "You are docs-scanner: fetch pages from docs.mcpproxy.app, extract every CLI flag and config option mentioned, and post them as #finding messages with structured metadata.",
|
||||
billingCode: "doc-gardener/scan",
|
||||
},
|
||||
{
|
||||
name: "cli-verifier", display: "CLI Verifier", role: "cli-verifier",
|
||||
tier: trust.TierAssisted,
|
||||
toolScope: []string{"messages:read", "messages:send", "reactions:add"},
|
||||
model: "gemini-2.5-flash",
|
||||
systemPrompt: "You are cli-verifier: read #finding messages, run `mcpproxy --help` to confirm each flag exists, react to the finding message with #verified or #missing.",
|
||||
billingCode: "doc-gardener/verify",
|
||||
},
|
||||
{
|
||||
name: "drift-reporter", display: "Drift Reporter", role: "drift-reporter",
|
||||
tier: trust.TierAssisted,
|
||||
toolScope: []string{"messages:read", "messages:send"},
|
||||
model: "gemini-2.5-flash",
|
||||
systemPrompt: "You are drift-reporter: aggregate #verified and #missing reactions from cli-verifier and post a summary with a count of matches vs drift.",
|
||||
billingCode: "doc-gardener/report",
|
||||
},
|
||||
}
|
||||
|
||||
specialistsByRole := map[string]int64{}
|
||||
for _, spec := range specialists {
|
||||
proposed := trust.Grant{
|
||||
AutonomyTier: spec.tier,
|
||||
ToolScope: spec.toolScope,
|
||||
BudgetTokens: goalTokens / 6,
|
||||
BudgetDollarsCents: budget / 6,
|
||||
SpawnDepth: 1, // child's proposed depth
|
||||
}
|
||||
effective, violations := trust.DelegationCap(coordGrant, proposed, g.MaxSpawnDepth)
|
||||
if len(violations) > 0 {
|
||||
return 0, fmt.Errorf("delegation cap violation for %s: %v", spec.name, violations)
|
||||
}
|
||||
hash := trust.ConfigHash(trust.AgentConfig{
|
||||
Model: spec.model,
|
||||
SystemPrompt: spec.systemPrompt,
|
||||
ToolScope: spec.toolScope,
|
||||
})
|
||||
// Child reputation is seeded at 70 % of parent's.
|
||||
if err := f.ledger.SeedFromParent(ctx, f.coordinatorHash, hash, f.ownerUserID, "default", 30); err != nil {
|
||||
return 0, fmt.Errorf("seed reputation for %s: %w", spec.name, err)
|
||||
}
|
||||
id, err := f.ensureAgent(ctx, ensureAgentInput{
|
||||
Name: spec.name,
|
||||
DisplayName: spec.display,
|
||||
OwnerID: f.ownerUserID,
|
||||
SystemPrompt: spec.systemPrompt,
|
||||
ConfigHash: hash,
|
||||
AutonomyTier: effective.AutonomyTier,
|
||||
ToolScope: effective.ToolScope,
|
||||
SpawnDepth: 1,
|
||||
ParentAgentID: &f.coordinatorAgentID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("spawn %s: %w", spec.name, err)
|
||||
}
|
||||
specialistsByRole[spec.role] = id
|
||||
_ = f.channels.addMember(ctx, g.ChannelID, spec.name)
|
||||
f.postSystemMessage(ctx, g.ChannelID,
|
||||
fmt.Sprintf("Spawned specialist %q (config_hash=%s..., spawn_depth=1, tier=%s).",
|
||||
spec.name, hash[:12], effective.AutonomyTier))
|
||||
f.logger.Info("specialist spawned",
|
||||
"name", spec.name,
|
||||
"config_hash", hash[:12],
|
||||
"tier", effective.AutonomyTier,
|
||||
)
|
||||
}
|
||||
|
||||
f.logger.Info("=== Phase 4: claim + work + verify ===")
|
||||
tasks, err := f.tasks.ListByGoal(ctx, g.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// We drive only the leaf tasks — the root is a parent and doesn't get claimed.
|
||||
for _, t := range tasks {
|
||||
if t.ParentTaskID == nil {
|
||||
continue
|
||||
}
|
||||
role := leafRoleFor(t)
|
||||
agentID, ok := specialistsByRole[role]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Claim atomically.
|
||||
if err := f.tasks.Claim(ctx, t.ID, agentID, nil); err != nil {
|
||||
return 0, fmt.Errorf("claim task %d by %s: %w", t.ID, role, err)
|
||||
}
|
||||
// Move through the state machine.
|
||||
if err := f.tasks.Transition(ctx, t.ID, goaltasks.StatusInProgress, goaltasks.Extras{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Simulate the agent doing work: post an artifact, burn some tokens.
|
||||
tokensUsed := int64(1500 + 500*(t.ID%3))
|
||||
costCents := int64(25 + 10*(t.ID%3))
|
||||
if err := f.tasks.AddSpend(ctx, t.ID, tokensUsed, costCents); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Artifact message posted to the goal channel.
|
||||
artifactMsgID, err := f.postArtifact(ctx, g.ChannelID, role, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := f.tasks.Transition(ctx, t.ID, goaltasks.StatusAwaitingVerification, goaltasks.Extras{
|
||||
CompletionMessageID: &artifactMsgID,
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Verification: auto-approve for the scan + verify tasks; command-style
|
||||
// verification (success) for the drift reporter.
|
||||
verdict := goaltasks.StatusDone
|
||||
scoreDelta := 0.15
|
||||
evidenceRef := fmt.Sprintf("task:%d verified=auto", t.ID)
|
||||
if role == "drift-reporter" {
|
||||
// Simulate a command verifier — assume exit 0.
|
||||
scoreDelta = 0.2
|
||||
evidenceRef = fmt.Sprintf("task:%d verified=command(exit=0)", t.ID)
|
||||
}
|
||||
if err := f.tasks.Transition(ctx, t.ID, verdict, goaltasks.Extras{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Append reputation evidence for the assignee.
|
||||
var hash string
|
||||
if err := f.db.QueryRowContext(ctx, `SELECT config_hash FROM agents WHERE id=?`, agentID).Scan(&hash); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := f.ledger.Append(ctx, trust.Evidence{
|
||||
ConfigHash: hash,
|
||||
OwnerUserID: f.ownerUserID,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: scoreDelta,
|
||||
EvidenceRef: evidenceRef,
|
||||
Weight: 1.0,
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f.postSystemMessage(ctx, g.ChannelID,
|
||||
fmt.Sprintf("Task %d %q completed by %s (tokens=%d, cost=$%.2f, Δrep=%+.2f).",
|
||||
t.ID, t.Title, role, tokensUsed, float64(costCents)/100, scoreDelta))
|
||||
}
|
||||
|
||||
// Roll the root task up, finalize the goal.
|
||||
_, _, _, err = f.tasks.RollupCosts(ctx, rootTaskID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Transition the root task to done via its parent chain — skip transition
|
||||
// for the root because the MVP demo isn't finalizing parents; they
|
||||
// remain 'approved' to keep the demo data realistic.
|
||||
if err := f.goals.TransitionStatus(ctx, g.ID, goals.StatusCompleted); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f.postSystemMessage(ctx, g.ChannelID, "Goal marked completed.")
|
||||
return g.ID, nil
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------
|
||||
|
||||
func buildTaskTree() goaltasks.TreeNode {
|
||||
return goaltasks.TreeNode{
|
||||
Title: "Verify docs.mcpproxy.app against source",
|
||||
Description: "Root task for the doc-gardener goal.",
|
||||
AcceptanceCriteria: "A drift report exists citing count of matches vs. missing items.",
|
||||
BillingCode: "doc-gardener",
|
||||
Children: []goaltasks.TreeNode{
|
||||
{
|
||||
Title: "Scan docs for CLI flags and config keys",
|
||||
Description: "Fetch all pages under docs.mcpproxy.app/*; extract flags/options into #finding messages.",
|
||||
AcceptanceCriteria: "At least one #finding message per documented flag.",
|
||||
BillingCode: "doc-gardener/scan",
|
||||
VerifierConfig: &goaltasks.VerifierConfig{Kind: goaltasks.VerifierKindAuto},
|
||||
},
|
||||
{
|
||||
Title: "Verify flags exist in mcpproxy binary",
|
||||
Description: "Run `mcpproxy --help` and react #verified or #missing on each #finding.",
|
||||
AcceptanceCriteria: "Every #finding has a #verified or #missing reaction.",
|
||||
BillingCode: "doc-gardener/verify",
|
||||
VerifierConfig: &goaltasks.VerifierConfig{Kind: goaltasks.VerifierKindAuto},
|
||||
},
|
||||
{
|
||||
Title: "Produce drift report",
|
||||
Description: "Aggregate reactions from cli-verifier; post a final summary message.",
|
||||
AcceptanceCriteria: "Summary message contains counts of matches, drifts, and recommended patches.",
|
||||
BillingCode: "doc-gardener/report",
|
||||
VerifierConfig: &goaltasks.VerifierConfig{
|
||||
Kind: goaltasks.VerifierKindCommand,
|
||||
Cmd: "test -s report.txt",
|
||||
TimeoutSec: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func leafRoleFor(t *goaltasks.Task) string {
|
||||
switch t.BillingCode {
|
||||
case "doc-gardener/scan":
|
||||
return "docs-scanner"
|
||||
case "doc-gardener/verify":
|
||||
return "cli-verifier"
|
||||
case "doc-gardener/report":
|
||||
return "drift-reporter"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ensureAgentInput struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
OwnerID int64
|
||||
SystemPrompt string
|
||||
ConfigHash string
|
||||
AutonomyTier string
|
||||
ToolScope []string
|
||||
SpawnDepth int
|
||||
ParentAgentID *int64
|
||||
}
|
||||
|
||||
// ensureAgent upserts an agent row, creating it with a fresh API key
|
||||
// on first call and updating the new dynamic-spawning columns on
|
||||
// every call. Returns the agent id.
|
||||
func (f *flow) ensureAgent(ctx context.Context, in ensureAgentInput) (int64, error) {
|
||||
var existingID int64
|
||||
err := f.db.QueryRowContext(ctx, `SELECT id FROM agents WHERE name=?`, in.Name).Scan(&existingID)
|
||||
toolScopeJSON, _ := json.Marshal(in.ToolScope)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Mint an API key.
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
apiKey := "sk-dg-" + hex.EncodeToString(buf)
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(apiKey), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO agents (
|
||||
name, display_name, type, capabilities, owner_id, api_key_hash, status,
|
||||
config_hash, parent_agent_id, spawn_depth, system_prompt, autonomy_tier, tool_scope_json
|
||||
) VALUES (?, ?, 'ai', '{}', ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
|
||||
in.Name, in.DisplayName, in.OwnerID, string(hashed),
|
||||
in.ConfigHash, in.ParentAgentID, in.SpawnDepth, in.SystemPrompt, in.AutonomyTier, string(toolScopeJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Update the new columns on an existing row.
|
||||
_, err = f.db.ExecContext(ctx, `
|
||||
UPDATE agents
|
||||
SET config_hash = ?,
|
||||
parent_agent_id = ?,
|
||||
spawn_depth = ?,
|
||||
system_prompt = ?,
|
||||
autonomy_tier = ?,
|
||||
tool_scope_json = ?,
|
||||
display_name = COALESCE(NULLIF(display_name, ''), ?)
|
||||
WHERE id = ?`,
|
||||
in.ConfigHash, in.ParentAgentID, in.SpawnDepth, in.SystemPrompt, in.AutonomyTier, string(toolScopeJSON),
|
||||
in.DisplayName, existingID)
|
||||
return existingID, err
|
||||
}
|
||||
|
||||
func (f *flow) postSystemMessage(ctx context.Context, channelID int64, body string) int64 {
|
||||
now := time.Now().UTC()
|
||||
res, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at)
|
||||
VALUES (?, 'system', NULL, ?, ?, 3, 'done', '{"kind":"system"}', ?, ?)`,
|
||||
f.conversationID, channelID, body, now, now)
|
||||
if err != nil {
|
||||
f.logger.Warn("post system message failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *flow) postArtifact(ctx context.Context, channelID int64, role string, t *goaltasks.Task) (int64, error) {
|
||||
var body string
|
||||
switch role {
|
||||
case "docs-scanner":
|
||||
body = fmt.Sprintf("#finding artifact for task %d: found 12 flags on docs.mcpproxy.app (--port, --config, --socket, --data-dir, --log-format, --log-level, --otel-endpoint, --tls-cert, --tls-key, --metrics-port, --retention, --version).", t.ID)
|
||||
case "cli-verifier":
|
||||
body = fmt.Sprintf("#verified artifact for task %d: 10/12 flags confirmed present in `mcpproxy --help`. #missing: --otel-endpoint, --retention.", t.ID)
|
||||
case "drift-reporter":
|
||||
body = fmt.Sprintf("#summary artifact for task %d: 10 matches, 2 drifts (--otel-endpoint and --retention documented but not implemented). Recommend patching the docs or filing bugs.", t.ID)
|
||||
default:
|
||||
body = fmt.Sprintf("artifact for task %d from %s", t.ID, role)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
res, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, ?, ?, 5, 'done', '{"kind":"artifact"}', ?, ?)`,
|
||||
f.conversationID, role, channelID, body, now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- coordinator config ----------------------------------------------
|
||||
|
||||
type coordCfg struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
SystemPrompt string
|
||||
ToolScope []string
|
||||
}
|
||||
|
||||
func (c coordCfg) ToTrustConfig() trust.AgentConfig {
|
||||
return trust.AgentConfig{
|
||||
Model: "coordinator/v1",
|
||||
SystemPrompt: c.SystemPrompt,
|
||||
ToolScope: c.ToolScope,
|
||||
}
|
||||
}
|
||||
|
||||
func coordinatorConfig() coordCfg {
|
||||
return coordCfg{
|
||||
Name: "doc-gardener-coordinator",
|
||||
DisplayName: "Doc-gardener Coordinator",
|
||||
SystemPrompt: `You are the doc-gardener coordinator. Your job is to decompose a high-level goal ("keep docs accurate against the source code") into a tree of sub-tasks, propose specialist agents to carry out the leaf tasks, monitor progress via the goal channel, and iterate. You never act on leaf tasks directly. You communicate via SynapBus MCP tools.`,
|
||||
ToolScope: []string{
|
||||
"messages:read", "messages:send", "channels:read", "reactions:add",
|
||||
"goals:create", "tasks:propose_tree", "agents:propose",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// docgardener is a self-contained demo driver for the dynamic
|
||||
// agent spawning feature (spec 018). It talks directly to the
|
||||
// SynapBus SQLite database that a running `synapbus serve` instance
|
||||
// created, drives a goal → task tree → spawned specialists flow
|
||||
// through the new primitives, and renders a rich HTML report.
|
||||
//
|
||||
// It is intentionally NOT wired through the MCP tool layer or the
|
||||
// reactor — the MVP's goal is to prove the core data primitives
|
||||
// (goals, goal_tasks, config_hash, delegation cap, reputation ledger,
|
||||
// atomic claim, cost rollup) work end-to-end and produce a
|
||||
// human-readable report. Real LLM autonomy + subprocess execution
|
||||
// is a follow-up PR (see specs/018-dynamic-agent-spawning/tasks.md).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
flagDBPath string
|
||||
flagGoalID int64
|
||||
flagOutputPath string
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "docgardener",
|
||||
Short: "Dynamic-agent-spawning demo driver",
|
||||
}
|
||||
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Execute the doc-gardener demo flow end-to-end",
|
||||
RunE: runDemo,
|
||||
}
|
||||
runCmd.Flags().StringVar(&flagDBPath, "db", "./data/synapbus.db", "Path to SynapBus SQLite DB")
|
||||
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Render the HTML report for a completed run",
|
||||
RunE: renderReport,
|
||||
}
|
||||
reportCmd.Flags().StringVar(&flagDBPath, "db", "./data/synapbus.db", "Path to SynapBus SQLite DB")
|
||||
reportCmd.Flags().Int64Var(&flagGoalID, "goal", 0, "Goal id to report on (0 = latest)")
|
||||
reportCmd.Flags().StringVar(&flagOutputPath, "out", "./report.html", "Output HTML file path")
|
||||
|
||||
root.AddCommand(runCmd, reportCmd)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// openDB opens the SynapBus SQLite DB with the same settings the
|
||||
// server uses (WAL, foreign keys on) so direct writes interleave
|
||||
// safely with the running process.
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("db not found at %s (did you run ./start.sh?): %w", path, err)
|
||||
}
|
||||
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, nil
|
||||
}
|
||||
|
||||
func runDemo(_ *cobra.Command, _ []string) error {
|
||||
db, err := openDB(flagDBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
logger = logger.With("component", "docgardener")
|
||||
|
||||
flow := newFlow(db, logger)
|
||||
if err := flow.bootstrap(ctx); err != nil {
|
||||
return fmt.Errorf("bootstrap: %w", err)
|
||||
}
|
||||
goalID, err := flow.run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("demo run: %w", err)
|
||||
}
|
||||
|
||||
// Leave a marker so report.sh knows which goal is "latest".
|
||||
if err := os.WriteFile(".last_goal_id", []byte(fmt.Sprintf("%d\n", goalID)), 0644); err != nil {
|
||||
logger.Warn("could not write .last_goal_id", "err", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ Demo run complete. Goal id: %d\n", goalID)
|
||||
fmt.Printf(" Render report: ./report.sh\n")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
const reportTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Doc-gardener run — {{.Goal.Title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f17;
|
||||
--panel: #121826;
|
||||
--panel-alt: #1a2233;
|
||||
--border: #232c42;
|
||||
--text: #e6ebf5;
|
||||
--muted: #8893a8;
|
||||
--accent: #7dd3fc;
|
||||
--accent-dim: #38bdf8;
|
||||
--ok: #4ade80;
|
||||
--warn: #fbbf24;
|
||||
--err: #f87171;
|
||||
--chip: #2a364f;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin:0; padding:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; font-size:14px; line-height:1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 32px; }
|
||||
h1 { font-size: 28px; margin: 0 0 4px 0; }
|
||||
h2 { font-size: 18px; color: var(--accent); margin: 32px 0 12px 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
h3 { font-size: 14px; color: var(--muted); margin: 12px 0 6px 0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.subtitle { color: var(--muted); font-size: 14px; margin: 0 0 18px 0; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin: 12px 0; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.metric { background: var(--panel-alt); border-radius: 6px; padding: 12px 16px; }
|
||||
.metric .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.metric .value { font-size: 22px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||
.status-badge.done { background: rgba(74,222,128,0.15); color: var(--ok); border: 1px solid rgba(74,222,128,0.4); }
|
||||
.status-badge.failed { background: rgba(248,113,113,0.15); color: var(--err); border: 1px solid rgba(248,113,113,0.4); }
|
||||
.status-badge.in_progress, .status-badge.claimed, .status-badge.awaiting_verification { background: rgba(251,191,36,0.15); color: var(--warn); border: 1px solid rgba(251,191,36,0.4); }
|
||||
.status-badge.approved, .status-badge.proposed, .status-badge.active, .status-badge.draft, .status-badge.completed, .status-badge.paused, .status-badge.cancelled, .status-badge.stuck { background: var(--chip); color: var(--text); border: 1px solid var(--border); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.tree { list-style: none; padding: 0; margin: 0; }
|
||||
.tree li { margin: 6px 0; }
|
||||
.tree-node { background: var(--panel-alt); border: 1px solid var(--border); border-left: 4px solid var(--border); border-radius: 6px; padding: 10px 14px; }
|
||||
.tree-node.done { border-left-color: var(--ok); }
|
||||
.tree-node.failed { border-left-color: var(--err); }
|
||||
.tree-node.in_progress, .tree-node.awaiting_verification, .tree-node.claimed { border-left-color: var(--warn); }
|
||||
.tree-node .title-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.tree-node .title { font-weight: 600; }
|
||||
.tree-node .meta { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.tree ul.children { list-style: none; padding-left: 20px; border-left: 1px dashed var(--border); margin-top: 8px; }
|
||||
.agent-card { background: var(--panel-alt); border: 1px solid var(--border); border-radius: 6px; padding: 14px; }
|
||||
.agent-card .name { font-weight: 700; font-size: 15px; }
|
||||
.agent-card .hash { font-family: "SF Mono", Menlo, monospace; font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.agent-card .rep-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin: 8px 0 4px 0; }
|
||||
.agent-card .rep-fill { height: 100%; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); }
|
||||
.agent-card .tool-scope { margin-top: 8px; }
|
||||
.chip { display: inline-block; background: var(--chip); color: var(--text); font-size: 11px; padding: 2px 8px; border-radius: 10px; margin: 2px 4px 2px 0; font-family: "SF Mono", Menlo, monospace; }
|
||||
.timeline { position: relative; padding-left: 24px; border-left: 2px solid var(--border); }
|
||||
.timeline-item { position: relative; padding: 10px 14px; margin: 6px 0; background: var(--panel-alt); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.timeline-item::before { content: ""; position: absolute; left: -30px; top: 16px; width: 10px; height: 10px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 0 3px var(--bg); }
|
||||
.timeline-item .when { color: var(--muted); font-size: 11px; font-family: "SF Mono", Menlo, monospace; }
|
||||
.timeline-item .actor { color: var(--accent); font-weight: 600; margin-left: 6px; }
|
||||
.timeline-item .body { margin-top: 4px; }
|
||||
.footer { color: var(--muted); font-size: 12px; text-align: center; margin: 40px 0 0 0; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.artifact { background: var(--panel-alt); border-left: 4px solid var(--accent); padding: 12px 16px; margin: 8px 0; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12px; white-space: pre-wrap; }
|
||||
.artifact-meta { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{{.Goal.Title}}</h1>
|
||||
<p class="subtitle">Goal #{{.Goal.ID}} · slug <code>{{.Goal.Slug}}</code> · owner <strong>{{.Goal.Owner}}</strong> · backing channel <code>#{{.Goal.ChannelName}}</code> · <span class="status-badge {{.Goal.Status}}">{{.Goal.Status}}</span></p>
|
||||
|
||||
<div class="grid-3">
|
||||
<div class="metric">
|
||||
<div class="label">Spend</div>
|
||||
<div class="value">{{dollars .TotalDollarsC}}</div>
|
||||
<div class="label">of {{dollars .BudgetDollarsC}} budget · {{pct .SpendPctDollar}}% used</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Tokens</div>
|
||||
<div class="value">{{.TotalTokens}}</div>
|
||||
<div class="label">of {{.BudgetTokens}} budget</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Agents spawned</div>
|
||||
<div class="value">{{len .Agents}}</div>
|
||||
<div class="label">including coordinator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Goal description</h2>
|
||||
<div class="card">
|
||||
<p>{{.Goal.Description}}</p>
|
||||
</div>
|
||||
|
||||
<h2>Task tree</h2>
|
||||
{{template "taskList" .Tree}}
|
||||
|
||||
<h2>Spawned agents</h2>
|
||||
<div class="grid-2">
|
||||
{{range .Agents}}
|
||||
<div class="agent-card">
|
||||
<div class="name">{{.DisplayName}} <span style="color:var(--muted); font-weight: 400">({{.Name}})</span></div>
|
||||
<div class="hash">config_hash: <code>{{shortHash .ConfigHash}}…</code>
|
||||
{{if .ParentAgentName}}· parent: <strong>{{.ParentAgentName}}</strong>{{else}}· root{{end}}
|
||||
· depth {{.SpawnDepth}}
|
||||
</div>
|
||||
<div class="rep-bar"><div class="rep-fill" style="width: {{pct .RollingRep}}%"></div></div>
|
||||
<div style="display:flex; justify-content:space-between; font-size:12px; color:var(--muted)">
|
||||
<span>Reputation: <strong style="color:var(--text)">{{pct .RollingRep}}%</strong></span>
|
||||
<span>{{.EvidenceCount}} evidence row(s)</span>
|
||||
<span>Tier: <strong style="color:var(--text)">{{.AutonomyTier}}</strong></span>
|
||||
</div>
|
||||
<div class="tool-scope">
|
||||
{{range .ToolScope}}<span class="chip">{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{if .SystemPromptFirst}}<div style="color:var(--muted); font-size: 12px; margin-top: 8px; font-style: italic">"{{.SystemPromptFirst}}"</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h2>Cost breakdown by billing code</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Billing code</th><th>Tasks</th><th>Tokens</th><th>Dollars</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .BillingBreakdown}}
|
||||
<tr>
|
||||
<td><code>{{.Code}}</code></td>
|
||||
<td>{{.TaskCount}}</td>
|
||||
<td>{{.Tokens}}</td>
|
||||
<td>{{dollars .DollarsCents}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Artifacts}}
|
||||
<h2>Artifacts posted by specialists</h2>
|
||||
{{range .Artifacts}}
|
||||
<div class="artifact">
|
||||
<div class="artifact-meta">from <strong>{{.From}}</strong> @ {{formatTime .When}}</div>
|
||||
{{.Body}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<h2>Timeline</h2>
|
||||
<div class="timeline">
|
||||
{{range .Timeline}}
|
||||
<div class="timeline-item">
|
||||
<span class="when">{{formatTime .When}}</span>
|
||||
<span class="actor">{{.Actor}}</span>
|
||||
<span style="color: var(--muted); font-size: 11px; margin-left: 6px">{{.Kind}}</span>
|
||||
<div class="body">{{.Message}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Generated at {{formatTime .GeneratedAt}} by docgardener · SynapBus feature 018-dynamic-agent-spawning
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{define "taskList"}}
|
||||
<ul class="tree">
|
||||
{{range .}}
|
||||
<li>
|
||||
<div class="tree-node {{.Status}}">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="title">{{.Title}}</div>
|
||||
<div class="meta">
|
||||
#{{.ID}} · depth {{.Depth}}
|
||||
{{if .BillingCode}}· <code>{{.BillingCode}}</code>{{end}}
|
||||
{{if .Assignee}}· assignee <strong>{{.Assignee}}</strong>{{end}}
|
||||
{{if .VerifierKind}}· verifier <code>{{.VerifierKind}}</code>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; white-space: nowrap;">
|
||||
{{if nonZero .SpentDollarsC}}<span style="color: var(--muted); font-size: 12px">{{dollars .SpentDollarsC}} · {{.SpentTokens}} tok</span>{{end}}
|
||||
<span class="status-badge {{.Status}}">{{.Status}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Description}}<div style="color: var(--muted); font-size: 12px; margin-top: 6px;">{{.Description}}</div>{{end}}
|
||||
</div>
|
||||
{{if .Children}}
|
||||
<ul class="children">
|
||||
{{template "taskList" .Children}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -0,0 +1,57 @@
|
||||
# SynapBus examples
|
||||
|
||||
Runnable demos of SynapBus features. Each example is self-contained under its own directory, launches an isolated synapbus instance on a distinct port, and cleans up after itself.
|
||||
|
||||
| Example | Feature | Real LLM? | Port |
|
||||
|---|---|---|---|
|
||||
| [`cold-topic-explainer/`](./cold-topic-explainer/) | Reactive agent triggers + subprocess harness — three Gemini agents (decomposer → writer → critic) collaborate via DMs to produce a 3-paragraph explainer, with real LLM calls end-to-end. | ✅ yes (`gemini` CLI) | 18088 |
|
||||
| [`doc-gardener/`](./doc-gardener/) | Dynamic agent spawning (spec 018) — a coordinator meta-agent decomposes a goal into a task tree, spawns specialists with `config_hash`-rooted trust + delegation-cap enforcement, runs them through the state machine, generates a rich HTML report. | ❌ v1 is synthetic (primitives demo); real LLM coordinator is a follow-up PR | 18089 |
|
||||
|
||||
## Quick start
|
||||
|
||||
Pick an example, `cd` into it, and follow its README. In general:
|
||||
|
||||
```bash
|
||||
cd examples/<name>
|
||||
./start.sh # rebuild + launch an isolated synapbus instance
|
||||
./run_task.sh # drive the demo flow
|
||||
./report.sh # (where applicable) render an HTML report
|
||||
./stop.sh # shut down
|
||||
```
|
||||
|
||||
Both examples use the same layout for consistency:
|
||||
|
||||
```
|
||||
examples/<name>/
|
||||
├── start.sh # build & launch
|
||||
├── run_task.sh # execute the demo flow
|
||||
├── stop.sh # shut down
|
||||
├── report.sh # (doc-gardener only) render HTML report
|
||||
├── bin/
|
||||
│ ├── synapbus # built from the current checkout
|
||||
│ └── <helper> # example-specific driver binary
|
||||
├── configs/ # per-agent JSON configs (harness_config, prompts, etc.)
|
||||
├── data/ # isolated SQLite DB + attachment store + sockets
|
||||
├── synapbus.log # server stdout+stderr
|
||||
└── README.md # example-specific docs
|
||||
```
|
||||
|
||||
## What each example proves
|
||||
|
||||
- **cold-topic-explainer** proves that the SynapBus reactor + subprocess harness can drive a real multi-agent loop with three distinct LLMs, with depth and budget guards, OpenTelemetry tracing, and harness_runs accounting.
|
||||
- **doc-gardener** proves that the dynamic-agent-spawning data primitives — `goals`, `goal_tasks` with denormalized ancestry, atomic optimistic-lock claim, `config_hash`-keyed reputation ledger, delegation-cap enforcement, per-billing-code cost rollup — work end-to-end against real SQLite, and feed a rich HTML report.
|
||||
|
||||
The two examples are complementary: cold-topic-explainer exercises the **runtime path** (reactor → harness → LLM → DMs), doc-gardener exercises the **work-tracking path** (goals → tasks → trust → report). A future example will combine them into a full LLM-driven coordinator loop.
|
||||
|
||||
## Global prereqs
|
||||
|
||||
- Go 1.25+
|
||||
- `sqlite3`, `curl`, `jq` on `$PATH`
|
||||
- A free TCP port per example (see table above)
|
||||
- For `cold-topic-explainer` only: `gemini` CLI authenticated via `gemini auth login`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Port already in use: set `SYNAPBUS_PORT=18090 ./start.sh` (each example honors the env var).
|
||||
- Web UI is blank: rebuild the embedded Svelte SPA with `make web` from the repo root once, then re-run `./start.sh`.
|
||||
- Stale binary: delete the example's `bin/` directory and rerun `./start.sh` to force a rebuild.
|
||||
@@ -0,0 +1,6 @@
|
||||
bin/
|
||||
data/
|
||||
synapbus.log
|
||||
.synapbus.pid
|
||||
.last_goal_id
|
||||
report.html
|
||||
@@ -0,0 +1,166 @@
|
||||
# doc-gardener
|
||||
|
||||
End-to-end demo of the **dynamic agent spawning** feature (spec `018-dynamic-agent-spawning`).
|
||||
|
||||
A human owner defines a high-level goal ("verify docs.mcpproxy.app against the mcpproxy source code"). A pre-built **coordinator** meta-agent decomposes the goal into a task tree, proposes spawning **specialist sub-agents** with capped autonomy, the specialists claim tasks and produce artifacts, and a rich HTML report is generated from the run.
|
||||
|
||||
This example exercises the feature's **data primitives** end-to-end: goal creation with a backing channel, task-tree materialization with denormalized ancestry, `config_hash`-rooted trust, delegation-cap enforcement, atomic task claim, append-only reputation ledger, cost rollup, HTML rendering from DB state.
|
||||
|
||||
## Status of the MVP demo
|
||||
|
||||
| Piece | Status |
|
||||
|---|---|
|
||||
| Goal creation + backing channel | ✅ real |
|
||||
| Task tree materialization (ancestry snapshots) | ✅ real |
|
||||
| Atomic optimistic-lock task claim | ✅ real (covered by 50-goroutine race test in `internal/goaltasks/`) |
|
||||
| Config-hash computation (deterministic, sensitive to capability changes) | ✅ real (tested in `internal/trust/`) |
|
||||
| Delegation cap enforcement (child ≤ parent) | ✅ real (tested in `internal/trust/`) |
|
||||
| Append-only reputation ledger with 70 %-of-parent seed and exponential decay | ✅ real (tested in `internal/trust/`) |
|
||||
| Cost rollup via recursive CTE | ✅ real (tested in `internal/goaltasks/`) |
|
||||
| Rich HTML report (goal / tree / agents / costs / timeline) | ✅ real |
|
||||
| Secret encryption + scoped env injection | ✅ real (`internal/secrets/`, tested) |
|
||||
| Coordinator driven by a real LLM | ❌ deferred — the demo's coordinator logic lives in Go (`cmd/docgardener/flow.go`); the LLM-in-the-loop path needs MCP tool wiring + reactor integration |
|
||||
| Specialist subprocess runs via the harness | ❌ deferred — the demo produces synthetic artifacts |
|
||||
| Full MCP tool surface (`create_goal`, `propose_task_tree`, `propose_agent`, `claim_task`, `verify_task`, `request_resource`, `list_resources`) | ❌ contracts live in `specs/018-dynamic-agent-spawning/contracts/mcp-tools.md`; wiring is deferred |
|
||||
| Svelte `/goals` UI | ❌ deferred |
|
||||
|
||||
See `specs/018-dynamic-agent-spawning/tasks.md` for the full phase breakdown and what remains.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Go 1.25+
|
||||
- `sqlite3`, `curl` on `$PATH`
|
||||
- A free TCP port (default `18089`)
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
./start.sh # build + launch synapbus on port 18089
|
||||
./run_task.sh # execute the demo flow
|
||||
./report.sh # render report.html
|
||||
./stop.sh # shut down synapbus
|
||||
```
|
||||
|
||||
`run_task.sh` can be re-run any number of times against a running instance — each invocation creates a new goal + task tree + reputation evidence, all appended to the ledger.
|
||||
|
||||
## What happens under the hood
|
||||
|
||||
`./run_task.sh` invokes `./bin/docgardener run` which:
|
||||
|
||||
1. **Bootstraps**: creates user `algis` (password `algis-demo-pw`), creates the `approvals` and `requests` channels, and materializes the pre-built coordinator agent (`doc-gardener-coordinator`) with its `config_hash` computed from its system prompt and tool scope.
|
||||
2. **Creates a goal** via `goals.Service.CreateGoal` — slug `keep-docs-mcpproxy-app-accurate-against-source`, budget `$50.00`, `max_spawn_depth=3`. Auto-creates the `#goal-...` backing channel.
|
||||
3. **Decomposes** the goal into a 4-node task tree (root + `scan-docs` + `verify-cli` + `drift-report` leaves) via `goaltasks.Service.CreateTree`, which denormalizes the full ancestry onto each child task in a single transaction.
|
||||
4. **Spawns specialists** — three agents (`docs-scanner`, `cli-verifier`, `drift-reporter`), each one running through `trust.DelegationCap()` to verify its proposed grant does not exceed the coordinator's, then computing a deterministic `trust.ConfigHash(...)` and seeding its reputation ledger at **70 % of the parent's rolling score** via `trust.Ledger.SeedFromParent()`.
|
||||
5. **Atomically claims tasks** — each specialist invokes `goaltasks.Service.Claim()` which runs the optimistic-lock `UPDATE ... WHERE assignee_agent_id IS NULL AND status='approved'` pattern. A concurrent-claim race test in `internal/goaltasks/service_test.go` verifies exactly-one-winner over 50 goroutine rounds.
|
||||
6. **Runs specialists** — simulated for the v1 demo. Each task:
|
||||
- transitions `claimed → in_progress → awaiting_verification → done`
|
||||
- increments leaf spend (`tokens`, `dollars_cents`)
|
||||
- posts an artifact message (`#finding`, `#verified`, `#summary`) to the goal channel with `metadata.kind="artifact"`
|
||||
- appends a **positive evidence row** to the reputation ledger with `score_delta=+0.15` (auto verifier) or `+0.2` (command verifier)
|
||||
7. **Marks the goal completed**.
|
||||
|
||||
`./report.sh` then invokes `./bin/docgardener report`, which:
|
||||
|
||||
1. reads the goal id from `.last_goal_id`
|
||||
2. queries all tasks, agents, reputation, messages, billing codes for that goal
|
||||
3. computes rolling reputation via `trust.Ledger.RollingScore()` (exponential decay, `half_life_days=30`)
|
||||
4. builds a recursive task tree + a chronological timeline
|
||||
5. renders `report.html.tmpl` into `report.html`
|
||||
6. opens it in the default browser
|
||||
|
||||
## Inspect during / after the run
|
||||
|
||||
- **Web UI**: http://localhost:18089 — log in as `algis` / `algis-demo-pw`. The existing channels, messages, and agents views all work on the new data.
|
||||
- **DB shell**:
|
||||
```bash
|
||||
sqlite3 ./data/synapbus.db -header -column "
|
||||
SELECT id, title, status, spent_dollars_cents, assignee_agent_id FROM goal_tasks;
|
||||
"
|
||||
```
|
||||
- **Trust ledger**:
|
||||
```bash
|
||||
sqlite3 ./data/synapbus.db -header -column "
|
||||
SELECT substr(config_hash,1,12) AS hash, score_delta, evidence_ref, created_at
|
||||
FROM reputation_evidence ORDER BY created_at;
|
||||
"
|
||||
```
|
||||
- **Cost rollup**:
|
||||
```bash
|
||||
sqlite3 ./data/synapbus.db -header -column "
|
||||
SELECT COALESCE(billing_code,''), SUM(spent_tokens), SUM(spent_dollars_cents)
|
||||
FROM goal_tasks GROUP BY billing_code;
|
||||
"
|
||||
```
|
||||
|
||||
## Expected HTML report
|
||||
|
||||
`report.html` contains six sections:
|
||||
|
||||
1. **Header** — goal title, status, budget, owner, backing channel
|
||||
2. **Spend metrics** — total dollars / tokens / agents spawned
|
||||
3. **Goal description**
|
||||
4. **Task tree** — recursive, collapsible, status badges, per-task spend, verifier kind
|
||||
5. **Spawned agents** — each with name, `config_hash` (first 12 chars), parent agent, spawn depth, autonomy tier, rolling reputation bar, tool-scope chips, truncated system prompt
|
||||
6. **Cost breakdown by billing code** — per-code task count, tokens, dollars
|
||||
7. **Artifacts posted by specialists** — the raw `#finding`, `#verified`, `#summary` messages
|
||||
8. **Timeline** — every message in the goal channel, chronologically, annotated with actor and kind
|
||||
|
||||
Screenshot-equivalent output (minus images):
|
||||
|
||||
```
|
||||
Doc-gardener run — Keep docs.mcpproxy.app accurate against source
|
||||
Goal #4 · slug keep-docs-... · owner algis · backing channel #goal-... · [completed]
|
||||
|
||||
Spend Tokens Agents spawned
|
||||
$1.05 6000 4
|
||||
|
||||
Task tree
|
||||
├─ Verify docs.mcpproxy.app against source [approved]
|
||||
│ ├─ Scan docs for CLI flags [done] $0.45 · 1500 tok · auto
|
||||
│ ├─ Verify flags exist in mcpproxy binary [done] $0.25 · 2000 tok · auto
|
||||
│ └─ Produce drift report [done] $0.35 · 2500 tok · command
|
||||
|
||||
Spawned agents
|
||||
• Doc-gardener Coordinator config_hash 70a9a06e9595… root · assisted · rep 80%
|
||||
• Docs Scanner config_hash a0b5c6538b2d… parent=coordinator · depth 1 · assisted · rep 58%
|
||||
• CLI Verifier config_hash 47c6839eed73… parent=coordinator · depth 1 · assisted · rep 58%
|
||||
• Drift Reporter config_hash ceaa7816aa42… parent=coordinator · depth 1 · assisted · rep 59%
|
||||
|
||||
Cost breakdown
|
||||
doc-gardener 1 task 0 tok $0.00
|
||||
doc-gardener/report 1 task 2500 tok $0.35
|
||||
doc-gardener/scan 1 task 1500 tok $0.45
|
||||
doc-gardener/verify 1 task 2000 tok $0.25
|
||||
```
|
||||
|
||||
## Tests for the primitives
|
||||
|
||||
The feature ships with passing test suites for every critical invariant:
|
||||
|
||||
```bash
|
||||
go test ./internal/goals/... ./internal/goaltasks/... ./internal/trust/... ./internal/secrets/...
|
||||
```
|
||||
|
||||
- `internal/goaltasks/service_test.go`
|
||||
- `TestCreateTree_AncestryAndDepth` — recursive tree build with correct depth + ancestry
|
||||
- `TestCreateTree_AncestryOverflow` — 16 KB cap enforcement
|
||||
- `TestClaimAtomic_Race` — 50 rounds × 2 racing goroutines, exactly one winner per round
|
||||
- `TestRollupCosts` — recursive CTE over 4-level tree
|
||||
- `TestTransition_StateMachine` — legal and illegal transitions
|
||||
- `internal/trust/config_hash_test.go` — determinism under shuffled inputs, sensitivity to capability changes
|
||||
- `internal/trust/delegation_test.go` — full tier-matrix + tool-scope subset enforcement
|
||||
- `internal/trust/ledger_test.go` — exponential decay, 70%-of-parent seed, clamping
|
||||
- `internal/secrets/store_test.go` — NaCl roundtrip, scope precedence, name sanitization
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `./start.sh` fails at "admin socket never appeared" | Another instance on port 18089 — set `SYNAPBUS_PORT=18090 ./start.sh` |
|
||||
| `./run_task.sh` fails with "DB not found" | `./start.sh` hasn't run — run it first |
|
||||
| Report page is empty or missing sections | `.last_goal_id` is stale — rerun `./run_task.sh` then `./report.sh` |
|
||||
| Stale binary | `rm -rf bin && ./start.sh` — forces rebuild |
|
||||
|
||||
## Next steps (out of scope for this MVP)
|
||||
|
||||
The spec at `specs/018-dynamic-agent-spawning/` lays out what comes after this demo, including the full MCP tool surface, reactor integration for real subprocess runs, the Svelte `/goals` page, the resource-request protocol, quarantine on low reputation, and the LLM-driven coordinator. This example establishes that the foundational primitives work; the follow-up work layers on top.
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# report.sh — render the HTML report for the most recent doc-gardener run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BIN="$SCRIPT_DIR/bin/docgardener"
|
||||
DB="$SCRIPT_DIR/data/synapbus.db"
|
||||
OUT="$SCRIPT_DIR/report.html"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
say() { printf '\033[1;36m[report]\033[0m %s\n' "$*"; }
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
printf '\033[1;31m[FAIL]\033[0m docgardener binary not found at %s — run ./start.sh first\n' "$BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "rendering $OUT"
|
||||
"$BIN" report --db "$DB" --out "$OUT"
|
||||
|
||||
say "opening in browser..."
|
||||
if command -v open >/dev/null 2>&1; then
|
||||
open "$OUT"
|
||||
elif command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$OUT"
|
||||
else
|
||||
say "(no opener found — browse to file://$OUT)"
|
||||
fi
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# run_task.sh — drive the docgardener demo against a running synapbus
|
||||
# instance. This is the demo's "coordinator run + specialist loop"
|
||||
# shortcut: it invokes ./bin/docgardener run, which creates a goal,
|
||||
# builds a task tree, spawns specialists (with real config_hash +
|
||||
# delegation cap checks + reputation seeding), walks tasks through
|
||||
# the state machine, and records reputation evidence.
|
||||
#
|
||||
# The demo does NOT launch real LLM subprocesses in v1 — it produces
|
||||
# synthetic artifacts so we can demonstrate the data primitives
|
||||
# end-to-end. Real subprocess execution comes via the reactor
|
||||
# integration in a follow-up PR (see specs/018/tasks.md, Phase 9+).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BIN="$SCRIPT_DIR/bin/docgardener"
|
||||
DB="$SCRIPT_DIR/data/synapbus.db"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
say() { printf '\033[1;36m[run]\033[0m %s\n' "$*"; }
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
printf '\033[1;31m[FAIL]\033[0m docgardener binary not found at %s — run ./start.sh first\n' "$BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$DB" ]; then
|
||||
printf '\033[1;31m[FAIL]\033[0m DB not found at %s — is synapbus running?\n' "$DB" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "executing docgardener run..."
|
||||
"$BIN" run --db "$DB"
|
||||
|
||||
say "done. View the run in the Web UI or render a report:"
|
||||
echo " ./report.sh"
|
||||
echo " open report.html"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# start.sh — launch an isolated synapbus instance and build the
|
||||
# docgardener demo driver. Mirrors cold-topic-explainer layout.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 everything came up
|
||||
# 1 synapbus failed to start
|
||||
# 2 admin socket never appeared
|
||||
# 3 preflight failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
PORT="${SYNAPBUS_PORT:-18089}"
|
||||
DATA_DIR="$SCRIPT_DIR/data"
|
||||
BIN_DIR="$SCRIPT_DIR/bin"
|
||||
BIN="$BIN_DIR/synapbus"
|
||||
DOCGARDENER="$BIN_DIR/docgardener"
|
||||
SOCKET="$DATA_DIR/synapbus.sock"
|
||||
PID_FILE="$SCRIPT_DIR/.synapbus.pid"
|
||||
LOG_FILE="$SCRIPT_DIR/synapbus.log"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
say() { printf '\033[1;36m[start]\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m[start][FAIL]\033[0m %s\n' "$*" >&2; exit "${2:-1}"; }
|
||||
|
||||
# --- preflight ---------------------------------------------------------
|
||||
for cmd in go sqlite3 curl; do
|
||||
command -v "$cmd" >/dev/null || die "missing required CLI: $cmd" 3
|
||||
done
|
||||
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
die "synapbus already running (pid $(cat "$PID_FILE")); run ./stop.sh first"
|
||||
fi
|
||||
|
||||
# --- build -------------------------------------------------------------
|
||||
say "building synapbus + docgardener binaries..."
|
||||
mkdir -p "$BIN_DIR"
|
||||
(cd "$REPO_ROOT" && CGO_ENABLED=0 go build -o "$BIN" ./cmd/synapbus)
|
||||
(cd "$REPO_ROOT" && CGO_ENABLED=0 go build -o "$DOCGARDENER" ./cmd/docgardener)
|
||||
|
||||
# --- fresh data dir ----------------------------------------------------
|
||||
say "wiping data dir $DATA_DIR"
|
||||
rm -rf "$DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# --- launch synapbus ---------------------------------------------------
|
||||
say "starting synapbus on port $PORT"
|
||||
nohup "$BIN" serve \
|
||||
--port "$PORT" \
|
||||
--data "$DATA_DIR" \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
say "pid $(cat "$PID_FILE") → $LOG_FILE"
|
||||
|
||||
# Wait for the admin socket + HTTP to appear.
|
||||
for i in $(seq 1 100); do
|
||||
if [ -S "$SOCKET" ]; then break; fi
|
||||
if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
die "synapbus crashed during boot — see $LOG_FILE" 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -S "$SOCKET" ]; then
|
||||
die "admin socket $SOCKET never appeared after 10s" 2
|
||||
fi
|
||||
for i in $(seq 1 100); do
|
||||
if curl -fsS "http://localhost:$PORT/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
say "synapbus is up"
|
||||
echo
|
||||
echo " Web UI: http://localhost:$PORT (login: algis / algis-demo-pw)"
|
||||
echo " Log: tail -f $LOG_FILE"
|
||||
echo " Admin socket: $SOCKET"
|
||||
echo
|
||||
echo "Next: ./run_task.sh"
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# stop.sh — shut down the synapbus instance started by ./start.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_FILE="$SCRIPT_DIR/.synapbus.pid"
|
||||
|
||||
say() { printf '\033[1;36m[stop]\033[0m %s\n' "$*"; }
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
say "no pid file — nothing to stop"
|
||||
exit 0
|
||||
fi
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ! kill -0 "$PID" 2>/dev/null; then
|
||||
say "process $PID already gone"
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "signaling synapbus (pid $PID)"
|
||||
kill "$PID"
|
||||
for i in $(seq 1 50); do
|
||||
if ! kill -0 "$PID" 2>/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
say "process did not exit gracefully — sending SIGKILL"
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
say "stopped"
|
||||
@@ -46,4 +46,14 @@ type Agent struct {
|
||||
HarnessName string `json:"harness_name,omitempty"` // explicit backend; empty = auto-resolve
|
||||
LocalCommand string `json:"local_command,omitempty"` // JSON-encoded argv for subprocess backend
|
||||
HarnessConfigJSON string `json:"harness_config_json,omitempty"` // opaque per-backend config
|
||||
|
||||
// Dynamic-spawning trust fields (migration 023).
|
||||
ConfigHash string `json:"config_hash,omitempty"`
|
||||
ParentAgentID *int64 `json:"parent_agent_id,omitempty"`
|
||||
SpawnDepth int `json:"spawn_depth"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
AutonomyTier string `json:"autonomy_tier,omitempty"`
|
||||
ToolScopeJSON string `json:"tool_scope_json,omitempty"`
|
||||
QuarantinedAt *time.Time `json:"quarantined_at,omitempty"`
|
||||
QuarantineReason string `json:"quarantine_reason,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package goals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ChannelCreator abstracts the channels package so goals can auto-create
|
||||
// its backing channel without a direct import cycle.
|
||||
type ChannelCreator interface {
|
||||
// CreateGoalChannel creates a private blackboard channel for a goal and
|
||||
// returns its id. The implementation wraps channels.Service.CreateChannel
|
||||
// with the right ChannelType and adds the owner as a member.
|
||||
CreateGoalChannel(ctx context.Context, slug, title, description, ownerUsername string) (int64, error)
|
||||
}
|
||||
|
||||
// Service is the high-level API for the goals package.
|
||||
type Service struct {
|
||||
store *Store
|
||||
chans ChannelCreator
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService constructs a goals service.
|
||||
func NewService(store *Store, chans ChannelCreator, logger *slog.Logger) *Service {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{store: store, chans: chans, logger: logger}
|
||||
}
|
||||
|
||||
// CreateGoal writes a goal row and auto-creates its backing channel.
|
||||
// Slug collisions are resolved by appending -2, -3, ... up to 99.
|
||||
func (s *Service) CreateGoal(ctx context.Context, in CreateGoalInput) (*Goal, error) {
|
||||
if in.Title == "" || in.Description == "" {
|
||||
return nil, fmt.Errorf("title and description are required")
|
||||
}
|
||||
if in.MaxSpawnDepth <= 0 {
|
||||
in.MaxSpawnDepth = 3
|
||||
}
|
||||
baseSlug := slugify(in.Title)
|
||||
slug := baseSlug
|
||||
for i := 2; i < 100; i++ {
|
||||
exists, err := s.store.SlugExists(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
break
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", baseSlug, i)
|
||||
}
|
||||
|
||||
channelID, err := s.chans.CreateGoalChannel(ctx, slug, in.Title, in.Description, in.OwnerUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create backing channel: %w", err)
|
||||
}
|
||||
|
||||
g := &Goal{
|
||||
Slug: slug,
|
||||
Title: in.Title,
|
||||
Description: in.Description,
|
||||
OwnerUserID: in.OwnerUserID,
|
||||
ChannelID: channelID,
|
||||
CoordinatorAgentID: in.CoordinatorAgentID,
|
||||
Status: StatusDraft,
|
||||
BudgetTokens: in.BudgetTokens,
|
||||
BudgetDollarsCents: in.BudgetDollarsCents,
|
||||
MaxSpawnDepth: in.MaxSpawnDepth,
|
||||
}
|
||||
if _, err := s.store.Insert(ctx, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("goal created", "goal_id", g.ID, "slug", slug, "channel_id", channelID, "owner", in.OwnerUserID)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGoal fetches a goal by id.
|
||||
func (s *Service) GetGoal(ctx context.Context, id int64) (*Goal, error) {
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
// ListGoals returns goals, optionally filtered by owner.
|
||||
func (s *Service) ListGoals(ctx context.Context, ownerUserID *int64, limit int) ([]*Goal, error) {
|
||||
return s.store.List(ctx, ownerUserID, limit)
|
||||
}
|
||||
|
||||
// TransitionStatus moves a goal to a new status. Legal transitions:
|
||||
//
|
||||
// draft → active | cancelled
|
||||
// active → paused | completed | stuck | cancelled
|
||||
// paused → active | cancelled
|
||||
// stuck → active | cancelled
|
||||
func (s *Service) TransitionStatus(ctx context.Context, goalID int64, newStatus string) error {
|
||||
g, err := s.store.Get(ctx, goalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok := legalTransition(g.Status, newStatus)
|
||||
if !ok {
|
||||
return fmt.Errorf("illegal goal transition: %s → %s", g.Status, newStatus)
|
||||
}
|
||||
return s.store.SetStatus(ctx, goalID, newStatus)
|
||||
}
|
||||
|
||||
func legalTransition(from, to string) bool {
|
||||
switch from {
|
||||
case StatusDraft:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
case StatusActive:
|
||||
return to == StatusPaused || to == StatusCompleted || to == StatusStuck || to == StatusCancelled
|
||||
case StatusPaused:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
case StatusStuck:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package goals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the SQLite-backed persistence for goals.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store from a database handle.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// DB returns the underlying handle so the service layer can start its
|
||||
// own transactions (e.g. when creating a goal + channel atomically).
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// SlugExists reports whether any goal already has the given slug.
|
||||
func (s *Store) SlugExists(ctx context.Context, slug string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM goals WHERE slug = ?`, slug).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// Insert writes a new goal row and returns its id. Caller is responsible
|
||||
// for providing a valid channel_id (auto-created by the service layer).
|
||||
func (s *Store) Insert(ctx context.Context, g *Goal) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO goals
|
||||
(slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
status, budget_tokens, budget_dollars_cents, max_spawn_depth)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.Slug, g.Title, g.Description, g.OwnerUserID, g.ChannelID, g.CoordinatorAgentID,
|
||||
g.Status, g.BudgetTokens, g.BudgetDollarsCents, g.MaxSpawnDepth)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert goal: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
g.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get fetches a single goal by id.
|
||||
func (s *Store) Get(ctx context.Context, id int64) (*Goal, error) {
|
||||
g := &Goal{}
|
||||
var alert int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
FROM goals WHERE id = ?`, id).Scan(
|
||||
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
|
||||
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
|
||||
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrGoalNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Alert80PctPosted = alert != 0
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// List returns goals optionally filtered by owner.
|
||||
func (s *Store) List(ctx context.Context, ownerUserID *int64, limit int) ([]*Goal, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if ownerUserID != nil {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
FROM goals WHERE owner_user_id = ? ORDER BY id DESC LIMIT ?`, *ownerUserID, limit)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
FROM goals ORDER BY id DESC LIMIT ?`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Goal, 0, limit)
|
||||
for rows.Next() {
|
||||
g := &Goal{}
|
||||
var alert int
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
|
||||
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
|
||||
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Alert80PctPosted = alert != 0
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetRootTask updates the goal's root_task_id.
|
||||
func (s *Store) SetRootTask(ctx context.Context, goalID, rootTaskID int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE goals SET root_task_id = ?, updated_at = ? WHERE id = ?`,
|
||||
rootTaskID, time.Now().UTC(), goalID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetStatus transitions a goal's status.
|
||||
func (s *Store) SetStatus(ctx context.Context, goalID int64, newStatus string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE goals SET status = ?, updated_at = ? WHERE id = ?`,
|
||||
newStatus, time.Now().UTC(), goalID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkSoftAlertPosted flips the idempotency flag so the 80 % alert is
|
||||
// posted only once per goal.
|
||||
func (s *Store) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE goals SET alert_80pct_posted = 1 WHERE id = ?`, goalID)
|
||||
return err
|
||||
}
|
||||
|
||||
// slugify normalizes a title into a URL-safe slug.
|
||||
func slugify(title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
case r == ' ' || r == '-' || r == '_' || r == '/' || r == '.':
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
prevDash = true
|
||||
}
|
||||
default:
|
||||
// drop
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
out = "goal"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sentinel errors.
|
||||
var ErrGoalNotFound = errors.New("goal not found")
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package goals implements the goal/task tree data model for dynamic
|
||||
// agent spawning. A goal is a human-owned top-level objective with a
|
||||
// backing channel and a coordinator agent; it roots a tree of
|
||||
// goal_tasks assigned to specialist agents.
|
||||
package goals
|
||||
|
||||
import "time"
|
||||
|
||||
// GoalStatus values.
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
StatusCompleted = "completed"
|
||||
StatusCancelled = "cancelled"
|
||||
StatusStuck = "stuck"
|
||||
)
|
||||
|
||||
// Goal is a top-level objective.
|
||||
type Goal struct {
|
||||
ID int64
|
||||
Slug string
|
||||
Title string
|
||||
Description string
|
||||
OwnerUserID int64
|
||||
ChannelID int64
|
||||
CoordinatorAgentID *int64
|
||||
RootTaskID *int64
|
||||
Status string
|
||||
BudgetTokens *int64
|
||||
BudgetDollarsCents *int64
|
||||
MaxSpawnDepth int
|
||||
Alert80PctPosted bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// CreateGoalInput captures the public arguments of CreateGoal.
|
||||
type CreateGoalInput struct {
|
||||
Title string
|
||||
Description string
|
||||
OwnerUserID int64 // DB foreign key
|
||||
OwnerUsername string // used as created_by for the backing channel
|
||||
CoordinatorAgentID *int64
|
||||
BudgetTokens *int64
|
||||
BudgetDollarsCents *int64
|
||||
MaxSpawnDepth int
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package goaltasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Service is the high-level API for creating, claiming, and advancing tasks.
|
||||
type Service struct {
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService constructs a task service.
|
||||
func NewService(store *Store, logger *slog.Logger) *Service {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{store: store, logger: logger}
|
||||
}
|
||||
|
||||
// Store exposes the backing store for callers that need direct access
|
||||
// (e.g. the HTML report generator).
|
||||
func (s *Service) Store() *Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// CreateTreeInput captures the arguments of CreateTree.
|
||||
type CreateTreeInput struct {
|
||||
GoalID int64
|
||||
CreatedByAgent *int64
|
||||
CreatedByUser *int64
|
||||
Root TreeNode
|
||||
InitialStatus string // defaults to StatusApproved (for auto-approved flows)
|
||||
DefaultBilling string
|
||||
}
|
||||
|
||||
// CreateTree materializes a tree of tasks under a goal in a single
|
||||
// transaction. Ancestry is denormalized at create time. Returns the
|
||||
// root task id and the flat list of all created ids in insertion order.
|
||||
func (s *Service) CreateTree(ctx context.Context, in CreateTreeInput) (rootTaskID int64, allIDs []int64, err error) {
|
||||
if in.InitialStatus == "" {
|
||||
in.InitialStatus = StatusApproved
|
||||
}
|
||||
tx, err := s.store.DB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var walk func(node TreeNode, parentID *int64, depth int, ancestry []AncestryNode) (int64, error)
|
||||
walk = func(node TreeNode, parentID *int64, depth int, ancestry []AncestryNode) (int64, error) {
|
||||
billing := node.BillingCode
|
||||
if billing == "" {
|
||||
billing = in.DefaultBilling
|
||||
}
|
||||
t := &Task{
|
||||
GoalID: in.GoalID,
|
||||
ParentTaskID: parentID,
|
||||
Ancestry: ancestry,
|
||||
Depth: depth,
|
||||
Title: node.Title,
|
||||
Description: node.Description,
|
||||
AcceptanceCriteria: node.AcceptanceCriteria,
|
||||
CreatedByAgentID: in.CreatedByAgent,
|
||||
CreatedByUserID: in.CreatedByUser,
|
||||
Status: in.InitialStatus,
|
||||
BillingCode: billing,
|
||||
BudgetTokens: node.BudgetTokens,
|
||||
BudgetDollarsCents: node.BudgetDollarsCents,
|
||||
VerifierConfig: node.VerifierConfig,
|
||||
HeartbeatConfig: node.HeartbeatConfig,
|
||||
}
|
||||
id, err := s.store.Insert(ctx, tx, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
allIDs = append(allIDs, id)
|
||||
|
||||
if len(node.Children) > 0 {
|
||||
childAncestry := append([]AncestryNode(nil), ancestry...)
|
||||
childAncestry = append(childAncestry, AncestryNode{
|
||||
ID: id,
|
||||
Title: node.Title,
|
||||
AcceptanceCriteria: node.AcceptanceCriteria,
|
||||
})
|
||||
for _, child := range node.Children {
|
||||
if _, err := walk(child, &id, depth+1, childAncestry); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
rootTaskID, err = walk(in.Root, nil, 0, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.logger.Info("task tree created", "goal_id", in.GoalID, "root_task_id", rootTaskID, "total", len(allIDs))
|
||||
return rootTaskID, allIDs, nil
|
||||
}
|
||||
|
||||
// Claim atomically locks a task to an agent.
|
||||
func (s *Service) Claim(ctx context.Context, taskID, agentID int64, claimMessageID *int64) error {
|
||||
return s.store.ClaimAtomic(ctx, taskID, agentID, claimMessageID)
|
||||
}
|
||||
|
||||
// Transition moves a task through the state machine.
|
||||
func (s *Service) Transition(ctx context.Context, taskID int64, newStatus string, extras Extras) error {
|
||||
t, err := s.store.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !legalTransition(t.Status, newStatus) {
|
||||
return fmt.Errorf("%w: %s → %s", ErrIllegalTransition, t.Status, newStatus)
|
||||
}
|
||||
return s.store.TransitionStatus(ctx, taskID, newStatus, extras)
|
||||
}
|
||||
|
||||
// Get exposes the store's Get.
|
||||
func (s *Service) Get(ctx context.Context, id int64) (*Task, error) {
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
// ListByGoal exposes the store's ListByGoal.
|
||||
func (s *Service) ListByGoal(ctx context.Context, goalID int64) ([]*Task, error) {
|
||||
return s.store.ListByGoal(ctx, goalID)
|
||||
}
|
||||
|
||||
// AddSpend is used by the reactor post-run to increment leaf cost.
|
||||
func (s *Service) AddSpend(ctx context.Context, taskID, tokens, dollarsCents int64) error {
|
||||
return s.store.AddSpend(ctx, taskID, tokens, dollarsCents)
|
||||
}
|
||||
|
||||
// RollupCosts exposes the store's recursive CTE.
|
||||
func (s *Service) RollupCosts(ctx context.Context, rootTaskID int64) (tokens, dollarsCents int64, count int, err error) {
|
||||
return s.store.RollupCosts(ctx, rootTaskID)
|
||||
}
|
||||
|
||||
// RollupByBillingCode exposes the per-billing-code rollup.
|
||||
func (s *Service) RollupByBillingCode(ctx context.Context, rootTaskID int64) (map[string]Spend, error) {
|
||||
return s.store.RollupByBillingCode(ctx, rootTaskID)
|
||||
}
|
||||
|
||||
// legalTransition encodes the task state machine.
|
||||
func legalTransition(from, to string) bool {
|
||||
if to == StatusCancelled {
|
||||
return from != StatusDone && from != StatusFailed && from != StatusCancelled
|
||||
}
|
||||
switch from {
|
||||
case StatusProposed:
|
||||
return to == StatusApproved
|
||||
case StatusApproved:
|
||||
return to == StatusClaimed
|
||||
case StatusClaimed:
|
||||
return to == StatusInProgress || to == StatusAwaitingVerification
|
||||
case StatusInProgress:
|
||||
return to == StatusAwaitingVerification
|
||||
case StatusAwaitingVerification:
|
||||
return to == StatusDone || to == StatusFailed
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package goaltasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
// testDB spins up an in-memory SQLite with all migrations applied and a
|
||||
// minimal user/channel/agent/goal/goal_tasks set suitable for service tests.
|
||||
func testDB(t *testing.T) (*sql.DB, int64, int64) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_foreign_keys=on&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES ('algis', 'x')`); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
var userID int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM users WHERE username='algis'`).Scan(&userID); err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO channels (name, description, type, is_private, is_system, created_by)
|
||||
VALUES ('goal-test', 'Test goal channel', 'blackboard', 1, 0, 'algis')`); err != nil {
|
||||
t.Fatalf("insert channel: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name='goal-test'`).Scan(&channelID); err != nil {
|
||||
t.Fatalf("get channel: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO goals (slug, title, description, owner_user_id, channel_id, status, max_spawn_depth)
|
||||
VALUES ('test', 'Test', 'Desc', ?, ?, 'active', 3)`, userID, channelID); err != nil {
|
||||
t.Fatalf("insert goal: %v", err)
|
||||
}
|
||||
var goalID int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM goals WHERE slug='test'`).Scan(&goalID); err != nil {
|
||||
t.Fatalf("get goal: %v", err)
|
||||
}
|
||||
return db, userID, goalID
|
||||
}
|
||||
|
||||
func insertTestAgent(t *testing.T, db *sql.DB, name string, ownerID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO agents (name, type, capabilities, owner_id, api_key_hash, status)
|
||||
VALUES (?, 'ai', '[]', ?, 'hash', 'active')`, name, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert agent: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func TestCreateTree_AncestryAndDepth(t *testing.T) {
|
||||
db, userID, goalID := testDB(t)
|
||||
svc := NewService(NewStore(db), slog.Default())
|
||||
|
||||
root := TreeNode{
|
||||
Title: "root",
|
||||
Description: "root desc",
|
||||
Children: []TreeNode{
|
||||
{
|
||||
Title: "child-1",
|
||||
Description: "c1 desc",
|
||||
Children: []TreeNode{
|
||||
{Title: "grandchild", Description: "gc desc"},
|
||||
},
|
||||
},
|
||||
{Title: "child-2", Description: "c2 desc"},
|
||||
},
|
||||
}
|
||||
|
||||
rootID, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByUser: &userID,
|
||||
Root: root,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTree: %v", err)
|
||||
}
|
||||
if len(allIDs) != 4 {
|
||||
t.Fatalf("expected 4 tasks, got %d", len(allIDs))
|
||||
}
|
||||
|
||||
tasks, err := svc.ListByGoal(context.Background(), goalID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByGoal: %v", err)
|
||||
}
|
||||
byID := map[int64]*Task{}
|
||||
for _, task := range tasks {
|
||||
byID[task.ID] = task
|
||||
}
|
||||
|
||||
if r := byID[rootID]; r == nil || r.Depth != 0 || len(r.Ancestry) != 0 {
|
||||
t.Errorf("root depth/ancestry wrong: %+v", r)
|
||||
}
|
||||
// grandchild should have two ancestors
|
||||
var gc *Task
|
||||
for _, task := range tasks {
|
||||
if task.Title == "grandchild" {
|
||||
gc = task
|
||||
}
|
||||
}
|
||||
if gc == nil || gc.Depth != 2 || len(gc.Ancestry) != 2 {
|
||||
t.Fatalf("grandchild depth/ancestry wrong: %+v", gc)
|
||||
}
|
||||
if gc.Ancestry[0].Title != "root" || gc.Ancestry[1].Title != "child-1" {
|
||||
t.Errorf("ancestry chain wrong: %+v", gc.Ancestry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTree_AncestryOverflow(t *testing.T) {
|
||||
db, userID, goalID := testDB(t)
|
||||
svc := NewService(NewStore(db), slog.Default())
|
||||
// Huge title on an intermediate node — the grandchild's ancestry snapshot
|
||||
// will contain this title and must exceed the 16 KB cap.
|
||||
huge := strings.Repeat("x", 20000)
|
||||
root := TreeNode{
|
||||
Title: "root",
|
||||
Description: "d",
|
||||
Children: []TreeNode{
|
||||
{
|
||||
Title: huge,
|
||||
Description: "d",
|
||||
Children: []TreeNode{
|
||||
{Title: "victim", Description: "d"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, _, err := svc.CreateTree(context.Background(), CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByUser: &userID,
|
||||
Root: root,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ancestry overflow error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAtomic_Race(t *testing.T) {
|
||||
db, userID, goalID := testDB(t)
|
||||
svc := NewService(NewStore(db), slog.Default())
|
||||
|
||||
// Create one task in approved state.
|
||||
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByUser: &userID,
|
||||
Root: TreeNode{Title: "solo", Description: "d"},
|
||||
InitialStatus: StatusApproved,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTree: %v", err)
|
||||
}
|
||||
taskID := allIDs[0]
|
||||
|
||||
// Two racing agents.
|
||||
agent1 := insertTestAgent(t, db, "racer1", userID)
|
||||
agent2 := insertTestAgent(t, db, "racer2", userID)
|
||||
|
||||
const rounds = 50
|
||||
var oneWinsCount, alreadyClaimedCount int32
|
||||
for i := 0; i < rounds; i++ {
|
||||
// Reset the task to approved + unassigned each round.
|
||||
if _, err := db.ExecContext(context.Background(),
|
||||
`UPDATE goal_tasks SET status='approved', assignee_agent_id=NULL, claimed_at=NULL WHERE id=?`, taskID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for _, a := range []int64{agent1, agent2} {
|
||||
agentID := a
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := svc.Claim(context.Background(), taskID, agentID, nil)
|
||||
switch err {
|
||||
case nil:
|
||||
atomic.AddInt32(&oneWinsCount, 1)
|
||||
case ErrAlreadyClaimed:
|
||||
atomic.AddInt32(&alreadyClaimedCount, 1)
|
||||
default:
|
||||
t.Errorf("unexpected claim error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
if oneWinsCount != rounds {
|
||||
t.Errorf("expected %d wins, got %d", rounds, oneWinsCount)
|
||||
}
|
||||
if alreadyClaimedCount != rounds {
|
||||
t.Errorf("expected %d ErrAlreadyClaimed, got %d", rounds, alreadyClaimedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollupCosts(t *testing.T) {
|
||||
db, userID, goalID := testDB(t)
|
||||
svc := NewService(NewStore(db), slog.Default())
|
||||
|
||||
// Build: root → a, b; a → a1
|
||||
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByUser: &userID,
|
||||
Root: TreeNode{
|
||||
Title: "root", Description: "d",
|
||||
Children: []TreeNode{
|
||||
{Title: "a", Description: "d", Children: []TreeNode{
|
||||
{Title: "a1", Description: "d"},
|
||||
}},
|
||||
{Title: "b", Description: "d"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTree: %v", err)
|
||||
}
|
||||
if len(allIDs) != 4 {
|
||||
t.Fatalf("expected 4 tasks, got %d", len(allIDs))
|
||||
}
|
||||
rootID := allIDs[0]
|
||||
|
||||
// Spend on a1 and b (the leaves).
|
||||
a1ID := allIDs[2]
|
||||
bID := allIDs[3]
|
||||
if err := svc.AddSpend(context.Background(), a1ID, 100, 50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.AddSpend(context.Background(), bID, 200, 75); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tokens, dollars, count, err := svc.RollupCosts(context.Background(), rootID)
|
||||
if err != nil {
|
||||
t.Fatalf("RollupCosts: %v", err)
|
||||
}
|
||||
if tokens != 300 || dollars != 125 || count != 4 {
|
||||
t.Errorf("rollup wrong: tokens=%d dollars=%d count=%d", tokens, dollars, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_StateMachine(t *testing.T) {
|
||||
db, userID, goalID := testDB(t)
|
||||
svc := NewService(NewStore(db), slog.Default())
|
||||
|
||||
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByUser: &userID,
|
||||
Root: TreeNode{Title: "solo", Description: "d"},
|
||||
InitialStatus: StatusApproved,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID := allIDs[0]
|
||||
|
||||
// Legal: approved → claimed → in_progress → awaiting_verification → done
|
||||
steps := []string{StatusClaimed, StatusInProgress, StatusAwaitingVerification, StatusDone}
|
||||
for _, step := range steps {
|
||||
if err := svc.Transition(context.Background(), taskID, step, Extras{}); err != nil {
|
||||
t.Fatalf("transition to %s: %v", step, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Illegal: done → approved
|
||||
if err := svc.Transition(context.Background(), taskID, StatusApproved, Extras{}); err == nil {
|
||||
t.Error("expected illegal transition from done → approved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package goaltasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the SQLite-backed persistence for goal tasks.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store from a database handle.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// DB exposes the underlying handle for transactions.
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Insert writes a single task row. The caller is responsible for
|
||||
// providing a valid ancestry and depth.
|
||||
func (s *Store) Insert(ctx context.Context, tx *sql.Tx, t *Task) (int64, error) {
|
||||
ancestryJSON, err := marshalAncestry(t.Ancestry)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var verifierJSON, heartbeatJSON sql.NullString
|
||||
if t.VerifierConfig != nil {
|
||||
b, err := json.Marshal(t.VerifierConfig)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
verifierJSON = sql.NullString{String: string(b), Valid: true}
|
||||
}
|
||||
if t.HeartbeatConfig != nil {
|
||||
b, err := json.Marshal(t.HeartbeatConfig)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
heartbeatJSON = sql.NullString{String: string(b), Valid: true}
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO goal_tasks
|
||||
(goal_id, parent_task_id, ancestry_json, depth, title, description, acceptance_criteria,
|
||||
created_by_agent_id, created_by_user_id, assignee_agent_id, status,
|
||||
billing_code, budget_tokens, budget_dollars_cents,
|
||||
heartbeat_config_json, verifier_config_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
var res sql.Result
|
||||
if tx != nil {
|
||||
res, err = tx.ExecContext(ctx, q,
|
||||
t.GoalID, t.ParentTaskID, ancestryJSON, t.Depth, t.Title, t.Description, t.AcceptanceCriteria,
|
||||
t.CreatedByAgentID, t.CreatedByUserID, t.AssigneeAgentID, t.Status,
|
||||
nullableString(t.BillingCode), t.BudgetTokens, t.BudgetDollarsCents,
|
||||
heartbeatJSON, verifierJSON)
|
||||
} else {
|
||||
res, err = s.db.ExecContext(ctx, q,
|
||||
t.GoalID, t.ParentTaskID, ancestryJSON, t.Depth, t.Title, t.Description, t.AcceptanceCriteria,
|
||||
t.CreatedByAgentID, t.CreatedByUserID, t.AssigneeAgentID, t.Status,
|
||||
nullableString(t.BillingCode), t.BudgetTokens, t.BudgetDollarsCents,
|
||||
heartbeatJSON, verifierJSON)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert goal_task: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
t.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get fetches a single task by id.
|
||||
func (s *Store) Get(ctx context.Context, id int64) (*Task, error) {
|
||||
return s.getOne(ctx, `SELECT `+cols+` FROM goal_tasks WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
// ListByGoal returns all tasks under a goal in insertion order.
|
||||
func (s *Store) ListByGoal(ctx context.Context, goalID int64) ([]*Task, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+cols+` FROM goal_tasks WHERE goal_id = ? ORDER BY id`, goalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Task
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClaimAtomic performs the optimistic-lock claim — the core concurrency
|
||||
// primitive. Returns ErrAlreadyClaimed if the task is not in state
|
||||
// `approved` and unassigned.
|
||||
func (s *Store) ClaimAtomic(ctx context.Context, taskID, agentID int64, claimMessageID *int64) error {
|
||||
now := time.Now().UTC()
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE goal_tasks
|
||||
SET assignee_agent_id = ?,
|
||||
status = ?,
|
||||
claimed_at = ?,
|
||||
claim_message_id = ?
|
||||
WHERE id = ?
|
||||
AND assignee_agent_id IS NULL
|
||||
AND status = ?`,
|
||||
agentID, StatusClaimed, now, claimMessageID, taskID, StatusApproved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrAlreadyClaimed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransitionStatus unconditionally moves a task to a new status. The
|
||||
// service layer is responsible for legality checks before calling this.
|
||||
func (s *Store) TransitionStatus(ctx context.Context, taskID int64, newStatus string, extras Extras) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE goal_tasks
|
||||
SET status = ?,
|
||||
started_at = COALESCE(started_at, CASE WHEN ? = 'in_progress' THEN ? ELSE NULL END),
|
||||
completed_at = CASE WHEN ? IN ('done','failed','cancelled') THEN ? ELSE completed_at END,
|
||||
failure_reason = COALESCE(?, failure_reason),
|
||||
completion_message_id = COALESCE(?, completion_message_id)
|
||||
WHERE id = ?`,
|
||||
newStatus, newStatus, now, newStatus, now,
|
||||
nullableString(extras.FailureReason),
|
||||
extras.CompletionMessageID,
|
||||
taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddSpend increments a leaf task's spend counters after a harness run.
|
||||
func (s *Store) AddSpend(ctx context.Context, taskID int64, tokens, dollarsCents int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE goal_tasks
|
||||
SET spent_tokens = spent_tokens + ?,
|
||||
spent_dollars_cents = spent_dollars_cents + ?
|
||||
WHERE id = ?`, tokens, dollarsCents, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RollupCosts returns the total spend under a task subtree (inclusive).
|
||||
func (s *Store) RollupCosts(ctx context.Context, rootTaskID int64) (tokens, dollarsCents int64, count int, err error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
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(SUM(spent_tokens), 0),
|
||||
COALESCE(SUM(spent_dollars_cents), 0),
|
||||
COUNT(*)
|
||||
FROM goal_tasks WHERE id IN subtree`, rootTaskID)
|
||||
err = row.Scan(&tokens, &dollarsCents, &count)
|
||||
return
|
||||
}
|
||||
|
||||
// RollupByBillingCode returns spend grouped by billing code within a subtree.
|
||||
func (s *Store) RollupByBillingCode(ctx context.Context, rootTaskID int64) (map[string]Spend, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
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`, rootTaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]Spend{}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var tokens, dollars int64
|
||||
if err := rows.Scan(&code, &tokens, &dollars); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[code] = Spend{Tokens: tokens, DollarsCents: dollars}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Extras carries optional fields for TransitionStatus.
|
||||
type Extras struct {
|
||||
FailureReason string
|
||||
CompletionMessageID *int64
|
||||
}
|
||||
|
||||
// Spend is a tokens+dollars pair for rollups.
|
||||
type Spend struct {
|
||||
Tokens int64
|
||||
DollarsCents int64
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
const cols = `id, goal_id, parent_task_id, ancestry_json, depth, title, description, acceptance_criteria,
|
||||
created_by_agent_id, created_by_user_id, assignee_agent_id, status,
|
||||
billing_code, budget_tokens, budget_dollars_cents, spent_tokens, spent_dollars_cents,
|
||||
heartbeat_config_json, verifier_config_json,
|
||||
origin_message_id, claim_message_id, completion_message_id, failure_reason,
|
||||
created_at, approved_at, claimed_at, started_at, completed_at`
|
||||
|
||||
type rowLike interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func (s *Store) getOne(ctx context.Context, q string, args ...any) (*Task, error) {
|
||||
row := s.db.QueryRowContext(ctx, q, args...)
|
||||
t, err := scanTask(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func scanTask(r rowLike) (*Task, error) {
|
||||
t := &Task{}
|
||||
var (
|
||||
billing sql.NullString
|
||||
ancestry string
|
||||
verifierJSON sql.NullString
|
||||
heartbeatJSON sql.NullString
|
||||
failureReason sql.NullString
|
||||
)
|
||||
err := r.Scan(
|
||||
&t.ID, &t.GoalID, &t.ParentTaskID, &ancestry, &t.Depth, &t.Title, &t.Description, &t.AcceptanceCriteria,
|
||||
&t.CreatedByAgentID, &t.CreatedByUserID, &t.AssigneeAgentID, &t.Status,
|
||||
&billing, &t.BudgetTokens, &t.BudgetDollarsCents, &t.SpentTokens, &t.SpentDollarsCents,
|
||||
&heartbeatJSON, &verifierJSON,
|
||||
&t.OriginMessageID, &t.ClaimMessageID, &t.CompletionMessageID, &failureReason,
|
||||
&t.CreatedAt, &t.ApprovedAt, &t.ClaimedAt, &t.StartedAt, &t.CompletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if billing.Valid {
|
||||
t.BillingCode = billing.String
|
||||
}
|
||||
if failureReason.Valid {
|
||||
t.FailureReason = failureReason.String
|
||||
}
|
||||
if heartbeatJSON.Valid && heartbeatJSON.String != "" {
|
||||
hc := &HeartbeatConfig{}
|
||||
if err := json.Unmarshal([]byte(heartbeatJSON.String), hc); err == nil {
|
||||
t.HeartbeatConfig = hc
|
||||
}
|
||||
}
|
||||
if verifierJSON.Valid && verifierJSON.String != "" {
|
||||
vc := &VerifierConfig{}
|
||||
if err := json.Unmarshal([]byte(verifierJSON.String), vc); err == nil {
|
||||
t.VerifierConfig = vc
|
||||
}
|
||||
}
|
||||
nodes, err := unmarshalAncestry(ancestry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Ancestry = nodes
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func nullableString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package goaltasks implements the work-task tree rooted in a goal.
|
||||
// Table name is goal_tasks (not tasks) because the legacy channel
|
||||
// task-auction feature already owns the tasks table.
|
||||
//
|
||||
// Tasks are single-assignee, atomically claimable, and carry a
|
||||
// denormalized goal-ancestry snapshot so every subprocess run can
|
||||
// see the full root-to-parent context without recursive queries.
|
||||
package goaltasks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task status values.
|
||||
const (
|
||||
StatusProposed = "proposed"
|
||||
StatusApproved = "approved"
|
||||
StatusClaimed = "claimed"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusAwaitingVerification = "awaiting_verification"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Verifier kinds.
|
||||
const (
|
||||
VerifierKindAuto = "auto"
|
||||
VerifierKindPeer = "peer"
|
||||
VerifierKindCommand = "command"
|
||||
)
|
||||
|
||||
// AncestryNode is one entry in a task's denormalized ancestry chain,
|
||||
// copied from the root down to the parent at create time.
|
||||
type AncestryNode struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||
}
|
||||
|
||||
// VerifierConfig describes how to verify a task once the assignee
|
||||
// reports it complete. Exactly one kind is present.
|
||||
type VerifierConfig struct {
|
||||
Kind string `json:"kind"`
|
||||
AgentID int64 `json:"agent_id,omitempty"`
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatConfig controls how the reactor wakes the assignee.
|
||||
type HeartbeatConfig struct {
|
||||
Source string `json:"source"`
|
||||
IntervalSec int `json:"interval_sec,omitempty"`
|
||||
}
|
||||
|
||||
// Task is a node in a goal's task tree.
|
||||
type Task struct {
|
||||
ID int64
|
||||
GoalID int64
|
||||
ParentTaskID *int64
|
||||
Ancestry []AncestryNode
|
||||
Depth int
|
||||
Title string
|
||||
Description string
|
||||
AcceptanceCriteria string
|
||||
CreatedByAgentID *int64
|
||||
CreatedByUserID *int64
|
||||
AssigneeAgentID *int64
|
||||
Status string
|
||||
BillingCode string
|
||||
BudgetTokens *int64
|
||||
BudgetDollarsCents *int64
|
||||
SpentTokens int64
|
||||
SpentDollarsCents int64
|
||||
HeartbeatConfig *HeartbeatConfig
|
||||
VerifierConfig *VerifierConfig
|
||||
OriginMessageID *int64
|
||||
ClaimMessageID *int64
|
||||
CompletionMessageID *int64
|
||||
FailureReason string
|
||||
CreatedAt time.Time
|
||||
ApprovedAt *time.Time
|
||||
ClaimedAt *time.Time
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// TreeNode is the input shape for CreateTree — a recursive task spec.
|
||||
type TreeNode struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||
BillingCode string `json:"billing_code,omitempty"`
|
||||
BudgetTokens *int64 `json:"budget_tokens,omitempty"`
|
||||
BudgetDollarsCents *int64 `json:"budget_dollars_cents,omitempty"`
|
||||
VerifierConfig *VerifierConfig `json:"verifier_config,omitempty"`
|
||||
HeartbeatConfig *HeartbeatConfig `json:"heartbeat_config,omitempty"`
|
||||
Children []TreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// MaxAncestryBytes caps the denormalized ancestry blob on any single task.
|
||||
const MaxAncestryBytes = 16 * 1024
|
||||
|
||||
// marshalAncestry serializes an ancestry chain. Returns ErrAncestryOverflow
|
||||
// if the result exceeds MaxAncestryBytes.
|
||||
func marshalAncestry(nodes []AncestryNode) (string, error) {
|
||||
b, err := json.Marshal(nodes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(b) > MaxAncestryBytes {
|
||||
return "", ErrAncestryOverflow
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// unmarshalAncestry parses the stored JSON back into a chain.
|
||||
func unmarshalAncestry(s string) ([]AncestryNode, error) {
|
||||
if s == "" || s == "[]" {
|
||||
return nil, nil
|
||||
}
|
||||
var nodes []AncestryNode
|
||||
if err := json.Unmarshal([]byte(s), &nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrAlreadyClaimed = errors.New("task already claimed by another agent")
|
||||
ErrIllegalTransition = errors.New("illegal task status transition")
|
||||
ErrAncestryOverflow = errors.New("task ancestry exceeds 16 KB cap")
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildEnvMap returns a name→plaintext map for all active secrets visible to
|
||||
// the given user/agent/task, with scope precedence user < agent < task. Pass
|
||||
// 0 for any scope id you wish to skip. last_used_at is bumped to
|
||||
// CURRENT_TIMESTAMP for every secret returned.
|
||||
//
|
||||
// The returned map is intended to be merged into a subprocess env. Callers
|
||||
// must treat values as sensitive and never log them.
|
||||
func (s *Store) BuildEnvMap(ctx context.Context, userID, agentID, taskID int64) (map[string]string, error) {
|
||||
// Build (scope_type, scope_id, precedence) tuples; higher precedence wins.
|
||||
type scopeRow struct {
|
||||
typ string
|
||||
id int64
|
||||
precedence int
|
||||
}
|
||||
var scopes []scopeRow
|
||||
if userID > 0 {
|
||||
scopes = append(scopes, scopeRow{ScopeUser, userID, 1})
|
||||
}
|
||||
if agentID > 0 {
|
||||
scopes = append(scopes, scopeRow{ScopeAgent, agentID, 2})
|
||||
}
|
||||
if taskID > 0 {
|
||||
scopes = append(scopes, scopeRow{ScopeTask, taskID, 3})
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
parts []string
|
||||
args []any
|
||||
)
|
||||
for _, sc := range scopes {
|
||||
parts = append(parts, "(scope_type = ? AND scope_id = ?)")
|
||||
args = append(args, sc.typ, sc.id)
|
||||
}
|
||||
|
||||
query := `SELECT id, name, scope_type, value_blob
|
||||
FROM secrets
|
||||
WHERE revoked_at IS NULL
|
||||
AND (` + strings.Join(parts, " OR ") + `)`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: env query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type winner struct {
|
||||
id int64
|
||||
precedence int
|
||||
value string
|
||||
}
|
||||
winners := make(map[string]winner)
|
||||
var touched []int64
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
name string
|
||||
scopeType string
|
||||
blob []byte
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &scopeType, &blob); err != nil {
|
||||
return nil, fmt.Errorf("secrets: env scan: %w", err)
|
||||
}
|
||||
var prec int
|
||||
switch scopeType {
|
||||
case ScopeUser:
|
||||
prec = 1
|
||||
case ScopeAgent:
|
||||
prec = 2
|
||||
case ScopeTask:
|
||||
prec = 3
|
||||
default:
|
||||
continue
|
||||
}
|
||||
existing, ok := winners[name]
|
||||
if ok && existing.precedence >= prec {
|
||||
continue
|
||||
}
|
||||
plain, err := s.decrypt(blob)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: decrypt %q: %w", name, err)
|
||||
}
|
||||
winners[name] = winner{id: id, precedence: prec, value: string(plain)}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]string, len(winners))
|
||||
for name, w := range winners {
|
||||
out[name] = w.value
|
||||
touched = append(touched, w.id)
|
||||
}
|
||||
|
||||
if len(touched) > 0 {
|
||||
if err := s.bumpLastUsed(ctx, touched); err != nil {
|
||||
// Non-fatal for the caller's env, but log it.
|
||||
s.logger.Warn("failed to bump last_used_at", "error", err, "ids", touched)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bumpLastUsed updates last_used_at for the given secret ids in a single
|
||||
// statement.
|
||||
func (s *Store) bumpLastUsed(ctx context.Context, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
query := `UPDATE secrets SET last_used_at = CURRENT_TIMESTAMP WHERE id IN (` +
|
||||
strings.Join(placeholders, ",") + `)`
|
||||
_, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time guard that *sql.DB satisfies the methods we rely on.
|
||||
var _ = (*sql.DB)(nil)
|
||||
@@ -0,0 +1,360 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// nonceSize is the NaCl secretbox nonce size in bytes.
|
||||
nonceSize = 24
|
||||
// keySize is the NaCl secretbox key size in bytes.
|
||||
keySize = 32
|
||||
// masterKeyFilename is the file inside the data dir holding the 32-byte
|
||||
// master key. Stored with 0600 permissions.
|
||||
masterKeyFilename = "secrets.key"
|
||||
)
|
||||
|
||||
// Store provides CRUD over encrypted secrets backed by SQLite.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
logger *slog.Logger
|
||||
masterKey [keySize]byte
|
||||
}
|
||||
|
||||
// NewStore constructs a Store. It bootstraps the master key from
|
||||
// <dataDir>/secrets.key, generating a fresh 32-byte key (0600 perms) if the
|
||||
// file does not yet exist.
|
||||
func NewStore(db *sql.DB, dataDir string, logger *slog.Logger) (*Store, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("secrets: db is required")
|
||||
}
|
||||
if dataDir == "" {
|
||||
return nil, fmt.Errorf("secrets: dataDir is required")
|
||||
}
|
||||
|
||||
key, err := loadOrCreateMasterKey(dataDir, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Store{db: db, logger: logger}
|
||||
copy(s.masterKey[:], key)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func loadOrCreateMasterKey(dataDir string, logger *slog.Logger) ([]byte, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("%w: mkdir %s: %v", ErrMasterKeyMissing, dataDir, err)
|
||||
}
|
||||
path := filepath.Join(dataDir, masterKeyFilename)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if len(data) != keySize {
|
||||
return nil, fmt.Errorf("%w: %s has wrong size %d (want %d)", ErrMasterKeyMissing, path, len(data), keySize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%w: read %s: %v", ErrMasterKeyMissing, path, err)
|
||||
}
|
||||
|
||||
// Generate a new key.
|
||||
buf := make([]byte, keySize)
|
||||
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
||||
return nil, fmt.Errorf("%w: generate: %v", ErrMasterKeyMissing, err)
|
||||
}
|
||||
if err := os.WriteFile(path, buf, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("%w: write %s: %v", ErrMasterKeyMissing, path, err)
|
||||
}
|
||||
logger.Info("generated new secrets master key", "path", path)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Set encrypts value and writes a new secret row. If an active secret with the
|
||||
// same (scope_type, scope_id, name) already exists, it is revoked first so a
|
||||
// new immutable history row can be inserted.
|
||||
func (s *Store) Set(ctx context.Context, name, scopeType string, scopeID, createdBy int64, value string) (*Secret, error) {
|
||||
clean, err := sanitizeName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validScope(scopeType) {
|
||||
return nil, fmt.Errorf("secrets: invalid scope_type %q", scopeType)
|
||||
}
|
||||
|
||||
blob, err := s.encrypt([]byte(value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: encrypt: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Revoke any existing active row for the same (scope, name).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE secrets
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
AND scope_type = ?
|
||||
AND scope_id = ?
|
||||
AND revoked_at IS NULL`,
|
||||
clean, scopeType, scopeID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("secrets: revoke previous: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO secrets (name, scope_type, scope_id, value_blob, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clean, scopeType, scopeID, blob, createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: insert: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: last insert id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("secrets: commit: %w", err)
|
||||
}
|
||||
|
||||
// Re-read to populate created_at consistently.
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, scope_type, scope_id, created_by, created_at, revoked_at, last_used_at
|
||||
FROM secrets WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
sec, err := scanSecret(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: read back: %w", err)
|
||||
}
|
||||
s.logger.Info("secret set",
|
||||
"id", sec.ID,
|
||||
"name", sec.Name,
|
||||
"scope_type", sec.ScopeType,
|
||||
"scope_id", sec.ScopeID,
|
||||
)
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// Get decrypts and returns the plaintext for the active secret matching
|
||||
// (name, scope_type, scope_id). Caller must treat the returned string as
|
||||
// sensitive — never log it.
|
||||
func (s *Store) Get(ctx context.Context, name, scopeType string, scopeID int64) (string, error) {
|
||||
clean, err := sanitizeName(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !validScope(scopeType) {
|
||||
return "", fmt.Errorf("secrets: invalid scope_type %q", scopeType)
|
||||
}
|
||||
|
||||
var blob []byte
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT value_blob
|
||||
FROM secrets
|
||||
WHERE name = ?
|
||||
AND scope_type = ?
|
||||
AND scope_id = ?
|
||||
AND revoked_at IS NULL`,
|
||||
clean, scopeType, scopeID,
|
||||
).Scan(&blob)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("secrets: query: %w", err)
|
||||
}
|
||||
|
||||
plain, err := s.decrypt(blob)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("secrets: decrypt: %w", err)
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// List returns Info entries for all active secrets in the given scopes.
|
||||
// Values are never returned. Order is stable: by (scope_type, scope_id, name).
|
||||
func (s *Store) List(ctx context.Context, scopes []Scope) ([]Info, error) {
|
||||
if len(scopes) == 0 {
|
||||
return []Info{}, nil
|
||||
}
|
||||
|
||||
// Build dynamic IN clause: (scope_type=? AND scope_id=?) OR (...)
|
||||
var (
|
||||
parts []string
|
||||
args []any
|
||||
)
|
||||
for _, sc := range scopes {
|
||||
if !validScope(sc.Type) {
|
||||
return nil, fmt.Errorf("secrets: invalid scope_type %q", sc.Type)
|
||||
}
|
||||
parts = append(parts, "(scope_type = ? AND scope_id = ?)")
|
||||
args = append(args, sc.Type, sc.ID)
|
||||
}
|
||||
query := `SELECT name, scope_type, scope_id, last_used_at
|
||||
FROM secrets
|
||||
WHERE revoked_at IS NULL
|
||||
AND (` + strings.Join(parts, " OR ") + `)
|
||||
ORDER BY scope_type, scope_id, name`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secrets: list query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Info
|
||||
for rows.Next() {
|
||||
var (
|
||||
info Info
|
||||
lastUsed sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&info.Name, &info.ScopeType, &info.ScopeID, &lastUsed); err != nil {
|
||||
return nil, fmt.Errorf("secrets: scan: %w", err)
|
||||
}
|
||||
info.Available = true
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
info.LastUsedAt = &t
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Info{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Revoke marks a secret revoked by primary key. It is idempotent in the sense
|
||||
// that a non-existent row returns ErrNotFound and an already-revoked row
|
||||
// returns ErrAlreadyRevoked.
|
||||
func (s *Store) Revoke(ctx context.Context, id int64) error {
|
||||
var revokedAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT revoked_at FROM secrets WHERE id = ?`, id,
|
||||
).Scan(&revokedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("secrets: lookup: %w", err)
|
||||
}
|
||||
if revokedAt.Valid {
|
||||
return ErrAlreadyRevoked
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE secrets SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?`, id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("secrets: revoke: %w", err)
|
||||
}
|
||||
s.logger.Info("secret revoked", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// encrypt returns nonce(24) || ciphertext.
|
||||
func (s *Store) encrypt(plain []byte) ([]byte, error) {
|
||||
var nonce [nonceSize]byte
|
||||
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, nonceSize+len(plain)+secretbox.Overhead)
|
||||
out = append(out, nonce[:]...)
|
||||
out = secretbox.Seal(out, plain, &nonce, &s.masterKey)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decrypt parses nonce(24) || ciphertext and returns the plaintext.
|
||||
func (s *Store) decrypt(blob []byte) ([]byte, error) {
|
||||
if len(blob) < nonceSize+secretbox.Overhead {
|
||||
return nil, fmt.Errorf("secrets: ciphertext too short (%d bytes)", len(blob))
|
||||
}
|
||||
var nonce [nonceSize]byte
|
||||
copy(nonce[:], blob[:nonceSize])
|
||||
plain, ok := secretbox.Open(nil, blob[nonceSize:], &nonce, &s.masterKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("secrets: decryption failed (key mismatch or corruption)")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// sanitizeName uppercases raw and validates that it contains only [A-Z0-9_].
|
||||
// Empty input is rejected. Lowercase letters are folded to uppercase before
|
||||
// validation so callers may pass either case.
|
||||
func sanitizeName(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
upper := strings.ToUpper(raw)
|
||||
for i := 0; i < len(upper); i++ {
|
||||
c := upper[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '_':
|
||||
default:
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
}
|
||||
return upper, nil
|
||||
}
|
||||
|
||||
func validScope(t string) bool {
|
||||
switch t {
|
||||
case ScopeUser, ScopeAgent, ScopeTask:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// scanSecret scans a single secret row from a *sql.Row.
|
||||
func scanSecret(row *sql.Row) (*Secret, error) {
|
||||
var (
|
||||
s Secret
|
||||
revoked sql.NullTime
|
||||
lastUsed sql.NullTime
|
||||
createdAt time.Time
|
||||
)
|
||||
if err := row.Scan(&s.ID, &s.Name, &s.ScopeType, &s.ScopeID, &s.CreatedBy, &createdAt, &revoked, &lastUsed); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt = createdAt
|
||||
if revoked.Valid {
|
||||
t := revoked.Time
|
||||
s.RevokedAt = &t
|
||||
}
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
s.LastUsedAt = &t
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package secrets_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/secrets"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
// newTestStore opens an in-memory SQLite DB, runs all migrations, seeds a
|
||||
// user, and returns a ready-to-use *secrets.Store.
|
||||
func newTestStore(t *testing.T) (*secrets.Store, *sql.DB, int64) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Single connection so the in-memory DB persists across queries.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Seed a user so created_by FK is satisfied.
|
||||
res, err := db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, owner_id, role)
|
||||
VALUES ('tester', 'x', 1, 'admin')`,
|
||||
)
|
||||
if err != nil {
|
||||
// Schema may differ slightly across migrations; try the minimal column set.
|
||||
res, err = db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('tester', 'x')`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
uid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
|
||||
store, err := secrets.NewStore(db, t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return store, db, uid
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
store, _, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"simple", "API_KEY", "sk-abc123"},
|
||||
{"empty", "EMPTY", ""},
|
||||
{"unicode", "GREETING", "héllo, wörld"},
|
||||
{"long", "LONG", strings.Repeat("x", 8192)},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sec, err := store.Set(ctx, tc.key, secrets.ScopeUser, uid, uid, tc.value)
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if sec.Name != strings.ToUpper(tc.key) {
|
||||
t.Errorf("name: got %q want %q", sec.Name, strings.ToUpper(tc.key))
|
||||
}
|
||||
got, err := store.Get(ctx, tc.key, secrets.ScopeUser, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != tc.value {
|
||||
t.Errorf("Get value: got %q want %q", got, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeNameViaSet(t *testing.T) {
|
||||
store, _, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want string // expected stored name; "" means expect ErrInvalidName
|
||||
wantErr bool
|
||||
}{
|
||||
{"api_key", "API_KEY", false},
|
||||
{"OPENAI_KEY", "OPENAI_KEY", false},
|
||||
{"Token1", "TOKEN1", false},
|
||||
{"FOO_2", "FOO_2", false},
|
||||
{"", "", true},
|
||||
{"BAD-NAME", "", true},
|
||||
{"with space", "", true},
|
||||
{"dot.name", "", true},
|
||||
{"unicode_é", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
sec, err := store.Set(ctx, tc.input, secrets.ScopeUser, uid, uid, "v")
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for input %q, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if sec.Name != tc.want {
|
||||
t.Errorf("name: got %q want %q", sec.Name, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHidesValues(t *testing.T) {
|
||||
store, _, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Set(ctx, "SECRET1", secrets.ScopeUser, uid, uid, "value-one"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
infos, err := store.List(ctx, []secrets.Scope{{Type: secrets.ScopeUser, ID: uid}})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(infos) != 1 {
|
||||
t.Fatalf("len: got %d want 1", len(infos))
|
||||
}
|
||||
|
||||
// Reflectively assert that Info has no field named like a value.
|
||||
tInfo := reflect.TypeOf(infos[0])
|
||||
for i := 0; i < tInfo.NumField(); i++ {
|
||||
f := tInfo.Field(i)
|
||||
lower := strings.ToLower(f.Name)
|
||||
if lower == "value" || lower == "plaintext" || lower == "secret" {
|
||||
t.Errorf("Info exposes sensitive field %q", f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if infos[0].Name != "SECRET1" || !infos[0].Available {
|
||||
t.Errorf("unexpected info: %+v", infos[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeHidesFromList(t *testing.T) {
|
||||
store, _, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sec, err := store.Set(ctx, "TO_REVOKE", secrets.ScopeUser, uid, uid, "v")
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Revoke(ctx, sec.ID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
infos, err := store.List(ctx, []secrets.Scope{{Type: secrets.ScopeUser, ID: uid}})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
for _, i := range infos {
|
||||
if i.Name == "TO_REVOKE" {
|
||||
t.Fatalf("revoked secret should not appear in List")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := store.Get(ctx, "TO_REVOKE", secrets.ScopeUser, uid); err == nil {
|
||||
t.Fatalf("Get should fail for revoked secret")
|
||||
}
|
||||
|
||||
env, err := store.BuildEnvMap(ctx, uid, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEnvMap: %v", err)
|
||||
}
|
||||
if _, ok := env["TO_REVOKE"]; ok {
|
||||
t.Fatalf("revoked secret leaked into env map")
|
||||
}
|
||||
|
||||
// Revoking again should return ErrAlreadyRevoked.
|
||||
if err := store.Revoke(ctx, sec.ID); err == nil {
|
||||
t.Fatalf("expected ErrAlreadyRevoked, got nil")
|
||||
}
|
||||
// Revoking unknown id should return ErrNotFound.
|
||||
if err := store.Revoke(ctx, 99999); err == nil {
|
||||
t.Fatalf("expected ErrNotFound, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopePrecedence(t *testing.T) {
|
||||
store, _, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
agentID = int64(42)
|
||||
taskID = int64(7)
|
||||
name = "OPENAI_API_KEY"
|
||||
)
|
||||
|
||||
if _, err := store.Set(ctx, name, secrets.ScopeUser, uid, uid, "user-val"); err != nil {
|
||||
t.Fatalf("user Set: %v", err)
|
||||
}
|
||||
if _, err := store.Set(ctx, name, secrets.ScopeAgent, agentID, uid, "agent-val"); err != nil {
|
||||
t.Fatalf("agent Set: %v", err)
|
||||
}
|
||||
if _, err := store.Set(ctx, name, secrets.ScopeTask, taskID, uid, "task-val"); err != nil {
|
||||
t.Fatalf("task Set: %v", err)
|
||||
}
|
||||
|
||||
env, err := store.BuildEnvMap(ctx, uid, agentID, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEnvMap: %v", err)
|
||||
}
|
||||
if env[name] != "task-val" {
|
||||
t.Errorf("task wins: got %q want %q", env[name], "task-val")
|
||||
}
|
||||
|
||||
// Without task: agent wins.
|
||||
env, err = store.BuildEnvMap(ctx, uid, agentID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEnvMap: %v", err)
|
||||
}
|
||||
if env[name] != "agent-val" {
|
||||
t.Errorf("agent wins: got %q want %q", env[name], "agent-val")
|
||||
}
|
||||
|
||||
// Only user.
|
||||
env, err = store.BuildEnvMap(ctx, uid, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEnvMap: %v", err)
|
||||
}
|
||||
if env[name] != "user-val" {
|
||||
t.Errorf("user wins: got %q want %q", env[name], "user-val")
|
||||
}
|
||||
|
||||
// last_used_at should be set after BuildEnvMap.
|
||||
infos, err := store.List(ctx, []secrets.Scope{{Type: secrets.ScopeUser, ID: uid}})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, i := range infos {
|
||||
if i.Name == name {
|
||||
found = true
|
||||
if i.LastUsedAt == nil {
|
||||
t.Errorf("expected LastUsedAt to be set after BuildEnvMap")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("user-scoped secret missing from List")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetReplacesPrevious(t *testing.T) {
|
||||
store, db, uid := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Set(ctx, "ROTATE", secrets.ScopeUser, uid, uid, "v1"); err != nil {
|
||||
t.Fatalf("Set v1: %v", err)
|
||||
}
|
||||
if _, err := store.Set(ctx, "ROTATE", secrets.ScopeUser, uid, uid, "v2"); err != nil {
|
||||
t.Fatalf("Set v2: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, "ROTATE", secrets.ScopeUser, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != "v2" {
|
||||
t.Errorf("got %q want %q", got, "v2")
|
||||
}
|
||||
|
||||
// Two history rows should exist; one revoked, one active.
|
||||
var total, active int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM secrets WHERE name='ROTATE'`).Scan(&total); err != nil {
|
||||
t.Fatalf("count total: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM secrets WHERE name='ROTATE' AND revoked_at IS NULL`).Scan(&active); err != nil {
|
||||
t.Fatalf("count active: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total rows: got %d want 2", total)
|
||||
}
|
||||
if active != 1 {
|
||||
t.Errorf("active rows: got %d want 1", active)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package secrets provides encrypted, scoped secret storage for SynapBus.
|
||||
//
|
||||
// Secrets are stored in SQLite, encrypted at rest with NaCl secretbox under a
|
||||
// local 32-byte master key (kept in <data-dir>/secrets.key with 0600 perms).
|
||||
// Secrets are scoped to a user, agent, or task and are intended to be injected
|
||||
// into subprocess environments as sanitized A-Z0-9_ variable names. The MCP
|
||||
// surface never returns plaintext values — only names and availability.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scope type constants. The same values are used in the secrets.scope_type
|
||||
// column (CHECK constraint enforced at the SQL level).
|
||||
const (
|
||||
ScopeUser = "user"
|
||||
ScopeAgent = "agent"
|
||||
ScopeTask = "task"
|
||||
)
|
||||
|
||||
// Sentinel errors for the secrets package.
|
||||
var (
|
||||
// ErrNotFound is returned when no active secret matches the lookup.
|
||||
ErrNotFound = errors.New("secret not found")
|
||||
// ErrAlreadyRevoked is returned when revoking a secret that is already revoked.
|
||||
ErrAlreadyRevoked = errors.New("secret already revoked")
|
||||
// ErrInvalidName is returned when a secret name fails sanitization.
|
||||
ErrInvalidName = errors.New("invalid secret name: must be non-empty and contain only A-Z, 0-9, _")
|
||||
// ErrMasterKeyMissing is returned when the master key file cannot be read or generated.
|
||||
ErrMasterKeyMissing = errors.New("secrets master key missing or unreadable")
|
||||
)
|
||||
|
||||
// Secret is a stored, encrypted secret row. The plaintext value is never
|
||||
// included — callers fetch it explicitly via Store.Get.
|
||||
type Secret struct {
|
||||
ID int64
|
||||
Name string
|
||||
ScopeType string
|
||||
ScopeID int64
|
||||
CreatedBy int64
|
||||
CreatedAt time.Time
|
||||
RevokedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
// Info is the public, value-free projection of a Secret used for listings
|
||||
// exposed via MCP / API. It deliberately has no value field.
|
||||
type Info struct {
|
||||
Name string
|
||||
ScopeType string
|
||||
ScopeID int64
|
||||
Available bool
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
// Scope identifies a (type, id) pair used when listing or building env maps.
|
||||
type Scope struct {
|
||||
Type string
|
||||
ID int64
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
-- 021: Goals and tasks — first-class data model for dynamic agent spawning.
|
||||
--
|
||||
-- Goals are human-owned top-level objectives. Each goal has a backing
|
||||
-- #goal-<slug> channel and a pre-built coordinator agent.
|
||||
--
|
||||
-- Tasks are nodes in a goal's work tree. They are single-assignee,
|
||||
-- atomically claimable (optimistic-lock UPDATE), carry a denormalized
|
||||
-- goal-ancestry JSON snapshot, and accumulate leaf-only cost counters
|
||||
-- that roll up via a recursive CTE at read time.
|
||||
|
||||
CREATE TABLE goals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
coordinator_agent_id INTEGER REFERENCES agents(id) ON DELETE SET NULL,
|
||||
root_task_id INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','paused','completed','cancelled','stuck')),
|
||||
budget_tokens INTEGER,
|
||||
budget_dollars_cents INTEGER,
|
||||
max_spawn_depth INTEGER NOT NULL DEFAULT 3,
|
||||
alert_80pct_posted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX idx_goals_owner ON goals(owner_user_id);
|
||||
CREATE INDEX idx_goals_status ON goals(status);
|
||||
CREATE INDEX idx_goals_channel ON goals(channel_id);
|
||||
|
||||
CREATE TABLE goal_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
goal_id INTEGER NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
||||
parent_task_id INTEGER REFERENCES goal_tasks(id) ON DELETE CASCADE,
|
||||
ancestry_json TEXT NOT NULL DEFAULT '[]',
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
acceptance_criteria TEXT NOT NULL DEFAULT '',
|
||||
created_by_agent_id INTEGER REFERENCES agents(id) ON DELETE SET NULL,
|
||||
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
assignee_agent_id INTEGER REFERENCES agents(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'proposed'
|
||||
CHECK (status IN ('proposed','approved','claimed','in_progress',
|
||||
'awaiting_verification','done','failed','cancelled')),
|
||||
billing_code TEXT,
|
||||
budget_tokens INTEGER,
|
||||
budget_dollars_cents INTEGER,
|
||||
spent_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
spent_dollars_cents INTEGER NOT NULL DEFAULT 0,
|
||||
heartbeat_config_json TEXT,
|
||||
verifier_config_json TEXT,
|
||||
origin_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
claim_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
completion_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
failure_reason TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_at DATETIME,
|
||||
claimed_at DATETIME,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
CHECK (created_by_agent_id IS NOT NULL OR created_by_user_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_goal_tasks_goal ON goal_tasks(goal_id);
|
||||
CREATE INDEX idx_goal_tasks_parent ON goal_tasks(parent_task_id);
|
||||
CREATE INDEX idx_goal_tasks_assignee ON goal_tasks(assignee_agent_id);
|
||||
CREATE INDEX idx_goal_tasks_status ON goal_tasks(status);
|
||||
CREATE INDEX idx_goal_tasks_billing ON goal_tasks(billing_code);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 022: Agent proposals + resource requests.
|
||||
--
|
||||
-- Agent proposals: a pending request by an existing agent to spawn a new
|
||||
-- specialist sub-agent. Delegation-cap and spawn-depth checks run at
|
||||
-- propose time; approval via the reactions workflow on #approvals.
|
||||
--
|
||||
-- Resource requests: an agent asking the human owner for a missing
|
||||
-- secret (API key, credential) via #requests.
|
||||
|
||||
CREATE TABLE agent_proposals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proposer_agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
goal_id INTEGER NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
||||
parent_task_id INTEGER REFERENCES goal_tasks(id) ON DELETE SET NULL,
|
||||
proposed_name TEXT NOT NULL,
|
||||
proposed_model TEXT NOT NULL,
|
||||
proposed_system_prompt TEXT NOT NULL,
|
||||
proposed_tool_scope_json TEXT NOT NULL DEFAULT '[]',
|
||||
proposed_skills_json TEXT NOT NULL DEFAULT '[]',
|
||||
proposed_mcp_servers_json TEXT NOT NULL DEFAULT '[]',
|
||||
proposed_subagents_json TEXT NOT NULL DEFAULT '[]',
|
||||
proposed_autonomy_tier TEXT NOT NULL
|
||||
CHECK (proposed_autonomy_tier IN ('supervised','assisted','autonomous')),
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','approved','rejected','materialized','cancelled')),
|
||||
approval_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
materialized_agent_id INTEGER REFERENCES agents(id) ON DELETE SET NULL,
|
||||
rejection_reason TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
decided_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proposals_goal ON agent_proposals(goal_id);
|
||||
CREATE INDEX idx_proposals_parent ON agent_proposals(parent_task_id);
|
||||
CREATE INDEX idx_proposals_status ON agent_proposals(status);
|
||||
|
||||
CREATE TABLE resource_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES goal_tasks(id) ON DELETE CASCADE,
|
||||
resource_name TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','fulfilled','rejected','revoked')),
|
||||
request_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fulfilled_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX idx_requests_task ON resource_requests(task_id);
|
||||
CREATE INDEX idx_requests_requester ON resource_requests(requester_agent_id);
|
||||
CREATE INDEX idx_requests_status ON resource_requests(status);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 023: Dynamic-agent trust model columns + reputation ledger.
|
||||
--
|
||||
-- Coexists with the existing `agent_trust` table from migration 014
|
||||
-- (which remains keyed by agent_name + action_type and is still used
|
||||
-- by the reactions + hybrid MCP paths). The new ledger below is a
|
||||
-- parallel append-only evidence log keyed by (config_hash, task_domain)
|
||||
-- for the dynamic-spawning trust model.
|
||||
|
||||
ALTER TABLE agents ADD COLUMN config_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE agents ADD COLUMN parent_agent_id INTEGER REFERENCES agents(id) ON DELETE SET NULL;
|
||||
ALTER TABLE agents ADD COLUMN spawn_depth INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE agents ADD COLUMN system_prompt TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE agents ADD COLUMN autonomy_tier TEXT NOT NULL DEFAULT 'supervised';
|
||||
ALTER TABLE agents ADD COLUMN tool_scope_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE agents ADD COLUMN quarantined_at DATETIME;
|
||||
ALTER TABLE agents ADD COLUMN quarantine_reason TEXT;
|
||||
|
||||
CREATE INDEX idx_agents_config_hash ON agents(config_hash);
|
||||
CREATE INDEX idx_agents_parent ON agents(parent_agent_id);
|
||||
|
||||
-- Append-only reputation ledger for the dynamic-spawning trust model.
|
||||
-- The current rolling score is derived at read time via exponential
|
||||
-- time decay; this table is the source of truth.
|
||||
CREATE TABLE reputation_evidence (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_hash TEXT NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
task_domain TEXT NOT NULL DEFAULT 'default',
|
||||
score_delta REAL NOT NULL,
|
||||
evidence_ref TEXT NOT NULL,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rep_hash_domain ON reputation_evidence(config_hash, task_domain);
|
||||
CREATE INDEX idx_rep_owner ON reputation_evidence(owner_user_id);
|
||||
CREATE INDEX idx_rep_created ON reputation_evidence(created_at);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 024: Secrets — encrypted per-scope values, injected into subprocess env
|
||||
-- as sanitized A-Z0-9_ variable names. Values are NaCl-secretbox
|
||||
-- encrypted under a local master key file (<data-dir>/secrets.key).
|
||||
-- MCP tools never return the plaintext value — only names and
|
||||
-- availability.
|
||||
|
||||
CREATE TABLE secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('user','agent','task')),
|
||||
scope_id INTEGER NOT NULL,
|
||||
value_blob BLOB NOT NULL, -- nonce(24) || ciphertext
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at DATETIME,
|
||||
last_used_at DATETIME
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_secrets_scope_name ON secrets(scope_type, scope_id, name) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX idx_secrets_scope ON secrets(scope_type, scope_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 025: Link harness runs to tasks.
|
||||
--
|
||||
-- When a harness run fires in the context of a task (i.e. an agent
|
||||
-- working on a claimed task), the reactor sets ExecRequest.TaskID and
|
||||
-- the harness Observer writes it here so the Web UI and the HTML
|
||||
-- report can correlate runs → tasks.
|
||||
|
||||
ALTER TABLE harness_runs ADD COLUMN task_id INTEGER;
|
||||
|
||||
CREATE INDEX idx_harness_runs_task ON harness_runs(task_id);
|
||||
@@ -0,0 +1,75 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// AgentConfig captures the immutable inputs that define an agent's identity
|
||||
// for the dynamic-spawning trust model. Two agents with the same canonical
|
||||
// AgentConfig share a config_hash and therefore share reputation.
|
||||
type AgentConfig struct {
|
||||
Model string `json:"model"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
ToolScope []string `json:"tool_scope"`
|
||||
Skills []string `json:"skills"`
|
||||
Subagents []string `json:"subagents"`
|
||||
MCPServers []MCPServerRef `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
// MCPServerRef describes a single MCP server attached to an agent.
|
||||
type MCPServerRef struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Transport string `json:"transport"`
|
||||
}
|
||||
|
||||
// ConfigHash returns the hex-encoded SHA-256 of the canonical-JSON
|
||||
// representation of cfg.
|
||||
//
|
||||
// The function is deterministic: the input slices may be in any order,
|
||||
// the result depends only on the multiset of values. Object keys are sorted
|
||||
// (encoding/json already does this) and slice contents are sorted alphabetically
|
||||
// before hashing so that two equivalent configs always produce the same hash.
|
||||
func ConfigHash(cfg AgentConfig) string {
|
||||
tools := append([]string(nil), cfg.ToolScope...)
|
||||
sort.Strings(tools)
|
||||
skills := append([]string(nil), cfg.Skills...)
|
||||
sort.Strings(skills)
|
||||
subagents := append([]string(nil), cfg.Subagents...)
|
||||
sort.Strings(subagents)
|
||||
|
||||
servers := make([]map[string]string, 0, len(cfg.MCPServers))
|
||||
for _, s := range cfg.MCPServers {
|
||||
servers = append(servers, map[string]string{
|
||||
"name": s.Name,
|
||||
"url": s.URL,
|
||||
"transport": s.Transport,
|
||||
})
|
||||
}
|
||||
sort.Slice(servers, func(i, j int) bool {
|
||||
return servers[i]["name"] < servers[j]["name"]
|
||||
})
|
||||
|
||||
canonical := map[string]any{
|
||||
"model": cfg.Model,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"tool_scope": tools,
|
||||
"skills": skills,
|
||||
"subagents": subagents,
|
||||
"mcp_servers": servers,
|
||||
}
|
||||
|
||||
// encoding/json sorts map keys alphabetically, giving canonical output.
|
||||
data, err := json.Marshal(canonical)
|
||||
if err != nil {
|
||||
// Marshalling a map of strings/slices cannot fail in practice;
|
||||
// fall back to a stable sentinel hash so callers never see a panic.
|
||||
sum := sha256.Sum256([]byte("synapbus:trust:config_hash:marshal_error"))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func baseConfig() AgentConfig {
|
||||
return AgentConfig{
|
||||
Model: "claude-opus-4",
|
||||
SystemPrompt: "You are a careful research assistant.",
|
||||
ToolScope: []string{"search", "fetch", "summarize"},
|
||||
Skills: []string{"writing", "research"},
|
||||
Subagents: []string{"reviewer", "critic"},
|
||||
MCPServers: []MCPServerRef{
|
||||
{Name: "synapbus", URL: "http://localhost:8080/mcp", Transport: "http"},
|
||||
{Name: "fs", URL: "stdio://fs", Transport: "stdio"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigHash_Deterministic(t *testing.T) {
|
||||
canonical := baseConfig()
|
||||
want := ConfigHash(canonical)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*AgentConfig)
|
||||
}{
|
||||
{
|
||||
name: "shuffled tool_scope",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.ToolScope = []string{"summarize", "fetch", "search"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shuffled skills",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.Skills = []string{"research", "writing"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shuffled subagents",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.Subagents = []string{"critic", "reviewer"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shuffled mcp_servers",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.MCPServers = []MCPServerRef{
|
||||
{Name: "fs", URL: "stdio://fs", Transport: "stdio"},
|
||||
{Name: "synapbus", URL: "http://localhost:8080/mcp", Transport: "http"},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shuffled all collections",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.ToolScope = []string{"fetch", "summarize", "search"}
|
||||
c.Skills = []string{"research", "writing"}
|
||||
c.Subagents = []string{"critic", "reviewer"}
|
||||
c.MCPServers = []MCPServerRef{
|
||||
{Name: "fs", URL: "stdio://fs", Transport: "stdio"},
|
||||
{Name: "synapbus", URL: "http://localhost:8080/mcp", Transport: "http"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
tt.mutate(&cfg)
|
||||
got := ConfigHash(cfg)
|
||||
if got != want {
|
||||
t.Errorf("ConfigHash mismatch\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigHash_SensitiveToChanges(t *testing.T) {
|
||||
base := baseConfig()
|
||||
baseHash := ConfigHash(base)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*AgentConfig)
|
||||
}{
|
||||
{
|
||||
name: "model changed",
|
||||
mutate: func(c *AgentConfig) { c.Model = "claude-sonnet-4" },
|
||||
},
|
||||
{
|
||||
name: "system prompt changed",
|
||||
mutate: func(c *AgentConfig) { c.SystemPrompt = "You are a sloppy assistant." },
|
||||
},
|
||||
{
|
||||
name: "tool added",
|
||||
mutate: func(c *AgentConfig) { c.ToolScope = append(c.ToolScope, "execute") },
|
||||
},
|
||||
{
|
||||
name: "tool removed",
|
||||
mutate: func(c *AgentConfig) { c.ToolScope = []string{"search", "fetch"} },
|
||||
},
|
||||
{
|
||||
name: "tool renamed",
|
||||
mutate: func(c *AgentConfig) { c.ToolScope = []string{"search", "fetch", "summarise"} },
|
||||
},
|
||||
{
|
||||
name: "mcp server url changed",
|
||||
mutate: func(c *AgentConfig) {
|
||||
c.MCPServers[0].URL = "http://kubic.home.arpa:30088/mcp"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
tt.mutate(&cfg)
|
||||
got := ConfigHash(cfg)
|
||||
if got == baseHash {
|
||||
t.Errorf("expected different hash, both = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigHash_EmptyFieldsStable(t *testing.T) {
|
||||
empty := AgentConfig{}
|
||||
h1 := ConfigHash(empty)
|
||||
h2 := ConfigHash(AgentConfig{
|
||||
ToolScope: []string{},
|
||||
Skills: []string{},
|
||||
Subagents: []string{},
|
||||
MCPServers: []MCPServerRef{},
|
||||
})
|
||||
if h1 != h2 {
|
||||
t.Errorf("nil and empty slice configs should hash equal\n h1: %s\n h2: %s", h1, h2)
|
||||
}
|
||||
if len(h1) != 64 {
|
||||
t.Errorf("expected 64-char hex sha256, got len=%d", len(h1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Autonomy tier constants, ordered low → high.
|
||||
//
|
||||
// Higher tiers grant the agent more freedom: supervised requires human
|
||||
// approval for outbound actions, assisted may act with logging, autonomous
|
||||
// may act without per-action review (but always within tool/budget caps).
|
||||
const (
|
||||
TierSupervised = "supervised"
|
||||
TierAssisted = "assisted"
|
||||
TierAutonomous = "autonomous"
|
||||
)
|
||||
|
||||
var tierRank = map[string]int{
|
||||
TierSupervised: 1,
|
||||
TierAssisted: 2,
|
||||
TierAutonomous: 3,
|
||||
}
|
||||
|
||||
// Grant is a single delegation envelope: the maximum authority a parent
|
||||
// confers to a child agent. The dynamic-spawning trust model enforces the
|
||||
// "child ≤ parent" rule across every dimension of a Grant.
|
||||
type Grant struct {
|
||||
AutonomyTier string
|
||||
ToolScope []string
|
||||
BudgetTokens int64
|
||||
BudgetDollarsCents int64
|
||||
SpawnDepth int
|
||||
}
|
||||
|
||||
// DelegationCap computes the effective grant a child should receive given
|
||||
// the parent's grant and the child's proposal.
|
||||
//
|
||||
// Rules:
|
||||
// - Autonomy tier: effective = min(parent, proposed). A proposed tier above
|
||||
// the parent's tier is a violation.
|
||||
// - Tool scope: effective = intersection of parent and proposed. Any tool in
|
||||
// proposed that is not in parent is a violation. Order does not matter.
|
||||
// - Budget tokens / dollars: effective = min, with 0 on parent meaning
|
||||
// unlimited and 0 on child meaning "any up to parent". Proposed > parent
|
||||
// (when parent is non-zero) is a violation.
|
||||
// - Spawn depth: child must have parent.SpawnDepth + 1 <= maxDepth, otherwise
|
||||
// a violation is emitted; effective.SpawnDepth is set to parent + 1 anyway.
|
||||
//
|
||||
// violations is a slice of human-readable strings, one per violated rule,
|
||||
// in stable order. effective is always populated (best-effort cap).
|
||||
func DelegationCap(parent, proposed Grant, maxDepth int) (effective Grant, violations []string) {
|
||||
// --- Autonomy tier --------------------------------------------------
|
||||
parentRank, parentOK := tierRank[parent.AutonomyTier]
|
||||
proposedRank, proposedOK := tierRank[proposed.AutonomyTier]
|
||||
if !parentOK {
|
||||
parentRank = tierRank[TierSupervised]
|
||||
}
|
||||
if !proposedOK {
|
||||
proposedRank = tierRank[TierSupervised]
|
||||
violations = append(violations,
|
||||
fmt.Sprintf("unknown autonomy tier %q (treating as supervised)", proposed.AutonomyTier))
|
||||
}
|
||||
if proposedRank > parentRank {
|
||||
violations = append(violations,
|
||||
fmt.Sprintf("autonomy tier %q exceeds parent tier %q",
|
||||
proposed.AutonomyTier, parent.AutonomyTier))
|
||||
effective.AutonomyTier = parent.AutonomyTier
|
||||
} else {
|
||||
effective.AutonomyTier = proposed.AutonomyTier
|
||||
}
|
||||
|
||||
// --- Tool scope -----------------------------------------------------
|
||||
parentTools := make(map[string]struct{}, len(parent.ToolScope))
|
||||
for _, t := range parent.ToolScope {
|
||||
parentTools[t] = struct{}{}
|
||||
}
|
||||
|
||||
var allowed []string
|
||||
var disallowed []string
|
||||
for _, t := range proposed.ToolScope {
|
||||
if _, ok := parentTools[t]; ok {
|
||||
allowed = append(allowed, t)
|
||||
} else {
|
||||
disallowed = append(disallowed, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(disallowed)
|
||||
for _, t := range disallowed {
|
||||
violations = append(violations,
|
||||
fmt.Sprintf("tool %q not in parent scope", t))
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
effective.ToolScope = allowed
|
||||
|
||||
// --- Budget tokens --------------------------------------------------
|
||||
effective.BudgetTokens = capBudget("budget_tokens",
|
||||
parent.BudgetTokens, proposed.BudgetTokens, &violations)
|
||||
|
||||
// --- Budget dollars (cents) -----------------------------------------
|
||||
effective.BudgetDollarsCents = capBudget("budget_dollars_cents",
|
||||
parent.BudgetDollarsCents, proposed.BudgetDollarsCents, &violations)
|
||||
|
||||
// --- Spawn depth ----------------------------------------------------
|
||||
effective.SpawnDepth = parent.SpawnDepth + 1
|
||||
if effective.SpawnDepth > maxDepth {
|
||||
violations = append(violations,
|
||||
fmt.Sprintf("spawn depth %d exceeds max %d", effective.SpawnDepth, maxDepth))
|
||||
}
|
||||
|
||||
return effective, violations
|
||||
}
|
||||
|
||||
// capBudget enforces the parent ≥ child rule for a budget dimension, where
|
||||
// 0 on the parent means "unlimited" and 0 on the child means "inherit any
|
||||
// value up to the parent's cap".
|
||||
func capBudget(label string, parent, proposed int64, violations *[]string) int64 {
|
||||
switch {
|
||||
case parent == 0:
|
||||
// Parent unlimited: any non-negative proposal is fine.
|
||||
if proposed < 0 {
|
||||
*violations = append(*violations,
|
||||
fmt.Sprintf("%s %d is negative", label, proposed))
|
||||
return 0
|
||||
}
|
||||
return proposed
|
||||
case proposed == 0:
|
||||
// Child wants "as much as parent allows".
|
||||
return parent
|
||||
case proposed > parent:
|
||||
*violations = append(*violations,
|
||||
fmt.Sprintf("%s %d exceeds parent cap %d", label, proposed, parent))
|
||||
return parent
|
||||
case proposed < 0:
|
||||
*violations = append(*violations,
|
||||
fmt.Sprintf("%s %d is negative", label, proposed))
|
||||
return 0
|
||||
default:
|
||||
return proposed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDelegationCap_TierMatrix(t *testing.T) {
|
||||
tiers := []string{TierSupervised, TierAssisted, TierAutonomous}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
parent string
|
||||
proposed string
|
||||
wantViolation bool
|
||||
wantEffective string
|
||||
}{
|
||||
{"sup→sup ok", TierSupervised, TierSupervised, false, TierSupervised},
|
||||
{"sup→assisted violation", TierSupervised, TierAssisted, true, TierSupervised},
|
||||
{"sup→autonomous violation", TierSupervised, TierAutonomous, true, TierSupervised},
|
||||
{"assisted→sup ok", TierAssisted, TierSupervised, false, TierSupervised},
|
||||
{"assisted→assisted ok", TierAssisted, TierAssisted, false, TierAssisted},
|
||||
{"assisted→autonomous violation", TierAssisted, TierAutonomous, true, TierAssisted},
|
||||
{"autonomous→sup ok", TierAutonomous, TierSupervised, false, TierSupervised},
|
||||
{"autonomous→assisted ok", TierAutonomous, TierAssisted, false, TierAssisted},
|
||||
{"autonomous→autonomous ok", TierAutonomous, TierAutonomous, false, TierAutonomous},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := Grant{AutonomyTier: tt.parent, ToolScope: []string{"search"}}
|
||||
proposed := Grant{AutonomyTier: tt.proposed, ToolScope: []string{"search"}}
|
||||
eff, viols := DelegationCap(parent, proposed, 5)
|
||||
|
||||
gotViolation := false
|
||||
for _, v := range viols {
|
||||
if strings.Contains(v, "autonomy tier") {
|
||||
gotViolation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotViolation != tt.wantViolation {
|
||||
t.Errorf("violation = %v, want %v (viols=%v)", gotViolation, tt.wantViolation, viols)
|
||||
}
|
||||
if eff.AutonomyTier != tt.wantEffective {
|
||||
t.Errorf("effective tier = %q, want %q", eff.AutonomyTier, tt.wantEffective)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sanity: ensure all known tiers are mapped
|
||||
for _, ti := range tiers {
|
||||
if _, ok := tierRank[ti]; !ok {
|
||||
t.Errorf("tier %q missing from tierRank", ti)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCap_ToolScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parentTools []string
|
||||
proposedTools []string
|
||||
wantEffective []string
|
||||
wantDisallowed []string
|
||||
}{
|
||||
{
|
||||
name: "exact subset",
|
||||
parentTools: []string{"search", "fetch", "summarize"},
|
||||
proposedTools: []string{"search", "fetch"},
|
||||
wantEffective: []string{"fetch", "search"},
|
||||
wantDisallowed: nil,
|
||||
},
|
||||
{
|
||||
name: "full overlap",
|
||||
parentTools: []string{"search", "fetch"},
|
||||
proposedTools: []string{"search", "fetch"},
|
||||
wantEffective: []string{"fetch", "search"},
|
||||
wantDisallowed: nil,
|
||||
},
|
||||
{
|
||||
name: "single forbidden tool",
|
||||
parentTools: []string{"search"},
|
||||
proposedTools: []string{"search", "execute"},
|
||||
wantEffective: []string{"search"},
|
||||
wantDisallowed: []string{"execute"},
|
||||
},
|
||||
{
|
||||
name: "all forbidden",
|
||||
parentTools: []string{"search"},
|
||||
proposedTools: []string{"execute", "delete"},
|
||||
wantEffective: nil,
|
||||
wantDisallowed: []string{"delete", "execute"},
|
||||
},
|
||||
{
|
||||
name: "empty proposed",
|
||||
parentTools: []string{"search"},
|
||||
proposedTools: nil,
|
||||
wantEffective: nil,
|
||||
wantDisallowed: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := Grant{AutonomyTier: TierAssisted, ToolScope: tt.parentTools}
|
||||
proposed := Grant{AutonomyTier: TierAssisted, ToolScope: tt.proposedTools}
|
||||
eff, viols := DelegationCap(parent, proposed, 10)
|
||||
|
||||
if !equalSlices(eff.ToolScope, tt.wantEffective) {
|
||||
t.Errorf("effective tools = %v, want %v", eff.ToolScope, tt.wantEffective)
|
||||
}
|
||||
|
||||
for _, want := range tt.wantDisallowed {
|
||||
found := false
|
||||
for _, v := range viols {
|
||||
if strings.Contains(v, want) && strings.Contains(v, "not in parent scope") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing violation for tool %q in %v", want, viols)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCap_Budgets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parentTokens int64
|
||||
proposedTokens int64
|
||||
wantTokens int64
|
||||
wantViolation bool
|
||||
}{
|
||||
{"parent unlimited, child 100", 0, 100, 100, false},
|
||||
{"parent 100, child 50", 100, 50, 50, false},
|
||||
{"parent 100, child 100", 100, 100, 100, false},
|
||||
{"parent 100, child 200 (violation)", 100, 200, 100, true},
|
||||
{"parent 100, child 0 (inherit)", 100, 0, 100, false},
|
||||
{"parent 0, child 0 (both unlimited)", 0, 0, 0, false},
|
||||
{"parent 100, child negative", 100, -5, 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := Grant{
|
||||
AutonomyTier: TierAssisted,
|
||||
ToolScope: []string{"x"},
|
||||
BudgetTokens: tt.parentTokens,
|
||||
}
|
||||
proposed := Grant{
|
||||
AutonomyTier: TierAssisted,
|
||||
ToolScope: []string{"x"},
|
||||
BudgetTokens: tt.proposedTokens,
|
||||
}
|
||||
eff, viols := DelegationCap(parent, proposed, 5)
|
||||
|
||||
if eff.BudgetTokens != tt.wantTokens {
|
||||
t.Errorf("budget tokens = %d, want %d", eff.BudgetTokens, tt.wantTokens)
|
||||
}
|
||||
|
||||
gotViolation := false
|
||||
for _, v := range viols {
|
||||
if strings.Contains(v, "budget_tokens") {
|
||||
gotViolation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotViolation != tt.wantViolation {
|
||||
t.Errorf("violation = %v, want %v (viols=%v)", gotViolation, tt.wantViolation, viols)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCap_SpawnDepth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parentDepth int
|
||||
maxDepth int
|
||||
wantEffective int
|
||||
wantViolation bool
|
||||
}{
|
||||
{"depth 0 → 1, max 5", 0, 5, 1, false},
|
||||
{"depth 4 → 5, max 5", 4, 5, 5, false},
|
||||
{"depth 5 → 6, max 5 (violation)", 5, 5, 6, true},
|
||||
{"depth 0 → 1, max 0 (violation)", 0, 0, 1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := Grant{
|
||||
AutonomyTier: TierAssisted,
|
||||
ToolScope: []string{"x"},
|
||||
SpawnDepth: tt.parentDepth,
|
||||
}
|
||||
proposed := Grant{
|
||||
AutonomyTier: TierAssisted,
|
||||
ToolScope: []string{"x"},
|
||||
}
|
||||
eff, viols := DelegationCap(parent, proposed, tt.maxDepth)
|
||||
|
||||
if eff.SpawnDepth != tt.wantEffective {
|
||||
t.Errorf("spawn depth = %d, want %d", eff.SpawnDepth, tt.wantEffective)
|
||||
}
|
||||
|
||||
gotViolation := false
|
||||
for _, v := range viols {
|
||||
if strings.Contains(v, "spawn depth") {
|
||||
gotViolation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotViolation != tt.wantViolation {
|
||||
t.Errorf("violation = %v, want %v (viols=%v)", gotViolation, tt.wantViolation, viols)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCap_MultipleViolations(t *testing.T) {
|
||||
parent := Grant{
|
||||
AutonomyTier: TierSupervised,
|
||||
ToolScope: []string{"search"},
|
||||
BudgetTokens: 100,
|
||||
SpawnDepth: 5,
|
||||
}
|
||||
proposed := Grant{
|
||||
AutonomyTier: TierAutonomous, // violation: tier
|
||||
ToolScope: []string{"execute"}, // violation: tool
|
||||
BudgetTokens: 1000, // violation: budget
|
||||
}
|
||||
_, viols := DelegationCap(parent, proposed, 5) // violation: depth (5+1>5)
|
||||
|
||||
if len(viols) < 4 {
|
||||
t.Errorf("expected at least 4 violations, got %d: %v", len(viols), viols)
|
||||
}
|
||||
}
|
||||
|
||||
func equalSlices(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultHalfLifeDays is used when a caller passes 0 to RollingScore
|
||||
// or SeedFromParent.
|
||||
const defaultHalfLifeDays = 30.0
|
||||
|
||||
// neutralScore is the rolling score returned when no evidence exists for a
|
||||
// (config_hash, task_domain) pair. 0.5 signals "no information yet".
|
||||
const neutralScore = 0.5
|
||||
|
||||
// seedFromParentFraction is the fraction of the parent's rolling score that
|
||||
// gets seeded onto a fresh child config_hash when SeedFromParent is called.
|
||||
const seedFromParentFraction = 0.7
|
||||
|
||||
// Evidence is a single append-only entry in the reputation ledger.
|
||||
//
|
||||
// score_delta is unbounded (positive or negative); the rolling score is
|
||||
// computed at read time by summing decayed deltas and clamping to [0, 1].
|
||||
type Evidence struct {
|
||||
ID int64
|
||||
ConfigHash string
|
||||
OwnerUserID int64
|
||||
TaskDomain string
|
||||
ScoreDelta float64
|
||||
Weight float64
|
||||
EvidenceRef string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Ledger is the new dynamic-spawning reputation store. It is fully separate
|
||||
// from the legacy *Service / Store types: this one is keyed by config_hash
|
||||
// and is append-only, while the legacy store is keyed by agent name and
|
||||
// performs in-place upserts.
|
||||
type Ledger struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLedger constructs a Ledger backed by the given *sql.DB. The caller is
|
||||
// responsible for migration ordering — migration 023 must already be applied.
|
||||
func NewLedger(db *sql.DB) *Ledger {
|
||||
return &Ledger{db: db}
|
||||
}
|
||||
|
||||
// Append writes one evidence row and returns its row id.
|
||||
//
|
||||
// The CreatedAt field, if zero, defaults to the database's CURRENT_TIMESTAMP.
|
||||
// TaskDomain defaults to "default" and Weight defaults to 1.0.
|
||||
func (l *Ledger) Append(ctx context.Context, ev Evidence) (int64, error) {
|
||||
if ev.TaskDomain == "" {
|
||||
ev.TaskDomain = "default"
|
||||
}
|
||||
if ev.Weight == 0 {
|
||||
ev.Weight = 1.0
|
||||
}
|
||||
|
||||
var (
|
||||
res sql.Result
|
||||
err error
|
||||
)
|
||||
if ev.CreatedAt.IsZero() {
|
||||
res, err = l.db.ExecContext(ctx,
|
||||
`INSERT INTO reputation_evidence
|
||||
(config_hash, owner_user_id, task_domain, score_delta, evidence_ref, weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
ev.ConfigHash, ev.OwnerUserID, ev.TaskDomain, ev.ScoreDelta, ev.EvidenceRef, ev.Weight,
|
||||
)
|
||||
} else {
|
||||
res, err = l.db.ExecContext(ctx,
|
||||
`INSERT INTO reputation_evidence
|
||||
(config_hash, owner_user_id, task_domain, score_delta, evidence_ref, weight, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
ev.ConfigHash, ev.OwnerUserID, ev.TaskDomain, ev.ScoreDelta, ev.EvidenceRef, ev.Weight,
|
||||
ev.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("append evidence: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RollingScore returns the time-decayed rolling reputation score for a
|
||||
// (config_hash, task_domain) pair, clamped to [0.0, 1.0], plus the count of
|
||||
// evidence rows considered.
|
||||
//
|
||||
// halfLifeDays controls how quickly old evidence loses weight. Pass 0 for the
|
||||
// 30-day default.
|
||||
//
|
||||
// When no evidence exists, the result is (neutralScore, 0, nil).
|
||||
func (l *Ledger) RollingScore(ctx context.Context, configHash, taskDomain string, halfLifeDays float64) (float64, int, error) {
|
||||
if halfLifeDays <= 0 {
|
||||
halfLifeDays = defaultHalfLifeDays
|
||||
}
|
||||
if taskDomain == "" {
|
||||
taskDomain = "default"
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT COALESCE(SUM(score_delta * weight *
|
||||
exp(-0.6931471805599453 * (julianday('now') - julianday(created_at)) / ?)), 0.5) AS score,
|
||||
COUNT(*) AS cnt
|
||||
FROM reputation_evidence
|
||||
WHERE config_hash = ? AND task_domain = ?`
|
||||
|
||||
var (
|
||||
score float64
|
||||
cnt int
|
||||
)
|
||||
if err := l.db.QueryRowContext(ctx, q, halfLifeDays, configHash, taskDomain).Scan(&score, &cnt); err != nil {
|
||||
return 0, 0, fmt.Errorf("rolling score query: %w", err)
|
||||
}
|
||||
|
||||
if cnt == 0 {
|
||||
return neutralScore, 0, nil
|
||||
}
|
||||
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
return score, cnt, nil
|
||||
}
|
||||
|
||||
// SeedFromParent reads the parent config's current rolling score and writes
|
||||
// a single seed evidence row for the child config at
|
||||
// seedFromParentFraction × parent_score.
|
||||
//
|
||||
// Used when a new agent config is spawned: rather than starting at the neutral
|
||||
// 0.5, the child inherits 70% of the parent's reputation as a starting prior.
|
||||
func (l *Ledger) SeedFromParent(ctx context.Context, parentHash, childHash string, ownerID int64, taskDomain string, halfLifeDays float64) error {
|
||||
parentScore, _, err := l.RollingScore(ctx, parentHash, taskDomain, halfLifeDays)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read parent score: %w", err)
|
||||
}
|
||||
|
||||
seedDelta := seedFromParentFraction * parentScore
|
||||
_, err = l.Append(ctx, Evidence{
|
||||
ConfigHash: childHash,
|
||||
OwnerUserID: ownerID,
|
||||
TaskDomain: taskDomain,
|
||||
ScoreDelta: seedDelta,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: fmt.Sprintf("seed_from_parent:%s", parentHash),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write seed evidence: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newLedgerTestDB(t *testing.T) (*sql.DB, int64) {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:ledger_%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Insert a test owner user.
|
||||
res, err := db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)`,
|
||||
"trust_test_user_"+t.Name(), "x", "Test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
uid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
return db, uid
|
||||
}
|
||||
|
||||
func TestLedger_AppendAndRollingScore_EmptyIsNeutral(t *testing.T) {
|
||||
db, _ := newLedgerTestDB(t)
|
||||
ledger := NewLedger(db)
|
||||
ctx := context.Background()
|
||||
|
||||
score, cnt, err := ledger.RollingScore(ctx, "no-such-hash", "default", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RollingScore: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("count = %d, want 0", cnt)
|
||||
}
|
||||
if score != neutralScore {
|
||||
t.Errorf("score = %f, want %f (neutral)", score, neutralScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedger_AppendAndRollingScore_Decays(t *testing.T) {
|
||||
db, owner := newLedgerTestDB(t)
|
||||
ledger := NewLedger(db)
|
||||
ctx := context.Background()
|
||||
|
||||
hash := "decay-hash"
|
||||
|
||||
// Old row (60 days ago) with large positive delta.
|
||||
if _, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: hash,
|
||||
OwnerUserID: owner,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: 0.9,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: "old",
|
||||
CreatedAt: time.Now().UTC().Add(-60 * 24 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("append old: %v", err)
|
||||
}
|
||||
|
||||
// Fresh row with smaller delta.
|
||||
if _, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: hash,
|
||||
OwnerUserID: owner,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: 0.4,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: "fresh",
|
||||
}); err != nil {
|
||||
t.Fatalf("append fresh: %v", err)
|
||||
}
|
||||
|
||||
score, cnt, err := ledger.RollingScore(ctx, hash, "default", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("RollingScore: %v", err)
|
||||
}
|
||||
if cnt != 2 {
|
||||
t.Errorf("count = %d, want 2", cnt)
|
||||
}
|
||||
|
||||
// After 60d with 30d half-life, the old row's contribution is 0.9 * 0.25 = 0.225.
|
||||
// The fresh row contributes ~0.4. Total ~0.625, well below the old delta but
|
||||
// dominated by the fresh contribution.
|
||||
if score < 0.5 || score > 0.75 {
|
||||
t.Errorf("score = %f, expected ~0.625", score)
|
||||
}
|
||||
|
||||
// Fresh-only score should be greater than score with the old row dragging it down? Actually old row is positive too here, so let's instead verify that fresh delta dominates: removing fresh row should drop the score sharply.
|
||||
score2, _, err := ledger.RollingScore(ctx, hash, "missing-domain", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("RollingScore missing: %v", err)
|
||||
}
|
||||
if score2 != neutralScore {
|
||||
t.Errorf("missing-domain score = %f, want neutral", score2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedger_SeedFromParent_70Percent(t *testing.T) {
|
||||
db, owner := newLedgerTestDB(t)
|
||||
ledger := NewLedger(db)
|
||||
ctx := context.Background()
|
||||
|
||||
parent := "parent-hash"
|
||||
child := "child-hash"
|
||||
|
||||
// Seed the parent with evidence summing to ~0.8 (recent, full weight).
|
||||
if _, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: parent,
|
||||
OwnerUserID: owner,
|
||||
ScoreDelta: 0.8,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: "parent_seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("append parent: %v", err)
|
||||
}
|
||||
|
||||
parentScore, _, err := ledger.RollingScore(ctx, parent, "default", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("parent score: %v", err)
|
||||
}
|
||||
// Parent score should be ~0.8 (just inserted, decay ≈ 1.0).
|
||||
if math.Abs(parentScore-0.8) > 0.01 {
|
||||
t.Fatalf("parent score = %f, want ~0.8", parentScore)
|
||||
}
|
||||
|
||||
if err := ledger.SeedFromParent(ctx, parent, child, owner, "default", 30); err != nil {
|
||||
t.Fatalf("SeedFromParent: %v", err)
|
||||
}
|
||||
|
||||
childScore, cnt, err := ledger.RollingScore(ctx, child, "default", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("child score: %v", err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Errorf("child evidence count = %d, want 1", cnt)
|
||||
}
|
||||
want := 0.7 * 0.8 // 0.56
|
||||
if math.Abs(childScore-want) > 0.01 {
|
||||
t.Errorf("child score = %f, want ~%f", childScore, want)
|
||||
}
|
||||
|
||||
// Verify the seed row references the parent.
|
||||
var ref string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT evidence_ref FROM reputation_evidence WHERE config_hash = ?`, child,
|
||||
).Scan(&ref); err != nil {
|
||||
t.Fatalf("select ref: %v", err)
|
||||
}
|
||||
if ref != "seed_from_parent:"+parent {
|
||||
t.Errorf("evidence_ref = %q, want %q", ref, "seed_from_parent:"+parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedger_Clamping(t *testing.T) {
|
||||
db, owner := newLedgerTestDB(t)
|
||||
ledger := NewLedger(db)
|
||||
ctx := context.Background()
|
||||
|
||||
hash := "clamp-hash"
|
||||
|
||||
// Many large negative deltas.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: hash,
|
||||
OwnerUserID: owner,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: -2.0,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: fmt.Sprintf("neg_%d", i),
|
||||
}); err != nil {
|
||||
t.Fatalf("append neg: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
score, cnt, err := ledger.RollingScore(ctx, hash, "default", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("RollingScore: %v", err)
|
||||
}
|
||||
if cnt != 5 {
|
||||
t.Errorf("count = %d, want 5", cnt)
|
||||
}
|
||||
if score != 0.0 {
|
||||
t.Errorf("score = %f, want 0.0 (clamped)", score)
|
||||
}
|
||||
|
||||
// And the inverse: many large positive deltas should clamp to 1.0.
|
||||
hash2 := "clamp-hash-pos"
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: hash2,
|
||||
OwnerUserID: owner,
|
||||
TaskDomain: "default",
|
||||
ScoreDelta: 2.0,
|
||||
Weight: 1.0,
|
||||
EvidenceRef: fmt.Sprintf("pos_%d", i),
|
||||
}); err != nil {
|
||||
t.Fatalf("append pos: %v", err)
|
||||
}
|
||||
}
|
||||
score2, _, err := ledger.RollingScore(ctx, hash2, "default", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("RollingScore pos: %v", err)
|
||||
}
|
||||
if score2 != 1.0 {
|
||||
t.Errorf("score = %f, want 1.0 (clamped)", score2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedger_Append_Defaults(t *testing.T) {
|
||||
db, owner := newLedgerTestDB(t)
|
||||
ledger := NewLedger(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// TaskDomain empty, Weight zero → should default to "default" and 1.0.
|
||||
id, err := ledger.Append(ctx, Evidence{
|
||||
ConfigHash: "defaults-hash",
|
||||
OwnerUserID: owner,
|
||||
ScoreDelta: 0.3,
|
||||
EvidenceRef: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Errorf("id = %d, want > 0", id)
|
||||
}
|
||||
|
||||
var domain string
|
||||
var weight float64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT task_domain, weight FROM reputation_evidence WHERE id = ?`, id,
|
||||
).Scan(&domain, &weight); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if domain != "default" {
|
||||
t.Errorf("domain = %q, want default", domain)
|
||||
}
|
||||
if weight != 1.0 {
|
||||
t.Errorf("weight = %f, want 1.0", weight)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ description: "Task list for Dynamic Agent Spawning"
|
||||
**Purpose**: Create new packages and migration files; wire through the build. No behavior yet.
|
||||
|
||||
- [ ] T001 Create empty Go packages with `doc.go` files at `internal/goals/doc.go`, `internal/tasks/doc.go`, `internal/trust/doc.go`, `internal/secrets/doc.go`
|
||||
- [ ] T002 [P] Create migration files (empty DDL, just table shells per data-model.md) at `internal/storage/schema/021_goals_tasks.sql`, `internal/storage/schema/022_agent_proposals.sql`, `internal/storage/schema/023_agent_trust_model.sql`, `internal/storage/schema/024_secrets.sql`, `internal/storage/schema/025_harness_runs_task_id.sql`
|
||||
- [X] T002 [P] Create migration files (empty DDL, just table shells per data-model.md) at `internal/storage/schema/021_goals_tasks.sql`, `internal/storage/schema/022_agent_proposals.sql`, `internal/storage/schema/023_agent_trust_model.sql`, `internal/storage/schema/024_secrets.sql`, `internal/storage/schema/025_harness_runs_task_id.sql` — **note**: table renamed from `tasks` to `goal_tasks` (legacy `tasks` table exists from migration 001 for channel task auctions)
|
||||
- [ ] T003 [P] Create example scaffolding at `examples/doc-gardener/` mirroring `examples/cold-topic-explainer/` — copy `start.sh`, `stop.sh`, `run_task.sh`, `wrapper.sh`, `README.md` with doc-gardener placeholders
|
||||
- [ ] T004 [P] Add `golang.org/x/crypto/nacl/secretbox` to `go.mod` via `go get` and verify the pure-Go build still works (`CGO_ENABLED=0 go build ./...`)
|
||||
- [ ] T005 Create `specs/018-dynamic-agent-spawning/BUILD_NOTES.md` with the developer runbook (build commands, test commands, example commands) for the whole feature
|
||||
@@ -43,11 +43,11 @@ description: "Task list for Dynamic Agent Spawning"
|
||||
|
||||
**Purpose**: Migrations, base types, and the trust primitive that every user story depends on.
|
||||
|
||||
- [ ] T010 Fill migration 021 DDL (goals + tasks tables, indexes, state CHECK constraints) in `internal/storage/schema/021_goals_tasks.sql` per data-model.md
|
||||
- [ ] T011 Fill migration 022 DDL (agent_proposals + resource_requests tables) in `internal/storage/schema/022_agent_proposals.sql` per data-model.md
|
||||
- [ ] T012 Fill migration 023 DDL (drop+recreate trust table; ALTER agents with config_hash/parent_agent_id/spawn_depth/system_prompt/autonomy_tier/tool_scope_json/quarantined_at; reputation_evidence table) in `internal/storage/schema/023_agent_trust_model.sql`
|
||||
- [ ] T013 Fill migration 024 DDL (secrets table with nonce||ciphertext BLOB, unique-index-where-not-revoked) in `internal/storage/schema/024_secrets.sql`
|
||||
- [ ] T014 Fill migration 025 DDL (ALTER harness_runs ADD task_id + index) in `internal/storage/schema/025_harness_runs_task_id.sql`
|
||||
- [X] T010 Fill migration 021 DDL (goals + goal_tasks tables, indexes, state CHECK constraints)
|
||||
- [X] T011 Fill migration 022 DDL (agent_proposals + resource_requests tables)
|
||||
- [X] T012 Fill migration 023 DDL — **revised**: keep existing agent_trust table intact (wired to reactions), only ALTER agents + CREATE reputation_evidence
|
||||
- [X] T013 Fill migration 024 DDL (secrets table)
|
||||
- [X] T014 Fill migration 025 DDL (harness_runs.task_id)
|
||||
- [ ] T015 [P] Write data-migration Go code that extracts `system_prompt` from existing `harness_config_json` for pre-existing agents, idempotent, in `internal/storage/migrations_data.go`
|
||||
- [ ] T016 [P] Write data-migration Go code that computes `config_hash` for every existing agent and backfills the column in `internal/storage/migrations_data.go`
|
||||
- [ ] T017 Implement `trust.ConfigHash(agent)` canonical SHA-256 hashing (sorted-keys JSON) in `internal/trust/hash.go` with table-driven unit tests in `internal/trust/hash_test.go` verifying stability across shuffled input arrays
|
||||
|
||||
Reference in New Issue
Block a user