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,261 @@
1
+ module Agentboard
2
+ # DAG scheduler with precise semantics (spec §28):
3
+ # - a step is runnable when all dependencies succeeded and its condition is true
4
+ # - failed/skipped dependencies skip downstream steps unless the step has an explicit
5
+ # condition (a failure branch) that evaluates true
6
+ # - bounded retries with backoff create new attempt rows; non-idempotent steps never retry
7
+ # - cancellation propagates to running executions, then finalizes the run
8
+ module WorkflowEngine
9
+ module_function
10
+
11
+ def workflow_dir = Agentboard.config.root_path.join(".agentboard", "workflows")
12
+
13
+ # Registers (or reuses) an immutable version of a workflow definition.
14
+ def register!(project:, yaml:, actor: Authorization.system_actor)
15
+ Authorization.authorize!(actor, "workflow.manage")
16
+ definition = Workflows::Definition.parse_yaml(yaml, enforce_features: true)
17
+ digest = definition.digest
18
+ existing = WorkflowDefinition.where(project: project, name: definition.name).order(version: :desc).first
19
+ return existing if existing&.digest == digest
20
+
21
+ WorkflowDefinition.create!(project: project, name: definition.name, version: (existing&.version || 0) + 1,
22
+ definition: definition.to_h, source_yaml: yaml.to_s[0, 100_000], digest: digest)
23
+ end
24
+
25
+ def sync_from_disk!(project:)
26
+ Dir[workflow_dir.join("*.yml").to_s].sort.map do |path|
27
+ register!(project: project, yaml: File.read(path))
28
+ rescue Agentboard::Error => e
29
+ Agentboard.logger.warn("[agentboard] workflow #{File.basename(path)} invalid: #{e.message}")
30
+ nil
31
+ end.compact
32
+ end
33
+
34
+ def latest(project:, name:)
35
+ WorkflowDefinition.where(project: project, name: name).order(version: :desc).first or
36
+ raise Error.not_found("Workflow #{name} is not registered")
37
+ end
38
+
39
+ def start!(definition:, ticket:, actor:)
40
+ Authorization.authorize!(actor, "execution.start")
41
+ # Re-checked at start: a licence may have lapsed since the definition was registered.
42
+ Agentboard.require_feature!(:advanced_workflows) if definition.parsed.advanced_features.any?
43
+ raise Error.workspace_conflict("#{ticket.key} already has an active workflow run") if ticket.workflow_runs.active.exists?
44
+
45
+ parsed = definition.parsed
46
+ WorkflowRun.transaction do
47
+ run = WorkflowRun.create!(workflow_definition: definition, ticket: ticket, status: "running", started_at: Time.current,
48
+ base_sha: ticket.project.repository.head_sha)
49
+ parsed.steps.each do |s|
50
+ run.steps.create!(step_key: s.id, step_type: s.type, status: "pending", attempt: 1, input: { "depends_on" => s.depends_on })
51
+ end
52
+ EventStore.append!(type: "workflow.started", aggregate: run, ticket: ticket, actor: actor,
53
+ payload: { workflow: definition.name, version: definition.version, digest: definition.digest, run_id: run.id })
54
+ run
55
+ end
56
+ end
57
+
58
+ def cancel!(run:, actor:, reason: "cancelled")
59
+ WorkflowRun.transaction do
60
+ run.lock!
61
+ return run if run.terminal?
62
+
63
+ run.update!(status: "cancelled", cancel_reason: reason, finished_at: Time.current)
64
+ run.steps.where(status: %w[pending waiting running]).find_each do |step|
65
+ step.executions.active.each { |e| Executions.request_cancel!(execution: e, actor: actor, reason: "workflow cancelled") }
66
+ step.update!(status: "cancelled", finished_at: Time.current)
67
+ end
68
+ EventStore.append!(type: "workflow.cancelled", aggregate: run, ticket: run.ticket, actor: actor, payload: { reason: reason })
69
+ end
70
+ run
71
+ end
72
+
73
+ def tick
74
+ WorkflowRun.where(status: %w[running waiting_for_approval]).find_each { |run| advance(run) }
75
+ end
76
+
77
+ def advance(run)
78
+ definition = run.definition
79
+ if definition.timeout_seconds && run.started_at && Time.current - run.started_at > definition.timeout_seconds
80
+ return cancel!(run: run, actor: Authorization.system_actor, reason: "workflow timeout")
81
+ end
82
+
83
+ steps = run.current_steps
84
+ steps.each_value { |step| poll_waiting(run, step, definition) if step.status == "waiting" }
85
+ Extensions.run(:workflow_advance, run: run, steps: steps, definition: definition)
86
+ steps = run.reload.current_steps
87
+
88
+ definition.topological_order.each do |key|
89
+ step = steps[key]
90
+ next unless step&.status == "pending"
91
+ next if step.available_at && step.available_at > Time.current
92
+
93
+ spec = definition.step(key)
94
+ deps = spec.depends_on.map { |d| steps[d] }
95
+ next unless deps.all?(&:terminal?)
96
+
97
+ ctx = condition_context(run, steps)
98
+ all_ok = deps.all? { |d| d.status == "succeeded" }
99
+ runnable =
100
+ if spec.condition
101
+ Workflows::Condition.parse(spec.condition).evaluate(ctx)
102
+ else
103
+ all_ok
104
+ end
105
+ if runnable
106
+ start_step(run, step, spec, deps)
107
+ else
108
+ step.update!(status: "skipped", finished_at: Time.current, output: { "reason" => spec.condition ? "condition false" : "dependency did not succeed" })
109
+ EventStore.append!(type: "workflow.step_skipped", aggregate: run, ticket: run.ticket, payload: { step: key })
110
+ end
111
+ end
112
+ finalize(run.reload)
113
+ rescue StandardError => e
114
+ Agentboard.logger.error("[agentboard] workflow #{run.id} advance failed: #{e.class}: #{e.message}")
115
+ end
116
+
117
+ def condition_context(run, steps)
118
+ {
119
+ "steps" => steps.transform_values { |s| { "status" => s.status, "output" => s.output } },
120
+ "ticket" => { "priority" => run.ticket.priority, "kind" => run.ticket.kind, "status" => run.ticket.status }
121
+ }
122
+ end
123
+
124
+ def start_step(run, step, spec, deps)
125
+ handoff = latest_handoff(deps)
126
+ verify_handoff!(run, handoff) if handoff
127
+ step.update!(status: "running", started_at: Time.current, input: step.input.merge("handoff" => handoff).compact)
128
+ EventStore.append!(type: "workflow.step_started", aggregate: run, ticket: run.ticket, payload: { step: spec.id, type: spec.type, attempt: step.attempt })
129
+
130
+ case spec.type
131
+ when "agent"
132
+ Executions.enqueue!(ticket: run.ticket.reload, provider_key: spec.provider, action: spec.action, actor: Authorization.system_actor,
133
+ workflow_step: step, idempotency_key: "wf:#{step.id}",
134
+ metadata: { "handoff" => handoff, "base_ref" => handoff&.dig("head_sha"), "workflow_run_id" => run.id,
135
+ "timeout_seconds" => spec.timeout_seconds }.compact)
136
+ when "command", "verification"
137
+ run_command_step(run, step, spec, handoff)
138
+ when "human_approval"
139
+ head = handoff&.dig("head_sha") || run.base_sha
140
+ approval = Approvals.request!(project: run.ticket.project, ticket: run.ticket, actor: Authorization.system_actor,
141
+ action_kind: "workflow_gate", head_sha: head, workflow_step: step,
142
+ required_role: spec.required_role || "maintainer",
143
+ action_payload: { workflow_run_id: run.id, step: spec.id, head_sha: head },
144
+ reason: "Workflow #{run.workflow_definition.name} requires approval at step #{spec.id}")
145
+ step.update!(status: "waiting", output: { "approval_id" => approval.id, "head_sha" => head })
146
+ run.update!(status: "waiting_for_approval")
147
+ when "condition"
148
+ finish_step(run, step, "succeeded", output: { "result" => true })
149
+
150
+ when "git_commit"
151
+ path = handoff&.dig("worktree_path")
152
+ raise Error.validation_failed("git_commit needs a worktree from a previous step") unless path
153
+
154
+ manager = Git::WorktreeManager.new(repository: run.ticket.project.repository, workspace: Agentboard.config.workspace_path)
155
+ head = manager.snapshot!(path, message: "#{run.ticket.key}: workflow commit")
156
+ finish_step(run, step, "succeeded", output: (handoff || {}).merge("head_sha" => head))
157
+ else
158
+ handler = Extensions.step_handler(spec.type)
159
+ unless handler
160
+ return finish_step(run, step, "failed", error: "Step type #{spec.type} requires AgentBoard Pro", failure_code: "capability_unavailable")
161
+ end
162
+
163
+ handler.call(run: run, step: step, spec: spec, handoff: handoff)
164
+ end
165
+ rescue Agentboard::Error => e
166
+ finish_step(run, step, "failed", error: e.message, failure_code: e.code.downcase)
167
+ end
168
+
169
+
170
+
171
+ def latest_handoff(deps)
172
+ deps.select { |d| d.status == "succeeded" && d.output["head_sha"] }.max_by { |d| d.finished_at.to_f }&.output
173
+ end
174
+
175
+ # The receiving step verifies actual repository state rather than trusting the summary.
176
+ def verify_handoff!(run, handoff)
177
+ repo = run.ticket.project.repository
178
+ if handoff["head_sha"] && repo.resolve_commit(handoff["head_sha"]).nil?
179
+ raise Error.validation_failed("Handoff head #{handoff["head_sha"]} does not exist in the repository")
180
+ end
181
+ ids = Array(handoff["artifact_ids"])
182
+ if ids.any? && Artifact.where(id: ids, ticket_id: run.ticket_id).count != ids.size
183
+ raise Error.validation_failed("Handoff references unknown artifacts")
184
+ end
185
+ end
186
+
187
+ def run_command_step(run, step, spec, handoff)
188
+ dir = handoff&.dig("worktree_path").presence || run.ticket.project.root_path
189
+ PathGuard.within!(Agentboard.config.workspace_path, dir) if handoff&.dig("worktree_path").present?
190
+ Thread.new do
191
+ ActiveRecord::Base.connection_pool.with_connection do
192
+ execution = Execution.create!(ticket: run.ticket, provider_key: "local", action: "verify", status: "verifying",
193
+ attempt: Execution.next_attempt_for(run.ticket), workflow_step: step, worktree_path: dir,
194
+ base_sha: handoff&.dig("base_sha"), head_sha: Git::Repository.new(dir).head_sha(chdir: dir),
195
+ started_at: Time.current, metadata: { "command" => spec.command })
196
+ results = VerificationService.run_catalog!(execution: execution, chdir: dir, names: [ spec.command ], timeout: spec.timeout_seconds)
197
+ ok = results.all?(&:passed?)
198
+ Executions.transition!(execution, ok ? "completed" : "failed", failure_code: ok ? nil : "verification_failed")
199
+ end
200
+ rescue StandardError => e
201
+ Agentboard.logger.error("[agentboard] command step failed: #{e.class}: #{e.message}")
202
+ ActiveRecord::Base.connection_pool.with_connection { finish_step(run, step.reload, "failed", error: e.class.name) }
203
+ end
204
+ end
205
+
206
+ # Callback from Executions.transition! when an execution linked to a step finishes.
207
+ def step_execution_finished(execution)
208
+ step = execution.workflow_step
209
+ return unless step && step.status == "running"
210
+
211
+ run = step.workflow_run
212
+ ok = execution.status == "completed"
213
+ output = {
214
+ "schema_version" => 1, "ticket_key" => run.ticket.key, "from_step" => step.step_key,
215
+ "base_sha" => execution.base_sha, "head_sha" => execution.head_sha, "branch" => execution.branch,
216
+ "worktree_path" => execution.worktree_path, "execution_id" => execution.id,
217
+ "artifact_ids" => execution.artifacts.where(kind: %w[commit diff test_report]).pluck(:id),
218
+ "verification_result_ids" => execution.verification_results.pluck(:id),
219
+ "summary" => execution.summary.to_s[0, 2000], "known_issues" => ok ? [] : [ execution.failure_message ].compact
220
+ }
221
+ finish_step(run, step, ok ? "succeeded" : "failed", output: output, error: execution.failure_message,
222
+ failure_code: execution.failure_code)
223
+ end
224
+
225
+ def poll_waiting(run, step, definition)
226
+ approval = Approval.find_by(id: step.output["approval_id"])
227
+ return finish_step(run, step, "failed", error: "approval missing") unless approval
228
+
229
+ head = step.output["head_sha"]
230
+ digest = approval.action_digest
231
+ if approval.valid_for?(action_digest: digest, head_sha: head)
232
+ finish_step(run, step, "succeeded", output: step.output.merge("approved_by" => approval.decided_by))
233
+ elsif %w[rejected expired invalidated].include?(approval.decision) || approval.expired?
234
+ finish_step(run, step, "failed", error: "approval #{approval.decision}")
235
+ end
236
+ end
237
+
238
+ def finish_step(run, step, status, output: nil, error: nil, failure_code: nil)
239
+ WorkflowStep.transaction do
240
+ step.update!(status: status, finished_at: Time.current, output: output || step.output, error: error&.to_s&.[](0, 2000))
241
+ EventStore.append!(type: "workflow.step_#{status}", aggregate: run, ticket: run.ticket,
242
+ payload: { step: step.step_key, attempt: step.attempt, error: error }.compact)
243
+ run.update!(status: "running") if run.status == "waiting_for_approval" && run.steps.where(status: "waiting").none?
244
+ Extensions.run(:workflow_step_failed, run: run, step: step, failure_code: failure_code) if status == "failed"
245
+ end
246
+ end
247
+
248
+
249
+ def finalize(run)
250
+ return if run.terminal?
251
+
252
+ steps = run.current_steps.values
253
+ return unless steps.all?(&:terminal?)
254
+
255
+ status = steps.any? { |s| s.status == "failed" } ? "failed" : "succeeded"
256
+ run.update!(status: status, finished_at: Time.current)
257
+ EventStore.append!(type: "workflow.#{status}", aggregate: run, ticket: run.ticket, payload: { run_id: run.id })
258
+ end
259
+
260
+ end
261
+ end
@@ -0,0 +1,51 @@
1
+ <% content_for :title, "Agents" %>
2
+ <% content_for :crumbs do %><%= icon("bot", size: 16, klass: "muted") %> <span>Agents</span><% end %>
3
+ <div class="page-head"><div><h1>Providers & sessions</h1><p>Capabilities are detected from installed binaries — configuration only expresses intent.</p></div></div>
4
+ <div class="stats mb">
5
+ <% @probes.each do |p, probe| %>
6
+ <div class="stat">
7
+ <div class="row"><%= provider_avatar(p.key, size: 26) %><strong><%= p.display_name %></strong>
8
+ <span style="margin-left:auto"><%= probe.installed ? execution_pill(probe.supports?(:active_execution) ? "completed" : "waiting") : execution_pill("skipped") %></span></div>
9
+ <div class="stat-sub mt-sm"><%= probe.installed ? "v#{probe.version} · #{probe.validation}" : "Not installed" %></div>
10
+ <div class="row row-wrap mt-sm" style="gap:4px">
11
+ <% Agentboard::Providers::CAPABILITIES.each do |cap| %>
12
+ <span class="prov <%= probe.supports?(cap) ? "prov-git-observed" : "prov-inferred" %>" title="<%= cap %>"><%= cap.to_s.tr("_", " ") %></span>
13
+ <% end %>
14
+ </div>
15
+ <% probe.diagnostics.first(1).each do |d| %><div class="hint mt-sm"><%= d %></div><% end %>
16
+ </div>
17
+ <% end %>
18
+ </div>
19
+ <div class="card card-body-flush">
20
+ <div class="card-head"><h2><%= icon("user", size: 15) %> Sessions</h2><span class="muted" style="margin-left:auto;font-size:12px">Agent-supplied session ids are labels; trust comes from hooks, the runtime, or the MCP process.</span></div>
21
+ <% if @sessions.empty? %>
22
+ <div class="empty"><%= icon("bot", size: 26) %><strong>No agent sessions yet</strong><span>Run <code>bundle exec agentboard provider setup claude</code>, then start <code>claude</code> in this repository.</span></div>
23
+ <% else %>
24
+ <table class="table table-compact">
25
+ <thead><tr><th>Session</th><th>Trust</th><th>State</th><th>Ticket</th><th>Attach</th><th class="right">Last seen</th></tr></thead>
26
+ <tbody>
27
+ <% @sessions.each do |s| %>
28
+ <% link = s.ticket_links.find { |l| l.ended_at.nil? } %>
29
+ <tr>
30
+ <td><span class="row"><%= provider_avatar(s.provider_key, size: 20) %><span class="truncate"><%= s.display_name %></span></span></td>
31
+ <td><span class="prov <%= { "launched" => "prov-confirmed", "hook" => "prov-provider-reported", "mcp_stdio" => "prov-git-observed" }.fetch(s.trust_level, "prov-inferred") %>"><%= s.trust_level %></span></td>
32
+ <td><%= execution_pill({ "active" => "running", "revoked" => "rejected" }.fetch(s.state, "skipped")) %></td>
33
+ <td><% if link %><%= link_to link.ticket.key, ticket_path(link.ticket), class: "link mono" %> <span class="faint" style="font-size:11px"><%= link.source %><%= " · unconfirmed" if link.confidence == "needs_confirmation" %></span><% else %><span class="faint">—</span><% end %></td>
34
+ <td>
35
+ <%= form_with url: session_path(s), method: :patch, class: "row" do %>
36
+ <select name="ticket_key" class="select" style="height:26px;width:150px;font-size:12px"><option value="">Detach</option><% @open_tickets.each do |t| %><option value="<%= t.key %>" <%= "selected" if link&.ticket_id == t.id %>><%= t.key %></option><% end %></select>
37
+ <button class="btn btn-sm">Set</button>
38
+ <% end %>
39
+ </td>
40
+ <td class="right muted"><%= ago(s.last_seen_at || s.created_at) %>
41
+ <% if s.state == "active" && %w[mcp_stdio hook].include?(s.trust_level) && can?("session.manage") %>
42
+ <%= button_to "Revoke", session_path(s), method: :patch, params: { revoke: "1" }, class: "btn btn-sm btn-danger", form: { style: "display:inline;margin-left:6px" } %>
43
+ <% end %>
44
+ <% if s.metadata["correlated_session_id"] %><span class="prov prov-confirmed" title="Same agent process (pid <%= s.metadata["agent_pid"] %>)">correlated</span><% end %>
45
+ </td>
46
+ </tr>
47
+ <% end %>
48
+ </tbody>
49
+ </table>
50
+ <% end %>
51
+ </div>
@@ -0,0 +1,78 @@
1
+ <% content_for :title, "Analytics" %>
2
+ <% content_for :crumbs do %><%= icon("chart", size: 16, klass: "muted") %> <span>Analytics</span><% end %>
3
+ <div class="page-head"><div><h1>Delivery analytics</h1><p>Unknown values are shown as “—”, never as zero. Costs are only what providers reported.</p></div></div>
4
+ <div class="stats mb">
5
+ <div class="stat"><div class="stat-label">Executions</div><div class="stat-value"><%= @total %></div><div class="stat-sub"><%= @by_status["completed"].to_i %> completed · <%= @by_status["failed"].to_i %> failed</div></div>
6
+ <div class="stat"><div class="stat-label">First-pass success</div><div class="stat-value"><%= @first_pass_rate ? "#{@first_pass_rate}%" : "—" %></div><div class="stat-sub">implement attempts passing checks on try #1</div></div>
7
+ <div class="stat"><div class="stat-label">Median duration</div><div class="stat-value"><%= duration(@median_duration) %></div><div class="stat-sub"><%= @retries %> retries</div></div>
8
+ <div class="stat"><div class="stat-label">Human interventions</div><div class="stat-value"><%= @human_interventions %></div><div class="stat-sub">approval latency <%= duration(@approval_latency) %></div></div>
9
+ </div>
10
+ <% if @metrics.nil? %>
11
+ <div class="callout mb"><%= icon("chart", size: 16) %><div><strong>Operational metrics</strong> (queue wait p95, event lag, orphan worktrees, provider errors), the support bundle and audit export are part of <strong>AgentBoard Pro</strong>. The statistics below are free.</div></div>
12
+ <% else %>
13
+ <div class="stats mb">
14
+ <div class="stat"><div class="stat-label">Queue wait (median / p95)</div><div class="stat-value" style="font-size:18px"><%= duration(@metrics[:queue_wait_median]) %> <span class="muted" style="font-size:13px">/ <%= duration(@metrics[:queue_wait_p95]) %></span></div></div>
15
+ <div class="stat"><div class="stat-label">Event processing lag</div><div class="stat-value" style="font-size:18px"><%= @metrics[:outbox_lag_median] ? "#{@metrics[:outbox_lag_median].round(2)}s" : "—" %></div><div class="stat-sub"><%= @metrics[:outbox_pending] %> pending · <%= @metrics[:outbox_dead] %> dead</div></div>
16
+ <div class="stat"><div class="stat-label">Provider errors / cancellations</div><div class="stat-value" style="font-size:18px"><%= @metrics[:provider_errors] %> <span class="muted" style="font-size:13px">/ <%= @metrics[:cancellations] %></span></div></div>
17
+ <div class="stat"><div class="stat-label">Sessions · orphan worktrees</div><div class="stat-value" style="font-size:18px"><%= @metrics[:active_sessions] %> <span class="muted" style="font-size:13px">· <%= @metrics[:orphan_worktrees] %></span></div><div class="stat-sub">orphans: run <code>agentboard cleanup</code></div></div>
18
+ </div>
19
+ <% end %>
20
+ <div class="detail" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr)">
21
+ <div class="card">
22
+ <div class="card-head"><h3>Executions · last 14 days</h3></div>
23
+ <div class="card-body">
24
+ <% days = (13.downto(0)).map { |i| i.days.ago.to_date } %>
25
+ <% peak = [@daily.values.max.to_i, 1].max %>
26
+ <div class="bars">
27
+ <% days.each do |d| %>
28
+ <div class="bar" title="<%= d %>: <%= @daily[d].to_i %>"><span style="height:<%= (@daily[d].to_i * 100.0 / peak).round %>%"></span><small><%= d.strftime("%d") %></small></div>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ <div class="card">
34
+ <div class="card-head"><h3>Outcomes by provider</h3></div>
35
+ <div class="card-body">
36
+ <% if @by_provider.empty? %><div class="empty" style="padding:16px">No data yet.</div><% end %>
37
+ <% @by_provider.each do |provider, statuses| %>
38
+ <% sum = statuses.values.sum.to_f %>
39
+ <div class="hbar">
40
+ <span class="row"><%= provider_avatar(provider, size: 18) %><%= provider %></span>
41
+ <span class="hbar-track">
42
+ <% { "completed" => "seg-completed", "failed" => "seg-failed", "interrupted" => "seg-interrupted", "cancelled" => "seg-cancelled" }.each do |s, cls| %>
43
+ <% if statuses[s].positive? %><span class="<%= cls %>" style="width:<%= (statuses[s] / sum * 100).round(1) %>%" title="<%= s %>: <%= statuses[s] %>"></span><% end %>
44
+ <% end %>
45
+ <% active = statuses.sum { |s, c| Agentboard::StateMachines::Execution::ACTIVE.include?(s) ? c : 0 } %>
46
+ <% if active.positive? %><span class="seg-active" style="width:<%= (active / sum * 100).round(1) %>%"></span><% end %>
47
+ </span>
48
+ <span class="right muted"><%= sum.to_i %></span>
49
+ </div>
50
+ <% end %>
51
+ </div>
52
+ </div>
53
+ <div class="card">
54
+ <div class="card-head"><h3>Token usage (provider-reported)</h3></div>
55
+ <div class="card-body-flush">
56
+ <table class="table table-compact"><thead><tr><th>Provider</th><th class="right">Input</th><th class="right">Output</th><th class="right">Reported cost</th></tr></thead>
57
+ <tbody>
58
+ <% if @tokens.empty? %><tr><td colspan="4" class="muted">No usage reported.</td></tr><% end %>
59
+ <% @tokens.each do |provider, tin, tout, _n| %>
60
+ <% costs = @reported_cost.select { |(p, _c), _| p == provider } %>
61
+ <tr><td><%= provider %></td><td class="right mono"><%= tin ? number_with_delimiter(tin) : "—" %></td><td class="right mono"><%= tout ? number_with_delimiter(tout) : "—" %></td>
62
+ <td class="right mono"><%= costs.any? ? costs.map { |(_, cur), v| "#{number_with_precision(v, precision: 4)} #{cur}" }.join(", ") : "—" %></td></tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ <p class="hint" style="padding:10px 14px;margin:0">Subscription-based CLI usage is not billed per token; reported cost is the provider's own estimate.</p>
67
+ </div>
68
+ </div>
69
+ <div class="card">
70
+ <div class="card-head"><h3>Verification outcomes</h3></div>
71
+ <div class="card-body">
72
+ <% vsum = [@verification.values.sum, 1].max.to_f %>
73
+ <% %w[passed failed timeout error].each do |s| %>
74
+ <div class="hbar"><span><%= s.humanize %></span><span class="hbar-track"><span class="<%= s == "passed" ? "seg-completed" : "seg-failed" %>" style="width:<%= (@verification[s].to_i / vsum * 100).round(1) %>%"></span></span><span class="right muted"><%= @verification[s].to_i %></span></div>
75
+ <% end %>
76
+ </div>
77
+ </div>
78
+ </div>
@@ -0,0 +1,27 @@
1
+ <% ticket = approval.ticket %>
2
+ <div class="stack-sm" style="padding:10px 12px;border:1px solid var(--border);border-radius:11px;background:var(--surface)">
3
+ <div class="row">
4
+ <span class="pill pill-review"><span class="pill-dot"></span><%= approval.action_kind.humanize %></span>
5
+ <% if ticket %><%= link_to ticket.key, ticket_path(ticket), class: "link mono", style: "font-size:12px" %><% end %>
6
+ <span class="faint" style="margin-left:auto;font-size:11.5px"><%= ago(approval.created_at) %></span>
7
+ </div>
8
+ <% if approval.reason.present? %><div style="font-size:13px;white-space:pre-wrap"><%= approval.reason.truncate(600) %></div><% end %>
9
+ <% if (summary = approval.action_payload["summary"]).present? %><pre class="code-block" style="padding:8px 10px;font-size:11.5px"><%= summary.truncate(800) %></pre><% end %>
10
+ <% if approval.execution_id %><div class="hint"><%= link_to "Open execution →", execution_path(approval.execution_id), class: "link" %></div><% end %>
11
+ <div class="row muted" style="font-size:11.5px;flex-wrap:wrap">
12
+ <span>by <%= approval.requested_by %></span><span>·</span><span>head <%= sha_tag(approval.head_sha) %></span><span>·</span>
13
+ <span title="<%= approval.action_digest %>">digest <code><%= approval.action_digest[0, 10] %></code></span>
14
+ <% if approval.expires_at %><span>·</span><span>expires <%= ago(approval.expires_at).to_s.sub(" ago", "").html_safe %></span><% end %>
15
+ </div>
16
+ <% if !can?("approval.decide") %>
17
+ <div class="hint">Waiting for a <%= approval.required_role %>.</div>
18
+ <% elsif approval.requested_by == current_actor.label && Agentboard.feature?(:team_mode) && Agentboard.config.authenticate_with %>
19
+ <div class="hint">You requested this — another <%= approval.required_role %> must decide (separation of duties).</div>
20
+ <% else %>
21
+ <%= form_with url: approval_path(approval), method: :patch, class: "row" do %>
22
+ <input class="input" name="reason" placeholder="Note (optional)" style="height:28px">
23
+ <button name="decision" value="rejected" class="btn btn-sm btn-danger"><%= icon("x", size: 12) %> Reject</button>
24
+ <button name="decision" value="approved" class="btn btn-sm btn-success"><%= icon("check", size: 12) %> Approve</button>
25
+ <% end %>
26
+ <% end %>
27
+ </div>
@@ -0,0 +1,31 @@
1
+ <% content_for :title, "Approvals" %>
2
+ <% content_for :crumbs do %><%= icon("shield", size: 16, klass: "muted") %> <span>Approvals</span><% end %>
3
+ <div class="content-narrow">
4
+ <div class="page-head"><div><h1>Approvals</h1><p>Each approval is bound to an exact action digest and commit. Any code change invalidates it.</p></div></div>
5
+ <div class="section-title">Pending · <%= @pending.size %></div>
6
+ <% if @pending.empty? %>
7
+ <div class="card"><div class="empty"><%= icon("shield", size: 26) %><strong>Nothing waiting for you</strong><span>Privileged actions and reviews that need a human will show up here.</span></div></div>
8
+ <% else %>
9
+ <div class="stack"><% @pending.each do |a| %><%= render "approval", approval: a %><% end %></div>
10
+ <% end %>
11
+ <div class="section-title mt">History</div>
12
+ <div class="card card-body-flush">
13
+ <% if @decided.empty? %><div class="empty" style="padding:20px">No decisions yet.</div><% else %>
14
+ <table class="table table-compact">
15
+ <thead><tr><th>Action</th><th>Ticket</th><th>Decision</th><th>Head</th><th>By</th><th class="right">When</th></tr></thead>
16
+ <tbody>
17
+ <% @decided.each do |a| %>
18
+ <tr>
19
+ <td><%= a.action_kind.humanize %></td>
20
+ <td><% if (tk = a.ticket) %><%= link_to tk.key, ticket_path(tk), class: "link mono" %><% end %></td>
21
+ <td><%= execution_pill(a.decision) %><% if a.decision_reason.present? %> <span class="muted" style="font-size:12px"><%= a.decision_reason.truncate(60) %></span><% end %></td>
22
+ <td><%= sha_tag(a.head_sha) %></td>
23
+ <td class="muted"><%= a.decided_by || "system" %></td>
24
+ <td class="right muted"><%= ago(a.decided_at || a.updated_at) %></td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ <% end %>
30
+ </div>
31
+ </div>
@@ -0,0 +1,36 @@
1
+ <% lease = ticket.leases.find { |l| l.released_at.nil? && l.expires_at > Time.current } %>
2
+ <% running = ticket.executions.find { |e| Agentboard::StateMachines::Execution::ACTIVE.include?(e.status) } %>
3
+ <% done, total = ticket.criteria_progress %>
4
+ <% allowed = Agentboard::StateMachines::Ticket.next_states(ticket.status) %>
5
+ <%= link_to ticket_path(ticket), class: "tcard#{" is-blocked" if ticket.status == "blocked"}#{" is-running" if running}",
6
+ draggable: "true", id: "ticket-#{ticket.key}",
7
+ data: { key: ticket.key, status: ticket.status, allowed: allowed.join(" "), position: ticket.position, move_url: move_ticket_path(ticket), turbo_frame: "_top" } do %>
8
+ <div class="tcard-top">
9
+ <%= priority_icon(ticket.priority) %>
10
+ <span class="tcard-key"><%= ticket.key %></span>
11
+ <% if %w[blocked reopened].include?(ticket.status) %>
12
+ <%= status_pill(ticket.status) %>
13
+ <% end %>
14
+ <span class="tcard-foot">
15
+ <% if running %><%= provider_avatar(running.provider_key, size: 18) %><% end %>
16
+ <% if lease %><span title="Leased by <%= lease.holder_label %> until <%= lease.expires_at.strftime("%H:%M") %>"><%= avatar(lease.holder_label, kind: lease.agent_session_id ? "agent" : "human", size: 18) %></span><% end %>
17
+ </span>
18
+ </div>
19
+ <div class="tcard-title"><%= ticket.title %></div>
20
+ <% if ticket.status == "blocked" && ticket.blocked_reason.present? %>
21
+ <div class="tcard-blocker"><%= icon("ban", size: 13) %><span class="truncate"><%= ticket.blocked_reason %></span></div>
22
+ <% end %>
23
+ <div class="tcard-meta">
24
+ <%= kind_badge(ticket.kind) %>
25
+ <% ticket.labels.first(2).each do |l| %><span class="label-chip" style="--h:<%= Digest::MD5.hexdigest(l)[0, 2].to_i(16) * 360 / 255 %>"><%= l %></span><% end %>
26
+ <% if total.positive? %>
27
+ <span class="row" style="gap:5px" title="Acceptance criteria"><%= meter(done, total) %><span><%= done %>/<%= total %></span></span>
28
+ <% end %>
29
+ <% if ticket.depends_on.any? { |d| d.status != "done" } %>
30
+ <span class="row" style="gap:3px;color:var(--c-orange)" title="Waiting on dependencies"><%= icon("link", size: 12) %><%= ticket.depends_on.count { |d| d.status != "done" } %></span>
31
+ <% end %>
32
+ <% if running %>
33
+ <span class="row" style="gap:4px;color:var(--accent-text)"><%= icon("pulse", size: 12) %><%= running.status.humanize.downcase %></span>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
@@ -0,0 +1,73 @@
1
+ <% content_for :title, "Board" %>
2
+ <% content_for :bare, "1" %>
3
+ <% content_for :crumbs do %>
4
+ <%= icon("kanban", size: 16, klass: "muted") %> <span>Board</span>
5
+ <span class="faint" style="font-weight:500"><%= @columns.values.sum(&:size) %> tickets</span>
6
+ <% end %>
7
+ <% content_for :actions do %>
8
+ <%= form_with url: board_path, method: :get, class: "search", data: { controller: "autosubmit", turbo_action: "replace" } do %>
9
+ <%= icon("search", size: 14) %>
10
+ <input class="input" type="search" name="q" value="<%= @filters["q"] %>" placeholder="Search or jump to <%= current_project.key %>-…" data-hotkey="search" data-action="input->autosubmit#submit" autocomplete="off">
11
+ <% end %>
12
+ <%= link_to new_ticket_path, class: "btn btn-primary" do %><%= icon("plus", size: 14) %> New ticket<% end %>
13
+ <% end %>
14
+ <% content_for :toolbar do %>
15
+ <div class="board-toolbar">
16
+ <span class="muted" style="font-size:12px;margin-right:2px">Filter</span>
17
+ <% %w[feature bug task chore spike].each do |k| %>
18
+ <%= link_to k.capitalize, board_path(@filters.merge("kind" => (@filters["kind"] == k ? nil : k)).compact), class: "filter-chip #{"is-on" if @filters["kind"] == k}" %>
19
+ <% end %>
20
+ <span class="faint">·</span>
21
+ <% Agentboard::Ticket::PRIORITIES.each do |p, label| %>
22
+ <%= link_to board_path(@filters.merge("priority" => (@filters["priority"] == p.to_s ? nil : p.to_s)).compact), class: "filter-chip #{"is-on" if @filters["priority"] == p.to_s}" do %><%= priority_icon(p) %> <%= label %><% end %>
23
+ <% end %>
24
+ <% if @labels.any? %>
25
+ <span class="faint">·</span>
26
+ <% @labels.first(8).each do |l| %>
27
+ <%= link_to l, board_path(@filters.merge("label" => (@filters["label"] == l ? nil : l)).compact), class: "filter-chip #{"is-on" if @filters["label"] == l}" %>
28
+ <% end %>
29
+ <% end %>
30
+ <% if @milestones.any? %>
31
+ <span class="faint">·</span>
32
+ <% @milestones.each do |m| %>
33
+ <%= link_to board_path(@filters.merge("milestone" => (@filters["milestone"] == m.id ? nil : m.id)).compact), class: "filter-chip #{"is-on" if @filters["milestone"] == m.id}" do %><%= icon("flag", size: 12) %> <%= m.name %><% end %>
34
+ <% end %>
35
+ <% end %>
36
+ <% if @filters.any? %>
37
+ <%= link_to board_path, class: "btn btn-ghost btn-sm" do %><%= icon("x", size: 12) %> Clear<% end %>
38
+ <% end %>
39
+ </div>
40
+ <% if @active_executions.any? %>
41
+ <div class="live-strip">
42
+ <% @active_executions.each do |e| %>
43
+ <%= link_to execution_path(e), class: "live-item" do %>
44
+ <%= provider_avatar(e.provider_key, size: 20) %>
45
+ <strong class="mono" style="font-size:11.5px"><%= e.ticket.key %></strong>
46
+ <%= execution_pill(e.status) %>
47
+ <span class="muted"><%= e.action %> · <%= duration(e.duration) %></span>
48
+ <% end %>
49
+ <% end %>
50
+ </div>
51
+ <% end %>
52
+ <% end %>
53
+
54
+ <div class="board" data-controller="board">
55
+ <% @columns.each do |status, tickets| %>
56
+ <section class="column" data-column="<%= status %>">
57
+ <header class="column-head">
58
+ <%= status_glyph(status) %>
59
+ <h3><%= Agentboard::StateMachines::Ticket::LABELS[status] %></h3>
60
+ <span class="column-count"><%= tickets.size %></span>
61
+ <% if %w[backlog ready].include?(status) %>
62
+ <%= link_to new_ticket_path(status: status), class: "btn btn-ghost btn-sm btn-icon", title: "New ticket in #{status}" do %><%= icon("plus", size: 14) %><% end %>
63
+ <% end %>
64
+ </header>
65
+ <div class="column-body">
66
+ <% if tickets.empty? %>
67
+ <div class="column-empty"><%= status == "ready" ? "Tickets with acceptance criteria land here" : "Drop tickets here" %></div>
68
+ <% end %>
69
+ <%= render partial: "agentboard/board/card", collection: tickets, as: :ticket %>
70
+ </div>
71
+ </section>
72
+ <% end %>
73
+ </div>
@@ -0,0 +1,33 @@
1
+ <% content_for :title, "Executions" %>
2
+ <% content_for :crumbs do %><%= icon("terminal", size: 16, klass: "muted") %> <span>Executions</span><% end %>
3
+ <% unless @daemon[:running] %>
4
+ <div class="callout callout-warn mb"><%= icon("alert", size: 16) %><div><strong>The runtime daemon is not running.</strong> Queued executions wait until you start it:<br><code>bundle exec agentboard daemon start</code> — agents run in that separate process, so restarting the web server never kills them.</div></div>
5
+ <% end %>
6
+ <div class="tabs">
7
+ <%= link_to "All", executions_path, class: "tab #{"is-active" unless @status}" %>
8
+ <% %w[queued running verifying completed failed cancelled interrupted].each do |s| %>
9
+ <%= link_to s.humanize, executions_path(status: s), class: "tab #{"is-active" if @status == s}" %>
10
+ <% end %>
11
+ </div>
12
+ <div class="card card-body-flush">
13
+ <% if @executions.empty? %>
14
+ <div class="empty"><%= icon("terminal", size: 28) %><strong>No executions</strong><span>Open a ticket and start an agent, or run <code>bundle exec agentboard run <%= current_project.key %>-1 --provider fake</code>.</span></div>
15
+ <% else %>
16
+ <table class="table">
17
+ <thead><tr><th>Execution</th><th>Ticket</th><th>Status</th><th>Checks</th><th>Head</th><th>Duration</th><th class="right">Started</th></tr></thead>
18
+ <tbody>
19
+ <% @executions.each do |e| %>
20
+ <tr>
21
+ <td><%= link_to execution_path(e), class: "row-link" do %><%= provider_avatar(e.provider_key, size: 20) %><span><strong style="font-weight:550"><%= e.action.humanize %></strong> <span class="muted">#<%= e.attempt %></span></span><% end %></td>
22
+ <td><%= link_to e.ticket.key, ticket_path(e.ticket), class: "link mono", style: "font-size:12px" %> <span class="muted truncate" style="max-width:240px;display:inline-block;vertical-align:bottom"><%= e.ticket.title %></span></td>
23
+ <td><%= execution_pill(e.status) %><% if e.failure_code %> <span class="faint" style="font-size:11.5px"><%= e.failure_code %></span><% end %></td>
24
+ <td class="muted"><%= e.verification_results.any? ? "#{e.verification_results.count(&:passed?)}/#{e.verification_results.size}" : "—" %></td>
25
+ <td><%= sha_tag(e.head_sha) %></td>
26
+ <td class="muted"><%= duration(e.duration) %></td>
27
+ <td class="right muted"><%= ago(e.started_at || e.created_at) %></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ <% end %>
33
+ </div>