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
@@ -591,23 +591,7 @@ module ActionAgent
591
591
  end
592
592
 
593
593
  def version_json(version, include_diff: false)
594
- json = {
595
- id: version.id,
596
- version_number: version.version_number,
597
- change_summary: version.change_summary,
598
- created_by: version.created_by,
599
- created_at: version.created_at,
600
- is_latest: version.latest?,
601
- release: version.release?,
602
- release_digest: version.release_digest,
603
- revision: version.revision
604
- }
605
-
606
- if include_diff && version.previous
607
- json[:diff] = version.diff(version.previous)
608
- end
609
-
610
- json
594
+ AgentVersionSerializer.call(version, include_diff: include_diff)
611
595
  end
612
596
 
613
597
  def default_appearance_for(preset)
@@ -10,11 +10,14 @@ module ActionAgent
10
10
  # stored by EvaluationReportImport. Responds:
11
11
  #
12
12
  # 201 — stored; the receipt the publisher checks
13
- # 200 — an identical retry; the receipt names the stored run. Never
14
- # refused by the quota or the rate limit.
13
+ # 200 — a delivery under a run_id already stored: an identical retry,
14
+ # or progress on a running run (EvaluationReportImport); the
15
+ # receipt names the stored run and its status. Never refused by
16
+ # the quota or the rate limit.
15
17
  # 409 — this run_id already holds a different report
16
18
  # 422 — not a valid version-1 report, or an evaluation name the agent
17
- # already holds for another suite or scope
19
+ # already holds for another suite or scope. The body's
20
+ # `capabilities` lists EvaluationReportImport::CAPABILITIES.
18
21
  # 403 — storing it needs an operator first: a cap on observed agents,
19
22
  # evaluations or scenarios, or no owner for the tenant
20
23
  # 429 — a new report over the host's quota (kind :evaluation_report) or
@@ -58,14 +61,15 @@ module ActionAgent
58
61
  body = request.body.read(EvaluationReportImport::MAX_BYTES + 1).to_s
59
62
  return report_too_large if body.bytesize > EvaluationReportImport::MAX_BYTES
60
63
 
61
- run, duplicate = EvaluationReportImport.call(account: @account, payload: JSON.parse(body), admit: -> { admission_denial })
62
- ActionAgent.record_usage(@account, :evaluation_report) unless duplicate
64
+ run, duplicate, progressed = EvaluationReportImport.call(account: @account, payload: JSON.parse(body), admit: -> { admission_denial })
65
+ stored = !duplicate && !progressed
66
+ ActionAgent.record_usage(@account, :evaluation_report) if stored
63
67
 
64
- render json: receipt(run, duplicate), status: duplicate ? :ok : :created
68
+ render json: receipt(run, duplicate), status: stored ? :created : :ok
65
69
  rescue JSON::ParserError
66
70
  render json: { error: "Invalid JSON" }, status: :bad_request
67
71
  rescue EvaluationReportImport::Invalid, ActiveRecord::RecordInvalid => e
68
- render json: { error: e.message }, status: :unprocessable_entity
72
+ render json: { error: e.message, capabilities: EvaluationReportImport::CAPABILITIES }, status: :unprocessable_entity
69
73
  rescue EvaluationReportImport::Conflict => e
70
74
  render json: { error: e.message }, status: :conflict
