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
@@ -37,8 +37,8 @@ module ActionAgent
37
37
 
38
38
  NAMES = %w[
39
39
  evaluations_list evaluations_get evaluations_create scenarios_merge explorations_submit evaluations_run
40
- evaluation_runs_get evaluation_runs_compare traces_search traces_get input_requests_list input_requests_answer
41
- catalogs_list catalogs_import catalog_set_run
40
+ evaluation_runs_get evaluation_runs_compare traces_search traces_get traces_export input_requests_list
41
+ input_requests_answer catalogs_list catalogs_import catalogs_preview catalogs_export catalog_set_run agents_versions
42
42
  ].freeze
43
43
 
44
44
  # The kinds input_requests_answer answers. An approval and a secret are
@@ -320,6 +320,17 @@ module ActionAgent
320
320
  required: [ "trace_id" ]
321
321
  }
322
322
  },
323
+ {
324
+ name: "traces_export",
325
+ description: "One trace, whole, as the payload POST <mount>/api/traces ingests ({ traces: [trace], sdk }): " \
326
+ "its spans untruncated, with the owner's credentials masked, so the trace can be sent to another " \
327
+ "dashboard with that dashboard's ingest key. Read one to work with it instead: traces_get.",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: { trace_id: { type: "string", description: "The trace's id, its OpenTelemetry trace id, or that id's first 8 characters" } },
331
+ required: [ "trace_id" ]
332
+ }
333
+ },
323
334
  {
324
335
  name: "input_requests_list",
325
336
  description: "List the requests for input this key's paused agent runs are waiting on, newest first: each " \
@@ -364,11 +375,50 @@ module ActionAgent
364
375
  type: "object",
365
376
  properties: {
366
377
  document: { type: "string", description: "The catalog as YAML" },
367
- name: { type: "string", description: "The catalog key when the document sets no `catalog:`" }
378
+ name: { type: "string", description: "The catalog key when the document sets no `catalog:`" },
379
+ mode: {
380
+ type: "string", enum: ScenarioCatalogImport::MODES,
381
+ description: "replace (the default) makes the catalog the document; merge keeps what the document leaves out"
382
+ }
383
+ },
384
+ required: [ "document" ]
385
+ }
386
+ },
387
+ {
388
+ name: "catalogs_preview",
389
+ description: "What catalogs_import of this document would change, without writing anything: whether the " \
390
+ "catalog would be created, updated or left unchanged, how many products, sets and scenarios " \
391
+ "would be created, updated, removed (or kept, in merge mode), and the first " \
392
+ "#{ScenarioCatalogImport::PREVIEW_LIMIT} changes by path.",
393
+ inputSchema: {
394
+ type: "object",
395
+ properties: {
396
+ document: { type: "string", description: "The catalog as YAML" },
397
+ name: { type: "string", description: "The catalog key when the document sets no `catalog:`" },
398
+ mode: {
399
+ type: "string", enum: ScenarioCatalogImport::MODES,
400
+ description: "replace (the default) makes the catalog the document; merge keeps what the document leaves out"
401
+ }
368
402
  },
369
403
  required: [ "document" ]
370
404
  }
371
405
  },
406
+ {
407
+ name: "catalogs_export",
408
+ description: "A catalog's canonical document from its records, as YAML (the default) or JSON: the whole " \
409
+ "catalog, or one product or one set of it, as a document of the catalog's key that a merge " \
410
+ "import reads back. The same document the dashboard's Export YAML downloads.",
411
+ inputSchema: {
412
+ type: "object",
413
+ properties: {
414
+ catalog: { type: "string", description: "The catalog's key" },
415
+ product: { type: "string", description: "Only this product, by key" },
416
+ set: { type: "string", description: "Only this set of the product, by key" },
417
+ format: { type: "string", enum: %w[yaml json], description: "yaml (the default) or json" }
418
+ },
419
+ required: [ "catalog" ]
420
+ }
421
+ },
372
422
  {
373
423
  name: "catalog_set_run",
374
424
  description: "Run one set of a catalog as an evaluation of the agent under test: the product's agent, the " \
@@ -388,6 +438,20 @@ module ActionAgent
388
438
  },
389
439
  required: %w[catalog product set]
390
440
  }
