agentboard 0.2.0
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 +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- metadata +285 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Diagnostics shared by `agentboard doctor` and the Settings page (spec §31.3).
|
|
3
|
+
class Doctor
|
|
4
|
+
Check = Struct.new(:name, :status, :detail, keyword_init: true) # status: ok | warn | fail
|
|
5
|
+
|
|
6
|
+
def initialize(root:)
|
|
7
|
+
@root = root.to_s
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def checks
|
|
11
|
+
[ ruby_check, git_check, worktree_check, database_check, migrations_check, mount_check, config_check,
|
|
12
|
+
daemon_check, mcp_check, sandbox_check, edition_check, *Extensions.run(:doctor_checks).flatten.compact, *provider_checks ]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ok? = checks.none? { |c| c.status == "fail" }
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def edition_check
|
|
20
|
+
ext = Extensions.extensions.values
|
|
21
|
+
if ext.empty?
|
|
22
|
+
Check.new(name: "Edition", status: "ok", detail: "Community (MIT) — Pro features: #{Extensions::FEATURES.keys.join(", ")}")
|
|
23
|
+
else
|
|
24
|
+
Check.new(name: "Edition", status: "ok", detail: ext.map { |e| "#{e.name} #{e.version} (#{e.enabled_features.join(", ").presence || "no licensed features"})" }.join("; "))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ruby_check
|
|
29
|
+
ok = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3")
|
|
30
|
+
Check.new(name: "Ruby", status: ok ? "ok" : "fail", detail: "#{RUBY_VERSION} (requires >= 3.3)")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def git_check
|
|
34
|
+
out, _e, st = Open3.capture3("git", "--version")
|
|
35
|
+
repo = Git::Repository.new(@root)
|
|
36
|
+
return Check.new(name: "Git", status: "fail", detail: "git not available") unless st.success?
|
|
37
|
+
return Check.new(name: "Git", status: "fail", detail: "#{@root} is not a Git repository") unless repo.repository?
|
|
38
|
+
|
|
39
|
+
Check.new(name: "Git", status: "ok", detail: "#{out.strip} · default branch #{repo.default_branch}")
|
|
40
|
+
rescue Errno::ENOENT
|
|
41
|
+
Check.new(name: "Git", status: "fail", detail: "git not found on PATH")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def worktree_check
|
|
45
|
+
out, _e, st = Open3.capture3("git", "worktree", "list", chdir: @root)
|
|
46
|
+
Check.new(name: "Git worktrees", status: st.success? ? "ok" : "fail", detail: st.success? ? "#{out.lines.size} worktree(s)" : "git worktree unsupported")
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
Check.new(name: "Git worktrees", status: "fail", detail: "git not found")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def database_check
|
|
52
|
+
adapter = ActiveRecord::Base.connection_db_config.adapter
|
|
53
|
+
status = adapter.include?("postgres") ? "ok" : "warn"
|
|
54
|
+
detail = adapter.include?("sqlite") ? "#{adapter} — single-user development mode (PostgreSQL recommended for concurrent leases)" : adapter
|
|
55
|
+
Check.new(name: "Database", status: status, detail: detail)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Check.new(name: "Database", status: "fail", detail: e.class.name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def migrations_check
|
|
61
|
+
missing = %w[agentboard_tickets agentboard_executions agentboard_events].reject { |t| ActiveRecord::Base.connection.table_exists?(t) }
|
|
62
|
+
if missing.any?
|
|
63
|
+
Check.new(name: "Migrations", status: "fail", detail: "missing tables: #{missing.join(", ")} — run bin/rails db:migrate")
|
|
64
|
+
else
|
|
65
|
+
Check.new(name: "Migrations", status: "ok", detail: "AgentBoard tables present")
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Check.new(name: "Migrations", status: "fail", detail: e.class.name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mount_check
|
|
72
|
+
mounted = Rails.application.routes.routes.any? { |r| r.app.respond_to?(:app) && r.app.app == Agentboard::Engine }
|
|
73
|
+
Check.new(name: "Dashboard mount", status: mounted ? "ok" : "warn",
|
|
74
|
+
detail: mounted ? "mounted (development only by default)" : "Agentboard::Engine is not mounted in config/routes.rb")
|
|
75
|
+
rescue StandardError
|
|
76
|
+
Check.new(name: "Dashboard mount", status: "warn", detail: "could not inspect routes")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def config_check
|
|
80
|
+
files = Agentboard.config.loaded_files
|
|
81
|
+
Check.new(name: "Configuration", status: files.any? ? "ok" : "warn",
|
|
82
|
+
detail: files.any? ? files.map { |f| f.delete_prefix(@root + "/") }.join(", ") : "no config file found — run `agentboard init`")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def daemon_check
|
|
86
|
+
st = Runtime::DaemonControl.status
|
|
87
|
+
if st[:running]
|
|
88
|
+
Check.new(name: "Runtime daemon", status: "ok", detail: "pid #{st[:pid]}, #{st.dig(:health, "active")} active")
|
|
89
|
+
else
|
|
90
|
+
Check.new(name: "Runtime daemon", status: "warn", detail: st[:stale_pid_file] ? "stale PID file; daemon not running" : "not running — `agentboard daemon start`")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mcp_check
|
|
95
|
+
require "mcp"
|
|
96
|
+
Check.new(name: "MCP server", status: "ok", detail: "mcp gem #{Gem.loaded_specs["mcp"]&.version} · stdio transport")
|
|
97
|
+
rescue LoadError
|
|
98
|
+
Check.new(name: "MCP server", status: "fail", detail: "mcp gem not available")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sandbox_check
|
|
102
|
+
sec = Agentboard.config.security
|
|
103
|
+
if AgentContainer.enabled?
|
|
104
|
+
return Check.new(name: "Execution isolation", status: "fail", detail: "sandbox.agents is on but #{AgentContainer.runtime} is not running") unless ContainerSandbox.available?
|
|
105
|
+
return Check.new(name: "Execution isolation", status: "fail", detail: "sandbox.agent_image is not set (agentboard sandbox build)") if ContainerSandbox.settings["agent_image"].blank?
|
|
106
|
+
|
|
107
|
+
return Check.new(name: "Execution isolation", status: "ok",
|
|
108
|
+
detail: "agents in containers (#{ContainerSandbox.settings["agent_image"]}), internal network, egress: #{AgentContainer.egress_allowlist.size} allowed endpoints")
|
|
109
|
+
end
|
|
110
|
+
if sec["trusted_local_execution"] && sec["sandbox"] != "container"
|
|
111
|
+
Check.new(name: "Execution isolation", status: "warn", detail: "trusted local subprocesses (worktrees are not sandboxes)")
|
|
112
|
+
elsif sec["sandbox"] == "container"
|
|
113
|
+
if ContainerSandbox.available?
|
|
114
|
+
Check.new(name: "Execution isolation", status: "ok", detail: "verification runs in #{ContainerSandbox.settings["runtime"]} (network #{ContainerSandbox.settings["network"]}); agents require trusted_local_execution")
|
|
115
|
+
else
|
|
116
|
+
Check.new(name: "Execution isolation", status: "fail", detail: "sandbox: container but #{ContainerSandbox.settings["runtime"]} is not running")
|
|
117
|
+
end
|
|
118
|
+
else
|
|
119
|
+
Check.new(name: "Execution isolation", status: "ok", detail: "active execution of real agents disabled until trusted_local_execution is enabled")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def provider_checks
|
|
124
|
+
Providers.registry.all.reject { |p| p.key == "fake" }.map do |p|
|
|
125
|
+
probe = p.probe
|
|
126
|
+
status = !probe.installed ? "warn" : (probe.supports?(:active_execution) ? "ok" : "warn")
|
|
127
|
+
caps = probe.supported_capabilities.map(&:to_s).join(", ")
|
|
128
|
+
Check.new(name: "Provider: #{p.display_name}", status: status,
|
|
129
|
+
detail: probe.installed ? "#{probe.version} · #{probe.validation} · #{caps.presence || "no capabilities"}" : "not installed")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Appends events and their outbox messages in the caller's transaction, giving the
|
|
3
|
+
# transactional state-change + outbox guarantee (spec §11). Consumers must be idempotent;
|
|
4
|
+
# delivery is at-least-once, never exactly-once.
|
|
5
|
+
module EventStore
|
|
6
|
+
DEFAULT_TOPICS = %w[projections].freeze
|
|
7
|
+
MAX_PAYLOAD_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
class Duplicate < StandardError
|
|
10
|
+
attr_reader :event
|
|
11
|
+
def initialize(event) = (@event = event; super("duplicate event #{event.id}"))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Returns the persisted Event. With a dedupe_key, a replay returns the original event
|
|
17
|
+
# and does not insert a second row (raises Duplicate when raise_on_duplicate).
|
|
18
|
+
def append!(type:, aggregate:, source: "agentboard", provenance: "confirmed", payload: {},
|
|
19
|
+
actor: nil, project: nil, ticket: nil, execution: nil, session: nil,
|
|
20
|
+
correlation_id: nil, causation_id: nil, dedupe_key: nil, occurred_at: nil,
|
|
21
|
+
topics: DEFAULT_TOPICS, raise_on_duplicate: false, event_id: nil)
|
|
22
|
+
if dedupe_key && (existing = Event.find_by(dedupe_key: dedupe_key))
|
|
23
|
+
raise Duplicate, existing if raise_on_duplicate
|
|
24
|
+
|
|
25
|
+
return existing
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
aggregate_type, aggregate_id = resolve_aggregate(aggregate)
|
|
29
|
+
project_id = project&.id || ticket&.project_id || (aggregate.respond_to?(:project_id) && aggregate.project_id) || nil
|
|
30
|
+
safe_payload = bounded(Redactor.redact_value(deep_stringify(payload)))
|
|
31
|
+
now = Time.current
|
|
32
|
+
|
|
33
|
+
attempts = 0
|
|
34
|
+
begin
|
|
35
|
+
attempts += 1
|
|
36
|
+
Event.transaction(requires_new: true) do
|
|
37
|
+
sequence = Event.where(aggregate_type: aggregate_type, aggregate_id: aggregate_id).maximum(:sequence).to_i + 1
|
|
38
|
+
event = Event.create!(
|
|
39
|
+
id: event_id || Ids.uuid, project_id: project_id, aggregate_type: aggregate_type, aggregate_id: aggregate_id,
|
|
40
|
+
sequence: sequence, event_type: type, schema_version: EventSchema.current_version(type), source: source, provenance: provenance,
|
|
41
|
+
ticket_id: ticket&.id || (aggregate.is_a?(Ticket) ? aggregate.id : nil),
|
|
42
|
+
execution_id: execution&.id || (aggregate.is_a?(Execution) ? aggregate.id : nil),
|
|
43
|
+
session_id: session&.id, actor_type: actor&.type, actor_id: actor&.id,
|
|
44
|
+
dedupe_key: dedupe_key, occurred_at: occurred_at || now, received_at: now, payload: safe_payload,
|
|
45
|
+
correlation_id: correlation_id || Current.correlation_id || Ids.correlation, causation_id: causation_id
|
|
46
|
+
)
|
|
47
|
+
Array(topics).each do |topic|
|
|
48
|
+
OutboxMessage.create!(event_id: event.id, topic: topic, state: "pending", available_at: now)
|
|
49
|
+
end
|
|
50
|
+
event
|
|
51
|
+
end
|
|
52
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
53
|
+
if dedupe_key && (existing = Event.find_by(dedupe_key: dedupe_key))
|
|
54
|
+
raise Duplicate, existing if raise_on_duplicate
|
|
55
|
+
|
|
56
|
+
return existing
|
|
57
|
+
end
|
|
58
|
+
retry if attempts < 5
|
|
59
|
+
raise e
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_aggregate(aggregate)
|
|
64
|
+
case aggregate
|
|
65
|
+
when Array then aggregate.map(&:to_s)
|
|
66
|
+
when ActiveRecord::Base then [ aggregate.class.name.demodulize.underscore, aggregate.id.to_s ]
|
|
67
|
+
else raise ArgumentError, "aggregate must be a record or [type, id]"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deep_stringify(value)
|
|
72
|
+
case value
|
|
73
|
+
when Hash then value.to_h { |k, v| [ k.to_s, deep_stringify(v) ] }
|
|
74
|
+
when Array then value.map { |v| deep_stringify(v) }
|
|
75
|
+
when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601(3)
|
|
76
|
+
when Symbol then value.to_s
|
|
77
|
+
else value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def bounded(payload)
|
|
82
|
+
json = payload.to_json
|
|
83
|
+
return payload if json.bytesize <= MAX_PAYLOAD_BYTES
|
|
84
|
+
|
|
85
|
+
{ "truncated" => true, "original_bytes" => json.bytesize, "excerpt" => json.byteslice(0, 4096).scrub }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Resolves the code state a ticket is currently judged against, and records evidence
|
|
3
|
+
# artifacts with explicit provenance.
|
|
4
|
+
module Evidence
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# The head commit is the most recent *verified-in-repo* commit evidence: execution head
|
|
8
|
+
# or attached commit artifact. Agent-claimed SHAs that aren't in the repository don't count.
|
|
9
|
+
def head_sha(ticket)
|
|
10
|
+
candidates = []
|
|
11
|
+
ticket.executions.where.not(head_sha: nil).where.not(action: "verify").limit(5).each { |e| candidates << [ e.finished_at || e.updated_at, e.head_sha ] }
|
|
12
|
+
ticket.artifacts.where(kind: "commit").limit(20).select { |a| a.metadata["verified_in_repo"] }.each { |a| candidates << [ a.created_at, a.commit_sha ] }
|
|
13
|
+
candidates.compact.max_by { |(t, _)| t.to_f }&.last
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def attach_commit!(ticket:, commit_sha:, actor:, execution: nil, provenance: nil, session: nil)
|
|
17
|
+
sha = commit_sha.to_s.strip.downcase
|
|
18
|
+
raise Error.validation_failed("commit_sha must be a 7-64 character hex string") unless sha.match?(/\A[0-9a-f]{7,64}\z/)
|
|
19
|
+
|
|
20
|
+
repo = ticket.project.repository
|
|
21
|
+
full = repo.resolve_commit(sha)
|
|
22
|
+
verified = full.present?
|
|
23
|
+
Artifact.transaction do
|
|
24
|
+
artifact = ticket.artifacts.create!(
|
|
25
|
+
execution: execution, kind: "commit", uri: "git:#{full || sha}", commit_sha: full || sha, title: (verified ? repo.commit_subject(full) : nil),
|
|
26
|
+
provenance: provenance || (verified ? "git_observed" : (actor.agent? ? "provider_reported" : "confirmed")),
|
|
27
|
+
metadata: { verified_in_repo: verified, attached_by: actor.label }
|
|
28
|
+
)
|
|
29
|
+
EventStore.append!(type: "git.commit_attached", aggregate: ticket, ticket: ticket, actor: actor, session: session,
|
|
30
|
+
provenance: artifact.provenance, payload: { artifact_id: artifact.id, commit_sha: artifact.commit_sha, verified_in_repo: verified })
|
|
31
|
+
artifact
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def attach_artifact!(ticket:, actor:, kind:, uri:, title: nil, metadata: {}, execution: nil, session: nil)
|
|
36
|
+
raise Error.validation_failed("Unknown artifact kind #{kind}") unless Artifact::KINDS.include?(kind.to_s)
|
|
37
|
+
raise Error.validation_failed("Artifacts of kind commit must use attach_commit") if kind.to_s == "commit"
|
|
38
|
+
if uri.to_s.match?(/\A(?:javascript|data):/i)
|
|
39
|
+
raise Error.validation_failed("Unsupported URI scheme")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Artifact.transaction do
|
|
43
|
+
artifact = ticket.artifacts.create!(execution: execution, kind: kind, uri: uri.to_s[0, 2000], title: title&.to_s&.[](0, 255),
|
|
44
|
+
provenance: actor.agent? ? "provider_reported" : "confirmed",
|
|
45
|
+
metadata: Redactor.redact_value(metadata.to_h))
|
|
46
|
+
EventStore.append!(type: "ticket.artifact_attached", aggregate: ticket, ticket: ticket, actor: actor, session: session,
|
|
47
|
+
provenance: artifact.provenance, payload: { artifact_id: artifact.id, kind: kind, uri: artifact.uri })
|
|
48
|
+
artifact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Execution lifecycle operations shared by the dashboard, CLI, IPC and workflow engine.
|
|
3
|
+
# The database is the source of truth; the runtime daemon claims queued rows.
|
|
4
|
+
module Executions
|
|
5
|
+
RUNNABLE_TICKET_STATES = %w[ready claimed in_progress testing].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def enqueue!(ticket:, provider_key:, actor:, action: "implement", profile: nil, workflow_step: nil,
|
|
10
|
+
idempotency_key: nil, metadata: {}, correlation_id: nil)
|
|
11
|
+
Authorization.authorize!(actor, "execution.start") unless actor.system?
|
|
12
|
+
request = { ticket_id: ticket.id, provider_key: provider_key.to_s, action: action, step: workflow_step&.id }
|
|
13
|
+
response = Idempotency.call(scope: "execution.enqueue", key: idempotency_key, request: request) do
|
|
14
|
+
execution = create_queued!(ticket: ticket, provider_key: provider_key.to_s, actor: actor, action: action, profile: profile,
|
|
15
|
+
workflow_step: workflow_step, idempotency_key: idempotency_key, metadata: metadata, correlation_id: correlation_id)
|
|
16
|
+
{ "execution_id" => execution.id, "status" => execution.status }
|
|
17
|
+
end
|
|
18
|
+
Execution.find(response["execution_id"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_queued!(ticket:, provider_key:, actor:, action:, profile:, workflow_step:, idempotency_key:, metadata:, correlation_id:)
|
|
22
|
+
provider = Providers.registry.fetch(provider_key)
|
|
23
|
+
unless Agentboard.config.provider_enabled?(provider_key)
|
|
24
|
+
raise Error.policy_denied("Provider #{provider_key} is disabled in configuration")
|
|
25
|
+
end
|
|
26
|
+
if Agentboard.config.provider_settings(provider_key)["active"] == false
|
|
27
|
+
raise Error.policy_denied("Active execution is disabled for #{provider_key}")
|
|
28
|
+
end
|
|
29
|
+
probe = provider.probe
|
|
30
|
+
raise Error.provider_unavailable("#{provider.display_name} is not installed") unless probe.installed || AgentContainer.enabled?
|
|
31
|
+
unless probe.supports?(:active_execution) || (AgentContainer.enabled? && !probe.installed)
|
|
32
|
+
raise Error.capability_unavailable("#{provider.display_name} #{probe.version} cannot run active executions: #{probe.diagnostics.first}")
|
|
33
|
+
end
|
|
34
|
+
enforce_isolation_policy!(provider_key)
|
|
35
|
+
|
|
36
|
+
profile ||= AgentProfile.default_for(ticket.project, provider_key)
|
|
37
|
+
Execution.transaction do
|
|
38
|
+
ticket.lock!
|
|
39
|
+
unless RUNNABLE_TICKET_STATES.include?(ticket.status) || (workflow_step && ticket.status == "review")
|
|
40
|
+
raise Error.invalid_transition("#{ticket.key} is #{ticket.status}; executions need a ready or in-flight ticket")
|
|
41
|
+
end
|
|
42
|
+
raise Error.policy_denied("#{ticket.key} has unfinished dependencies") unless ticket.dependencies_satisfied?
|
|
43
|
+
if (running = ticket.executions.active.first) && workflow_step.nil?
|
|
44
|
+
raise Error.workspace_conflict("#{ticket.key} already has an active execution (#{running.label})")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
session = AgentSession.create!(project: ticket.project, provider_key: provider_key, trust_level: "launched",
|
|
48
|
+
label: "#{provider.display_name} · #{ticket.key}", state: "active", last_seen_at: Time.current)
|
|
49
|
+
acquire_lease_for!(ticket, session, actor)
|
|
50
|
+
|
|
51
|
+
execution = Execution.create!(
|
|
52
|
+
ticket: ticket, agent_profile: profile, agent_session: session, workflow_step: workflow_step,
|
|
53
|
+
provider_key: provider_key, action: action, status: "queued", attempt: Execution.next_attempt_for(ticket),
|
|
54
|
+
idempotency_key: idempotency_key,
|
|
55
|
+
metadata: metadata.to_h.merge("requested_by" => actor.label, "probe_version" => probe.version,
|
|
56
|
+
"isolation" => AgentContainer.enabled? ? "container" : "local")
|
|
57
|
+
)
|
|
58
|
+
EventStore.append!(type: "agent.execution.queued", aggregate: execution, ticket: ticket, execution: execution, session: session,
|
|
59
|
+
actor: actor, correlation_id: correlation_id,
|
|
60
|
+
payload: { provider: provider_key, action: action, attempt: execution.attempt, profile: profile.name })
|
|
61
|
+
execution
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Spec §35.2 recommended default: trusted local subprocess execution is opt-in; untrusted
|
|
66
|
+
# work requires a container sandbox. The built-in fake provider is always allowed.
|
|
67
|
+
def enforce_isolation_policy!(provider_key)
|
|
68
|
+
return if provider_key.to_s == "fake"
|
|
69
|
+
|
|
70
|
+
sec = Agentboard.config.security
|
|
71
|
+
if AgentContainer.enabled?
|
|
72
|
+
raise Error.capability_unavailable("sandbox.agents is on but #{AgentContainer.runtime} is not running") unless ContainerSandbox.available?
|
|
73
|
+
|
|
74
|
+
AgentContainer.image # validates configuration
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
return if sec["trusted_local_execution"]
|
|
78
|
+
|
|
79
|
+
if sec["sandbox"].to_s == "container"
|
|
80
|
+
raise Error.capability_unavailable("Set sandbox.agents: true (with sandbox.agent_image) to run agents in containers, " \
|
|
81
|
+
"or security.trusted_local_execution: true to run them as trusted local subprocesses")
|
|
82
|
+
end
|
|
83
|
+
raise Error.policy_denied("Active execution requires security.trusted_local_execution: true (agents run as your user; worktrees are not sandboxes)")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def acquire_lease_for!(ticket, session, actor)
|
|
87
|
+
LeaseService.expire_stale!(ticket)
|
|
88
|
+
live = ticket.leases.live.first
|
|
89
|
+
if live
|
|
90
|
+
if live.agent_session_id && live.agent_session&.trust_level != "launched"
|
|
91
|
+
raise Error.lease_conflict("#{ticket.key} is leased by #{live.holder_label}")
|
|
92
|
+
end
|
|
93
|
+
if live.agent_session&.trust_level == "launched" && live.agent_session.executions.active.exists?
|
|
94
|
+
raise Error.lease_conflict("#{ticket.key} is leased by a running execution")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Human-held or finished-runtime lease: hand over to this execution's session.
|
|
98
|
+
live.update!(agent_session: session, holder_label: session.display_name,
|
|
99
|
+
expires_at: Time.current + Agentboard.config.runtime["execution_timeout_seconds"].to_i + 300)
|
|
100
|
+
elsif ticket.status == "ready"
|
|
101
|
+
claim = LeaseService.claim!(ticket: ticket, actor: Authorization.system_actor, session: session,
|
|
102
|
+
lease_seconds: Agentboard.config.leases["max_seconds"])
|
|
103
|
+
claim.lease.update!(expires_at: Time.current + Agentboard.config.runtime["execution_timeout_seconds"].to_i + 300)
|
|
104
|
+
else
|
|
105
|
+
fencing = ticket.fencing_counter + 1
|
|
106
|
+
ticket.leases.create!(agent_session: session, holder_label: session.display_name, token_digest: Ids.digest(Ids.token),
|
|
107
|
+
fencing_token: fencing, expires_at: Time.current + Agentboard.config.runtime["execution_timeout_seconds"].to_i + 300)
|
|
108
|
+
ticket.update!(fencing_counter: fencing)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def transition!(execution, to, **attrs)
|
|
113
|
+
Execution.transaction do
|
|
114
|
+
execution.lock!
|
|
115
|
+
from = execution.status
|
|
116
|
+
return execution if from == to.to_s
|
|
117
|
+
|
|
118
|
+
StateMachines::Execution.assert!(from, to)
|
|
119
|
+
now = Time.current
|
|
120
|
+
updates = attrs.merge(status: to.to_s)
|
|
121
|
+
updates[:started_at] ||= now if to.to_s == "running" && execution.started_at.nil?
|
|
122
|
+
updates[:finished_at] = now if StateMachines::Execution.terminal?(to)
|
|
123
|
+
execution.update!(updates)
|
|
124
|
+
EventStore.append!(type: "agent.execution.#{to}", aggregate: execution, ticket: execution.ticket, execution: execution,
|
|
125
|
+
session: execution.agent_session, actor: Authorization.system_actor,
|
|
126
|
+
payload: { from: from, to: to.to_s, exit_code: attrs[:exit_code], failure_code: attrs[:failure_code],
|
|
127
|
+
reason: attrs[:failure_message] || attrs[:cancel_reason] }.compact)
|
|
128
|
+
if StateMachines::Execution.terminal?(to)
|
|
129
|
+
execution.agent_session&.update!(state: "ended", ended_at: now)
|
|
130
|
+
WorkflowEngine.step_execution_finished(execution) if execution.workflow_step_id
|
|
131
|
+
end
|
|
132
|
+
execution
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def request_cancel!(execution:, actor:, reason: "cancelled by user")
|
|
137
|
+
Authorization.authorize!(actor, "execution.cancel") unless actor.system?
|
|
138
|
+
Execution.transaction do
|
|
139
|
+
execution.lock!
|
|
140
|
+
return execution if execution.terminal?
|
|
141
|
+
|
|
142
|
+
execution.update!(cancel_requested_at: Time.current, cancel_reason: reason.to_s[0, 500])
|
|
143
|
+
EventStore.append!(type: "agent.execution.cancel_requested", aggregate: execution, ticket: execution.ticket, execution: execution,
|
|
144
|
+
actor: actor, payload: { reason: reason })
|
|
145
|
+
# Not yet picked up by the runtime: cancel immediately.
|
|
146
|
+
transition!(execution, "cancelled", cancel_reason: reason) if execution.status == "queued"
|
|
147
|
+
end
|
|
148
|
+
execution.reload
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Retries create a new attempt; history is preserved (spec §4).
|
|
152
|
+
def retry!(execution:, actor:)
|
|
153
|
+
raise Error.invalid_transition("Only finished executions can be retried") unless execution.terminal?
|
|
154
|
+
|
|
155
|
+
enqueue!(ticket: execution.ticket, provider_key: execution.provider_key, actor: actor, action: execution.action,
|
|
156
|
+
profile: execution.agent_profile, metadata: { "retry_of" => execution.id, "scenario" => execution.metadata["scenario"] }.compact)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Resumes the provider session of a finished attempt as a new attempt in the same worktree.
|
|
160
|
+
def resume!(execution:, actor:, message: "Continue the task.")
|
|
161
|
+
raise Error.invalid_transition("Only finished executions can be resumed") unless execution.terminal?
|
|
162
|
+
|
|
163
|
+
provider = Providers.registry.fetch(execution.provider_key)
|
|
164
|
+
sid = execution.metadata["provider_session_id"]
|
|
165
|
+
raise UnsupportedCapability.new(:session_resume, provider: execution.provider_key) unless sid && provider.supports?(:session_resume)
|
|
166
|
+
raise Error.workspace_conflict("The worktree of that attempt no longer exists") unless execution.worktree_path && File.directory?(execution.worktree_path)
|
|
167
|
+
|
|
168
|
+
enqueue!(ticket: execution.ticket, provider_key: execution.provider_key, action: execution.action, actor: actor, profile: execution.agent_profile,
|
|
169
|
+
metadata: { "resume_of" => execution.id, "provider_session_id" => sid, "resume_message" => message.to_s[0, 4000] })
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Persists a provider-normalized event with provider_reported provenance.
|
|
173
|
+
def record_provider_event!(execution, normalized)
|
|
174
|
+
payload = normalized.payload.to_h
|
|
175
|
+
if (sid = normalized.external_session_id).present? && execution.metadata["provider_session_id"] != sid
|
|
176
|
+
execution.update_columns(metadata: execution.metadata.merge("provider_session_id" => sid.to_s[0, 128]))
|
|
177
|
+
session = execution.agent_session
|
|
178
|
+
if session && session.external_session_id.nil?
|
|
179
|
+
session.update_columns(external_session_id: sid.to_s[0, 128])
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
if (u = normalized.usage)
|
|
183
|
+
UsageRecord.create!(execution: execution, provider_key: execution.provider_key, metering_kind: u.metering_kind || "unknown",
|
|
184
|
+
input_tokens: u.input_tokens, output_tokens: u.output_tokens,
|
|
185
|
+
reported_cost: u.reported_cost, currency: u.currency, source: "provider_reported")
|
|
186
|
+
end
|
|
187
|
+
text = payload["text"].to_s
|
|
188
|
+
_, redactions = Redactor.redact_with_count(text)
|
|
189
|
+
EventStore.append!(type: normalized.type, aggregate: execution, ticket: execution.ticket, execution: execution,
|
|
190
|
+
session: execution.agent_session, source: "provider.#{execution.provider_key}",
|
|
191
|
+
provenance: normalized.provenance || "provider_reported", payload: payload, topics: [])
|
|
192
|
+
if redactions.positive?
|
|
193
|
+
EventStore.append!(type: "security.secret_redacted", aggregate: execution, ticket: execution.ticket, execution: execution,
|
|
194
|
+
payload: { where: "provider_output", redactions: redactions })
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Receives provider hook payloads (spec §7): validate, redact, normalize, deduplicate,
|
|
3
|
+
# associate with sessions, and queue. Payloads are bounded; only documented fields are kept.
|
|
4
|
+
module HookIngestion
|
|
5
|
+
MAX_PAYLOAD_BYTES = 256 * 1024
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def ingest!(provider_key:, event_name:, raw:, project: Project.current, received_at: Time.current)
|
|
10
|
+
raise Error.validation_failed("payload too large") if raw.to_s.bytesize > MAX_PAYLOAD_BYTES
|
|
11
|
+
raise Error.validation_failed("invalid event name") unless event_name.to_s.match?(/\A[A-Za-z_.\-]{1,64}\z/)
|
|
12
|
+
|
|
13
|
+
payload = raw.to_s.strip.empty? ? {} : JSON.parse(raw)
|
|
14
|
+
raise Error.validation_failed("hook payload must be a JSON object") unless payload.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
provider = Providers.registry.fetch(provider_key)
|
|
17
|
+
events = provider.parse_hook(event_name: event_name, payload: payload)
|
|
18
|
+
raw_digest = Digest::SHA256.hexdigest(raw.to_s)
|
|
19
|
+
|
|
20
|
+
events.map.with_index do |ev, i|
|
|
21
|
+
session = resolve_session(project, provider_key, ev.external_session_id, payload)
|
|
22
|
+
dedupe = "hook:#{provider_key}:#{raw_digest}:#{i}"
|
|
23
|
+
ticket = session&.active_ticket
|
|
24
|
+
event = EventStore.append!(type: ev.type, aggregate: session || [ "project", project.id ], project: project, ticket: ticket,
|
|
25
|
+
session: session, source: "hook.#{provider_key}", provenance: "provider_reported",
|
|
26
|
+
payload: ev.payload.merge("hook_event" => event_name), dedupe_key: dedupe)
|
|
27
|
+
if session && session.metadata["agent_pid"].nil?
|
|
28
|
+
SessionCorrelation.record_agent!(session, ProcessAncestry.find_agent)
|
|
29
|
+
end
|
|
30
|
+
maybe_associate_from_branch(session, project, payload) if session && ev.type == "agent.session.started" && session.reload.active_link.nil?
|
|
31
|
+
if session && ev.type == "agent.session.ended"
|
|
32
|
+
session.update!(state: "ended", ended_at: Time.current)
|
|
33
|
+
end
|
|
34
|
+
event
|
|
35
|
+
end
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
raise Error.validation_failed("hook payload is not valid JSON")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A hook-reported session id is corroborated by the provider runtime (trust "hook").
|
|
41
|
+
def resolve_session(project, provider_key, external_id, payload)
|
|
42
|
+
return nil if external_id.blank?
|
|
43
|
+
|
|
44
|
+
external_id = external_id.to_s[0, 128]
|
|
45
|
+
session = AgentSession.find_or_create_by!(project: project, provider_key: provider_key, external_session_id: external_id) do |s|
|
|
46
|
+
s.trust_level = "hook"
|
|
47
|
+
s.state = "active"
|
|
48
|
+
s.cwd = payload["cwd"].to_s[0, 500].presence
|
|
49
|
+
s.label = "#{Providers.registry.adapter_class(provider_key).display_name} #{external_id[0, 8]}"
|
|
50
|
+
end
|
|
51
|
+
fresh = session.state == "active" && session.last_seen_at && session.last_seen_at > 1.minute.ago
|
|
52
|
+
session.update_columns(last_seen_at: Time.current, state: "active") unless fresh
|
|
53
|
+
session
|
|
54
|
+
rescue ActiveRecord::RecordNotUnique
|
|
55
|
+
AgentSession.find_by!(project: project, provider_key: provider_key, external_session_id: external_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Branch-name heuristic (precedence 4): recorded as needs_confirmation, never overrides explicit links.
|
|
59
|
+
def maybe_associate_from_branch(session, project, payload)
|
|
60
|
+
return if session.active_link
|
|
61
|
+
|
|
62
|
+
cwd = payload["cwd"].presence || project.root_path
|
|
63
|
+
branch = Git::Repository.new(cwd).current_branch(chdir: cwd)
|
|
64
|
+
ticket = SessionAssociation.ticket_from_branch(branch, project: project)
|
|
65
|
+
return unless ticket
|
|
66
|
+
|
|
67
|
+
SessionAssociation.link!(session: session, ticket: ticket, source: "branch_heuristic", confidence: "needs_confirmation",
|
|
68
|
+
actor: Authorization.system_actor)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Local spooling when the database is unavailable: payloads are appended (redacted) to a
|
|
74
|
+
# user-private spool file and replayed by `agentboard hook replay` / the daemon.
|
|
75
|
+
def spool!(root:, provider_key:, event_name:, raw:)
|
|
76
|
+
dir = File.join(root.to_s, ".agentboard", "spool")
|
|
77
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
78
|
+
line = { provider: provider_key, event: event_name, raw: Redactor.redact(raw.to_s[0, MAX_PAYLOAD_BYTES]), at: Time.now.utc.iso8601 }.to_json
|
|
79
|
+
File.open(File.join(dir, "hooks.jsonl"), "a", 0o600) { |f| f.flock(File::LOCK_EX); f.puts(line) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def replay_spool!(root:)
|
|
83
|
+
path = File.join(root.to_s, ".agentboard", "spool", "hooks.jsonl")
|
|
84
|
+
return 0 unless File.exist?(path)
|
|
85
|
+
|
|
86
|
+
processing = "#{path}.#{Process.pid}.processing"
|
|
87
|
+
File.rename(path, processing)
|
|
88
|
+
count = 0
|
|
89
|
+
File.foreach(processing) do |line|
|
|
90
|
+
entry = JSON.parse(line)
|
|
91
|
+
ingest!(provider_key: entry["provider"], event_name: entry["event"], raw: entry["raw"])
|
|
92
|
+
count += 1
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Agentboard.logger.warn("[agentboard] spool replay skipped entry: #{e.class}")
|
|
95
|
+
end
|
|
96
|
+
File.delete(processing)
|
|
97
|
+
count
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Stores the response of a mutating request keyed by (scope, key). A replay with the same
|
|
3
|
+
# request digest returns the stored response; a different payload under the same key is
|
|
4
|
+
# rejected (VALIDATION_FAILED) to prevent accidental key reuse.
|
|
5
|
+
module Idempotency
|
|
6
|
+
TTL = 24.hours
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(scope:, key:, request:)
|
|
11
|
+
return yield if key.blank?
|
|
12
|
+
|
|
13
|
+
digest = Ids.canonical_digest(request)
|
|
14
|
+
if (existing = IdempotencyKey.where(scope: scope, key: key).where("expires_at > ?", Time.current).first)
|
|
15
|
+
raise Error.validation_failed("Idempotency key reused with a different request") if existing.request_digest != digest
|
|
16
|
+
|
|
17
|
+
return existing.response
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
response = yield
|
|
21
|
+
IdempotencyKey.where(scope: scope, key: key).where("expires_at <= ?", Time.current).delete_all
|
|
22
|
+
IdempotencyKey.create!(scope: scope, key: key, request_digest: digest, response: response, expires_at: Time.current + TTL)
|
|
23
|
+
response
|
|
24
|
+
rescue ActiveRecord::RecordNotUnique
|
|
25
|
+
IdempotencyKey.find_by!(scope: scope, key: key).response
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|