actionagent 1.9.3 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -1
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +61 -61
  5. data/app/controllers/action_agent/api/agents_controller.rb +1 -17
  6. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +11 -7
  7. data/app/controllers/action_agent/api/github_app_webhooks_controller.rb +98 -0
  8. data/app/controllers/action_agent/api/github_installations_controller.rb +9 -1
  9. data/app/controllers/action_agent/api/mcp_controller.rb +5 -2
  10. data/app/controllers/action_agent/api/projects_controller.rb +119 -40
  11. data/app/controllers/action_agent/dashboard_controller.rb +27 -0
  12. data/app/controllers/action_agent/traces_controller.rb +6 -0
  13. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +199 -24
  14. data/app/jobs/action_agent/github_webhook_job.rb +102 -0
  15. data/app/models/action_agent/github_connection.rb +21 -0
  16. data/app/models/action_agent/github_installation.rb +28 -1
  17. data/app/models/action_agent/github_webhook_delivery.rb +45 -0
  18. data/app/models/action_agent/provider_key.rb +8 -1
  19. data/app/models/action_agent/scenario_catalog.rb +8 -2
  20. data/app/serializers/action_agent/agent_version_serializer.rb +29 -0
  21. data/app/serializers/action_agent/evaluation_serializer.rb +9 -0
  22. data/app/services/action_agent/evaluation_report_import.rb +112 -19
  23. data/app/services/action_agent/github_client.rb +11 -2
  24. data/app/services/action_agent/local_sandbox_claude_login.rb +1 -1
  25. data/app/services/action_agent/project_capabilities.rb +6 -3
  26. data/app/views/action_agent/traces/_styles.html.erb +45 -0
  27. data/app/views/action_agent/traces/_summary.html.erb +8 -0
  28. data/app/views/action_agent/traces/_trace_detail.html.erb +33 -96
  29. data/app/views/action_agent/traces/index.html.erb +25 -134
  30. data/app/views/action_agent/traces/metrics.html.erb +22 -140
  31. data/app/views/action_agent/traces/show.html.erb +7 -36
  32. data/app/views/layouts/action_agent/application.html.erb +26 -86
  33. data/config/routes.rb +15 -0
  34. data/lib/action_agent/claude_login_process.rb +1 -1
  35. data/lib/action_agent/version.rb +1 -1
  36. data/lib/action_agent.rb +47 -0
  37. data/lib/generators/action_agent/templates/action_agent.rb.erb +10 -3
  38. data/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb +23 -0
  39. metadata +9 -2
@@ -17,9 +17,18 @@ module ActionAgent
17
17
  # evaluation — that agent's evaluation for the `suite` and the report's
18
18
  # scope (SCOPE_KEYS in its metadata), named for both
19
19
  # scenarios — one per reported scenario key, updated to the reported prompt
20
- # run — one complete EvaluationRun per tenant and run_id, with one
20
+ # run — one EvaluationRun per tenant and run_id, with one
21
21
  # EvaluationScenarioResult per scenario and model
22
22
  #
23
+ # An envelope's `status` is "complete" when absent. A "running" envelope
24
+ # stores the results so far as a running run, and each later delivery under
25
+ # its run_id replaces them, until a "complete" one finishes the run:
26
+ # running — "running" or "complete" replaces the stored results
27
+ # complete — "complete" is an identical retry or a Conflict, as for any
28
+ # stored run; "running" changes nothing, so a progress
29
+ # delivery that lands after the final one is harmless
30
+ # Only the first delivery under a run_id is admitted and counted.
31
+ #
23
32
  # The run's per-model summary, criterion scores and recommendations are
24
33
  # computed from the stored results, not taken from the report. The judge's
25
34
  # verdict and label are taken from it, and so is what the judge spent —
@@ -85,6 +94,11 @@ module ActionAgent
85
94
  MAX_JUDGE_KINDS = 10
