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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. 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