441
+ },
442
+ {
443
+ name: "agents_versions",
444
+ description: "An agent's versions, newest first: each with its number, what it changed from the version " \
445
+ "before (diff), who made it, whether it is the agent's current version, and whether it was cut " \
446
+ "from the agent's code on deploy (release) rather than edited on the dashboard.",
447
+ inputSchema: {
448
+ type: "object",
449
+ properties: {
450
+ agent: { type: "string", description: "The agent's slug or id" },
451
+ limit: { type: "integer", description: "At most this many (default #{LIST_LIMIT}, max #{MAX_LIST_LIMIT})" }
452
+ },
453
+ required: [ "agent" ]
454
+ }
391
455
  }
392
456
  ].freeze
393
457
 
@@ -426,11 +490,15 @@ module ActionAgent
426
490
  when "evaluation_runs_compare" then evaluation_runs_compare_tool
427
491
  when "traces_search" then traces_search_tool
428
492
  when "traces_get" then traces_get_tool
493
+ when "traces_export" then traces_export_tool
429
494
  when "input_requests_list" then input_requests_list_tool
430
495
  when "input_requests_answer" then input_requests_answer_tool
431
496
  when "catalogs_list" then catalogs_list_tool
432
497
  when "catalogs_import" then catalogs_import_tool
498
+ when "catalogs_preview" then catalogs_preview_tool
499
+ when "catalogs_export" then catalogs_export_tool
433
500
  when "catalog_set_run" then catalog_set_run_tool
501
+ when "agents_versions" then agents_versions_tool
434
502
  end
435
503
  end
436
504
 
@@ -821,18 +889,8 @@ module ActionAgent
821
889
  }
822
890
  end
823
891
 
824
- # Looked up as Api::TraceReportsController#show looks a trace up: the
825
- # OpenTelemetry trace id, then a prefix of it, then the record id.
826
892
  def traces_get_tool