86
95
  SCOPE_PATTERN = %r{\A[\w .:/@-]{1,100}\z}
87
96
  STATUSES = %w[passed failed errored].freeze
97
+ # The envelope statuses a delivery may name.
98
+ RUN_STATUSES = %w[running complete].freeze
99
+ # What this collector accepts beyond a version-1 report, sent with its
100
+ # refusal of a payload that is not one so a publisher can ask before a run.
101
+ CAPABILITIES = %w[progress].freeze
88
102
  # Report metadata that tells one evaluation of a suite from another, in the
89
103
  # order it is written into the evaluation's name.
90
104
  SCOPE_KEYS = %w[scope environment role].freeze
@@ -102,20 +116,22 @@ module ActionAgent
102
116
  # The evaluation_runs columns an import writes that later migrations added.
103
117
  REQUIRED_RUN_COLUMNS = %w[external_tenant external_run_id external_report_digest agent_version_id].freeze
104
118
 
105
- # Returns `[run, duplicate]`: the stored EvaluationRun, and whether an
106
- # earlier identical delivery had already stored it. `account` is the
107
- # tenant, or nil on a single-tenant install.
119
+ # Returns `[run, duplicate, progressed]`: the stored EvaluationRun, whether
120
+ # an earlier delivery had already stored this report, and whether this
121
+ # delivery replaced the results of a running run. `account` is the tenant,
122
+ # or nil on a single-tenant install.
108
123
  #
109
- # `admit` is asked only for a report that would be stored, never for an
110
- # identical retry. It returns nil to allow the report, or a denial, which
111
- # is raised as Denied.
124
+ # `admit` is asked only for a run_id the tenant holds no run under. It
125
+ # returns nil to allow the report, or a denial, which is raised as Denied.
112
126
  #
113
127
  # The envelope's identities are validated as sent. NUL characters, which
114
128
  # PostgreSQL cannot store, are removed from every string inside `report`.
115
129
  #
116
130
  # Raises Invalid for a payload that is not a valid version-1 report, or an
117
131
  # evaluation name the agent already holds for another suite or scope;
118
- # Conflict for different content under a stored run_id; Refused (and
132
+ # Conflict for different content under a complete run_id, or a delivery
133
+ # under a running run_id that the dashboard executed or that names another
134
+ # agent, suite or scope; Refused (and
119
135
  # LimitExceeded) when storing needs an operator to act first; Denied; and
120
136
  # Busy.
121
137
  def self.call(payload:, account: nil, admit: nil)
@@ -162,24 +178,25 @@ module ActionAgent
162
178
  validate!
163
179
  digest = self.class.digest(@payload)
164
180
  existing = stored_run
165
- return [ identical!(existing, digest), true ] if existing
181
+ return settled(existing, digest) if settles?(existing, digest)
166
182
 
167
183
  attempts = 0
168
184
  begin
169
185
  OwnerLock.new(EvaluationRun.connection, owner_lock_key).synchronize do
170
186
  EvaluationRun.transaction(requires_new: true) do
171
187
  existing = stored_run
172
- next [ identical!(existing, digest), true ] if existing
188
+ next settled(existing, digest) if settles?(existing, digest)
189
+ next [ progress(existing, digest), false, true ] if existing
173
190
 
174
191
  admit!
175
- [ import(digest), false ]
192
+ [ import(digest), false, false ]
176
193
  end
177
194
  end
178
195
  rescue ActiveRecord::RecordNotUnique
179
196
  # A concurrent delivery committed this run, or an agent, evaluation or
180
197
  # scenario this import was creating, first. Reading again sees it.
181
198
  existing = stored_run
182
- return [ identical!(existing, digest), true ] if existing
199
+ return settled(existing, digest) if settles?(existing, digest)
183
200
 
184
201
  attempts += 1
185
202
  retry if attempts < 2
