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.
- checksums.yaml +4 -4
- data/README.md +9 -1
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -61
- data/app/controllers/action_agent/api/agents_controller.rb +1 -17
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +11 -7
- data/app/controllers/action_agent/api/github_app_webhooks_controller.rb +98 -0
- data/app/controllers/action_agent/api/github_installations_controller.rb +9 -1
- data/app/controllers/action_agent/api/mcp_controller.rb +5 -2
- data/app/controllers/action_agent/api/projects_controller.rb +119 -40
- data/app/controllers/action_agent/dashboard_controller.rb +27 -0
- data/app/controllers/action_agent/traces_controller.rb +6 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +199 -24
- data/app/jobs/action_agent/github_webhook_job.rb +102 -0
- data/app/models/action_agent/github_connection.rb +21 -0
- data/app/models/action_agent/github_installation.rb +28 -1
- data/app/models/action_agent/github_webhook_delivery.rb +45 -0
- data/app/models/action_agent/provider_key.rb +8 -1
- data/app/models/action_agent/scenario_catalog.rb +8 -2
- data/app/serializers/action_agent/agent_version_serializer.rb +29 -0
- data/app/serializers/action_agent/evaluation_serializer.rb +9 -0
- data/app/services/action_agent/evaluation_report_import.rb +112 -19
- data/app/services/action_agent/github_client.rb +11 -2
- data/app/services/action_agent/local_sandbox_claude_login.rb +1 -1
- data/app/services/action_agent/project_capabilities.rb +6 -3
- data/app/views/action_agent/traces/_styles.html.erb +45 -0
- data/app/views/action_agent/traces/_summary.html.erb +8 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +33 -96
- data/app/views/action_agent/traces/index.html.erb +25 -134
- data/app/views/action_agent/traces/metrics.html.erb +22 -140
- data/app/views/action_agent/traces/show.html.erb +7 -36
- data/app/views/layouts/action_agent/application.html.erb +26 -86
- data/config/routes.rb +15 -0
- data/lib/action_agent/claude_login_process.rb +1 -1
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +47 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +10 -3
- data/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb +23 -0
- 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
|
|
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,
|
|
106
|
-
# earlier
|
|
107
|
-
#
|
|
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
|
|
110
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
340
|
+
raise unless e.status == 403
|
|
332
341
|
|
|
333
|
-
|
|
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
|
|
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 :
|
|
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
|
-
<
|
|
2
|
-
|
|
3
|
-
<div
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
37
|
-
<
|
|
38
|
-
<
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
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>
|