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>
139 lines
3.4 KiB
Go
139 lines
3.4 KiB
Go
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)
|