feat(ux): run detail page + reaction pills + captured prompt/response

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>
This commit is contained in:
Algis Dumbris
2026-04-14 09:26:18 +03:00
co-authored by Claude Opus 4.6
parent b140879bc3
commit fee73e33a0
13 changed files with 772 additions and 85 deletions
+14
View File
@@ -513,6 +513,7 @@ func runServe(cmd *cobra.Command, args []string) error {
harnessRunsStore := runs.New(db.DB, slog.Default()) harnessRunsStore := runs.New(db.DB, slog.Default())
harnessRegistry.Observer = harnessRunsStore harnessRegistry.Observer = harnessRunsStore
reactorEngine.SetHarnessRegistry(harnessRegistry) reactorEngine.SetHarnessRegistry(harnessRegistry)
reactorEngine.SetReactionNotifier(&reactorReactionAdapter{svc: reactionService})
slog.Info("harness registry configured", slog.Info("harness registry configured",
"backends", harnessRegistry.Names(), "backends", harnessRegistry.Names(),
) )
@@ -713,6 +714,7 @@ func runServe(cmd *cobra.Command, args []string) error {
TrustService: trustService, TrustService: trustService,
ReactorStore: reactorStore, ReactorStore: reactorStore,
ReactorEngine: reactorEngine, ReactorEngine: reactorEngine,
HarnessRunsStore: harnessRunsStore,
BaseURL: baseURL, BaseURL: baseURL,
WikiService: wikiService, WikiService: wikiService,
}) })
@@ -953,6 +955,18 @@ func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID
return results, nil return results, nil
} }
// reactorReactionAdapter adapts reactions.Service to the reactor's
// ReactionNotifier interface. It wraps Toggle so the reactor only
// sees one simple AddReaction call.
type reactorReactionAdapter struct {
svc *reactions.Service
}
func (a *reactorReactionAdapter) AddReaction(ctx context.Context, messageID int64, agentName, reactionType string) error {
_, err := a.svc.Toggle(ctx, messageID, agentName, reactionType, nil)
return err
}
// reactionEnricherAdapter adapts reactions.Service to messaging.ReactionEnricher. // reactionEnricherAdapter adapts reactions.Service to messaging.ReactionEnricher.
type reactionEnricherAdapter struct { type reactionEnricherAdapter struct {
svc *reactions.Service svc *reactions.Service
+9 -4
View File
@@ -44,8 +44,11 @@ PROMPT="$(cat GEMINI.md)
Incoming DM from @${FROM}: Incoming DM from @${FROM}:
${BODY}" ${BODY}"
# Preserve the exact prompt and any stderr noise for forensics. # Preserve the exact prompt the model received — the subprocess
printf '%s' "$PROMPT" > gemini.prompt.txt # harness reads prompt.txt after the run completes and stores it in
# harness_runs.prompt so the Web UI can show "what the model saw".
printf '%s' "$PROMPT" > prompt.txt
set +e set +e
RAW=$(gemini -m "$GEMINI_MODEL" --approval-mode yolo -p "$PROMPT" 2>gemini.stderr.log) RAW=$(gemini -m "$GEMINI_MODEL" --approval-mode yolo -p "$PROMPT" 2>gemini.stderr.log)
GEMINI_EXIT=$? GEMINI_EXIT=$?
@@ -55,8 +58,10 @@ set -e
# stdout when its MCP config can't reach a server. Strip it. # stdout when its MCP config can't reach a server. Strip it.
RESPONSE=$(printf '%s' "$RAW" | sed 's|^MCP issues detected\. Run /mcp list for status\.||') RESPONSE=$(printf '%s' "$RAW" | sed 's|^MCP issues detected\. Run /mcp list for status\.||')
# Save the cleaned response for forensics before we decide next steps. # Save both the raw and the cleaned response. `response.txt` is the
printf '%s' "$RESPONSE" > gemini.stdout.txt # one the harness persists into harness_runs.response.
printf '%s' "$RAW" > gemini.stdout.raw
printf '%s' "$RESPONSE" > response.txt
if [ -z "$RESPONSE" ]; then if [ -z "$RESPONSE" ]; then
log "empty gemini response (exit=$GEMINI_EXIT); last stderr:" log "empty gemini response (exit=$GEMINI_EXIT); last stderr:"
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/synapbus/synapbus/internal/apikeys" "github.com/synapbus/synapbus/internal/apikeys"
"github.com/synapbus/synapbus/internal/attachments" "github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels" "github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/harness/runs"
"github.com/synapbus/synapbus/internal/k8s" "github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging" "github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactor" "github.com/synapbus/synapbus/internal/reactor"
@@ -41,6 +42,7 @@ type RouterConfig struct {
TrustService *trust.Service TrustService *trust.Service
ReactorStore *reactor.Store ReactorStore *reactor.Store
ReactorEngine *reactor.Reactor ReactorEngine *reactor.Reactor
HarnessRunsStore *runs.Store
WikiService *wiki.Service WikiService *wiki.Service
SSEHub *SSEHub SSEHub *SSEHub
Broadcaster *SSEBroadcaster Broadcaster *SSEBroadcaster
@@ -246,7 +248,14 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
// Reactive Runs // Reactive Runs
if cfg.ReactorStore != nil && cfg.ReactorEngine != nil && cfg.AgentService != nil { if cfg.ReactorStore != nil && cfg.ReactorEngine != nil && cfg.AgentService != nil {
runsHandler := NewRunsHandler(cfg.ReactorStore, cfg.ReactorEngine, agents.NewSQLiteAgentStore(cfg.DB)) runsHandler := NewRunsHandler(
cfg.ReactorStore,
cfg.ReactorEngine,
agents.NewSQLiteAgentStore(cfg.DB),
cfg.HarnessRunsStore,
cfg.MsgService,
cfg.DB,
)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authMiddleware) r.Use(authMiddleware)
+103 -9
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"database/sql"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -8,22 +9,37 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents" "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" "github.com/synapbus/synapbus/internal/reactor"
) )
// RunsHandler handles REST API requests for reactive runs. // RunsHandler handles REST API requests for reactive runs.
type RunsHandler struct { type RunsHandler struct {
store *reactor.Store store *reactor.Store
reactor *reactor.Reactor reactor *reactor.Reactor
agentStore agents.AgentStore agentStore agents.AgentStore
harnessRuns *runs.Store
msgService *messaging.MessagingService
db *sql.DB
} }
// NewRunsHandler creates a new runs handler. // NewRunsHandler creates a new runs handler.
func NewRunsHandler(store *reactor.Store, r *reactor.Reactor, agentStore agents.AgentStore) *RunsHandler { func NewRunsHandler(
store *reactor.Store,
r *reactor.Reactor,
agentStore agents.AgentStore,
harnessRuns *runs.Store,
msgService *messaging.MessagingService,
db *sql.DB,
) *RunsHandler {
return &RunsHandler{ return &RunsHandler{
store: store, store: store,
reactor: r, reactor: r,
agentStore: agentStore, agentStore: agentStore,
harnessRuns: harnessRuns,
msgService: msgService,
db: db,
} }
} }
@@ -57,7 +73,12 @@ func (h *RunsHandler) ListRuns(w http.ResponseWriter, r *http.Request) {
}) })
} }
// GetRun returns a single run by ID. // 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) { func (h *RunsHandler) GetRun(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
@@ -72,7 +93,80 @@ func (h *RunsHandler) GetRun(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, run) 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. // RetryRun retries a failed run.
+18
View File
@@ -96,6 +96,12 @@ type ExecRequest struct {
// asks the backend to resume a prior conversation. // asks the backend to resume a prior conversation.
SessionID string SessionID string
// ReactiveRunID, when > 0, is the id of the reactive_runs row
// that triggered this dispatch. Stored alongside the harness_runs
// row so the Web UI can JOIN reactive_runs ↔ harness_runs and
// show operators which subprocess run produced which agent reply.
ReactiveRunID int64
// Budget bounds wall-clock, tokens, and cost. // Budget bounds wall-clock, tokens, and cost.
Budget Budget Budget Budget
@@ -125,6 +131,18 @@ type ExecResult struct {
// in the response body. // in the response body.
ResultJSON json.RawMessage ResultJSON json.RawMessage
// Prompt is the rendered prompt the child actually received —
// system instructions + user message + whatever context the
// backend assembled. Subprocess backends populate this by reading
// a `prompt.txt` the wrapper wrote into the workdir. Bounded in
// size by the observer when it persists.
Prompt string
// Response is the raw text the backend produced before any
// post-processing (routing decisions, JSON parsing). Subprocess
// backends read this from `response.txt`.
Response string
// Usage captures token / cost accounting when the backend can // Usage captures token / cost accounting when the backend can
// report it. Zero values mean "not reported". // report it. Zero values mean "not reported".
Usage Usage Usage Usage
+97 -38
View File
@@ -16,27 +16,32 @@ import (
"github.com/synapbus/synapbus/internal/observability" "github.com/synapbus/synapbus/internal/observability"
) )
// Run is one row of the harness_runs table. // Run is one row of the harness_runs table. JSON tags match the
// snake_case convention used everywhere else in the Web UI TypeScript
// layer; fields map 1:1 to column names.
type Run struct { type Run struct {
ID int64 ID int64 `json:"id"`
RunID string RunID string `json:"run_id"`
AgentName string AgentName string `json:"agent_name"`
Backend string Backend string `json:"backend"`
MessageID *int64 MessageID *int64 `json:"message_id,omitempty"`
Status string ReactiveRunID *int64 `json:"reactive_run_id,omitempty"`
ExitCode *int Status string `json:"status"`
TraceID string ExitCode *int `json:"exit_code,omitempty"`
SpanID string TraceID string `json:"trace_id,omitempty"`
SessionID string SpanID string `json:"span_id,omitempty"`
TokensIn int64 SessionID string `json:"session_id,omitempty"`
TokensOut int64 TokensIn int64 `json:"tokens_in"`
TokensCached int64 TokensOut int64 `json:"tokens_out"`
CostUSD float64 TokensCached int64 `json:"tokens_cached"`
DurationMs *int64 CostUSD float64 `json:"cost_usd"`
ResultJSON string DurationMs *int64 `json:"duration_ms,omitempty"`
LogsExcerpt string ResultJSON string `json:"result_json,omitempty"`
CreatedAt time.Time LogsExcerpt string `json:"logs_excerpt,omitempty"`
FinishedAt *time.Time Prompt string `json:"prompt,omitempty"`
Response string `json:"response,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
} }
// Status constants match the harness_runs.status column domain. // Status constants match the harness_runs.status column domain.
@@ -87,14 +92,20 @@ func (s *Store) OnStart(ctx context.Context, agent *agents.Agent, harnessName st
id := req.Message.ID id := req.Message.ID
msgID = &id msgID = &id
} }
var reactiveRunID *int64
if req.ReactiveRunID > 0 {
id := req.ReactiveRunID
reactiveRunID = &id
}
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`INSERT INTO harness_runs (run_id, agent_name, backend, message_id, status, trace_id, session_id, created_at) `INSERT INTO harness_runs (run_id, agent_name, backend, message_id, reactive_run_id, status, trace_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
req.RunID, req.RunID,
agentNameOf(agent), agentNameOf(agent),
harnessName, harnessName,
msgID, msgID,
reactiveRunID,
StatusRunning, StatusRunning,
observability.TraceIDFromContext(ctx), observability.TraceIDFromContext(ctx),
req.SessionID, req.SessionID,
@@ -125,6 +136,7 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
var exitCode *int var exitCode *int
logsExcerpt := "" logsExcerpt := ""
var resultJSON string var resultJSON string
var promptText, responseText string
var tokensIn, tokensOut, tokensCached int64 var tokensIn, tokensOut, tokensCached int64
var costUSD float64 var costUSD float64
sessionID := req.SessionID sessionID := req.SessionID
@@ -136,6 +148,8 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
if len(res.ResultJSON) > 0 { if len(res.ResultJSON) > 0 {
resultJSON = string(res.ResultJSON) resultJSON = string(res.ResultJSON)
} }
promptText = res.Prompt
responseText = res.Response
tokensIn = res.Usage.TokensIn tokensIn = res.Usage.TokensIn
tokensOut = res.Usage.TokensOut tokensOut = res.Usage.TokensOut
tokensCached = res.Usage.TokensCached tokensCached = res.Usage.TokensCached
@@ -150,11 +164,21 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
if execErr != nil { if execErr != nil {
status = StatusFailed status = StatusFailed
} }
// Cap logs excerpt to keep row sizes sane (matches design: bounded). // Cap each large text field so harness_runs rows stay reasonable.
const logsCap = 16 * 1024 const (
logsCap = 16 * 1024
promptCap = 32 * 1024
responseCap = 32 * 1024
)
if len(logsExcerpt) > logsCap { if len(logsExcerpt) > logsCap {
logsExcerpt = "... [truncated] ...\n" + logsExcerpt[len(logsExcerpt)-logsCap:] logsExcerpt = "... [truncated] ...\n" + logsExcerpt[len(logsExcerpt)-logsCap:]
} }
if len(promptText) > promptCap {
promptText = "... [truncated " + fmt.Sprintf("%d", len(promptText)-promptCap) + " bytes] ...\n" + promptText[len(promptText)-promptCap:]
}
if len(responseText) > responseCap {
responseText = "... [truncated " + fmt.Sprintf("%d", len(responseText)-responseCap) + " bytes] ...\n" + responseText[len(responseText)-responseCap:]
}
traceID := "" traceID := ""
if res != nil && res.TraceID != "" { if res != nil && res.TraceID != "" {
@@ -170,6 +194,7 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
status = ?, exit_code = ?, trace_id = ?, session_id = ?, status = ?, exit_code = ?, trace_id = ?, session_id = ?,
tokens_in = ?, tokens_out = ?, tokens_cached = ?, cost_usd = ?, tokens_in = ?, tokens_out = ?, tokens_cached = ?, cost_usd = ?,
duration_ms = ?, result_json = ?, logs_excerpt = ?, duration_ms = ?, result_json = ?, logs_excerpt = ?,
prompt = ?, response = ?,
finished_at = CURRENT_TIMESTAMP finished_at = CURRENT_TIMESTAMP
WHERE run_id = ?` WHERE run_id = ?`
@@ -177,19 +202,26 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
status, exitCode, traceID, sessionID, status, exitCode, traceID, sessionID,
tokensIn, tokensOut, tokensCached, costUSD, tokensIn, tokensOut, tokensCached, costUSD,
durationMs, nullableString(resultJSON), nullableString(logsExcerpt), durationMs, nullableString(resultJSON), nullableString(logsExcerpt),
nullableString(promptText), nullableString(responseText),
req.RunID, req.RunID,
) )
if err == nil { if err == nil {
if n, _ := result.RowsAffected(); n == 0 { if n, _ := result.RowsAffected(); n == 0 {
// Row didn't exist — insert it fresh. // Row didn't exist — insert it fresh.
var reactiveRunID *int64
if req.ReactiveRunID > 0 {
id := req.ReactiveRunID
reactiveRunID = &id
}
_, err = s.db.ExecContext(ctx, _, err = s.db.ExecContext(ctx,
`INSERT INTO harness_runs (run_id, agent_name, backend, status, exit_code, trace_id, session_id, `INSERT INTO harness_runs (run_id, agent_name, backend, reactive_run_id, status, exit_code, trace_id, session_id,
tokens_in, tokens_out, tokens_cached, cost_usd, duration_ms, result_json, logs_excerpt, created_at, finished_at) tokens_in, tokens_out, tokens_cached, cost_usd, duration_ms, result_json, logs_excerpt, prompt, response, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
req.RunID, agentNameOf(agent), harnessName, req.RunID, agentNameOf(agent), harnessName, reactiveRunID,
status, exitCode, traceID, sessionID, status, exitCode, traceID, sessionID,
tokensIn, tokensOut, tokensCached, costUSD, tokensIn, tokensOut, tokensCached, costUSD,
durationMs, nullableString(resultJSON), nullableString(logsExcerpt), durationMs, nullableString(resultJSON), nullableString(logsExcerpt),
nullableString(promptText), nullableString(responseText),
) )
} }
} }
@@ -206,6 +238,21 @@ func (s *Store) GetByRunID(ctx context.Context, runID string) (*Run, error) {
return scanRun(row) return scanRun(row)
} }
// GetByReactiveRunID returns the most recent harness_run linked to a
// reactive_run. Used by the Web UI to JOIN the two tables without
// leaking SQL into the API layer. Returns nil, nil when no row exists.
func (s *Store) GetByReactiveRunID(ctx context.Context, reactiveRunID int64) (*Run, error) {
row := s.db.QueryRowContext(ctx,
selectSQL()+` WHERE reactive_run_id = ? ORDER BY id DESC LIMIT 1`,
reactiveRunID,
)
run, err := scanRun(row)
if err == sql.ErrNoRows {
return nil, nil
}
return run, err
}
// ListByAgent returns recent runs for an agent, newest first. // ListByAgent returns recent runs for an agent, newest first.
func (s *Store) ListByAgent(ctx context.Context, agentName string, limit int) ([]*Run, error) { func (s *Store) ListByAgent(ctx context.Context, agentName string, limit int) ([]*Run, error) {
if limit <= 0 { if limit <= 0 {
@@ -239,23 +286,23 @@ func nullableString(s string) any {
} }
func selectSQL() string { func selectSQL() string {
return `SELECT id, run_id, agent_name, backend, message_id, status, exit_code, return `SELECT id, run_id, agent_name, backend, message_id, reactive_run_id, status, exit_code,
trace_id, span_id, session_id, tokens_in, tokens_out, tokens_cached, cost_usd, trace_id, span_id, session_id, tokens_in, tokens_out, tokens_cached, cost_usd,
duration_ms, result_json, logs_excerpt, created_at, finished_at duration_ms, result_json, logs_excerpt, prompt, response, created_at, finished_at
FROM harness_runs` FROM harness_runs`
} }
func scanRun(row *sql.Row) (*Run, error) { func scanRun(row *sql.Row) (*Run, error) {
var r Run var r Run
var msgID sql.NullInt64 var msgID, reactiveRunID sql.NullInt64
var exitCode sql.NullInt64 var exitCode sql.NullInt64
var traceID, spanID, sessionID, resultJSON, logsExcerpt sql.NullString var traceID, spanID, sessionID, resultJSON, logsExcerpt, prompt, response sql.NullString
var durationMs sql.NullInt64 var durationMs sql.NullInt64
var createdAt, finishedAt sql.NullTime var createdAt, finishedAt sql.NullTime
if err := row.Scan( if err := row.Scan(
&r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &r.Status, &exitCode, &r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &reactiveRunID, &r.Status, &exitCode,
&traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD, &traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD,
&durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt, &durationMs, &resultJSON, &logsExcerpt, &prompt, &response, &createdAt, &finishedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -263,6 +310,10 @@ func scanRun(row *sql.Row) (*Run, error) {
v := msgID.Int64 v := msgID.Int64
r.MessageID = &v r.MessageID = &v
} }
if reactiveRunID.Valid {
v := reactiveRunID.Int64
r.ReactiveRunID = &v
}
if exitCode.Valid { if exitCode.Valid {
v := int(exitCode.Int64) v := int(exitCode.Int64)
r.ExitCode = &v r.ExitCode = &v
@@ -272,6 +323,8 @@ func scanRun(row *sql.Row) (*Run, error) {
r.SessionID = sessionID.String r.SessionID = sessionID.String
r.ResultJSON = resultJSON.String r.ResultJSON = resultJSON.String
r.LogsExcerpt = logsExcerpt.String r.LogsExcerpt = logsExcerpt.String
r.Prompt = prompt.String
r.Response = response.String
if durationMs.Valid { if durationMs.Valid {
v := durationMs.Int64 v := durationMs.Int64
r.DurationMs = &v r.DurationMs = &v
@@ -290,15 +343,15 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
var out []*Run var out []*Run
for rows.Next() { for rows.Next() {
var r Run var r Run
var msgID sql.NullInt64 var msgID, reactiveRunID sql.NullInt64
var exitCode sql.NullInt64 var exitCode sql.NullInt64
var traceID, spanID, sessionID, resultJSON, logsExcerpt sql.NullString var traceID, spanID, sessionID, resultJSON, logsExcerpt, prompt, response sql.NullString
var durationMs sql.NullInt64 var durationMs sql.NullInt64
var createdAt, finishedAt sql.NullTime var createdAt, finishedAt sql.NullTime
if err := rows.Scan( if err := rows.Scan(
&r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &r.Status, &exitCode, &r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &reactiveRunID, &r.Status, &exitCode,
&traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD, &traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD,
&durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt, &durationMs, &resultJSON, &logsExcerpt, &prompt, &response, &createdAt, &finishedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -306,6 +359,10 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
v := msgID.Int64 v := msgID.Int64
r.MessageID = &v r.MessageID = &v
} }
if reactiveRunID.Valid {
v := reactiveRunID.Int64
r.ReactiveRunID = &v
}
if exitCode.Valid { if exitCode.Valid {
v := int(exitCode.Int64) v := int(exitCode.Int64)
r.ExitCode = &v r.ExitCode = &v
@@ -315,6 +372,8 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
r.SessionID = sessionID.String r.SessionID = sessionID.String
r.ResultJSON = resultJSON.String r.ResultJSON = resultJSON.String
r.LogsExcerpt = logsExcerpt.String r.LogsExcerpt = logsExcerpt.String
r.Prompt = prompt.String
r.Response = response.String
if durationMs.Valid { if durationMs.Valid {
v := durationMs.Int64 v := durationMs.Int64
r.DurationMs = &v r.DurationMs = &v
+22 -19
View File
@@ -25,25 +25,28 @@ func setupDB(t *testing.T) *sql.DB {
} }
schema := ` schema := `
CREATE TABLE harness_runs ( CREATE TABLE harness_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL UNIQUE, run_id TEXT NOT NULL UNIQUE,
agent_name TEXT NOT NULL, agent_name TEXT NOT NULL,
backend TEXT NOT NULL, backend TEXT NOT NULL,
message_id INTEGER, message_id INTEGER,
status TEXT NOT NULL, reactive_run_id INTEGER,
exit_code INTEGER, status TEXT NOT NULL,
trace_id TEXT, exit_code INTEGER,
span_id TEXT, trace_id TEXT,
session_id TEXT, span_id TEXT,
tokens_in INTEGER NOT NULL DEFAULT 0, session_id TEXT,
tokens_out INTEGER NOT NULL DEFAULT 0, tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_cached INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0, tokens_cached INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER, cost_usd REAL NOT NULL DEFAULT 0,
result_json TEXT, duration_ms INTEGER,
logs_excerpt TEXT, result_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, logs_excerpt TEXT,
finished_at DATETIME prompt TEXT,
response TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME
);` );`
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
t.Fatalf("schema: %v", err) t.Fatalf("schema: %v", err)
+13
View File
@@ -202,6 +202,17 @@ func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harne
} }
} }
// Load optional prompt.txt / response.txt that wrappers write as
// a post-hoc audit trail for the Web UI run detail view.
promptText := ""
if raw, readErr := os.ReadFile(filepath.Join(workdir, "prompt.txt")); readErr == nil {
promptText = string(raw)
}
responseText := ""
if raw, readErr := os.ReadFile(filepath.Join(workdir, "response.txt")); readErr == nil {
responseText = string(raw)
}
logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap) logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap)
// Cleanup policy: remove workdir on success unless configured to // Cleanup policy: remove workdir on success unless configured to
@@ -222,6 +233,8 @@ func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harne
ExitCode: exitCode, ExitCode: exitCode,
Logs: logs, Logs: logs,
ResultJSON: resultJSON, ResultJSON: resultJSON,
Prompt: promptText,
Response: responseText,
} }
// Distinguish context timeout from plain failures so the caller // Distinguish context timeout from plain failures so the caller
+52 -9
View File
@@ -33,12 +33,13 @@ const (
// Reactor is the reactive agent triggering engine. // Reactor is the reactive agent triggering engine.
type Reactor struct { type Reactor struct {
store *Store store *Store
agentStore agents.AgentStore agentStore agents.AgentStore
runner k8spkg.JobRunner runner k8spkg.JobRunner
registry *harness.Registry registry *harness.Registry
notifier FailureNotifier notifier FailureNotifier
logger *slog.Logger reactions ReactionNotifier
logger *slog.Logger
} }
// FailureNotifier sends system DMs on job failure. // FailureNotifier sends system DMs on job failure.
@@ -46,6 +47,15 @@ type FailureNotifier interface {
NotifyFailure(ctx context.Context, ownerAgentName, agentName, triggerFrom, triggerEvent string, durationMs int64, errorSummary string) error NotifyFailure(ctx context.Context, ownerAgentName, agentName, triggerFrom, triggerEvent string, durationMs int64, errorSummary string) error
} }
// ReactionNotifier lets the reactor mark the triggering DM with a
// workflow reaction (in_progress / done / reject) so the sender can
// see at a glance whether their message is being processed. Errors
// are logged, never returned — reactions are cosmetic and must not
// block the main dispatch flow.
type ReactionNotifier interface {
AddReaction(ctx context.Context, messageID int64, agentName, reactionType string) error
}
// New creates a new Reactor. // New creates a new Reactor.
func New(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, logger *slog.Logger) *Reactor { func New(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, logger *slog.Logger) *Reactor {
return &Reactor{ return &Reactor{
@@ -68,6 +78,29 @@ func (r *Reactor) SetHarnessRegistry(reg *harness.Registry) {
r.registry = reg r.registry = reg
} }
// SetReactionNotifier wires the component that marks triggering DMs
// with in_progress / done / reject reactions. Optional — a nil
// notifier simply skips the reaction step.
func (r *Reactor) SetReactionNotifier(n ReactionNotifier) {
r.reactions = n
}
// react is a best-effort wrapper that swallows and logs errors so the
// dispatch flow is never blocked by a reaction-store hiccup.
func (r *Reactor) react(ctx context.Context, messageID int64, agentName, reactionType string) {
if r.reactions == nil || messageID <= 0 {
return
}
if err := r.reactions.AddReaction(ctx, messageID, agentName, reactionType); err != nil {
r.logger.Debug("reaction notifier failed",
"message_id", messageID,
"agent", agentName,
"reaction", reactionType,
"error", err,
)
}
}
// Dispatch implements dispatcher.EventDispatcher. Called by MultiDispatcher // Dispatch implements dispatcher.EventDispatcher. Called by MultiDispatcher
// when a message event occurs. // when a message event occurs.
func (r *Reactor) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error { func (r *Reactor) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error {
@@ -273,6 +306,10 @@ func (r *Reactor) dispatchHarness(ctx context.Context, agent *agents.Agent, even
metrics.ReactiveTriggersTotal.WithLabelValues(agent.Name, StatusRunning).Inc() metrics.ReactiveTriggersTotal.WithLabelValues(agent.Name, StatusRunning).Inc()
metrics.ReactiveAgentState.WithLabelValues(agent.Name).Set(1) metrics.ReactiveAgentState.WithLabelValues(agent.Name).Set(1)
// Eyes-on: mark the triggering DM in_progress as the targeted
// agent so the sender's Web UI shows visual progress.
r.react(ctx, event.MessageID, agent.Name, "in_progress")
r.logger.Info("reactive harness dispatch", r.logger.Info("reactive harness dispatch",
"agent", agent.Name, "agent", agent.Name,
"backend", r.agentBackendKind(agent), "backend", r.agentBackendKind(agent),
@@ -283,9 +320,10 @@ func (r *Reactor) dispatchHarness(ctx context.Context, agent *agents.Agent, even
) )
req := &harness.ExecRequest{ req := &harness.ExecRequest{
RunID: uuid.NewString(), RunID: uuid.NewString(),
AgentName: agent.Name, AgentName: agent.Name,
Agent: agent, Agent: agent,
ReactiveRunID: runID,
Message: &messaging.Message{ Message: &messaging.Message{
ID: event.MessageID, ID: event.MessageID,
FromAgent: event.FromAgent, FromAgent: event.FromAgent,
@@ -343,7 +381,11 @@ func (r *Reactor) runHarness(runID int64, agent *agents.Agent, event dispatcher.
todayCount, _ := r.store.CountTodayRuns(ctx, agent.Name) todayCount, _ := r.store.CountTodayRuns(ctx, agent.Name)
metrics.ReactiveBudgetUsed.WithLabelValues(agent.Name).Set(float64(todayCount)) metrics.ReactiveBudgetUsed.WithLabelValues(agent.Name).Set(float64(todayCount))
// Mark the triggering DM with the terminal reaction. Done wins
// over in_progress via priority (see reactions.reactionPriority),
// so we don't need to remove the in_progress reaction first.
if status == StatusFailed { if status == StatusFailed {
r.react(context.Background(), event.MessageID, agent.Name, "reject")
r.logger.Warn("reactive harness run failed", r.logger.Warn("reactive harness run failed",
"agent", agent.Name, "agent", agent.Name,
"run_id", runID, "run_id", runID,
@@ -351,6 +393,7 @@ func (r *Reactor) runHarness(runID int64, agent *agents.Agent, event dispatcher.
) )
r.notifyFailure(context.Background(), agent, event, durationMs, errorLog) r.notifyFailure(context.Background(), agent, event, durationMs, errorLog)
} else { } else {
r.react(context.Background(), event.MessageID, agent.Name, "done")
r.logger.Info("reactive harness run succeeded", r.logger.Info("reactive harness run succeeded",
"agent", agent.Name, "agent", agent.Name,
"run_id", runID, "run_id", runID,
@@ -0,0 +1,9 @@
-- 020: Harness run detail — link reactive_runs to harness_runs and
-- capture the rendered prompt + raw response so the Web UI can show
-- operators exactly what the agent saw and what it replied.
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);
+17
View File
@@ -2,6 +2,7 @@
import { openThread } from '$lib/stores/thread'; import { openThread } from '$lib/stores/thread';
import MessageBody from '$lib/components/MessageBody.svelte'; import MessageBody from '$lib/components/MessageBody.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte'; import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import ReactionPills from '$lib/components/ReactionPills.svelte';
type Attachment = { type Attachment = {
hash: string; hash: string;
@@ -11,6 +12,12 @@
is_image: boolean; is_image: boolean;
}; };
type ReactionEntry = {
agent_name: string;
reaction: string;
metadata?: Record<string, any>;
};
type Message = { type Message = {
id: number; id: number;
conversation_id: number; conversation_id: number;
@@ -22,6 +29,7 @@
created_at: string; created_at: string;
reply_count?: number; reply_count?: number;
attachments?: Attachment[]; attachments?: Attachment[];
reactions?: ReactionEntry[];
}; };
let { messages = [], showConversationLink = false, agentTypes = {} }: { messages: Message[]; showConversationLink?: boolean; agentTypes?: Record<string, string> } = $props(); let { messages = [], showConversationLink = false, agentTypes = {} }: { messages: Message[]; showConversationLink?: boolean; agentTypes?: Record<string, string> } = $props();
@@ -118,6 +126,15 @@
</div> </div>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={800} /></div> <div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={800} /></div>
<!-- Reactions: rendered below the body so lifecycle pills -->
<!-- (in_progress / done / reject) from the reactor are -->
<!-- visible to the sender in real time. -->
{#if msg.reactions && msg.reactions.length > 0}
<div onclick={(e) => e.stopPropagation()} role="presentation">
<ReactionPills reactions={msg.reactions} messageId={msg.id} />
</div>
{/if}
<!-- Attachments --> <!-- Attachments -->
{#if msg.attachments && msg.attachments.length > 0} {#if msg.attachments && msg.attachments.length > 0}
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
+15 -5
View File
@@ -197,11 +197,14 @@
<pre>{run.error_log}</pre> <pre>{run.error_log}</pre>
</div> </div>
{/if} {/if}
{#if run.status === 'failed'} <div class="detail-actions">
<button class="retry-btn" onclick={() => retryRun(run.id)}> <a href="/runs/{run.id}" class="detail-link">View full details →</a>
Retry {#if run.status === 'failed'}
</button> <button class="retry-btn" onclick={() => retryRun(run.id)}>
{/if} Retry
</button>
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -451,4 +454,11 @@
.retry-btn:hover { .retry-btn:hover {
opacity: 0.9; opacity: 0.9;
} }
.detail-actions {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
</style> </style>
+393
View File
@@ -0,0 +1,393 @@
<script lang="ts">
import { page } from '$app/stores';
import { runs as runsApi } from '$lib/api/client';
type Run = {
id: number;
agent_name: string;
status: string;
trigger_event: string;
trigger_from: string;
trigger_depth: number;
trigger_message_id?: number | null;
k8s_job_name?: string;
duration_ms?: number | null;
started_at?: string;
completed_at?: string;
created_at: string;
error_log?: string;
};
type HarnessRun = {
id: number;
run_id: string;
backend: string;
status: string;
exit_code?: number | null;
trace_id?: string;
session_id?: string;
tokens_in: number;
tokens_out: number;
tokens_cached: number;
cost_usd: number;
duration_ms?: number | null;
logs_excerpt?: string;
prompt?: string;
response?: string;
result_json?: string;
created_at: string;
finished_at?: string;
};
type AgentSnapshot = {
name: string;
display_name?: string;
type: string;
harness_name?: string;
local_command?: string;
harness_config_json?: string;
trigger_mode: string;
cooldown_seconds: number;
daily_trigger_budget: number;
max_trigger_depth: number;
};
type TriggerMessage = {
id: number;
from_agent: string;
to_agent?: string;
body: string;
priority: number;
status: string;
created_at: string;
};
type OutgoingMessage = {
id: number;
to_agent?: string;
body: string;
status: string;
created_at: string;
};
let loading = $state(true);
let error = $state('');
let run = $state<Run | null>(null);
let harnessRun = $state<HarnessRun | null>(null);
let agent = $state<AgentSnapshot | null>(null);
let triggerMessage = $state<TriggerMessage | null>(null);
let outgoingMessage = $state<OutgoingMessage | null>(null);
let harnessConfigParsed = $state<any>(null);
let runId = $derived(parseInt($page.params.id, 10));
async function load() {
loading = true;
error = '';
try {
const res: any = await runsApi.get(runId);
run = res.run;
harnessRun = res.harness_run ?? null;
agent = res.agent ?? null;
triggerMessage = res.trigger_message ?? null;
outgoingMessage = res.outgoing_message ?? null;
if (agent?.harness_config_json) {
try {
harnessConfigParsed = JSON.parse(agent.harness_config_json);
} catch {
harnessConfigParsed = null;
}
}
} catch (err: any) {
error = err?.message || 'Failed to load run';
} finally {
loading = false;
}
}
let _initialized = $state(false);
$effect(() => {
if (!_initialized && !Number.isNaN(runId)) {
_initialized = true;
load();
}
});
function statusDot(status: string): string {
switch (status) {
case 'running': return 'bg-accent-blue animate-pulse';
case 'succeeded':
case 'success': return 'bg-accent-green';
case 'failed': return 'bg-accent-red';
case 'queued':
case 'cooldown_skipped':
case 'budget_exhausted':
case 'depth_exceeded': return 'bg-accent-yellow';
default: return 'bg-text-secondary';
}
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'running': return 'bg-accent-blue/15 text-accent-blue border-accent-blue/30';
case 'succeeded':
case 'success': return 'bg-accent-green/15 text-accent-green border-accent-green/30';
case 'failed': return 'bg-accent-red/15 text-accent-red border-accent-red/30';
default: return 'bg-bg-tertiary text-text-secondary border-border';
}
}
function formatDuration(ms?: number | null): string {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
const m = s / 60;
return `${m.toFixed(1)}m`;
}
function formatCost(usd: number): string {
if (usd < 0.0001) return `$${usd.toFixed(6)}`;
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(3)}`;
}
function formatTokens(n: number): string {
if (n < 1000) return String(n);
return `${(n / 1000).toFixed(1)}k`;
}
async function copy(text: string) {
try { await navigator.clipboard.writeText(text); } catch {}
}
</script>
<div class="p-5 max-w-5xl mx-auto">
<a href="/runs" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to runs
</a>
{#if loading}
<div class="card p-8 text-center text-text-secondary text-sm">Loading run #{runId}…</div>
{:else if error}
<div class="card p-6 border border-accent-red/30">
<p class="text-sm text-accent-red font-semibold mb-1">Failed to load run</p>
<p class="text-xs text-text-secondary font-mono">{error}</p>
</div>
{:else if run}
<!-- Header strip -->
<div class="card mb-5">
<div class="p-5 border-b border-border flex items-start justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<span class="w-2.5 h-2.5 rounded-full {statusDot(run.status)}"></span>
<div>
<h1 class="text-lg font-bold text-text-primary font-display">
<a href="/agents/{run.agent_name}" class="hover:text-accent-blue">{run.agent_name}</a>
<span class="text-text-secondary font-mono text-sm ml-2">#{run.id}</span>
</h1>
<p class="text-xs text-text-secondary font-mono mt-0.5">
triggered by <a href="/agents/{run.trigger_from}" class="text-text-link hover:underline">@{run.trigger_from}</a>
&middot; {run.trigger_event}
&middot; depth {run.trigger_depth}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="badge border {statusBadgeClass(run.status)}">{run.status}</span>
{#if harnessRun}
<span class="badge bg-bg-tertiary text-text-secondary border border-border font-mono text-[10px]">{harnessRun.backend}</span>
{/if}
</div>
</div>
<!-- Metrics grid -->
<div class="p-5 grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Duration</p>
<p class="text-text-primary font-mono">{formatDuration(run.duration_ms)}</p>
</div>
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Tokens in → out</p>
<p class="text-text-primary font-mono">
{harnessRun ? `${formatTokens(harnessRun.tokens_in)} → ${formatTokens(harnessRun.tokens_out)}` : '—'}
</p>
</div>
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Cost</p>
<p class="text-text-primary font-mono">
{harnessRun ? formatCost(harnessRun.cost_usd) : '—'}
</p>
</div>
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Exit</p>
<p class="text-text-primary font-mono">
{harnessRun?.exit_code != null ? harnessRun.exit_code : '—'}
</p>
</div>
{#if run.started_at}
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Started</p>
<p class="text-text-primary text-xs">{new Date(run.started_at).toLocaleString()}</p>
</div>
{/if}
{#if run.completed_at}
<div>
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Completed</p>
<p class="text-text-primary text-xs">{new Date(run.completed_at).toLocaleString()}</p>
</div>
{/if}
{#if harnessRun?.trace_id}
<div class="col-span-2">
<p class="text-[10px] uppercase tracking-wider text-text-secondary">Trace ID</p>
<button
class="text-text-primary font-mono text-[11px] hover:text-accent-blue truncate block w-full text-left"
title="Copy"
onclick={() => copy(harnessRun?.trace_id || '')}
>{harnessRun.trace_id}</button>
</div>
{/if}
</div>
</div>
<!-- Triggering message -->
{#if triggerMessage}
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border flex items-center gap-2">
<svg class="w-4 h-4 text-accent-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<h2 class="font-semibold text-sm text-text-primary font-display">Triggering message</h2>
<span class="ml-auto text-[10px] font-mono text-text-secondary">#{triggerMessage.id}</span>
</div>
<div class="p-5">
<p class="text-xs text-text-secondary mb-1">
from <span class="text-text-primary font-mono">@{triggerMessage.from_agent}</span>
{#if triggerMessage.to_agent} → <span class="text-text-primary font-mono">@{triggerMessage.to_agent}</span>{/if}
&middot; priority {triggerMessage.priority}
</p>
<pre class="text-sm text-text-primary bg-bg-primary border border-border rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">{triggerMessage.body}</pre>
</div>
</div>
{/if}
<!-- What the model saw: system instructions + rendered prompt -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border flex items-center gap-2">
<svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<h2 class="font-semibold text-sm text-text-primary font-display">What the model saw</h2>
</div>
<div class="p-5 space-y-4">
{#if harnessConfigParsed?.gemini_md}
<details class="bg-bg-primary border border-border rounded-lg" open>
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary flex items-center justify-between">
<span>GEMINI.md (agent's current system instructions)</span>
<span class="font-mono text-[10px]">{harnessConfigParsed.gemini_md.length} bytes</span>
</summary>
<pre class="px-3 pb-3 text-xs font-mono text-text-primary/80 whitespace-pre-wrap leading-relaxed">{harnessConfigParsed.gemini_md}</pre>
</details>
{/if}
{#if harnessConfigParsed?.claude_md}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary flex items-center justify-between">
<span>CLAUDE.md (agent's current system instructions)</span>
<span class="font-mono text-[10px]">{harnessConfigParsed.claude_md.length} bytes</span>
</summary>
<pre class="px-3 pb-3 text-xs font-mono text-text-primary/80 whitespace-pre-wrap leading-relaxed">{harnessConfigParsed.claude_md}</pre>
</details>
{/if}
{#if harnessRun?.prompt}
<details class="bg-bg-primary border border-border rounded-lg" open>
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary flex items-center justify-between">
<span>Rendered prompt (captured from subprocess workdir)</span>
<span class="font-mono text-[10px]">{harnessRun.prompt.length} bytes</span>
</summary>
<pre class="px-3 pb-3 text-xs font-mono text-text-primary/80 whitespace-pre-wrap leading-relaxed">{harnessRun.prompt}</pre>
</details>
{:else}
<p class="text-xs text-text-secondary italic">No captured prompt — this run's backend does not record prompts, or the wrapper did not write <code class="font-mono">prompt.txt</code>.</p>
{/if}
</div>
</div>
<!-- What the model said: raw response -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border flex items-center gap-2">
<svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<h2 class="font-semibold text-sm text-text-primary font-display">What the model said</h2>
</div>
<div class="p-5">
{#if harnessRun?.response}
<pre class="text-sm text-text-primary bg-bg-primary border border-border rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed max-h-96 overflow-y-auto">{harnessRun.response}</pre>
{:else if harnessRun?.logs_excerpt}
<p class="text-xs text-text-secondary mb-2 italic">No captured response field — showing bounded logs excerpt instead.</p>
<pre class="text-xs text-text-primary/80 bg-bg-primary border border-border rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed max-h-96 overflow-y-auto">{harnessRun.logs_excerpt}</pre>
{:else if run.error_log}
<p class="text-xs text-accent-red mb-2 font-semibold">Error log</p>
<pre class="text-xs text-accent-red bg-accent-red/5 border border-accent-red/30 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">{run.error_log}</pre>
{:else}
<p class="text-xs text-text-secondary italic">Nothing captured.</p>
{/if}
</div>
</div>
<!-- Outgoing message -->
{#if outgoingMessage}
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border flex items-center gap-2">
<svg class="w-4 h-4 text-accent-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<h2 class="font-semibold text-sm text-text-primary font-display">Outgoing message</h2>
<span class="ml-auto text-[10px] font-mono text-text-secondary">#{outgoingMessage.id}</span>
</div>
<div class="p-5">
<p class="text-xs text-text-secondary mb-1">
<span class="text-text-primary font-mono">@{run.agent_name}</span>
{#if outgoingMessage.to_agent} → <a href="/agents/{outgoingMessage.to_agent}" class="text-text-link hover:underline font-mono">@{outgoingMessage.to_agent}</a>{/if}
&middot; {outgoingMessage.status}
</p>
<pre class="text-sm text-text-primary bg-bg-primary border border-border rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed max-h-80 overflow-y-auto">{outgoingMessage.body}</pre>
</div>
</div>
{/if}
<!-- Raw run / harness metadata -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Metadata</h2>
</div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs font-mono">
<div><span class="text-text-secondary">reactive_run.id</span> <span class="text-text-primary">{run.id}</span></div>
{#if harnessRun}
<div><span class="text-text-secondary">harness_run.run_id</span> <span class="text-text-primary break-all">{harnessRun.run_id}</span></div>
<div><span class="text-text-secondary">backend</span> <span class="text-text-primary">{harnessRun.backend}</span></div>
{#if harnessRun.session_id}
<div><span class="text-text-secondary">session_id</span> <span class="text-text-primary break-all">{harnessRun.session_id}</span></div>
{/if}
{#if harnessRun.tokens_cached > 0}
<div><span class="text-text-secondary">tokens_cached</span> <span class="text-text-primary">{formatTokens(harnessRun.tokens_cached)}</span></div>
{/if}
{/if}
{#if run.k8s_job_name}
<div><span class="text-text-secondary">k8s_job</span> <span class="text-text-primary break-all">{run.k8s_job_name}</span></div>
{/if}
{#if agent}
<div><span class="text-text-secondary">agent.harness_name</span> <span class="text-text-primary">{agent.harness_name || '—'}</span></div>
<div><span class="text-text-secondary">max_trigger_depth</span> <span class="text-text-primary">{agent.max_trigger_depth}</span></div>
<div><span class="text-text-secondary">daily_budget</span> <span class="text-text-primary">{agent.daily_trigger_budget}</span></div>
<div><span class="text-text-secondary">cooldown_s</span> <span class="text-text-primary">{agent.cooldown_seconds}</span></div>
{/if}
</div>
</div>
{/if}
</div>