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:
co-authored by
Claude Opus 4.6
parent
b140879bc3
commit
fee73e33a0
@@ -513,6 +513,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
harnessRunsStore := runs.New(db.DB, slog.Default())
|
||||
harnessRegistry.Observer = harnessRunsStore
|
||||
reactorEngine.SetHarnessRegistry(harnessRegistry)
|
||||
reactorEngine.SetReactionNotifier(&reactorReactionAdapter{svc: reactionService})
|
||||
slog.Info("harness registry configured",
|
||||
"backends", harnessRegistry.Names(),
|
||||
)
|
||||
@@ -713,6 +714,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
TrustService: trustService,
|
||||
ReactorStore: reactorStore,
|
||||
ReactorEngine: reactorEngine,
|
||||
HarnessRunsStore: harnessRunsStore,
|
||||
BaseURL: baseURL,
|
||||
WikiService: wikiService,
|
||||
})
|
||||
@@ -953,6 +955,18 @@ func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID
|
||||
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.
|
||||
type reactionEnricherAdapter struct {
|
||||
svc *reactions.Service
|
||||
|
||||
@@ -44,8 +44,11 @@ PROMPT="$(cat GEMINI.md)
|
||||
Incoming DM from @${FROM}:
|
||||
${BODY}"
|
||||
|
||||
# Preserve the exact prompt and any stderr noise for forensics.
|
||||
printf '%s' "$PROMPT" > gemini.prompt.txt
|
||||
# Preserve the exact prompt the model received — the subprocess
|
||||
# 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
|
||||
RAW=$(gemini -m "$GEMINI_MODEL" --approval-mode yolo -p "$PROMPT" 2>gemini.stderr.log)
|
||||
GEMINI_EXIT=$?
|
||||
@@ -55,8 +58,10 @@ set -e
|
||||
# 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\.||')
|
||||
|
||||
# Save the cleaned response for forensics before we decide next steps.
|
||||
printf '%s' "$RESPONSE" > gemini.stdout.txt
|
||||
# Save both the raw and the cleaned response. `response.txt` is the
|
||||
# one the harness persists into harness_runs.response.
|
||||
printf '%s' "$RAW" > gemini.stdout.raw
|
||||
printf '%s' "$RESPONSE" > response.txt
|
||||
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
log "empty gemini response (exit=$GEMINI_EXIT); last stderr:"
|
||||
|
||||
+10
-1
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/apikeys"
|
||||
"github.com/synapbus/synapbus/internal/attachments"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/harness/runs"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/reactor"
|
||||
@@ -41,6 +42,7 @@ type RouterConfig struct {
|
||||
TrustService *trust.Service
|
||||
ReactorStore *reactor.Store
|
||||
ReactorEngine *reactor.Reactor
|
||||
HarnessRunsStore *runs.Store
|
||||
WikiService *wiki.Service
|
||||
SSEHub *SSEHub
|
||||
Broadcaster *SSEBroadcaster
|
||||
@@ -246,7 +248,14 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
|
||||
// Reactive Runs
|
||||
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.Use(authMiddleware)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -8,22 +9,37 @@ import (
|
||||
"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
|
||||
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) *RunsHandler {
|
||||
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,
|
||||
store: store,
|
||||
reactor: r,
|
||||
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) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
@@ -72,7 +93,80 @@ func (h *RunsHandler) GetRun(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
|
||||
@@ -96,6 +96,12 @@ type ExecRequest struct {
|
||||
// asks the backend to resume a prior conversation.
|
||||
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 Budget
|
||||
|
||||
@@ -125,6 +131,18 @@ type ExecResult struct {
|
||||
// in the response body.
|
||||
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
|
||||
// report it. Zero values mean "not reported".
|
||||
Usage Usage
|
||||
|
||||
@@ -16,27 +16,32 @@ import (
|
||||
"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 {
|
||||
ID int64
|
||||
RunID string
|
||||
AgentName string
|
||||
Backend string
|
||||
MessageID *int64
|
||||
Status string
|
||||
ExitCode *int
|
||||
TraceID string
|
||||
SpanID string
|
||||
SessionID string
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
TokensCached int64
|
||||
CostUSD float64
|
||||
DurationMs *int64
|
||||
ResultJSON string
|
||||
LogsExcerpt string
|
||||
CreatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
ID int64 `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Backend string `json:"backend"`
|
||||
MessageID *int64 `json:"message_id,omitempty"`
|
||||
ReactiveRunID *int64 `json:"reactive_run_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
TokensIn int64 `json:"tokens_in"`
|
||||
TokensOut int64 `json:"tokens_out"`
|
||||
TokensCached int64 `json:"tokens_cached"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
DurationMs *int64 `json:"duration_ms,omitempty"`
|
||||
ResultJSON string `json:"result_json,omitempty"`
|
||||
LogsExcerpt string `json:"logs_excerpt,omitempty"`
|
||||
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.
|
||||
@@ -87,14 +92,20 @@ func (s *Store) OnStart(ctx context.Context, agent *agents.Agent, harnessName st
|
||||
id := req.Message.ID
|
||||
msgID = &id
|
||||
}
|
||||
var reactiveRunID *int64
|
||||
if req.ReactiveRunID > 0 {
|
||||
id := req.ReactiveRunID
|
||||
reactiveRunID = &id
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO harness_runs (run_id, agent_name, backend, message_id, status, trace_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
`INSERT INTO harness_runs (run_id, agent_name, backend, message_id, reactive_run_id, status, trace_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
req.RunID,
|
||||
agentNameOf(agent),
|
||||
harnessName,
|
||||
msgID,
|
||||
reactiveRunID,
|
||||
StatusRunning,
|
||||
observability.TraceIDFromContext(ctx),
|
||||
req.SessionID,
|
||||
@@ -125,6 +136,7 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
|
||||
var exitCode *int
|
||||
logsExcerpt := ""
|
||||
var resultJSON string
|
||||
var promptText, responseText string
|
||||
var tokensIn, tokensOut, tokensCached int64
|
||||
var costUSD float64
|
||||
sessionID := req.SessionID
|
||||
@@ -136,6 +148,8 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
|
||||
if len(res.ResultJSON) > 0 {
|
||||
resultJSON = string(res.ResultJSON)
|
||||
}
|
||||
promptText = res.Prompt
|
||||
responseText = res.Response
|
||||
tokensIn = res.Usage.TokensIn
|
||||
tokensOut = res.Usage.TokensOut
|
||||
tokensCached = res.Usage.TokensCached
|
||||
@@ -150,11 +164,21 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
|
||||
if execErr != nil {
|
||||
status = StatusFailed
|
||||
}
|
||||
// Cap logs excerpt to keep row sizes sane (matches design: bounded).
|
||||
const logsCap = 16 * 1024
|
||||
// Cap each large text field so harness_runs rows stay reasonable.
|
||||
const (
|
||||
logsCap = 16 * 1024
|
||||
promptCap = 32 * 1024
|
||||
responseCap = 32 * 1024
|
||||
)
|
||||
if 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 := ""
|
||||
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 = ?,
|
||||
tokens_in = ?, tokens_out = ?, tokens_cached = ?, cost_usd = ?,
|
||||
duration_ms = ?, result_json = ?, logs_excerpt = ?,
|
||||
prompt = ?, response = ?,
|
||||
finished_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?`
|
||||
|
||||
@@ -177,19 +202,26 @@ func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName s
|
||||
status, exitCode, traceID, sessionID,
|
||||
tokensIn, tokensOut, tokensCached, costUSD,
|
||||
durationMs, nullableString(resultJSON), nullableString(logsExcerpt),
|
||||
nullableString(promptText), nullableString(responseText),
|
||||
req.RunID,
|
||||
)
|
||||
if err == nil {
|
||||
if n, _ := result.RowsAffected(); n == 0 {
|
||||
// Row didn't exist — insert it fresh.
|
||||
var reactiveRunID *int64
|
||||
if req.ReactiveRunID > 0 {
|
||||
id := req.ReactiveRunID
|
||||
reactiveRunID = &id
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO harness_runs (run_id, agent_name, backend, 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
req.RunID, agentNameOf(agent), harnessName,
|
||||
`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, prompt, response, created_at, finished_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
req.RunID, agentNameOf(agent), harnessName, reactiveRunID,
|
||||
status, exitCode, traceID, sessionID,
|
||||
tokensIn, tokensOut, tokensCached, costUSD,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Store) ListByAgent(ctx context.Context, agentName string, limit int) ([]*Run, error) {
|
||||
if limit <= 0 {
|
||||
@@ -239,23 +286,23 @@ func nullableString(s string) any {
|
||||
}
|
||||
|
||||
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,
|
||||
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`
|
||||
}
|
||||
|
||||
func scanRun(row *sql.Row) (*Run, error) {
|
||||
var r Run
|
||||
var msgID sql.NullInt64
|
||||
var msgID, reactiveRunID 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 createdAt, finishedAt sql.NullTime
|
||||
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,
|
||||
&durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt,
|
||||
&durationMs, &resultJSON, &logsExcerpt, &prompt, &response, &createdAt, &finishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -263,6 +310,10 @@ func scanRun(row *sql.Row) (*Run, error) {
|
||||
v := msgID.Int64
|
||||
r.MessageID = &v
|
||||
}
|
||||
if reactiveRunID.Valid {
|
||||
v := reactiveRunID.Int64
|
||||
r.ReactiveRunID = &v
|
||||
}
|
||||
if exitCode.Valid {
|
||||
v := int(exitCode.Int64)
|
||||
r.ExitCode = &v
|
||||
@@ -272,6 +323,8 @@ func scanRun(row *sql.Row) (*Run, error) {
|
||||
r.SessionID = sessionID.String
|
||||
r.ResultJSON = resultJSON.String
|
||||
r.LogsExcerpt = logsExcerpt.String
|
||||
r.Prompt = prompt.String
|
||||
r.Response = response.String
|
||||
if durationMs.Valid {
|
||||
v := durationMs.Int64
|
||||
r.DurationMs = &v
|
||||
@@ -290,15 +343,15 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
|
||||
var out []*Run
|
||||
for rows.Next() {
|
||||
var r Run
|
||||
var msgID sql.NullInt64
|
||||
var msgID, reactiveRunID 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 createdAt, finishedAt sql.NullTime
|
||||
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,
|
||||
&durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt,
|
||||
&durationMs, &resultJSON, &logsExcerpt, &prompt, &response, &createdAt, &finishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -306,6 +359,10 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
|
||||
v := msgID.Int64
|
||||
r.MessageID = &v
|
||||
}
|
||||
if reactiveRunID.Valid {
|
||||
v := reactiveRunID.Int64
|
||||
r.ReactiveRunID = &v
|
||||
}
|
||||
if exitCode.Valid {
|
||||
v := int(exitCode.Int64)
|
||||
r.ExitCode = &v
|
||||
@@ -315,6 +372,8 @@ func scanRuns(rows *sql.Rows) ([]*Run, error) {
|
||||
r.SessionID = sessionID.String
|
||||
r.ResultJSON = resultJSON.String
|
||||
r.LogsExcerpt = logsExcerpt.String
|
||||
r.Prompt = prompt.String
|
||||
r.Response = response.String
|
||||
if durationMs.Valid {
|
||||
v := durationMs.Int64
|
||||
r.DurationMs = &v
|
||||
|
||||
@@ -25,25 +25,28 @@ func setupDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
schema := `
|
||||
CREATE TABLE harness_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
agent_name TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
trace_id TEXT,
|
||||
span_id TEXT,
|
||||
session_id TEXT,
|
||||
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_cached INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
result_json TEXT,
|
||||
logs_excerpt TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
agent_name TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
reactive_run_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
trace_id TEXT,
|
||||
span_id TEXT,
|
||||
session_id TEXT,
|
||||
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_cached INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
result_json TEXT,
|
||||
logs_excerpt TEXT,
|
||||
prompt TEXT,
|
||||
response TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME
|
||||
);`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 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,
|
||||
Logs: logs,
|
||||
ResultJSON: resultJSON,
|
||||
Prompt: promptText,
|
||||
Response: responseText,
|
||||
}
|
||||
|
||||
// Distinguish context timeout from plain failures so the caller
|
||||
|
||||
@@ -33,12 +33,13 @@ const (
|
||||
|
||||
// Reactor is the reactive agent triggering engine.
|
||||
type Reactor struct {
|
||||
store *Store
|
||||
agentStore agents.AgentStore
|
||||
runner k8spkg.JobRunner
|
||||
registry *harness.Registry
|
||||
notifier FailureNotifier
|
||||
logger *slog.Logger
|
||||
store *Store
|
||||
agentStore agents.AgentStore
|
||||
runner k8spkg.JobRunner
|
||||
registry *harness.Registry
|
||||
notifier FailureNotifier
|
||||
reactions ReactionNotifier
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func New(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, logger *slog.Logger) *Reactor {
|
||||
return &Reactor{
|
||||
@@ -68,6 +78,29 @@ func (r *Reactor) SetHarnessRegistry(reg *harness.Registry) {
|
||||
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
|
||||
// when a message event occurs.
|
||||
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.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",
|
||||
"agent", agent.Name,
|
||||
"backend", r.agentBackendKind(agent),
|
||||
@@ -283,9 +320,10 @@ func (r *Reactor) dispatchHarness(ctx context.Context, agent *agents.Agent, even
|
||||
)
|
||||
|
||||
req := &harness.ExecRequest{
|
||||
RunID: uuid.NewString(),
|
||||
AgentName: agent.Name,
|
||||
Agent: agent,
|
||||
RunID: uuid.NewString(),
|
||||
AgentName: agent.Name,
|
||||
Agent: agent,
|
||||
ReactiveRunID: runID,
|
||||
Message: &messaging.Message{
|
||||
ID: event.MessageID,
|
||||
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)
|
||||
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 {
|
||||
r.react(context.Background(), event.MessageID, agent.Name, "reject")
|
||||
r.logger.Warn("reactive harness run failed",
|
||||
"agent", agent.Name,
|
||||
"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)
|
||||
} else {
|
||||
r.react(context.Background(), event.MessageID, agent.Name, "done")
|
||||
r.logger.Info("reactive harness run succeeded",
|
||||
"agent", agent.Name,
|
||||
"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);
|
||||
@@ -2,6 +2,7 @@
|
||||
import { openThread } from '$lib/stores/thread';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import ReactionPills from '$lib/components/ReactionPills.svelte';
|
||||
|
||||
type Attachment = {
|
||||
hash: string;
|
||||
@@ -11,6 +12,12 @@
|
||||
is_image: boolean;
|
||||
};
|
||||
|
||||
type ReactionEntry = {
|
||||
agent_name: string;
|
||||
reaction: string;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
@@ -22,6 +29,7 @@
|
||||
created_at: string;
|
||||
reply_count?: number;
|
||||
attachments?: Attachment[];
|
||||
reactions?: ReactionEntry[];
|
||||
};
|
||||
|
||||
let { messages = [], showConversationLink = false, agentTypes = {} }: { messages: Message[]; showConversationLink?: boolean; agentTypes?: Record<string, string> } = $props();
|
||||
@@ -118,6 +126,15 @@
|
||||
</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 -->
|
||||
{#if msg.attachments && msg.attachments.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
|
||||
@@ -197,11 +197,14 @@
|
||||
<pre>{run.error_log}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if run.status === 'failed'}
|
||||
<button class="retry-btn" onclick={() => retryRun(run.id)}>
|
||||
Retry
|
||||
</button>
|
||||
{/if}
|
||||
<div class="detail-actions">
|
||||
<a href="/runs/{run.id}" class="detail-link">View full details →</a>
|
||||
{#if run.status === 'failed'}
|
||||
<button class="retry-btn" onclick={() => retryRun(run.id)}>
|
||||
Retry
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -451,4 +454,11 @@
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
· {run.trigger_event}
|
||||
· 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}
|
||||
· 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}
|
||||
· {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>
|
||||
Reference in New Issue
Block a user