Compare commits
3 Commits
ac48390796
...
claude/oik
| Author | SHA1 | Date | |
|---|---|---|---|
| 52e16e04ca | |||
| 6192c35c10 | |||
| 682326382e |
129
internal/httpapi/learning_view.go
Normal file
129
internal/httpapi/learning_view.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// capabilityTimelineItem summarizes one verb's track record — when the
|
||||||
|
// agent first succeeded at it, and how reliable it's been since. Derived
|
||||||
|
// directly from executions (which has real, growing data) rather than the
|
||||||
|
// patterns/skills tables, which are correctly modeled but have zero writers
|
||||||
|
// anywhere in the codebase today — building against them now would ship a
|
||||||
|
// permanently empty page. See plans/2026-07-10-general-gated-execution.md
|
||||||
|
// step 8 evaluation.
|
||||||
|
type capabilityTimelineItem struct {
|
||||||
|
Verb string `json:"verb"`
|
||||||
|
FirstSuccess *string `json:"first_success"`
|
||||||
|
Successes int `json:"successes"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLearningTimeline backs the Learning page's capability timeline: one
|
||||||
|
// row per distinct verb (parsed via splitAction, same helper the activity
|
||||||
|
// feed uses), ordered by when it first succeeded — an honest "the system
|
||||||
|
// learned to do X" signal without depending on the unpopulated patterns
|
||||||
|
// table.
|
||||||
|
func (s *Server) serveLearningTimeline(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT action, status, created_at::text
|
||||||
|
FROM executions
|
||||||
|
ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type agg struct {
|
||||||
|
firstSuccess *string
|
||||||
|
successes int
|
||||||
|
total int
|
||||||
|
}
|
||||||
|
byVerb := map[string]*agg{}
|
||||||
|
for rows.Next() {
|
||||||
|
var action, status, createdAt string
|
||||||
|
if err := rows.Scan(&action, &status, &createdAt); err != nil {
|
||||||
|
slog.Error("httpapi: learning/timeline row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verb, _ := splitAction(action)
|
||||||
|
a, ok := byVerb[verb]
|
||||||
|
if !ok {
|
||||||
|
a = &agg{}
|
||||||
|
byVerb[verb] = a
|
||||||
|
}
|
||||||
|
a.total++
|
||||||
|
if status == "completed" {
|
||||||
|
a.successes++
|
||||||
|
if a.firstSuccess == nil {
|
||||||
|
ca := createdAt
|
||||||
|
a.firstSuccess = &ca
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]capabilityTimelineItem, 0, len(byVerb))
|
||||||
|
for verb, a := range byVerb {
|
||||||
|
items = append(items, capabilityTimelineItem{
|
||||||
|
Verb: verb, FirstSuccess: a.firstSuccess, Successes: a.successes, Total: a.total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Verbs with at least one success sort by when that first happened;
|
||||||
|
// verbs that have never succeeded sort last (nothing to celebrate yet).
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
fi, fj := items[i].FirstSuccess, items[j].FirstSuccess
|
||||||
|
if fi == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fj == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *fi < *fj
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
type trendBucket struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
Successes int `json:"successes"`
|
||||||
|
Failures int `json:"failures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLearningTrend backs the Learning page's 30-day success/fail trend
|
||||||
|
// chart — a daily bucket of execution outcomes, straight off the executions
|
||||||
|
// table.
|
||||||
|
func (s *Server) serveLearningTrend(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT date_trunc('day', created_at)::date::text AS day,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'completed') AS successes,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') AS failures
|
||||||
|
FROM executions
|
||||||
|
WHERE created_at > now() - interval '30 days'
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day`)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []trendBucket{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b trendBucket
|
||||||
|
if err := rows.Scan(&b.Day, &b.Successes, &b.Failures); err != nil {
|
||||||
|
slog.Error("httpapi: learning/trend row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||||
|
}
|
||||||
@@ -150,6 +150,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
|||||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||||
|
|
||||||
|
// Learning view: capability timeline + success trend, both derived from
|
||||||
|
// executions (real, growing data) rather than the patterns/skills tables,
|
||||||
|
// which are correctly modeled but have no writers anywhere yet.
|
||||||
|
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
||||||
|
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
||||||
|
|
||||||
// Mount MCP at /mcp (plan R3-10)
|
// Mount MCP at /mcp (plan R3-10)
|
||||||
nomosAgentID := uuid.Nil
|
nomosAgentID := uuid.Nil
|
||||||
if cfg.NomosAgentID != "" {
|
if cfg.NomosAgentID != "" {
|
||||||
|
|||||||
@@ -302,6 +302,32 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restart, pct_exec, and systemctl (outside enable/disable) route
|
||||||
|
// through the same classify→gate path as `run` instead of executing
|
||||||
|
// immediately over SSH with a hardcoded risk_class='reversible_low'
|
||||||
|
// that was never actually checked against anything. Found live
|
||||||
|
// 2026-07-10: a chat request to "restart caddy" — the fleet's
|
||||||
|
// reverse proxy — executed instantly with zero approval, because
|
||||||
|
// this action bypassed the classifier entirely. classifyAndGate
|
||||||
|
// applies the same read-only/config-mutation/destructive
|
||||||
|
// classification and approval flow the `run` tool already uses.
|
||||||
|
if action == "restart" || action == "pct_exec" || (action == "systemctl" && params != "enable" && params != "disable") {
|
||||||
|
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||||
|
var cmd, purpose string
|
||||||
|
switch action {
|
||||||
|
case "restart":
|
||||||
|
cmd = fmt.Sprintf("systemctl restart %s; sleep 1; systemctl is-active %s", svc, svc)
|
||||||
|
purpose = "restart " + svc
|
||||||
|
case "pct_exec":
|
||||||
|
cmd = params
|
||||||
|
purpose = "pct_exec (legacy) on " + targetSlug
|
||||||
|
case "systemctl":
|
||||||
|
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
|
||||||
|
purpose = "systemctl " + params + " " + svc
|
||||||
|
}
|
||||||
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplicate: if a pending execution already exists for the same
|
// Deduplicate: if a pending execution already exists for the same
|
||||||
// target+action, return the existing one instead of creating a
|
// target+action, return the existing one instead of creating a
|
||||||
// duplicate. Prevents the LLM from re-requesting the same gated
|
// duplicate. Prevents the LLM from re-requesting the same gated
|
||||||
@@ -338,67 +364,16 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
||||||
id, targetID, action+":"+params, correlationID, agentID)
|
id, targetID, action+":"+params, correlationID, agentID)
|
||||||
|
|
||||||
// Execute reversible actions immediately
|
// Execute reversible actions immediately. restart/pct_exec/systemctl
|
||||||
|
// (outside enable/disable) never reach here — they're routed through
|
||||||
|
// classifyAndGate above, before this dedup+insert block.
|
||||||
switch action {
|
switch action {
|
||||||
case "restart":
|
|
||||||
host, user, err := resolveHost(ctx, pool, targetSlug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
|
||||||
}
|
|
||||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("systemctl restart %s 2>&1; sleep 1; systemctl is-active %s", svc, svc))
|
|
||||||
result := fmt.Sprintf("restart %s: %s", svc, out)
|
|
||||||
if err != nil {
|
|
||||||
result = fmt.Sprintf("restart %s: ERROR %v", svc, err)
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
|
|
||||||
id, jsonOut(out))
|
|
||||||
return textResult(result), nil
|
|
||||||
|
|
||||||
case "systemctl":
|
case "systemctl":
|
||||||
|
// Only enable/disable reach this case now.
|
||||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||||
if params == "enable" || params == "disable" {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||||
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
|
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
|
||||||
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
|
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
|
||||||
}
|
|
||||||
host, user, err := resolveHost(ctx, pool, targetSlug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
|
||||||
}
|
|
||||||
cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc)
|
|
||||||
out, err := sshExec(ctx, host, user, cmd)
|
|
||||||
result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out)
|
|
||||||
if err != nil {
|
|
||||||
result = fmt.Sprintf("systemctl %s %s: ERROR %v", params, svc, err)
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
|
|
||||||
id, jsonOut(out))
|
|
||||||
return textResult(result), nil
|
|
||||||
|
|
||||||
case "pct_exec":
|
|
||||||
var pveID string
|
|
||||||
if err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", targetSlug).Scan(&pveID); err != nil || pveID == "" {
|
|
||||||
return textResult(fmt.Sprintf("LXC not found: %s", targetSlug)), nil
|
|
||||||
}
|
|
||||||
// Resolve Proxmox host
|
|
||||||
var hostSlug string
|
|
||||||
pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", targetSlug).Scan(&hostSlug)
|
|
||||||
if hostSlug == "" {
|
|
||||||
hostSlug = "host:hubris" // default
|
|
||||||
}
|
|
||||||
host, user, err := resolveHost(ctx, pool, hostSlug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve Proxmox host: %v", err)), nil
|
|
||||||
}
|
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct exec %s -- %s 2>&1", pveID, params))
|
|
||||||
result := fmt.Sprintf("pct exec %s: %s", pveID, out)
|
|
||||||
if err != nil {
|
|
||||||
result = fmt.Sprintf("pct exec %s: ERROR %v", pveID, err)
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
|
|
||||||
id, jsonOut(out))
|
|
||||||
return textResult(result), nil
|
|
||||||
|
|
||||||
case "apt_upgrade":
|
case "apt_upgrade":
|
||||||
if params == "audit" {
|
if params == "audit" {
|
||||||
@@ -488,106 +463,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk), nil
|
||||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
|
||||||
actionCol := "run:" + string(runParams)
|
|
||||||
|
|
||||||
// Dedup: an identical pending command (same target, command, and
|
|
||||||
// purpose) blocks a re-request — stops a tool-calling loop from
|
|
||||||
// queuing the same approval repeatedly.
|
|
||||||
var existingID string
|
|
||||||
derr := pool.QueryRow(ctx, `
|
|
||||||
SELECT e.id::text FROM entities e
|
|
||||||
JOIN executions ex ON ex.entity_id = e.id
|
|
||||||
WHERE e.type = 'execution' AND ex.target_entity_id = $1
|
|
||||||
AND ex.action = $2 AND ex.status = 'pending_approval'
|
|
||||||
ORDER BY e.created_at DESC LIMIT 1`,
|
|
||||||
targetID, actionCol).Scan(&existingID)
|
|
||||||
if derr == nil && existingID != "" {
|
|
||||||
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
id, _ := uuid.NewV7()
|
|
||||||
correlationID := uuid.New().String()
|
|
||||||
// Full UUID, not a truncated prefix — see the matching comment on
|
|
||||||
// request_execution's exec slug generation above; the 8-char prefix
|
|
||||||
// collided for real under back-to-back requests.
|
|
||||||
execName := "run on " + targetSlug + " (" + id.String() + ")"
|
|
||||||
execSlug := "exec:" + targetSlug + ":" + id.String()
|
|
||||||
if _, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
|
||||||
id, execSlug, execName); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
|
||||||
id, targetID, actionCol, riskClass, correlationID, agentID)
|
|
||||||
|
|
||||||
if riskClass == policy.RiskReadOnly {
|
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
|
||||||
if rerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
|
||||||
return textResult(fmt.Sprintf("resolve target: %v", rerr)), nil
|
|
||||||
}
|
|
||||||
out, xerr := sshExec(ctx, host, user, wrap(command))
|
|
||||||
if xerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
|
||||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out)), nil
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
|
||||||
return textResult(fmt.Sprintf("run on %s (read_only, auto): %s", targetSlug, out)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assent window: if the operator recently approved a plan in this
|
|
||||||
// agent's chat session, config_mutation commands auto-run without
|
|
||||||
// re-approval. This is the "approve the plan, carry it out" path —
|
|
||||||
// the operator approved the overall direction; individual config
|
|
||||||
// steps within the window don't each need a separate yes.
|
|
||||||
// Destructive commands never auto-run, regardless of window.
|
|
||||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) {
|
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
|
||||||
if rerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
|
||||||
return textResult(fmt.Sprintf("resolve target: %v", rerr)), nil
|
|
||||||
}
|
|
||||||
out, xerr := sshExec(ctx, host, user, wrap(command))
|
|
||||||
if xerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
|
||||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out)), nil
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
|
||||||
slog.Info("mcp: run auto-executed via assent window", "target", targetSlug, "execution_id", id)
|
|
||||||
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via assent window): %s", targetSlug, out)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destructive window: a narrow, TARGET-scoped grant opened only after
|
|
||||||
// an operator's explicit typed confirmation ("I confirm") on this
|
|
||||||
// same target — never by loose assent. Exists for multi-step
|
|
||||||
// destructive recovery (e.g. a failed destroy needing stop, then
|
|
||||||
// destroy) so the operator isn't asked to re-type "I confirm" for
|
|
||||||
// every single command against the thing they just confirmed.
|
|
||||||
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) {
|
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
|
||||||
if rerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
|
||||||
return textResult(fmt.Sprintf("resolve target: %v", rerr)), nil
|
|
||||||
}
|
|
||||||
out, xerr := sshExec(ctx, host, user, wrap(command))
|
|
||||||
if xerr != nil {
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
|
||||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out)), nil
|
|
||||||
}
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
|
||||||
slog.Info("mcp: run auto-executed via destructive window", "target", targetSlug, "execution_id", id)
|
|
||||||
return textResult(fmt.Sprintf("run on %s (destructive, auto via confirmed-target window): %s", targetSlug, out)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
|
||||||
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
|
||||||
confirmNote := ""
|
|
||||||
if riskClass == policy.RiskDestructive {
|
|
||||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
|
||||||
}
|
|
||||||
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
|
||||||
targetSlug, riskClass, id, confirmNote)), nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||||
@@ -1360,6 +1236,115 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
|||||||
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
|
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// classifyAndGate is the shared classify→execute-or-queue path for every
|
||||||
|
// mutating command, used by both the general `run` tool and
|
||||||
|
// request_execution's restart/systemctl/pct_exec actions. Those legacy
|
||||||
|
// actions used to execute immediately over SSH with a hardcoded
|
||||||
|
// risk_class='reversible_low' that was never actually evaluated against the
|
||||||
|
// command — found live 2026-07-10 when a chat request to restart caddy (the
|
||||||
|
// fleet's reverse proxy) executed instantly with no approval at all. Routing
|
||||||
|
// every mutating path through the same classifier + approval-queue logic
|
||||||
|
// closes that gap without special-casing each caller.
|
||||||
|
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk string) *mcp.CallToolResult {
|
||||||
|
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
||||||
|
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||||
|
actionCol := "run:" + string(runParams)
|
||||||
|
|
||||||
|
// Dedup: an identical pending command (same target, command, and
|
||||||
|
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
||||||
|
// the same approval repeatedly.
|
||||||
|
var existingID string
|
||||||
|
derr := pool.QueryRow(ctx, `
|
||||||
|
SELECT e.id::text FROM entities e
|
||||||
|
JOIN executions ex ON ex.entity_id = e.id
|
||||||
|
WHERE e.type = 'execution' AND ex.target_entity_id = $1
|
||||||
|
AND ex.action = $2 AND ex.status = 'pending_approval'
|
||||||
|
ORDER BY e.created_at DESC LIMIT 1`,
|
||||||
|
targetID, actionCol).Scan(&existingID)
|
||||||
|
if derr == nil && existingID != "" {
|
||||||
|
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
correlationID := uuid.New().String()
|
||||||
|
execName := "run on " + targetSlug + " (" + id.String() + ")"
|
||||||
|
execSlug := "exec:" + targetSlug + ":" + id.String()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
||||||
|
id, execSlug, execName); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: failed to create execution: %v", err))
|
||||||
|
}
|
||||||
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
||||||
|
id, targetID, actionCol, riskClass, correlationID, agentID)
|
||||||
|
|
||||||
|
if riskClass == policy.RiskReadOnly {
|
||||||
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
|
if rerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
||||||
|
return textResult(fmt.Sprintf("resolve target: %v", rerr))
|
||||||
|
}
|
||||||
|
out, xerr := sshExec(ctx, host, user, wrap(command))
|
||||||
|
if xerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
||||||
|
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||||
|
}
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
||||||
|
return textResult(fmt.Sprintf("run on %s (read_only, auto): %s", targetSlug, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assent window: if the operator recently approved a plan in this
|
||||||
|
// agent's chat session, config_mutation commands auto-run without
|
||||||
|
// re-approval. This is the "approve the plan, carry it out" path — the
|
||||||
|
// operator approved the overall direction; individual config steps
|
||||||
|
// within the window don't each need a separate yes. Destructive
|
||||||
|
// commands never auto-run, regardless of window.
|
||||||
|
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) {
|
||||||
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
|
if rerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
||||||
|
return textResult(fmt.Sprintf("resolve target: %v", rerr))
|
||||||
|
}
|
||||||
|
out, xerr := sshExec(ctx, host, user, wrap(command))
|
||||||
|
if xerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
||||||
|
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||||
|
}
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
||||||
|
slog.Info("mcp: run auto-executed via assent window", "target", targetSlug, "execution_id", id)
|
||||||
|
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via assent window): %s", targetSlug, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive window: a narrow, TARGET-scoped grant opened only after an
|
||||||
|
// operator's explicit typed confirmation ("I confirm") on this same
|
||||||
|
// target — never by loose assent. Exists for multi-step destructive
|
||||||
|
// recovery (e.g. a failed destroy needing stop, then destroy) so the
|
||||||
|
// operator isn't asked to re-type "I confirm" for every single command
|
||||||
|
// against the thing they just confirmed.
|
||||||
|
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) {
|
||||||
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
|
if rerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
||||||
|
return textResult(fmt.Sprintf("resolve target: %v", rerr))
|
||||||
|
}
|
||||||
|
out, xerr := sshExec(ctx, host, user, wrap(command))
|
||||||
|
if xerr != nil {
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
|
||||||
|
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||||
|
}
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
|
||||||
|
slog.Info("mcp: run auto-executed via destructive window", "target", targetSlug, "execution_id", id)
|
||||||
|
return textResult(fmt.Sprintf("run on %s (destructive, auto via confirmed-target window): %s", targetSlug, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
||||||
|
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||||
|
confirmNote := ""
|
||||||
|
if riskClass == policy.RiskDestructive {
|
||||||
|
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||||
|
targetSlug, riskClass, id, confirmNote))
|
||||||
|
}
|
||||||
|
|
||||||
// autoApprove updates the approval + execution status in the DB to approved,
|
// autoApprove updates the approval + execution status in the DB to approved,
|
||||||
// mirroring what DecideApproval does. Returns true on success. This is used
|
// mirroring what DecideApproval does. Returns true on success. This is used
|
||||||
// by the assent-window path to skip the operator-approval queue when the
|
// by the assent-window path to skip the operator-approval queue when the
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import EntityDetail from './pages/EntityDetail.svelte'
|
import EntityDetail from './pages/EntityDetail.svelte'
|
||||||
import Agent from './pages/Agent.svelte'
|
import Agent from './pages/Agent.svelte'
|
||||||
import Knowledge from './pages/Knowledge.svelte'
|
import Knowledge from './pages/Knowledge.svelte'
|
||||||
|
import Learning from './pages/Learning.svelte'
|
||||||
import Audit from './pages/Audit.svelte'
|
import Audit from './pages/Audit.svelte'
|
||||||
import { newChat } from '$lib/stores/chat'
|
import { newChat } from '$lib/stores/chat'
|
||||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
import BotIcon from '@lucide/svelte/icons/bot'
|
import BotIcon from '@lucide/svelte/icons/bot'
|
||||||
import SearchIcon from '@lucide/svelte/icons/search'
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
|
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
|
||||||
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||||
|
|
||||||
let page = $state('chat')
|
let page = $state('chat')
|
||||||
let routeParam = $state('')
|
let routeParam = $state('')
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
||||||
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
||||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||||
|
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon },
|
||||||
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
@@ -210,6 +213,8 @@
|
|||||||
<Agent />
|
<Agent />
|
||||||
{:else if page === 'knowledge'}
|
{:else if page === 'knowledge'}
|
||||||
<Knowledge />
|
<Knowledge />
|
||||||
|
{:else if page === 'learning'}
|
||||||
|
<Learning />
|
||||||
{:else if page === 'audit'}
|
{:else if page === 'audit'}
|
||||||
<Audit />
|
<Audit />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -277,6 +277,72 @@ export async function fetchSessionDigest(sessionId: string): Promise<SessionDige
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CapabilityTimelineItem {
|
||||||
|
verb: string
|
||||||
|
first_success: string | null
|
||||||
|
successes: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
|
||||||
|
const res = await fetch(`${API}/learning/timeline`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendBucket {
|
||||||
|
day: string
|
||||||
|
successes: number
|
||||||
|
failures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
|
||||||
|
const res = await fetch(`${API}/learning/trend`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pattern {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
applies_type: string
|
||||||
|
action: string
|
||||||
|
pattern: string
|
||||||
|
confidence: number
|
||||||
|
evidence_count: number
|
||||||
|
success_count?: number
|
||||||
|
failure_count?: number
|
||||||
|
status: string
|
||||||
|
quarantined?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPatterns(): Promise<Pattern[]> {
|
||||||
|
const res = await fetch(`${API}/patterns`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
applies_type?: string | null
|
||||||
|
action: string
|
||||||
|
status: string
|
||||||
|
success_rate?: number | null
|
||||||
|
last_used_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSkills(): Promise<Skill[]> {
|
||||||
|
const res = await fetch(`${API}/skills`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
export interface Signal {
|
export interface Signal {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PendingApproval } from '$lib/stores/chat'
|
import type { PendingApproval } from '$lib/stores/chat'
|
||||||
import { decideApproval, getExecution, type Execution } from '$lib/api'
|
import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { SvelteMap } from 'svelte/reactivity'
|
import { SvelteMap } from 'svelte/reactivity'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/network'
|
||||||
|
|
||||||
let { approvals }: { approvals: PendingApproval[] } = $props()
|
let { approvals }: { approvals: PendingApproval[] } = $props()
|
||||||
|
|
||||||
|
// Downstream entities the target affects, keyed by executionId — fetched
|
||||||
|
// once per approval so the operator sees the graph-walk impact ("this
|
||||||
|
// affects 3 downstream") before deciding, not after. depth 0 is the target
|
||||||
|
// itself, excluded here since it's already shown as "on {target}".
|
||||||
|
const blastRadius = new SvelteMap<string, string[]>()
|
||||||
|
const blastRadiusFetched = new Set<string>()
|
||||||
|
async function loadBlastRadius(a: PendingApproval) {
|
||||||
|
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
|
||||||
|
blastRadiusFetched.add(a.executionId)
|
||||||
|
const items = await fetchBlastRadius(a.target)
|
||||||
|
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
|
||||||
|
if (affected.length) blastRadius.set(a.executionId, affected)
|
||||||
|
}
|
||||||
|
|
||||||
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
|
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
|
||||||
// action buttons permanently so the banner clears after a click and can
|
// action buttons permanently so the banner clears after a click and can
|
||||||
// never re-POST /decision.
|
// never re-POST /decision.
|
||||||
@@ -125,7 +140,10 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const a of approvals) {
|
for (const a of approvals) {
|
||||||
if (!phase.get(a.executionId)) void watchExternal(a.executionId)
|
if (!phase.get(a.executionId)) {
|
||||||
|
void watchExternal(a.executionId)
|
||||||
|
void loadBlastRadius(a)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -194,6 +212,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if approval.destructive}
|
{:else if approval.destructive}
|
||||||
|
{@const affected = blastRadius.get(approval.executionId)}
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
||||||
@@ -211,9 +230,17 @@
|
|||||||
{#if approval.command}
|
{#if approval.command}
|
||||||
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if affected}
|
||||||
|
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
|
||||||
|
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
||||||
|
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
{@const affected = blastRadius.get(approval.executionId)}
|
||||||
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||||
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
||||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||||
@@ -223,5 +250,12 @@
|
|||||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if affected}
|
||||||
|
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
|
||||||
|
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
||||||
|
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
174
web/src/pages/Learning.svelte
Normal file
174
web/src/pages/Learning.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte'
|
||||||
|
import uPlot from 'uplot'
|
||||||
|
import 'uplot/dist/uPlot.min.css'
|
||||||
|
import {
|
||||||
|
fetchLearningTimeline,
|
||||||
|
fetchLearningTrend,
|
||||||
|
fetchPatterns,
|
||||||
|
fetchSkills,
|
||||||
|
type CapabilityTimelineItem,
|
||||||
|
type TrendBucket,
|
||||||
|
type Pattern,
|
||||||
|
type Skill
|
||||||
|
} from '$lib/api'
|
||||||
|
import * as Card from '$lib/components/ui/card'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||||
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
|
|
||||||
|
let timeline = $state<CapabilityTimelineItem[]>([])
|
||||||
|
let trend = $state<TrendBucket[]>([])
|
||||||
|
let patterns = $state<Pattern[]>([])
|
||||||
|
let skills = $state<Skill[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let chartEl = $state<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true
|
||||||
|
const [t, tr, p, s] = await Promise.all([
|
||||||
|
fetchLearningTimeline(),
|
||||||
|
fetchLearningTrend(),
|
||||||
|
fetchPatterns(),
|
||||||
|
fetchSkills()
|
||||||
|
])
|
||||||
|
timeline = t
|
||||||
|
trend = tr
|
||||||
|
patterns = p
|
||||||
|
skills = s
|
||||||
|
loading = false
|
||||||
|
await tick()
|
||||||
|
renderChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load)
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chartEl || trend.length === 0) return
|
||||||
|
chartEl.innerHTML = ''
|
||||||
|
const xs = trend.map((b) => new Date(b.day).getTime() / 1000)
|
||||||
|
const succ = trend.map((b) => b.successes)
|
||||||
|
const fail = trend.map((b) => b.failures)
|
||||||
|
new uPlot(
|
||||||
|
{
|
||||||
|
width: chartEl.clientWidth || 600,
|
||||||
|
height: 180,
|
||||||
|
series: [
|
||||||
|
{},
|
||||||
|
{ label: 'succeeded', stroke: '#3fb950', width: 2 },
|
||||||
|
{ label: 'failed', stroke: '#f85149', width: 2 }
|
||||||
|
],
|
||||||
|
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
legend: { show: true }
|
||||||
|
},
|
||||||
|
[xs, succ, fail],
|
||||||
|
chartEl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineVariant(item: CapabilityTimelineItem): 'default' | 'secondary' | 'destructive' {
|
||||||
|
if (item.total === 0 || item.successes === 0) return 'destructive'
|
||||||
|
if (item.successes === item.total) return 'default'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
|
||||||
|
<h1 class="text-lg font-semibold">Learning</h1>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Execution outcomes — last 30 days</Card.Title>
|
||||||
|
<Card.Description class="text-xs">Every gated action, by day it ran, succeeded vs failed.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if trend.length === 0}
|
||||||
|
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions in the last 30 days yet.</p>{/if}
|
||||||
|
{:else}
|
||||||
|
<div bind:this={chartEl} class="w-full"></div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-1.5 text-sm"><TrendingUpIcon class="size-4" /> Capability timeline</Card.Title>
|
||||||
|
<Card.Description class="text-xs">What Nomos has learned to do, ordered by when it first succeeded.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each timeline as item (item.verb)}
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-mono text-sm">{item.verb}</span>
|
||||||
|
<span class="ml-2 text-xs text-muted-foreground">
|
||||||
|
{item.first_success ? `first succeeded ${fmtDate(item.first_success)}` : 'no successes yet'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={timelineVariant(item)}>{item.successes}/{item.total}</Badge>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions yet.</p>{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Patterns</Card.Title>
|
||||||
|
<Card.Description class="text-xs">Statistically validated behaviors, extracted from outcome feedback.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if patterns.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated actions.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each patterns as p (p.id)}
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm">{p.pattern}</span>
|
||||||
|
<span class="ml-2 text-xs text-muted-foreground">{p.applies_type} · {p.action}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{(p.confidence * 100).toFixed(0)}% conf.</Badge>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-1.5 text-sm"><SparklesIcon class="size-4" /> Promoted skills</Card.Title>
|
||||||
|
<Card.Description class="text-xs">Procedures promoted from validated patterns.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if skills.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">No skills promoted yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each skills as s (s.id)}
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm">{s.name}</span>
|
||||||
|
<span class="ml-2 text-xs text-muted-foreground">{s.status}</span>
|
||||||
|
</div>
|
||||||
|
{#if s.success_rate != null}
|
||||||
|
<Badge variant="outline">{(s.success_rate * 100).toFixed(0)}% success</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user