@@ -265,6 +282,29 @@ module ActionAgent
265
282
  EvaluationReportExport.find(@payload["run_id"], account: @account)
266
283
  end
267
284
 
285
+ def running_delivery?
286
+ @payload["status"] == "running"
287
+ end
288
+
289
+ # Whether a delivery under a stored run's run_id leaves its results as
290
+ # they are. Only a running run this collector imported takes new ones.
291
+ def settles?(run, digest)
292
+ return false unless run
293
+ return true unless run.running? && run.imported?
294
+
295
+ run.external_report_digest == digest
296
+ end
297
+
298
+ # Returns `[run, true, false]` for a delivery that leaves the stored run as
299
+ # it is. Raises Conflict for a run the dashboard is executing, and for a
300
+ # different "complete" report under a run that has finished.
301
+ def settled(run, digest)
302
+ raise Conflict, "run_id names a run the dashboard is executing" if run.running? && !run.imported?
303
+ return [ run, true, false ] if running_delivery? || run.running?
304
+
305
+ [ identical!(run, digest), true, false ]
306
+ end
307
+
268
308
  # The stored run, when the payload is the report it was stored from or
269
309
  # the run's own export (EvaluationReportExport), which is how an
270
310
  # exported run posted back to its install is told from a different
@@ -286,26 +326,60 @@ module ActionAgent
286
326
  raise Denied, denial if denial.present?
287
327
  end
288
328
 
329
+ # Replaces a running run's results with the delivery's.
330
+ def progress(run, digest)
331
+ agent = find_or_create_agent
332
+ evaluation = find_or_initialize_evaluation(agent)
333
+ raise Conflict, "run_id already exists for another agent, suite or scope" unless evaluation.id == run.evaluation_id
334
+
335
+ scenarios = upsert_scenarios(evaluation)
336
+ run.scenario_results.destroy_all
337
+ run.update!(run_attributes(agent, digest))
338
+ store_results(run, scenarios, agent)
339
+ end
340
+
341
+ # A new run is dated by the time its report says it finished, when it
342
+ # names one (see #finished_at), so a run exported from one install and
343
+ # imported into another keeps its place in the run history.
289
344
  def import(digest)
290
345
  agent = find_or_create_agent
291
346
  evaluation = find_or_initialize_evaluation(agent)
292
347
  scenarios = upsert_scenarios(evaluation)
293
348
  run = evaluation.evaluation_runs.create!(
294
- external_tenant: tenant_key,
295
- external_run_id: @payload["run_id"],
349
+ run_attributes(agent, digest).merge(external_tenant: tenant_key, external_run_id: @payload["run_id"], created_at: finished_at)
350
+ )
351
+ store_results(run, scenarios, agent)
352
+ end
353
+
354
+ def run_attributes(agent, digest)
355
+ {
296
356
  external_report_digest: digest,
297
357
  agent_version: reported_release_version(agent),
298
- status: :complete,
358
+ status: running_delivery? ? :running : :complete,
299
359
  selection: selection,
300
360
  scores: recorded_scores,
301
361
  samples_evaluated: results.size,
302
362
  samples_passed: results.count { |result| result["status"] == "passed" },
303
- completed_at: Time.current
304
- )
363
+ completed_at: running_delivery? ? nil : finished_at
364
+ }
365
+ end
366
+
367
+ # The report's `metadata.finished`, which EvaluationRun#to_report records
368
+ # as the run's completion time, or now when the report names none. A
369
+ # time later than now is read as now.
370
+ def finished_at
371
+ @finished_at ||= begin
372
+ now = Time.current
373
+ reported = metadata["finished"] && Time.iso8601(metadata["finished"])
374
+ reported && reported < now ? reported : now
375
+ end
376
+ end
377
+
378
+ def store_results(run, scenarios, agent)
305
379
  results.each { |result| persist(run, scenarios.fetch(result["scenario_key"]), result) }
306
380
  run.update!(scores: summarized_scores(run))