71
75
  rescue EvaluationReportImport::Refused => e
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module ActionAgent
6
+ module Api
7
+ # POST <mount>/api/github_app/webhooks: the GitHub App's webhook
8
+ # deliveries (installation, installation_repositories,
9
+ # github_app_authorization), applied by GithubWebhookJob.
10
+ #
11
+ # GitHub's signature authenticates a delivery on its own, so this is not a
12
+ # dashboard controller and sees no session, no forgery protection and none
13
+ # of the host's controller concerns. A delivery is accepted only when its
14
+ # X-Hub-Signature-256 is the HMAC-SHA256 of the raw body under
15
+ # ActionAgent.github_app_webhook_secret; with no secret configured there
16
+ # is no endpoint (404).
17
+ #
18
+ # The answer is 202 as soon as a delivery is recorded and enqueued, or
19
+ # recognized as one already recorded (GithubWebhookDelivery), or found to
20
+ # be an event the dashboard does not handle. Only a failure to enqueue
21
+ # answers otherwise, and leaves the delivery unrecorded so a redelivery
22
+ # of it is processed.
23
+ #
24
+ # The body may be JSON, or a form whose +payload+ field holds the JSON,
25
+ # as GitHub sends it when the App's content type is
26
+ # application/x-www-form-urlencoded. The signature covers the raw body
27
+ # either way.
28
+ class GithubAppWebhooksController < ActionController::API
29
+ wrap_parameters false
30
+
31
+ SIGNATURE_HEADER = "X-Hub-Signature-256"
32
+ EVENT_HEADER = "X-GitHub-Event"
33
+ DELIVERY_HEADER = "X-GitHub-Delivery"
34
+
35
+ def create
36
+ secret = ActionAgent.github_app_webhook_secret
37
+ return head(:not_found) if secret.blank?
38
+
39
+ body = request.raw_post.to_s
40
+ unless signed?(body, secret)
41
+ return render(json: { error: "Invalid or missing #{SIGNATURE_HEADER}" }, status: :unauthorized)
42
+ end
43
+
44
+ event = request.headers[EVENT_HEADER].to_s
45
+ return accepted("ignored") unless event.in?(GithubWebhookJob::EVENTS)
46
+
47
+ delivery_id = request.headers[DELIVERY_HEADER].to_s
48
+ unless delivery_id.match?(/\A[A-Za-z0-9-]{1,64}\z/)
49
+ return render(json: { error: "#{DELIVERY_HEADER} is required" }, status: :bad_request)
50
+ end
51
+
52
+ payload = parse(body)
53
+ return render(json: { error: "The delivery's body is not a JSON object" }, status: :bad_request) unless payload
54
+
55
+ job_payload = GithubWebhookJob.payload_for(event, payload)
56
+ recorded = GithubWebhookDelivery.record!(
57
+ delivery_id: delivery_id, event: event,
58
+ event_action: job_payload["action"].presence&.first(64),
59
+ installation_id: job_payload.dig("installation", "id").presence&.to_i
60
+ )
61
+ return accepted("duplicate") unless recorded
62
+
63
+ begin
64
+ GithubWebhookJob.perform_later(event, job_payload)
65
+ rescue StandardError
66
+ # Not left recorded, so a redelivery of it is processed.
67
+ GithubWebhookDelivery.forget!(delivery_id)
68
+ raise
69
+ end
70
+
71
+ accepted("queued")
72
+ end
73
+
74
+ private
75
+
76
+ # Constant time, whatever the header holds.
77
+ def signed?(body, secret)
78
+ given = request.headers[SIGNATURE_HEADER]
79
+ return false unless given.is_a?(String) && given.start_with?("sha256=")
80
+
81
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
82
+ ActiveSupport::SecurityUtils.secure_compare(expected, given)
83
+ end
84
+
85
+ def parse(body)
86
+ body = Rack::Utils.parse_query(body)["payload"].to_s if request.media_type == "application/x-www-form-urlencoded"
87
+ data = JSON.parse(body)
88
+ data if data.is_a?(Hash)
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def accepted(status)
94
+ render json: { status: status }, status: :accepted
95
+ end
96
+ end
97
+ end
98
+ end
@@ -151,6 +151,12 @@ module ActionAgent
151
151
  record.assign_from_github(installation)
152
152
  record.save!
153
153
  "linked"
154
+ # GitHub would not say whether the user administers the organization,
155
+ # which is not the same as saying they do not.
156
+ rescue GithubClient::MembershipForbidden => e
157
+ Rails.logger.warn("[ActionAgent] GitHub App installation #{installation_id} was not linked: #{e.message}. " \
158
+ "Grant the GitHub App the organization permission Members (read).")
159
+ "members_permission"
154
160
  # Another owner linked the same installation between the lookup and
155
161
  # the save.
156
162
  rescue ActiveRecord::RecordNotUnique
@@ -163,7 +169,9 @@ module ActionAgent
163
169
  end
164
170
 
165
171
  # Whether +github_user+ administers +account+: is that user account, or
166
- # an active admin of that organization.
172
+ # an active admin of that organization. The organization check needs
173
+ # the App's organization permission Members (read): without it GitHub
174
+ # answers 403, and GithubClient::MembershipForbidden is raised.
167
175
  def administers?(github, github_user, account)
168
176
  account = {} unless account.is_a?(Hash)
169
177
 