827
- id = tool_argument(:trace_id).to_s
828
- raise MCPController::McpError.new("Missing required argument: trace_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
829
-
830
- scope = owned_traces
831
- trace = scope.find_by(trace_id: id) ||
832
- scope.where("trace_id LIKE ?", "#{ActionAgent.trace_model.sanitize_sql_like(id)}%").first ||
833
- (id.match?(/\A\d+\z/) && scope.find_by(id: id)) or
834
- raise ToolError, "No trace #{id.truncate(64)} was found"
835
-
893
+ trace = find_tool_trace!
836
894
  detail = TelemetryTraceSerializer.detail(trace)
837
895
  spans = detail.delete(:spans)
838
896
  failed_spans = spans.select { |span| span[:error] }.map do |span|
@@ -849,6 +907,43 @@ module ActionAgent
849
907
  PayloadBounds.bound(payload, max_string: MAX_STRING, max_items: MAX_TRACE_SPANS)
850
908
  end
851
909
 
910
+ # The trace as Api::TracesController#create takes it, whole: the
911
+ # stored trace's spans and resource attributes with the SDK that
912
+ # reported it, so a POST of the result to another install's
913
+ # <mount>/api/traces stores the same trace there (an install skips a
914
+ # trace_id it already holds). Not bounded as traces_get bounds a trace,
915
+ # since the point is a copy; the owner's credentials are masked from
916
+ # it as from every tool's result.
917
+ def traces_export_tool
918
+ trace = find_tool_trace!
919
+
920
+ {
921
+ traces: [ {
922
+ trace_id: trace.trace_id,
923
+ service_name: trace.service_name,
924
+ environment: trace.environment,
925
+ timestamp: trace.timestamp&.iso8601(6),
926
+ resource_attributes: trace.resource_attributes.presence || {},
927
+ spans: Array(trace.spans)
928
+ } ],
929
+ sdk: trace.sdk_info.presence || {}
930
+ }
931
+ end
932
+
933
+ # The trace `trace_id` names, looked up as Api::TraceReportsController#show
934
+ # looks a trace up: the OpenTelemetry trace id, then a prefix of it,
935
+ # then the record id.
936
+ def find_tool_trace!
937
+ id = tool_argument(:trace_id).to_s
938
+ raise MCPController::McpError.new("Missing required argument: trace_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
939
+
940
+ scope = owned_traces
941
+ scope.find_by(trace_id: id) ||
942
+ scope.where("trace_id LIKE ?", "#{ActionAgent.trace_model.sanitize_sql_like(id)}%").first ||
943
+ (id.match?(/\A\d+\z/) && scope.find_by(id: id)) or
944
+ raise ToolError, "No trace #{id.truncate(64)} was found"
945
+ end
946
+
852
947
  def input_requests_list_tool
853
948
  InputRequest.expire_overdue!(owner_input_requests)
854
949
  scope = owner_input_requests.pending.for_listing.includes(:subject).recent
@@ -948,19 +1043,16 @@ module ActionAgent
948
1043
  end
949
1044
 
950
1045
  # Imports a catalog document as POST /api/scenario_catalogs does: the
951
- # owner's agents and projects resolve the products' targets.
1046
+ # owner's agents and projects resolve the products' targets, and `mode`
1047
+ # is replace (the default) or merge, as there.
952
1048
  def catalogs_import_tool
953
- document = required_tool_argument!(:document).to_s
954
- raise ToolError, "document is larger than #{ScenarioCatalog::MAX_BYTES} bytes" if document.bytesize > ScenarioCatalog::MAX_BYTES
955
-
1049
+ document = catalog_document_argument!
956
1050
  authorize_catalog_write!(ScenarioCatalog.new)
957
- catalog = ScenarioCatalogImport.new(
958
- owner: current_owner, document: document, name: tool_argument(:name).to_s.presence,
959
- source_kind: "api", agents: owner_agents, projects: owned(Project)
960
- ).call
1051
+ catalog = catalog_import(document).call
961
1052
 
962
1053
  {
963
1054
  catalog: ScenarioCatalogSerializer.summary(catalog),
1055
+ mode: catalog_mode_argument,
964
1056
  products: catalog.products.map do |product|
965
1057
  { key: product.key, agent: product.agent&.name, project_id: product.project_id,
966
1058
  sets: product.sets.map { |set| { key: set.key, scenario_count: set.scenarios.size } } }
@@ -970,12 +1062,95 @@ module ActionAgent
970
1062
  raise ToolError, e.message
971
1063
  end
972
1064
 
1065
+ # What catalogs_import of the document would change, as POST
1066
+ # /api/scenario_catalogs/preview answers it, writing nothing.
1067
+ def catalogs_preview_tool
1068
+ catalog_import(catalog_document_argument!).preview
1069
+ rescue ScenarioCatalogImport::Invalid => e
1070
+ raise ToolError, e.message
1071
+ end
1072
+
1073
+ # The catalog's canonical document from its records, as GET
1074
+ # /api/scenario_catalogs/:id/export serves it, whole or narrowed to one
1075
+ # product or one set.
1076
+ def catalogs_export_tool
1077
+ catalog = find_tool_catalog!
1078
+ format = tool_argument(:format).to_s.presence || "yaml"
1079
+ raise ToolError, "format is yaml or json" unless %w[yaml json].include?(format)
1080
+
1081
+ selection = {}
1082
+ product = set = nil
1083
+ if (product_key = tool_argument(:product).to_s.presence)
1084
+ product = catalog.products.find_by(key: product_key) ||
1085
+ raise(ToolError, "No product #{product_key.inspect} in catalog #{catalog.key}")
1086
+ if (set_key = tool_argument(:set).to_s.presence)
1087
+ set = product.sets.find_by(key: set_key) || raise(ToolError, "No set #{set_key.inspect} in product #{product.key}")
1088
+ selection[:set_ids] = [ set.id ]
1089
+ else
1090
+ selection[:product_ids] = [ product.id ]
1091
+ end
1092
+ elsif tool_argument(:set).present?
1093
+ raise ToolError, "set names a set of a product: give product too"
1094
+ end
1095
+
1096
+ exported = catalog.export_catalog(**selection)
1097
+ {
1098
+ catalog: { id: catalog.id, key: catalog.key, name: catalog.name, digest: catalog.digest },
1099
+ product: product&.key,
1100
+ set: set&.key,
1101
+ format: format,
1102
+ digest: exported.digest,
1103
+ document: format == "json" ? JSON.pretty_generate(exported.to_h) : exported.to_yaml
1104
+ }
1105
+ end
1106
+
1107
+ def catalog_import(document)
1108
+ ScenarioCatalogImport.new(
1109
+ owner: current_owner, document: document, name: tool_argument(:name).to_s.presence,
1110
+ source_kind: "api", agents: owner_agents, projects: owned(Project), mode: catalog_mode_argument
1111
+ )
1112
+ end
1113
+
1114
+ def catalog_document_argument!
1115
+ document = required_tool_argument!(:document).to_s
1116
+ raise ToolError, "document is larger than #{ScenarioCatalog::MAX_BYTES} bytes" if document.bytesize > ScenarioCatalog::MAX_BYTES
1117
+
1118
+ document
1119
+ end
1120
+
1121
+ # replace unless the call says merge; ScenarioCatalogImport refuses
1122
+ # anything else.
1123
+ def catalog_mode_argument
1124
+ tool_argument(:mode).to_s.presence || "replace"
1125
+ end
1126
+
1127
+ # The owner's catalog `catalog` names by key.
1128
+ def find_tool_catalog!
1129
+ key = required_tool_argument!(:catalog).to_s
1130
+ owned(ScenarioCatalog).find_by(key: key) || raise(ToolError, "No catalog #{key.inspect}; catalogs_list names them")
1131
+ end
1132
+
1133
+ # An agent's versions as GET /api/agents/:id/versions lists them, each
1134
+ # with its diff from the version before, newest first.
1135
+ def agents_versions_tool
1136
+ agent = resolve_tool_agent!(required_tool_argument!(:agent))
1137
+ limit = integer_argument(:limit, default: LIST_LIMIT, min: 1, max: MAX_LIST_LIMIT)
1138
+ versions = agent.agent_versions.recent.limit(limit + 1).to_a
1139
+
1140
+ {
1141
+ agent: { id: agent.id, slug: agent.slug, name: agent.name, version_count: agent.agent_versions.count },
1142
+ versions: versions.first(limit).map do |version|
1143
+ PayloadBounds.bound(AgentVersionSerializer.call(version, include_diff: true), max_string: MAX_STRING)
1144
+ end,
1145
+ truncated: versions.size > limit
1146
+ }
1147
+ end
1148
+
973
1149
  # Runs one set as POST /api/scenario_catalogs/:id/sets/:set_id/run
974
1150
  # does, gated as evaluations_run is. A project's sandbox must already
975
1151
  # be running: booting one is a dashboard action, not a tool's.
976
1152
  def catalog_set_run_tool
977
- catalog = owned(ScenarioCatalog).find_by(key: required_tool_argument!(:catalog).to_s) ||
978
- raise(ToolError, "No catalog #{tool_argument(:catalog).inspect}; catalogs_list names them")
1153
+ catalog = find_tool_catalog!
979
1154
  product = catalog.products.find_by(key: required_tool_argument!(:product).to_s) ||
980
1155
  raise(ToolError, "No product #{tool_argument(:product).inspect} in catalog #{catalog.key}")
981
1156
  set = product.sets.find_by(key: required_tool_argument!(:set).to_s) ||
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Applies a GitHub App webhook delivery that Api::GithubAppWebhooksController
5
+ # verified and recorded, so Settings -> Integrations learns of a change on
6
+ # GitHub when it happens rather than when a token mint is next refused.
7
+ #
8
+ # +payload+ is the slice of the delivery GithubWebhookJob.payload_for keeps.
9
+ # A delivery about an installation no owner linked changes nothing, and so
10
+ # does an event or action not handled here.
11
+ #
12
+ # Each handler sets state from the payload rather than toggling it, so
13
+ # running one twice leaves the same result; the delivery record is what
14
+ # keeps an old delivery from being replayed over a newer one.
15
+ class GithubWebhookJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ EVENTS = %w[installation installation_repositories github_app_authorization].freeze
19
+
20
+ # What the job needs from a delivery's +payload+, so the job's arguments
21
+ # carry neither the sender's profile nor every repository's details.
22
+ def self.payload_for(event, payload)
23
+ installation = payload["installation"].is_a?(Hash) ? payload["installation"] : {}
24
+ repositories = ->(key) { Array(payload[key]).filter_map { |repo| repo.slice("id", "full_name") if repo.is_a?(Hash) } }
25
+ sender = payload["sender"].is_a?(Hash) ? payload["sender"] : {}
26
+
27
+ {
28
+ "action" => payload["action"].to_s,
29
+ "installation" => installation.slice("id", "permissions", "repository_selection", "suspended_at"),
30
+ "repositories_added" => event == "installation_repositories" ? repositories.call("repositories_added") : [],
31
+ "repositories_removed" => event == "installation_repositories" ? repositories.call("repositories_removed") : [],
32
+ "sender_id" => sender["id"]
33
+ }
34
+ end
35
+
36
+ def perform(event, payload)
37
+ case event
38
+ when "installation" then installation(payload)
39
+ when "installation_repositories" then installation_repositories(payload)
40
+ when "github_app_authorization" then authorization(payload)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def installation(payload)
47
+ record = linked_installation(payload) or return
48
+ reported = payload["installation"]
49
+
50
+ case payload["action"]
51
+ when "deleted"
52
+ record.update!(removed_at: Time.current) if record.removed_at.nil?
53
+ when "suspend"
54
+ record.update!(suspended_at: parse_time(reported["suspended_at"]) || Time.current)
55
+ when "unsuspend"
56
+ record.update!(suspended_at: nil)
57
+ when "new_permissions_accepted"
58
+ record.update!(permissions: reported["permissions"]) if reported["permissions"].is_a?(Hash)
59
+ end
60
+ end
61
+
62
+ # The repository picker lists what the installation reaches from GitHub
63
+ # each time it opens, so an added repository needs nothing stored; a
64
+ # removed one leaves the owner's selection, which is what a checkout
65
+ # trusts.
66
+ def installation_repositories(payload)
67
+ record = linked_installation(payload) or return
68
+ return unless payload["action"].in?(%w[added removed])
69
+
70
+ removed = payload["repositories_removed"].map { |repo| repo["id"].to_i }.to_set
71
+ attributes = { repositories: record.repositories.reject { |repo| removed.include?(repo["id"].to_i) } }
72
+ selection = payload.dig("installation", "repository_selection")
73
+ attributes[:repository_selection] = selection if selection.present?
74
+ record.update!(attributes)
75
+ end
76
+
77
+ # GitHub revoked the user's authorization of the App. The App's own user
78
+ # tokens are never stored (see Api::GithubInstallationsController), so
79
+ # this reaches a stored GithubConnection only when the OAuth connection
80
+ # flow authorizes through this same App (github_client_id is the App's
81
+ # client id). A connection made through a separate OAuth App is a grant
82
+ # this revocation does not touch.
83
+ def authorization(payload)
84
+ return unless payload["action"] == "revoked"
85
+ return if payload["sender_id"].blank?
86
+ return unless ActionAgent.github_client_id.present? && ActionAgent.github_client_id == ActionAgent.github_app_client_id
87
+
88
+ GithubConnection.where(github_user_id: payload["sender_id"].to_i).destroy_all
89
+ end
90
+
91
+ def linked_installation(payload)
92
+ id = payload.dig("installation", "id")
93
+ GithubInstallation.find_by(installation_id: id.to_i) if id.present?
94
+ end
95
+
96
+ def parse_time(value)
97
+ Time.iso8601(value) if value.is_a?(String)
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+ end
102
+ end
@@ -38,6 +38,27 @@ module ActionAgent
38
38
  GithubClient.new(access_token)
39
39
  end
40
40
 
41
+ # Every repository the token reaches, whether selected or not. The same
42
+ # three methods are on GithubInstallation, so a project reaches a
43
+ # repository through either.
44
+ def reachable_repositories
45
+ client.repositories
46
+ end
47
+
48
+ # The repository +full_name+ names, as the token reaches it, or nil.
49
+ def find_repository(full_name)
50
+ client.repository(full_name)
51
+ end
52
+
53
+ # A client that reads +repo+'s files: the connection's own token.
54
+ def contents_client(_repo)
55
+ client
56
+ end
57
+
58
+ def checkout_source
59
+ "oauth"
60
+ end
61
+
41
62
  # The checkout a sandbox backend clones: repository, ref, and an
42
63
  # authenticated HTTPS clone URL. Carries the token — hand it to a backend,
43
64
  # never to a response.
@@ -49,7 +49,8 @@ module ActionAgent
49
49
 
50
50
  # Whether GitHub will still mint tokens for this installation, as far as
51
51
  # the dashboard last heard: false once a mint found it removed or
52
- # suspended, until a later mint succeeds or it is linked again.
52
+ # suspended, or a webhook delivery reported it (GithubWebhookJob), until
53
+ # a later mint succeeds, an unsuspend is delivered, or it is linked again.
53
54
  def usable?
54
55
  removed_at.nil? && suspended_at.nil?
55
56
  end
@@ -102,6 +103,32 @@ module ActionAgent
102
103
  GithubClient.new(mint_token!(permissions: LISTING_PERMISSIONS))
103
104
  end
104
105
 
106
+ # The repositories GitHub lists for the installation, whether selected
107
+ # or not (GithubClient#repositories' shape). Mints a listing token.
108
+ def reachable_repositories
109
+ listing_client.installation_repositories
110
+ end
111
+
112
+ # The repository +full_name+ names, as the installation reaches it, or
113
+ # nil when it reaches none: asked of GitHub by name, so it also finds one
114
+ # the owner has not selected. Mints a listing token.
115
+ def find_repository(full_name)
116
+ listing_client.repository(full_name)
117
+ end
118
+
119
+ # A client that reads the files of the one repository +repo+ (a
120
+ # repository row with an "id"), with a freshly minted token limited to
121
+ # its contents, for a project's preflight and secret discovery. The
122
+ # token is held by the client only.
123
+ def contents_client(repo)
124
+ GithubClient.new(mint_token!(permissions: CHECKOUT_PERMISSIONS, repository_ids: [ repo["id"] ]))
125
+ end
126
+
127
+ # How a project or sandbox says where its repository is reached through.
128
+ def checkout_source
129
+ "app"
130
+ end
131
+
105
132
  # Records what GitHub reported about the installation when it was linked.
106
133
  def assign_from_github(installation)
107
134
  account = installation["account"] || {}
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A GitHub App webhook delivery the dashboard accepted, by its
5
+ # X-GitHub-Delivery id. Api::GithubAppWebhooksController records one before
6
+ # it enqueues the delivery's GithubWebhookJob, so a redelivery of the same
7
+ # id (from GitHub's retry or its Redeliver button) finds it and is a no-op:
8
+ # a replayed +suspend+ cannot undo a later +unsuspend+.
9
+ #
10
+ # A row instead of Rails.cache: the unique index makes the check and the
11
+ # record one atomic insert across every web process, whatever cache store
12
+ # the host runs. Rows hold no payload and are pruned after RETENTION, a
13
+ # little longer than GitHub keeps a delivery redeliverable.
14
+ #
15
+ # Not owned: a delivery is about an installation, whichever owner (if any)
16
+ # linked it.
17
+ class GithubWebhookDelivery < ApplicationRecord
18
+ RETENTION = 7.days
19
+
20
+ validates :delivery_id, :event, presence: true
21
+
22
+ # Records the delivery, and answers whether it is the first time this id
23
+ # was seen. Rows older than RETENTION are pruned on the way.
24
+ #
25
+ # @return [Boolean] false for a redelivery
26
+ def self.record!(delivery_id:, event:, event_action: nil, installation_id: nil)
27
+ where(created_at: ...RETENTION.ago).delete_all
28
+
29
+ # A savepoint, so a duplicate inside a caller's transaction does not
30
+ # abort it on PostgreSQL.
31
+ transaction(requires_new: true) do
32
+ create!(delivery_id: delivery_id, event: event, event_action: event_action, installation_id: installation_id)
33
+ end
34
+ true
35
+ rescue ActiveRecord::RecordNotUnique
36
+ false
37
+ end
38
+
39
+ # Forgets the delivery, so GitHub's redelivery of it is processed. For a
40
+ # delivery that was recorded but could not be enqueued.
41
+ def self.forget!(delivery_id)
42
+ where(delivery_id: delivery_id).delete_all
43
+ end
44
+ end
45
+ end
@@ -261,7 +261,14 @@ module ActionAgent
261
261
  # +owner+ itself — unwrapped from a SimpleDelegator-style decorator —
262
262
  # once it is known to be an instance of the owner model.
263
263
  def owner_record(owner)
264
- owner = owner.__getobj__ while defined?(::Delegator) && owner.is_a?(::Delegator)
264
+ # Not `... while defined?(::Delegator) && ...`: Prism (Ruby 3.4 and
265
+ # 4.0) miscompiles `defined?` inside a `while` modifier's condition
266
+ # when the source arrives as a string, which is how bootsnap 1.24.6+
267
+ # compiles files on those Rubies, and the whole engine then fails to
268
+ # boot with "argument stack underflow".
269
+ if defined?(::Delegator)
270
+ owner = owner.__getobj__ while owner.is_a?(::Delegator)
271
+ end
265
272
  association = owner_association
266
273
  return owner if owner.nil? || association.nil?
267
274
 
@@ -69,10 +69,16 @@ module ActionAgent
69
69
  # that hold them), as a document of this catalog's key that a merge
70
70
  # import (ScenarioCatalogImport mode "merge") reads back into it.
71
71
  def export_yaml(product_ids: nil, set_ids: nil, scenario_ids: nil)
72
+ export_catalog(product_ids: product_ids, set_ids: set_ids, scenario_ids: scenario_ids).to_yaml
73
+ end
74
+
75
+ # The records as an ActiveAgent::Evals::Catalog, whole or narrowed as
76
+ # #export_yaml narrows it, for the YAML or the JSON of the document.
77
+ def export_catalog(product_ids: nil, set_ids: nil, scenario_ids: nil)
72
78
  selection = { product_ids: ids(product_ids), set_ids: ids(set_ids), scenario_ids: ids(scenario_ids) }
73
- return to_catalog.to_yaml if selection.values.all?(&:nil?)
79
+ return to_catalog if selection.values.all?(&:nil?)
74
80
 
75
- ActiveAgent::Evals::Catalog.new([ to_document(**selection) ], name: key).to_yaml
81
+ ActiveAgent::Evals::Catalog.new([ to_document(**selection) ], name: key)
76
82
  end
77
83
 
78
84
  # A key for a new product, set or scenario: +text+ as a key, made unique
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # JSON for one of an agent's versions: what GET /api/agents/:id/versions
5
+ # lists and the MCP facade's agents_versions returns.
6
+ module AgentVersionSerializer
7
+ module_function
8
+
9
+ # +include_diff+ adds `diff`, what the version changed from the one
10
+ # before it ({ key => { from:, to: } }), when there is one.
11
+ def call(version, include_diff: false)
12
+ json = {
13
+ id: version.id,
14
+ version_number: version.version_number,
15
+ change_summary: version.change_summary,
16
+ created_by: version.created_by,
17
+ created_at: version.created_at,
18
+ is_latest: version.latest?,
19
+ release: version.release?,
20
+ release_digest: version.release_digest,
21
+ revision: version.revision
22
+ }
23
+ if include_diff && (previous = version.previous)
24
+ json[:diff] = version.diff(previous)
25
+ end
26
+ json
27
+ end
28
+ end
29
+ end
@@ -86,6 +86,7 @@ module ActionAgent
86
86
  {
87
87
  id: run.id,
88
88
  number: number,
89
+ run_id: export_run_id(run),
89
90
  status: run.status,
90
91
  average_score: average_score(run),
91
92
  samples_evaluated: run.samples_evaluated,
@@ -98,6 +99,14 @@ module ActionAgent
98
99
  }
99
100
  end
100
101
 
102
+ # The run_id the run exports under (EvaluationReportExport.run_id_for),
103
+ # the handle `GET <mount>/api/evaluation_reports/:run_id` and the CLI's
104
+ # `evals report` take; nil on an install whose runs predate the report
105
+ # identity migration.
106
+ def export_run_id(run)
107
+ EvaluationRun.column_names.include?("external_run_id") ? EvaluationReportExport.run_id_for(run) : nil
108
+ end
109
+
101
110
  # Returns up to +limit+ of the evaluation's runs, newest first. Sorts the
102
111
  # preloaded association when one was loaded rather than issuing one
103
112
  # ORDER BY query per evaluation.