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