@@ -195,8 +195,11 @@ module ActionAgent
195
195
  "failures). A run_<slug> call that pauses to ask for input returns its input request ids: " \
196
196
  "input_requests_list shows what paused runs are waiting on, and input_requests_answer answers a text or " \
197
197
  "choice request. Scenarios kept as a YAML catalog of products and sets (an .activeagents/evals file) are " \
198
- "imported with catalogs_import, listed with catalogs_list, and one set is run as an evaluation named " \
199
- "catalog/product/set with catalog_set_run (pass project_id to run against a project's running sandbox)."
198
+ "imported with catalogs_import (catalogs_preview shows what an import would change first), listed with " \
199
+ "catalogs_list, written back out with catalogs_export, and one set is run as an evaluation named " \
200
+ "catalog/product/set with catalog_set_run (pass project_id to run against a project's running sandbox). " \
201
+ "agents_versions lists an agent's versions with what each changed, and traces_export reads one trace " \
202
+ "whole, as the payload the trace ingest endpoint takes."
200
203
  end
201
204
 
202
205
  MESSAGE_INPUT_SCHEMA = {
@@ -20,13 +20,14 @@ module ActionAgent
20
20
  UPDATABLE = %i[name default_ref start_url].freeze
21
21
 
22
22
  before_action :require_owner!
23
- before_action :set_project, except: [ :index, :create, :capabilities, :preflight, :discover_secrets ]
23
+ before_action :set_project, except: [ :index, :create, :capabilities, :repositories, :preflight, :discover_secrets ]
24
24
  before_action :require_execution_enabled!, only: [ :boot, :run_evaluation ]
25
25
 
26
26
  # Later handlers win, so subclasses are registered after GithubClient::Error.
27
27
  rescue_from GithubClient::Error, with: :github_unavailable
28
28
  rescue_from GithubClient::NotFound, with: :github_not_found
29
29
  rescue_from GithubClient::Unauthorized, with: :github_unauthorized
30
+ rescue_from GithubClient::InstallationUnavailable, with: :installation_unavailable
30
31
  rescue_from Project::ConfirmationRequired, with: :confirmation_required
31
32
  rescue_from Project::BootRefused, with: :boot_refused
32
33
  rescue_from SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
@@ -48,33 +49,68 @@ module ActionAgent
48
49
  render json: capabilities_check.call
49
50
  end
50
51
 
52
+ # GET /api/projects/repositories
53
+ # The New Project picker's list: the repositories the owner's GitHub
54
+ # sources reach (see #github_sources), each marked with whether it is
55
+ # selected and with the source ("app" or "oauth") a project from it
56
+ # would be read and checked out through. An installation lists every
57
+ # repository it reaches only to a caller who may manage it, and its
58
+ # selection to everyone else; the OAuth connection lists what its
59
+ # token reaches, as GET /api/github_connection/repositories does.
60
+ def repositories
61
+ sources = github_sources! or return
62
+
63
+ listed = {}
64
+ sources.each do |source|
65
+ source.repositories.each do |repo|
66
+ listed[repo["full_name"].downcase] ||= repo.merge("selected" => true, "source" => source.checkout_source)
67
+ end
68
+ end
69
+ unavailable = nil
70
+ sources.each do |source|
71
+ listing = source.is_a?(GithubConnection) || manages_github?(source) ? source.reachable_repositories : []
72
+ listing.each do |repo|
73
+ listed[repo["full_name"].downcase] ||= repo.merge("selected" => false, "source" => source.checkout_source)
74
+ end
75
+ rescue GithubClient::InstallationUnavailable => e
76
+ # Marked on the installation; the other sources still list.
77
+ unavailable = e
78
+ end
79
+ raise unavailable if unavailable && listed.empty?
80
+
81
+ render json: { repositories: listed.values }
82
+ end
83
+
51
84
  # GET /api/projects/preflight?repository=owner/name&ref=
52
85
  # Whether the repository can become a project, read through GitHub's
53
86
  # contents API before any sandbox exists (see ProjectPreflight). A
54
- # repository the connection has not selected is looked up by name,
87
+ # repository no GitHub source has selected is looked up by name,
55
88
  # which also reaches repositories past the listing's cap, and needs
56
89
  # :manage_github.
57
90
  def preflight
58
- connection = github_connection! or return
59
- repository = reachable_repository!(connection) or return
91
+ sources = github_sources! or return
92
+ source, repository = reachable_repository!(sources)
93
+ return unless source
60
94
 
61
95
  ref = ref_param || repository["default_branch"]
62
96
  render json: {
63
- repository: repository.merge("selected" => connection.repository(repository["full_name"]).present?),
64
- preflight: ProjectPreflight.call(connection.client, repository: repository["full_name"], ref: ref)
97
+ repository: repository.merge("selected" => source.repository(repository["full_name"]).present?,
98
+ "source" => source.checkout_source),
99
+ preflight: ProjectPreflight.call(source.contents_client(repository), repository: repository["full_name"], ref: ref)
65
100
  }
66
101
  end
67
102
 
68
103
  # GET /api/projects/discover_secrets?repository=owner/name&ref=
69
104
  # The environment variables the repository expects (see
70
105
  # ProjectSecretDiscovery), each marked with whether the project named by
71
- # `project_id`, if any, has it set. A repository the connection has not
106
+ # `project_id`, if any, has it set. A repository no GitHub source has
72
107
  # selected needs :manage_github, as for preflight.
73
108
  def discover_secrets
74
- connection = github_connection! or return
75
- repository = reachable_repository!(connection) or return
109
+ sources = github_sources! or return
110
+ source, repository = reachable_repository!(sources)
111
+ return unless source
76
112
 
77
- discovery = ProjectSecretDiscovery.call(connection.client, repository: repository["full_name"],
113
+ discovery = ProjectSecretDiscovery.call(source.contents_client(repository), repository: repository["full_name"],
78
114
  ref: ref_param || repository["default_branch"])
79
115
  set = params[:project_id].present? ? owned(Project).find(params[:project_id]).secrets.pluck(:name).to_set : Set.new
80
116
  discovery[:variables].each { |variable| variable[:set] = set.include?(variable[:name]) }
@@ -87,8 +123,9 @@ module ActionAgent
87
123
  #
88
124
  # Refused while a blocking capability fails, when the host's quota
89
125
  # denies a :project, and for a repository the preflight does not
90
- # support. Picking a repository the GitHub connection has not selected
91
- # selects it, which needs :manage_github.
126
+ # support. Picking a repository no GitHub source has selected selects
127
+ # it on the source it was found through (a GitHub App installation
128
+ # ahead of the OAuth connection), which needs :manage_github.
92
129
  def create
93
130
  failures = capabilities_check.blocking_failures
94
131
  if failures.any?
@@ -97,11 +134,13 @@ module ActionAgent
97
134
  end
98
135
  return if quota_denied?
99
136
 
100
- connection = github_connection! or return
101
- repository = reachable_repository!(connection) or return
137
+ sources = github_sources! or return
138
+ source, repository = reachable_repository!(sources)
139
+ return unless source
102
140
  full_name = repository["full_name"]
103
141
  ref = ref_param
104
- report = ProjectPreflight.call(connection.client, repository: full_name, ref: ref || repository["default_branch"])
142
+ report = ProjectPreflight.call(source.contents_client(repository), repository: full_name,
143
+ ref: ref || repository["default_branch"])
105
144
  if report["status"] == "unsupported"
106
145
  return render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
107
146
  status: :unprocessable_entity
@@ -114,10 +153,10 @@ module ActionAgent
114
153
  return unless secret_sources_allowed!(secrets)
115
154
  return unless authorize_secrets!(project, secrets)
116
155
 
117
- selecting = connection.repository(full_name).nil?
156
+ selecting = source.repository(full_name).nil?
118
157
  Project.transaction do
119
158
  # The selection is what a project's sandbox is booted from.
120
- connection.update!(repositories: connection.repositories + [ repository ]) if selecting
159
+ source.update!(repositories: source.repositories + [ repository ]) if selecting
121
160
  project.save!
122
161
  secrets.each { |attributes| assign_secret_params(project, attributes).save! }
123
162
  project.ensure_app_assistant! unless project.engine_installed?
@@ -273,7 +312,7 @@ module ActionAgent
273
312
  def capabilities_check
274
313
  @capabilities_check ||= ProjectCapabilities.new(
275
314
  owner: current_owner,
276
- github_connected: owned(GithubConnection).exists?,
315
+ github_connected: github_sources.any?,
277
316
  base_url: "#{request.base_url}#{request.script_name}"
278
317
  )
279
318
  end
@@ -296,45 +335,78 @@ module ActionAgent
296
335
  performed?
297
336
  end
298
337
 
299
- def github_connection!
300
- connection = owned(GithubConnection).first
301
- return connection if connection
338
+ # Where the owner's repositories are reached through, in the order a
339
+ # repository is looked for in them: the usable GitHub App
340
+ # installations in the order they were linked, then the OAuth
341
+ # connection. It is the order SandboxSession#repository_available
342
+ # checks a project's repository out in, so a project is read through
343
+ # the source its sandbox will clone from. Installations count only
344
+ # while a GitHub App is configured, since nothing else can mint their
345
+ # tokens. Each source answers #repository, #repositories,
346
+ # #reachable_repositories, #find_repository and #contents_client.
347
+ def github_sources
348
+ @github_sources ||= begin
349
+ installations = ActionAgent.github_app_configured? ? owned(GithubInstallation).order(:id).select(&:usable?) : []
350
+ [ *installations, owned(GithubConnection).first ].compact
351
+ end
352
+ end
353
+
354
+ def github_sources!
355
+ sources = github_sources
356
+ return sources if sources.any?
302
357
 
303
- render json: { error: "Connect GitHub in Settings → Integrations first", code: "github_not_connected" },
358
+ render json: { error: "Install the GitHub App or connect GitHub in Settings → Integrations first", code: "github_not_connected" },
304
359
  status: :unprocessable_entity
305
360
  nil
306
361
  end
307
362
 
308
- # The repository +name+ names, as the connection reaches it: from its
309
- # selection, else asked of GitHub by name. Renders a 403 for a
310
- # repository outside the selection when the caller may not manage the
311
- # GitHub connection, and a 404 when GitHub finds none it can reach.
363
+ def manages_github?(source)
364
+ ActionAgent.permitted?(current_user, :manage_github, source)
365
+ end
366
+
367
+ # [source, repository] for the repository +name+ names: from the first
368
+ # source that selected it, else asked of GitHub by name through each
369
+ # source the caller may manage, in #github_sources' order. Renders a
370
+ # 403 for a repository outside every selection when the caller may
371
+ # manage none of the sources, and a 404 when GitHub finds none they
372
+ # reach.
312
373
  #
313
374
  # The connection's token can be one member's, reaching that member's
314
- # own repositories, so only the selection is shared with every member.
315
- # The 403 is decided before GitHub is asked, so it says nothing about
316
- # whether the repository exists.
317
- def reachable_repository!(connection, name = params[:repository])
375
+ # own repositories, and an installation can reach repositories a
376
+ # member cannot see on GitHub, so only the selections are shared with
377
+ # every member. The 403 is decided before GitHub is asked, so it says
378
+ # nothing about whether the repository exists.
379
+ def reachable_repository!(sources, name = params[:repository])
318
380
  unless name.is_a?(String) && Project::REPOSITORY.match?(name)
319
381
  render json: { error: "repository must be owner/name" }, status: :bad_request
320
382
  return nil
321
383
  end
322
384
 
323
- selected = connection.repository(name)
324
- return selected if selected
385
+ sources.each do |source|
386
+ selected = source.repository(name)
387
+ return [ source, selected ] if selected
388
+ end
325
389
 
326
- unless ActionAgent.permitted?(current_user, :manage_github, connection)
390
+ managed = sources.select { |source| manages_github?(source) }
391
+ if managed.empty?
327
392
  render json: { error: "#{name} is not one of the repositories selected in Settings → Integrations, and only " \
328
393
  "someone who may manage the GitHub connection can pick another",
329
394
  code: "forbidden", permission: :manage_github }, status: :forbidden
330
395
  return nil
331
396
  end
332
397
 
333
- repository = connection.client.repository(name)
334
- return repository if repository
398
+ unavailable = []
399
+ managed.each do |source|
400
+ repository = source.find_repository(name)
401
+ return [ source, repository ] if repository
402
+ rescue GithubClient::InstallationUnavailable => e
403
+ unavailable << e
404
+ end
405
+ # Every source asked turned out removed or suspended.
406
+ raise unavailable.last if unavailable.size == managed.size
335
407
 
336
- render json: { error: "GitHub found no repository #{name} that this connection can reach", code: "repository_not_found" },
337
- status: :not_found
408
+ render json: { error: "GitHub found no repository #{name} that this dashboard's GitHub access can reach",
409
+ code: "repository_not_found" }, status: :not_found
338
410
  nil
339
411
  end
340
412
 
@@ -350,9 +422,10 @@ module ActionAgent
350
422
  return nil if requested == @project.default_ref.to_s
351
423
  return nil unless authorize_secret_handover!(@project)
352
424
 
353
- connection = github_connection! or return
354
- repository = reachable_repository!(connection, @project.repository) or return
355
- report = ProjectPreflight.call(connection.client, repository: @project.repository,
425
+ sources = github_sources! or return
426
+ source, repository = reachable_repository!(sources, @project.repository)
427
+ return unless source
428
+ report = ProjectPreflight.call(source.contents_client(repository), repository: @project.repository,
356
429
  ref: requested.presence || repository["default_branch"])
357
430
  if report["status"] == "unsupported"
358
431
  render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
@@ -500,6 +573,12 @@ module ActionAgent
500
573
  status: :unprocessable_entity
501
574
  end
502
575
 
576
+ def installation_unavailable(exception)
577
+ state = exception.reason == :suspended ? "is suspended" : "was removed"
578
+ render json: { error: "The GitHub App installation #{state} on GitHub. Reinstall the GitHub App in Settings → Integrations.",
579
+ code: "installation_unavailable", reinstall_required: true }, status: :unprocessable_entity
580
+ end
581
+
503
582
  def github_not_found(exception)
504
583
  render json: { error: exception.message, code: "not_found" }, status: :not_found
505
584
  end
@@ -24,8 +24,34 @@ module ActionAgent
24
24
  render "action_agent/dashboard/index", locals: { props: props }
25
25
  end
26
26
 
27
+ # A shareable link to an evaluation, one of its runs, or one scenario
28
+ # result of that run (/evaluations/:id/runs/:run_id/results/:result_id).
29
+ # The page is the dashboard either way; what the link names is checked
30
+ # against the caller's agents, the scope the evaluations API reads, so a
31
+ # member of the owning account opens it and anyone else gets a 404 —
32
+ # the same answer a link to nothing gets, so it says nothing about what
33
+ # another account has. The dashboard still renders, and its own request
34
+ # for the evaluation reports it unavailable. A telemetry-only install,
35
+ # with no evaluations table, has nothing to link to either.
36
+ def evaluation
37
+ linked_evaluation_records
38
+ index
39
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::StatementInvalid
40
+ render "action_agent/dashboard/index", locals: { props: props }, status: :not_found
41
+ end
42
+
27
43
  private
28
44
 
45
+ # Raises RecordNotFound unless the evaluation is the caller's, the run
46
+ # is one of its runs and the result one of that run's results.
47
+ def linked_evaluation_records
48
+ evaluation = Evaluation.joins(:agent).where(agent: owner_agents).find(params[:evaluation_id])
49
+ return unless params[:run_id]
50
+
51
+ run = evaluation.evaluation_runs.find(params[:run_id])
52
+ run.scenario_results.find(params[:result_id]) if params[:result_id]
53
+ end
54
+
29
55
  def props
30
56
  {
31
57
  user: current_user_props,
@@ -58,6 +84,7 @@ module ActionAgent
58
84
  providerKeyScope: ActionAgent.provider_key_scope,
59
85
  personalProviderKeys: ProviderKey.personal_keys_enabled?,
60
86
  signOutPath: ActionAgent.sign_out_path,
87
+ userMenuLinks: ActionAgent.user_menu_links_for(current_user, current_owner),
61
88
  # Named here rather than by the layout, so a host layout
62
89
  # (ActionAgent.layout) does not have to know about it. Nil when the
63
90
  # host turned capture off, and the dashboard then loads no recorder.
@@ -9,6 +9,8 @@ module ActionAgent
9
9
  # - Metrics overview
10
10
  # - Live updates via Turbo Streams
11
11
  class TracesController < ApplicationController
12
+ before_action :set_refresh_interval
13
+
12
14
  def index
13
15
  @traces = fetch_traces
14
16
  @metrics = calculate_metrics
@@ -38,6 +40,10 @@ module ActionAgent
38
40
 
39
41
  private
40
42
 
43
+ def set_refresh_interval
44
+ @refresh_interval = params[:refresh].to_i if %w[30 60].include?(params[:refresh])
45
+ end
46
+
41
47
  def turbo_stream_available?
42
48
  Mime::Type.lookup_by_extension(:turbo_stream).present?
43
49
  end