actionagent 1.9.4 → 1.9.6
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 +148 -44
- data/app/controllers/action_agent/api/sandboxes_controller.rb +31 -6
- data/app/controllers/action_agent/dashboard_controller.rb +26 -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/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 +15 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +6 -0
- data/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb +23 -0
- metadata +7 -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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
79
|
+
return to_catalog if selection.values.all?(&:nil?)
|
|
74
80
|
|
|
75
|
-
ActiveAgent::Evals::Catalog.new([ to_document(**selection) ], name: key)
|
|
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.
|