Makes the Web UI reflect what agents are actually doing: reactions
on DMs that trigger a subprocess run, a per-run detail page that
shows the exact prompt the model received and the raw response, and
cross-linked reactive_runs ↔ harness_runs data for a single composite
API call.
Migration 020 (internal/storage/schema/020_harness_run_detail.sql):
ALTER TABLE harness_runs ADD COLUMN reactive_run_id INTEGER;
ALTER TABLE harness_runs ADD COLUMN prompt TEXT;
ALTER TABLE harness_runs ADD COLUMN response TEXT;
CREATE INDEX idx_harness_runs_reactive ON harness_runs(reactive_run_id);
internal/harness:
* ExecRequest.ReactiveRunID — reactor pins the reactive_runs row id
so the observer can JOIN the two tables.
* ExecResult.Prompt / Response — the subprocess harness reads
prompt.txt / response.txt that wrappers write into the workdir,
and runs.Store persists them (capped at 32 KiB each).
* runs.Run struct now has JSON tags — previously the API returned
PascalCase field names that didn't match the Web UI's snake_case
TypeScript types.
* New runs.Store.GetByReactiveRunID for the composite API endpoint.
* Test schema updated to include the new columns.
internal/reactor:
* New ReactionNotifier interface + SetReactionNotifier.
* dispatchHarness now reacts `in_progress` on the triggering DM
before spawning the goroutine.
* runHarness reacts `done` on success, `reject` on failure. The
existing reactionPriority ordering means the terminal reaction
wins for badge display — no need to remove in_progress first.
* dispatchHarness sets ExecRequest.ReactiveRunID.
cmd/synapbus/main.go:
* reactorReactionAdapter: adapts reactions.Service.Toggle to the
reactor's one-shot AddReaction signature.
* HarnessRunsStore wired into the API router config.
internal/api/runs_handler.go — GetRun composite endpoint:
The GET /api/runs/{id} response now returns everything the Web UI
needs to render the run detail page in one call:
{
"run": <reactive_runs row>,
"harness_run": <linked harness_runs row with prompt/response>,
"agent": <current agent snapshot with harness_config_json>,
"trigger_message": <DM that started the run>,
"outgoing_message": <first DM the agent produced after startedAt>
}
The outgoing-message lookup wraps both sides of the created_at
comparison in datetime() so SQLite parses the stored 'YYYY-MM-DD
HH:MM:SS' and the Go-emitted RFC3339 into the same canonical form
before comparing — a raw string compare was silently returning no
rows.
internal/api/router.go: HarnessRunsStore field in RouterConfig, wired
through to NewRunsHandler.
examples/cold-topic-explainer/wrapper.sh:
Writes prompt.txt and response.txt alongside gemini.stdout.raw so
the subprocess harness can capture "what the model saw" and "what
the model said" post-hoc.
web/src/lib/components/MessageList.svelte:
New ReactionPills render below each message body when the message
carries a `reactions` array (already populated by
EnrichMessages/ReactionEnricher on the server side). Makes the
👀 in_progress / ✔ done / ❌ reject lifecycle visible in every DM
view and conversation.
web/src/routes/runs/[id]/+page.svelte (NEW):
New run detail page at /runs/:id with sections:
1. Header strip — agent, status pill, backend badge, trigger
info, duration, tokens in/out, cost, exit code, trace id.
2. Triggering message — body + sender.
3. What the model saw — GEMINI.md / CLAUDE.md from agent snapshot
+ the captured rendered prompt (byte count on each summary
bar, collapsible details).
4. What the model said — captured response, falling back to
logs_excerpt or error_log when unavailable.
5. Outgoing message — body + recipient + status.
6. Metadata — reactive_run.id, harness_run.run_id, backend,
session_id, tokens_cached, k8s_job, agent trigger config.
Styled against the existing dark tailwind system — no design
overhaul, fits the current aesthetic (editorial sectioning,
monospace for code-like content, accent-blue for links,
accent-purple for system-instructions, accent-green for model
output, accent-red for errors).
web/src/routes/runs/+page.svelte: the inline expand panel now has
a "View full details →" link next to the Retry button.
E2E VERIFIED on a live subprocess run:
* Topic: "why does the subprocess harness materialise GEMINI.md
alongside .gemini/settings.json in the per-run workdir?"
* 3 subprocess runs + 3 reactive_runs + 3 harness_runs, all linked.
* message_reactions: 6 rows — in_progress + done for each hop.
* GET /api/runs/1 returns a composite with
harness_run.prompt=883 bytes, harness_run.response=550 bytes,
reactive_run_id=1, trigger_message populated, outgoing_message
populated (decomposer-pro → writer-flash), agent.gemini_md=747
bytes. All keys are snake_case as the Svelte types expect.
Full go test ./... green. `vite build` green. Demo instance still
running on port 18088 for browser verification.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
7.6 KiB
Go
260 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/harness/runs"
|
|
"github.com/synapbus/synapbus/internal/messaging"
|
|
"github.com/synapbus/synapbus/internal/reactor"
|
|
)
|
|
|
|
// RunsHandler handles REST API requests for reactive runs.
|
|
type RunsHandler struct {
|
|
store *reactor.Store
|
|
reactor *reactor.Reactor
|
|
agentStore agents.AgentStore
|
|
harnessRuns *runs.Store
|
|
msgService *messaging.MessagingService
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewRunsHandler creates a new runs handler.
|
|
func NewRunsHandler(
|
|
store *reactor.Store,
|
|
r *reactor.Reactor,
|
|
agentStore agents.AgentStore,
|
|
harnessRuns *runs.Store,
|
|
msgService *messaging.MessagingService,
|
|
db *sql.DB,
|
|
) *RunsHandler {
|
|
return &RunsHandler{
|
|
store: store,
|
|
reactor: r,
|
|
agentStore: agentStore,
|
|
harnessRuns: harnessRuns,
|
|
msgService: msgService,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// ListRuns returns reactive runs with optional filters.
|
|
func (h *RunsHandler) ListRuns(w http.ResponseWriter, r *http.Request) {
|
|
agentName := r.URL.Query().Get("agent")
|
|
status := r.URL.Query().Get("status")
|
|
limit := 50
|
|
offset := 0
|
|
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 200 {
|
|
limit = v
|
|
}
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
runs, total, err := h.store.ListRuns(r.Context(), agentName, status, limit, offset)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"runs": runs,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// GetRun returns a composite view of a reactive run: the reactive_runs
|
|
// row itself, the linked harness_runs row (with captured prompt /
|
|
// response / usage), the triggering message, the outgoing message the
|
|
// agent produced (if any), and a snapshot of the agent's current
|
|
// harness config. Everything the Web UI needs to render "what happened
|
|
// on this run" in a single request.
|
|
func (h *RunsHandler) GetRun(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 run ID"))
|
|
return
|
|
}
|
|
|
|
run, err := h.store.GetRunByID(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorBody("not_found", "run not found"))
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"run": run,
|
|
}
|
|
|
|
// Linked harness_run (may be nil for K8s path which still uses
|
|
// the legacy reactive_runs-only flow).
|
|
if h.harnessRuns != nil {
|
|
hr, _ := h.harnessRuns.GetByReactiveRunID(r.Context(), id)
|
|
if hr != nil {
|
|
resp["harness_run"] = hr
|
|
}
|
|
}
|
|
|
|
// Triggering message body (what the sender wrote).
|
|
if run.TriggerMessageID != nil && h.msgService != nil {
|
|
if msg, err := h.msgService.GetMessageByID(r.Context(), *run.TriggerMessageID); err == nil && msg != nil {
|
|
resp["trigger_message"] = msg
|
|
}
|
|
}
|
|
|
|
// Agent snapshot — current harness config so the UI can show
|
|
// the gemini_md / claude_md the agent is currently running with.
|
|
if agent, err := h.agentStore.GetAgentByName(r.Context(), run.AgentName); err == nil && agent != nil {
|
|
resp["agent"] = map[string]any{
|
|
"name": agent.Name,
|
|
"display_name": agent.DisplayName,
|
|
"type": agent.Type,
|
|
"harness_name": agent.HarnessName,
|
|
"local_command": agent.LocalCommand,
|
|
"harness_config_json": agent.HarnessConfigJSON,
|
|
"trigger_mode": agent.TriggerMode,
|
|
"cooldown_seconds": agent.CooldownSeconds,
|
|
"daily_trigger_budget": agent.DailyTriggerBudget,
|
|
"max_trigger_depth": agent.MaxTriggerDepth,
|
|
}
|
|
}
|
|
|
|
// Outgoing message — the first DM this agent produced after
|
|
// the run started. We find it by querying messages where
|
|
// from_agent = this run's agent AND created_at >= run.StartedAt,
|
|
// ordered by id. Works for both success and failure cases.
|
|
if h.db != nil && run.StartedAt != nil {
|
|
var (
|
|
msgID int64
|
|
toAgent sql.NullString
|
|
body string
|
|
status string
|
|
createdAt string
|
|
)
|
|
// Wrap both sides in datetime() so SQLite parses and compares
|
|
// canonically — the messages table stores created_at as
|
|
// 'YYYY-MM-DD HH:MM:SS' (space separator) while Go emits
|
|
// RFC3339 with 'T'. A raw string comparison fails silently.
|
|
err := h.db.QueryRowContext(r.Context(),
|
|
`SELECT id, to_agent, body, status, created_at
|
|
FROM messages
|
|
WHERE from_agent = ?
|
|
AND datetime(created_at) >= datetime(?)
|
|
ORDER BY id ASC LIMIT 1`,
|
|
run.AgentName,
|
|
run.StartedAt.UTC().Format(time.RFC3339),
|
|
).Scan(&msgID, &toAgent, &body, &status, &createdAt)
|
|
if err == nil {
|
|
resp["outgoing_message"] = map[string]any{
|
|
"id": msgID,
|
|
"to_agent": toAgent.String,
|
|
"body": body,
|
|
"status": status,
|
|
"created_at": createdAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// RetryRun retries a failed run.
|
|
func (h *RunsHandler) RetryRun(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 run ID"))
|
|
return
|
|
}
|
|
|
|
newRun, err := h.reactor.RetryRun(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("retry_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"new_run_id": newRun.ID,
|
|
"status": newRun.Status,
|
|
})
|
|
}
|
|
|
|
// ReactiveAgents returns agents with reactive trigger config and current status.
|
|
func (h *RunsHandler) ReactiveAgents(w http.ResponseWriter, r *http.Request) {
|
|
agentsList, err := h.agentStore.ListReactiveAgents(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
|
|
return
|
|
}
|
|
|
|
type agentStatus struct {
|
|
Name string `json:"name"`
|
|
TriggerMode string `json:"trigger_mode"`
|
|
CooldownSeconds int `json:"cooldown_seconds"`
|
|
DailyTriggerBudget int `json:"daily_trigger_budget"`
|
|
MaxTriggerDepth int `json:"max_trigger_depth"`
|
|
K8sImage string `json:"k8s_image"`
|
|
PendingWork bool `json:"pending_work"`
|
|
State string `json:"state"`
|
|
TodayRuns int `json:"today_runs"`
|
|
CooldownUntil *string `json:"cooldown_until"`
|
|
}
|
|
|
|
result := make([]agentStatus, 0, len(agentsList))
|
|
for _, a := range agentsList {
|
|
as := agentStatus{
|
|
Name: a.Name,
|
|
TriggerMode: a.TriggerMode,
|
|
CooldownSeconds: a.CooldownSeconds,
|
|
DailyTriggerBudget: a.DailyTriggerBudget,
|
|
MaxTriggerDepth: a.MaxTriggerDepth,
|
|
K8sImage: a.K8sImage,
|
|
PendingWork: a.PendingWork,
|
|
}
|
|
|
|
// Compute state
|
|
todayCount, _ := h.store.CountTodayRuns(r.Context(), a.Name)
|
|
as.TodayRuns = todayCount
|
|
|
|
running, _ := h.store.IsAgentRunning(r.Context(), a.Name)
|
|
if running {
|
|
as.State = "running"
|
|
} else if a.PendingWork {
|
|
as.State = "queued"
|
|
} else if todayCount >= a.DailyTriggerBudget {
|
|
as.State = "budget_exhausted"
|
|
} else {
|
|
lastRun, _ := h.store.GetLastRunTime(r.Context(), a.Name)
|
|
if lastRun != nil {
|
|
cooldownEnd := lastRun.Add(time.Duration(a.CooldownSeconds) * time.Second)
|
|
if time.Now().Before(cooldownEnd) {
|
|
as.State = "cooldown"
|
|
t := cooldownEnd.UTC().Format(time.RFC3339)
|
|
as.CooldownUntil = &t
|
|
} else {
|
|
as.State = "idle"
|
|
}
|
|
} else {
|
|
as.State = "idle"
|
|
}
|
|
}
|
|
|
|
result = append(result, as)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"agents": result,
|
|
})
|
|
}
|