307
- evaluation.touch
308
- agent.update_columns(last_observed_at: run.completed_at, updated_at: Time.current)
381
+ run.evaluation.touch
382
+ agent.update_columns(last_observed_at: Time.current, updated_at: Time.current)
309
383
  run
310
384
  end
311
385
 
@@ -621,6 +695,9 @@ module ActionAgent
621
695
  object!(@payload, "payload")
622
696
  validate_json!(@payload)
623
697
  raise Invalid, "version must be 1" unless @payload["version"] == 1
698
+ unless @payload["status"].nil? || RUN_STATUSES.include?(@payload["status"])
699
+ raise Invalid, "status must be one of #{RUN_STATUSES.join(', ')}"
700
+ end
624
701
 
625
702
  %w[run_id source suite].each { |key| identifier!(@payload[key], key) }
626
703
  unless @payload["agent_name"].is_a?(String) && AGENT_NAME_PATTERN.match?(@payload["agent_name"]) && @payload["agent_name"].strip.length >= 2
@@ -634,6 +711,7 @@ module ActionAgent
634
711
  raise Invalid, "suite and scope name an evaluation longer than #{MAX_EVALUATION_NAME} characters"
635
712
  end
636
713
 
714
+ validate_finished!
637
715
  judge_trace_ids!(metadata["judge_trace_ids"])
638
716
  string!(report["judge"], "report.judge", 200)
639
717
  validate_release!
@@ -736,6 +814,21 @@ module ActionAgent
736
814
  string!(judge.dig("suggested_tool", "name"), "result.diagnosis.judge.suggested_tool.name", 200)
737
815
  end
738
816
 
817
+ def validate_finished!
818
+ finished = metadata["finished"]
819
+ return if finished.nil?
820
+ return if finished.is_a?(String) && finished.length <= 40 && iso8601?(finished)
821
+
822
+ raise Invalid, "report.metadata.finished must be an ISO 8601 time"
823
+ end
824
+
825
+ def iso8601?(value)
826
+ Time.iso8601(value)
827
+ true
828
+ rescue ArgumentError
829
+ false
830
+ end
831
+
739
832
  # The release the report says it evaluated: a digest the dashboard can
740
833
  # match to a version cut on deploy, and the deploy and label as text.
741
834
  def validate_release!
@@ -60,6 +60,10 @@ module ActionAgent
60
60
  # cannot see, which GitHub does not tell apart. Also 409, which GitHub
61
61
  # answers for the commits and trees of a repository with no commits.
62
62
  class NotFound < Error; end
63
+ # GitHub answered 403 to an organization membership lookup: the GitHub
64
+ # App lacks the organization permission Members (read), or the
65
+ # organization's SAML single sign-on has not authorized the token.
66
+ class MembershipForbidden < Error; end
63
67
  # GitHub minted no token because the installation was removed from its
64
68
  # account (404) or suspended (403). +reason+ is :removed or :suspended.
65
69
  class InstallationUnavailable < Error
@@ -323,14 +327,19 @@ module ActionAgent
323
327
 
324
328
  # The user's membership in +organization+ ({ "state" =>, "role" => }), or
325
329
  # nil when GitHub reports none.
330
+ #
331
+ # @raise [MembershipForbidden] when GitHub answers 403, as it does to a
332
+ # GitHub App user token while the App lacks Members (read)
326
333
  def organization_membership(organization)
327
334
  raise Error, "#{organization.inspect} is not a GitHub login" unless organization.is_a?(String) && organization.match?(LOGIN)
328
335
 
329
336
  get("/user/memberships/orgs/#{organization}")
337
+ rescue NotFound
338
+ nil
330
339
  rescue Error => e
331
- raise unless e.status.in?([ 403, 404 ])
340
+ raise unless e.status == 403
332
341
 
333
- nil
342
+ raise MembershipForbidden.new("GitHub refused to show the membership in #{organization}: #{e.message}", status: 403)
334
343
  end
335
344
 
336
345
  # The repositories an installation token reaches, reduced like
@@ -108,7 +108,7 @@ module ActionAgent
108
108
  data = JSON.parse(File.read(path, 8192))
109
109
  status = data["status"].to_s
110
110
  result = { status: %w[starting awaiting_code submitted completed failed expired cancelled].include?(status) ? status : "failed" }
111
- if status == "awaiting_code" && data["authorize_url"].to_s.match?(%r{\Ahttps://(?:claude\.ai|platform\.claude\.com)/oauth/authorize\?[^\s]+\z})
111
+ if status == "awaiting_code" && data["authorize_url"].to_s.match?(%r{\Ahttps://(?:claude\.ai|claude\.com|platform\.claude\.com)/(?:cai/)?oauth/authorize\?[^\s]+\z})
112
112
  result[:authorize_url] = data["authorize_url"]
113
113
  end
114
114
  result
@@ -14,7 +14,8 @@ module ActionAgent
14
14
  # project's secrets and bootstrap reach it
15
15
  # execution agent execution is enabled
16
16
  # github a GitHub App or OAuth app is configured
17
- # github_connection the owner connected GitHub
17
+ # github_connection the owner linked a usable GitHub App installation
18
+ # or connected GitHub through OAuth
18
19
  # model_credentials credentials for the model the project's agent
19
20
  # runs on (not blocking)
20
21
  # browser a browser for the project's sandbox, started and
@@ -24,7 +25,8 @@ module ActionAgent
24
25
  Item = Struct.new(:key, :label, :ok, :blocking, :detail, :fix, keyword_init: true)
25
26
 
26
27
  # @param owner [Object, nil] the caller's owner, for its credentials
27
- # @param github_connected [Boolean]
28
+ # @param github_connected [Boolean] whether the owner has a usable GitHub
29
+ # App installation or an OAuth connection
28
30
  # @param base_url [String] the engine's absolute mount URL, for callback URLs
29
31
  # @param environment [ActiveSupport::EnvironmentInquirer]
30
32
  def initialize(owner:, github_connected:, base_url:, environment: Rails.env)
@@ -137,9 +139,10 @@ module ActionAgent
137
139
  end
138
140
 
139
141
  def github_connection_item
142
+ fix = github[:mode] == "app" ? "Install the GitHub App in Settings → Integrations" : "Connect GitHub in Settings → Integrations"
140
143
  Item.new(key: "github_connection", label: "GitHub connection", ok: @github_connected, blocking: true,
141
144
  detail: @github_connected ? "Connected" : "GitHub is not connected",
142
- fix: @github_connected ? nil : "Connect GitHub in Settings → Integrations")
145
+ fix: @github_connected ? nil : fix)
143
146
  end
144
147
 
145
148
  def default_model
@@ -0,0 +1,45 @@
1
+ <style>
2
+ .trace-console { color-scheme: light dark; --background: #f8fafc; --surface: #fff; --text: #172033; --muted: #475569; --border: #cbd5e1; --link: #3730a3; --danger: #b91c1c; --success: #166534; margin: 0; background: var(--background); color: var(--text); font: 16px/1.5 system-ui, sans-serif; }
3
+ .trace-console * { box-sizing: border-box; }
4
+ .trace-console header { background: var(--surface); border-bottom: 1px solid var(--border); }
5
+ .trace-console nav, .trace-console main { max-width: 1200px; margin: auto; padding: 1rem; }
6
+ .trace-console nav, .trace-console .refresh-controls, .trace-console .tokens { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
7
+ .trace-console a { color: var(--link); text-underline-offset: .2em; }
8
+ .trace-console a:focus-visible, .trace-console summary:focus-visible { outline: 3px solid var(--link); outline-offset: 3px; }
9
+ .trace-console .muted, .trace-console dt { color: var(--muted); }
10
+ .trace-console .refresh-controls { justify-content: flex-end; margin-bottom: 1rem; }
11
+ .trace-console h1 { font-size: 1.5rem; }
12
+ .trace-console h2 { font-size: 1.2rem; }
13
+ .trace-console .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
14
+ .trace-console .summary, .trace-console .metadata { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin: 1rem 0; }
15
+ .trace-console .summary > div { padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .5rem; }
16
+ .trace-console dt { font-size: .875rem; }
17
+ .trace-console dd { margin: 0; overflow-wrap: anywhere; }
18
+ .trace-console .summary dd { font-size: 1.4rem; font-weight: 600; }
19
+ .trace-console details { margin: .5rem 0; }
20
+ .trace-console summary { cursor: pointer; padding: .5rem 0; }
21
+ .trace-console .trace-summary { display: inline-flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
22
+ .trace-console .badge { border: 1px solid currentColor; border-radius: .25rem; padding: .1rem .4rem; font-size: .8rem; }
23
+ .trace-console .error { color: var(--danger); }
24
+ .trace-console .success { color: var(--success); }
25
+ .trace-console .error-message { border-left: 4px solid var(--danger); padding: .5rem 1rem; overflow-wrap: anywhere; }
26
+ .trace-console pre { font: .8rem/1.5 ui-monospace, monospace; overflow: auto; padding: 1rem; background: var(--background); border: 1px solid var(--border); }
27
+ .trace-console .table-scroll { overflow-x: auto; }
28
+ .trace-console table { width: 100%; border-collapse: collapse; text-align: left; }
29
+ .trace-console th, .trace-console td { padding: .75rem; border-bottom: 1px solid var(--border); }
30
+ .trace-console .span-row { display: grid; grid-template-columns: minmax(5rem, 9rem) 1fr 5rem; gap: .5rem; align-items: center; margin: .5rem 0; font-size: .8rem; }
31
+ .trace-console .span-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
32
+ .trace-console .span-track { position: relative; height: 1.25rem; background: var(--background); border: 1px solid var(--border); overflow: hidden; border-radius: .2rem; }
33
+ .trace-console .span-bar { position: absolute; height: 100%; background: #6366f1; }
34
+ .trace-console .span-bar[data-type="tool"] { background: #b45309; }
35
+ .trace-console .span-bar[data-type="thinking"] { background: #9333ea; }
36
+ .trace-console .span-bar[data-type="response"] { background: #15803d; }
37
+ .trace-console .span-bar[data-status="ERROR"] { background: var(--danger); }
38
+ @media (prefers-color-scheme: dark) {
39
+ .trace-console { --background: #111827; --surface: #1f2937; --text: #f1f5f9; --muted: #cbd5e1; --border: #64748b; --link: #c7d2fe; --danger: #fca5a5; --success: #86efac; }
40
+ }
41
+ @media (max-width: 480px) {
42
+ .trace-console main { padding: .5rem; }
43
+ .trace-console .span-row { grid-template-columns: 5rem 1fr 4rem; }
44
+ }
45
+ </style>
@@ -0,0 +1,8 @@
1
+ <dl class="summary" aria-label="Last 24 hours">
2
+ <div><dt>Total Traces</dt><dd><%= number_with_delimiter(@metrics[:total_traces]) %></dd></div>
3
+ <div><dt>Total Tokens</dt><dd><%= number_to_human(@metrics[:total_tokens], precision: 2) %></dd></div>
4
+ <div><dt>Avg Duration</dt><dd><%= @metrics[:avg_duration_ms] >= 1000 ? "#{(@metrics[:avg_duration_ms] / 1000.0).round(2)}s" : "#{@metrics[:avg_duration_ms].round(0)}ms" %></dd></div>
5
+ <div><dt>Error Rate</dt><dd class="<%= 'error' if @metrics[:error_rate] > 5 %>"><%= @metrics[:error_rate] %>%</dd></div>
6
+ <div><dt>Active Agents</dt><dd><%= @metrics[:unique_agents] %></dd></div>
7
+ </dl>
8
+ <p class="muted">Last 24h</p>
@@ -1,64 +1,22 @@
1
- <div class="space-y-4">
2
- <!-- Trace Info -->
3
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
4
- <div>
5
- <span class="text-gray-500 dark:text-gray-400">Trace ID</span>
6
- <div class="font-mono text-gray-900 dark:text-white"><%= trace.trace_id %></div>
7
- </div>
8
- <div>
9
- <span class="text-gray-500 dark:text-gray-400">Service</span>
10
- <div class="text-gray-900 dark:text-white"><%= trace.service_name || "—" %></div>
11
- </div>
12
- <div>
13
- <span class="text-gray-500 dark:text-gray-400">Provider</span>
14
- <div class="text-gray-900 dark:text-white"><%= trace.provider || "—" %></div>
15
- </div>
16
- <div>
17
- <span class="text-gray-500 dark:text-gray-400">Model</span>
18
- <div class="text-gray-900 dark:text-white"><%= trace.model || "—" %></div>
19
- </div>
20
- </div>
21
-
22
- <% if trace.error? && trace.error_message.present? %>
23
- <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
24
- <div class="flex">
25
- <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
26
- <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
27
- </svg>
28
- <div class="ml-3">
29
- <h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error</h3>
30
- <p class="mt-1 text-sm text-red-700 dark:text-red-300"><%= trace.error_message %></p>
31
- </div>
32
- </div>
33
- </div>
1
+ <dl class="metadata">
2
+ <div><dt>Trace ID</dt><dd><%= trace.trace_id %></dd></div>
3
+ <div><dt>Service</dt><dd><%= trace.service_name || "Unknown" %></dd></div>
4
+ <div><dt>Provider</dt><dd><%= trace.provider || "Unknown" %></dd></div>
5
+ <div><dt>Model</dt><dd><%= trace.model || "Unknown" %></dd></div>
6
+ </dl>
7
+ <% if trace.error? && trace.error_message.present? %>
8
+ <p class="error-message error"><strong>Error:</strong> <%= trace.error_message %></p>
9
+ <% end %>
10
+ <div class="tokens">
11
+ <span>Input: <%= number_with_delimiter(trace.total_input_tokens || 0) %></span>
12
+ <span>Output: <%= number_with_delimiter(trace.total_output_tokens || 0) %></span>
13
+ <% if trace.total_thinking_tokens.to_i > 0 %>
14
+ <span>Thinking: <%= number_with_delimiter(trace.total_thinking_tokens) %></span>
34
15
  <% end %>
35
-
36
- <!-- Token Breakdown -->
37
- <div class="flex gap-6 text-sm">
38
- <div class="flex items-center gap-2">
39
- <div class="w-3 h-3 rounded-full bg-blue-500"></div>
40
- <span class="text-gray-500">Input:</span>
41
- <span class="font-medium text-gray-900 dark:text-white"><%= number_with_delimiter(trace.total_input_tokens || 0) %></span>
42
- </div>
43
- <div class="flex items-center gap-2">
44
- <div class="w-3 h-3 rounded-full bg-green-500"></div>
45
- <span class="text-gray-500">Output:</span>
46
- <span class="font-medium text-gray-900 dark:text-white"><%= number_with_delimiter(trace.total_output_tokens || 0) %></span>
47
- </div>
48
- <% if trace.total_thinking_tokens.to_i > 0 %>
49
- <div class="flex items-center gap-2">
50
- <div class="w-3 h-3 rounded-full bg-purple-500"></div>
51
- <span class="text-gray-500">Thinking:</span>
52
- <span class="font-medium text-purple-600 dark:text-purple-400"><%= number_with_delimiter(trace.total_thinking_tokens) %></span>
53
- </div>
54
- <% end %>
55
- </div>
56
-
57
- <!-- Spans Timeline -->
58
- <% if trace.spans.present? %>
59
- <div class="mt-4">
60
- <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Span Timeline</h4>
61
- <div class="relative bg-gray-100 dark:bg-gray-800 rounded-lg p-4">
16
+ </div>
17
+ <% if trace.spans.present? %>
18
+ <section aria-label="Span timeline">
19
+ <h2>Span Timeline</h2>
62
20
  <% total_duration = (trace.total_duration_ms || 1).to_f %>
63
21
  <% total_duration = 1.0 if total_duration.zero? %>
64
22
  <%
@@ -77,41 +35,20 @@
77
35
  start_pct = ((relative_ms || 0).to_f / total_duration * 100).clamp(0, 100)
78
36
  width_pct = ((span["duration_ms"] || 0).to_f / total_duration * 100).clamp(0.5, [ 100 - start_pct, 0.5 ].max)
79
37
  span_type = span["type"] || "unknown"
80
- colors = {
81
- "root" => "bg-gray-400",
82
- "prompt" => "bg-blue-400",
83
- "llm" => "bg-indigo-500",
84
- "generate" => "bg-indigo-400",
85
- "tool" => "bg-amber-500",
86
- "thinking" => "bg-purple-500",
87
- "response" => "bg-green-400"
88
- }
89
- bg_color = colors[span_type] || "bg-gray-400"
90
38
  %>
91
- <div class="flex items-center gap-2 mb-2">
92
- <div class="w-24 text-xs text-gray-500 dark:text-gray-400 truncate" title="<%= span["name"] %>">
93
- <%= span["name"]&.split(".")&.last || span_type %>
94
- </div>
95
- <div class="flex-1 h-6 bg-gray-200 dark:bg-gray-700 rounded relative overflow-hidden">
96
- <div class="span-bar absolute h-full <%= bg_color %> rounded <%= span["status"] == "ERROR" ? 'opacity-50 bg-red-500' : '' %>"
97
- style="left: <%= start_pct %>%; width: <%= width_pct %>%;"
98
- title="<%= span["name"] %>: <%= span["duration_ms"]&.round(1) %>ms">
99
- </div>
100
- </div>
101
- <div class="w-16 text-xs text-gray-500 dark:text-gray-400 text-right">
102
- <%= span["duration_ms"] ? "#{span["duration_ms"].round(1)}ms" : "—" %>
103
- </div>
104
- </div>
105
- <% end %>
39
+ <div class="span-row">
40
+ <span class="span-name" title="<%= span["name"] %>"><%= span["name"]&.split(".")&.last || span_type %></span>
41
+ <div class="span-track">
42
+ <div class="span-bar" data-type="<%= span_type %>" data-status="<%= span["status"] %>"
43
+ style="left: <%= start_pct %>%; width: <%= width_pct %>%;"
44
+ title="<%= span["name"] %>: <%= span["duration_ms"]&.round(1) %>ms"></div>
45
+ </div>
46
+ <span><%= span["duration_ms"] ? "#{span["duration_ms"].round(1)}ms" : "Unknown" %></span>
106
47
  </div>
107
- </div>
108
- <% end %>
109
-
110
- <!-- Raw Data (collapsed) -->
111
- <details class="mt-4">
112
- <summary class="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700">
113
- View raw trace data
114
- </summary>
115
- <pre class="mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto"><%= JSON.pretty_generate(trace.as_json(except: [:id, :created_at, :updated_at])) %></pre>
116
- </details>
117
- </div>
48
+ <% end %>
49
+ </section>
50
+ <% end %>
51
+ <details>
52
+ <summary>View raw trace data</summary>
53
+ <pre><%= JSON.pretty_generate(trace.as_json(except: [ :id, :created_at, :updated_at ])) %></pre>
54
+ </details>