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,196 @@
1
+ module Agentboard
2
+ module ApplicationHelper
3
+ # Lucide-style 24px stroke icons, inlined so the dashboard needs no icon font or CDN.
4
+ ICONS = {
5
+ "kanban" => '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7v7"/><path d="M12 7v4"/><path d="M16 7v9"/>',
6
+ "list" => '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
7
+ "bot" => '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/>',
8
+ "play" => '<polygon points="6 3 20 12 6 21 6 3"/>',
9
+ "terminal" => '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
10
+ "workflow" => '<rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M6 9v3a3 3 0 0 0 3 3h6"/>',
11
+ "chart" => '<path d="M3 3v18h18"/><path d="M7 16v-4"/><path d="M12 16V8"/><path d="M17 16v-7"/>',
12
+ "settings" => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
13
+ "shield" => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
14
+ "check" => '<path d="M20 6 9 17l-5-5"/>',
15
+ "x" => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
16
+ "clock" => '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
17
+ "alert" => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
18
+ "plus" => '<path d="M5 12h14"/><path d="M12 5v14"/>',
19
+ "search" => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
20
+ "commit" => '<circle cx="12" cy="12" r="3"/><line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/>',
21
+ "branch" => '<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
22
+ "file" => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
23
+ "link" => '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
24
+ "user" => '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
25
+ "lock" => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
26
+ "sparkles" => '<path d="M9.94 14.06 8 20l-1.94-5.94L0 12l6.06-1.94L8 4l1.94 6.06L16 12z" transform="translate(3 0) scale(.85)"/><path d="M20 3v4"/><path d="M22 5h-4"/>',
27
+ "chevron" => '<path d="m9 18 6-6-6-6"/>',
28
+ "dot" => '<circle cx="12" cy="12" r="4"/>',
29
+ "refresh" => '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
30
+ "stop" => '<rect x="5" y="5" width="14" height="14" rx="2"/>',
31
+ "sun" => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
32
+ "moon" => '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
33
+ "flask" => '<path d="M10 2v7.31"/><path d="M14 9.3V1.99"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/><path d="M5.52 16h12.96"/>',
34
+ "eye" => '<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>',
35
+ "inbox" => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
36
+ "pulse" => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
37
+ "ban" => '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
38
+ "edit" => '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/>',
39
+ "arrow-right" => '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
40
+ "flag" => '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
41
+ "layers" => '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
42
+ }.freeze
43
+
44
+ def icon(name, size: 16, klass: nil, stroke: 2)
45
+ body = ICONS.fetch(name.to_s) { ICONS["dot"] }
46
+ tag.svg(body.html_safe, xmlns: "http://www.w3.org/2000/svg", width: size, height: size, viewBox: "0 0 24 24", fill: "none",
47
+ stroke: "currentColor", "stroke-width": stroke, "stroke-linecap": "round", "stroke-linejoin": "round",
48
+ class: [ "ab-icon", klass ].compact.join(" "), "aria-hidden": "true")
49
+ end
50
+
51
+ STATUS_ICON = {
52
+ "backlog" => "dot", "ready" => "dot", "claimed" => "lock", "in_progress" => "pulse", "testing" => "flask",
53
+ "review" => "eye", "done" => "check", "blocked" => "ban", "cancelled" => "x", "reopened" => "refresh"
54
+ }.freeze
55
+
56
+ def status_pill(status, label: nil)
57
+ tag.span(class: "pill pill-#{status.to_s.tr("_", "-")}") do
58
+ safe_join([ tag.span(class: "pill-dot"), label || StateMachines::Ticket::LABELS[status.to_s] || status.to_s.humanize ])
59
+ end
60
+ end
61
+
62
+ def status_glyph(status)
63
+ tag.span(class: "status-glyph sg-#{status.to_s.tr("_", "-")}", title: StateMachines::Ticket::LABELS[status.to_s])
64
+ end
65
+
66
+ def execution_pill(status)
67
+ tag.span(class: "pill pill-exec-#{status.to_s.tr("_", "-")}") do
68
+ safe_join([ tag.span(class: "pill-dot#{StateMachines::Execution::ACTIVE.include?(status.to_s) && status.to_s != "queued" ? " pill-dot-live" : ""}"),
69
+ status.to_s.humanize ])
70
+ end
71
+ end
72
+
73
+ def priority_icon(priority)
74
+ label = Ticket::PRIORITIES[priority]
75
+ if priority.to_i.zero?
76
+ return tag.span(class: "prio prio-urgent", title: label) { "!" }
77
+ end
78
+
79
+ bars = 4 - priority.to_i
80
+ tag.span(class: "prio prio-#{priority}", title: label) do
81
+ safe_join((1..3).map { |i| tag.i(class: i <= bars ? "on" : nil, style: "height:#{3 + i * 3}px") })
82
+ end
83
+ end
84
+
85
+ def kind_badge(kind)
86
+ tag.span(kind, class: "kind kind-#{kind}")
87
+ end
88
+
89
+ PROVENANCE_SHORT = { "confirmed" => "AgentBoard", "provider_reported" => "Provider", "git_observed" => "Git", "inferred" => "Inferred" }.freeze
90
+
91
+ def provenance_badge(provenance)
92
+ tag.span(PROVENANCE_SHORT.fetch(provenance.to_s, provenance.to_s), class: "prov prov-#{provenance.to_s.tr("_", "-")}",
93
+ title: Event::PROVENANCE_LABELS[provenance.to_s])
94
+ end
95
+
96
+ def avatar(name, kind: "human", size: 22)
97
+ initials = name.to_s.scan(/[A-Za-z0-9]+/).first(2).map { |w| w[0] }.join.upcase.presence || "?"
98
+ hue = Digest::MD5.hexdigest(name.to_s)[0, 2].to_i(16) * 360 / 255
99
+ tag.span(kind == "agent" ? icon("bot", size: (size * 0.6).round) : initials,
100
+ class: "avatar avatar-#{kind}", title: name, style: "--h:#{hue};width:#{size}px;height:#{size}px")
101
+ end
102
+
103
+ def provider_avatar(provider_key, size: 22)
104
+ tag.span(provider_key.to_s[0, 2].capitalize, class: "avatar avatar-provider prov-key-#{provider_key}", title: provider_key.to_s, style: "width:#{size}px;height:#{size}px")
105
+ end
106
+
107
+ def ago(time)
108
+ return "—" unless time
109
+
110
+ tag.time(time_ago_in_words(time) + " ago", datetime: time.utc.iso8601, title: time.utc.strftime("%Y-%m-%d %H:%M:%S UTC"))
111
+ end
112
+
113
+ def duration(seconds)
114
+ return "—" unless seconds
115
+
116
+ s = seconds.to_i
117
+ return "#{s}s" if s < 60
118
+ return "#{s / 60}m #{s % 60}s" if s < 3600
119
+
120
+ "#{s / 3600}h #{(s % 3600) / 60}m"
121
+ end
122
+
123
+ def short_sha(sha) = sha.to_s.start_with?("unbound:") ? "unbound" : sha.to_s[0, 8]
124
+
125
+ def sha_tag(sha)
126
+ return tag.span("—", class: "muted") if sha.blank?
127
+
128
+ tag.code(short_sha(sha), class: "sha", title: sha)
129
+ end
130
+
131
+ def meter(done, total, klass: nil)
132
+ pct = total.to_i.zero? ? 0 : (done * 100.0 / total).round
133
+ tag.span(class: [ "meter", klass ].compact.join(" "), title: "#{done}/#{total}") { tag.span(style: "width:#{pct}%") }
134
+ end
135
+
136
+ def nav_link(label, path, icon_name, active:, count: nil, kbd: nil)
137
+ link_to(path, class: "nav-item#{active ? " is-active" : ""}") do
138
+ safe_join([ icon(icon_name, size: 16), tag.span(label, class: "nav-label"),
139
+ (count.to_i.positive? ? tag.span(count, class: "nav-count") : nil),
140
+ (kbd ? tag.kbd(kbd, class: "nav-kbd") : nil) ].compact)
141
+ end
142
+ end
143
+
144
+ def event_title(event)
145
+ p = event.data || {}
146
+ case event.event_type
147
+ when "ticket.created" then "created the ticket"
148
+ when "ticket.transitioned" then safe_join([ "moved ", status_pill(p["from"]), " → ", status_pill(p["to"]) ])
149
+ when "ticket.claimed" then "claimed the ticket (fencing ##{p["fencing_token"]})"
150
+ when "ticket.lease_released" then "released the lease (#{p["reason"]})"
151
+ when "ticket.commented" then "commented"
152
+ when "ticket.progress" then safe_join([ "progress", p["percent"] ? " · #{p["percent"]}%" : "", p["phase"] ? " · #{p["phase"]}" : "", ": ", p["message"].to_s ])
153
+ when "ticket.updated" then "updated #{(p["changes"] || {}).keys.join(", ")}"
154
+ when "git.commit_attached" then safe_join([ "attached commit ", sha_tag(p["commit_sha"]), p["verified_in_repo"] ? "" : " (not found in repository)" ])
155
+ when "git.snapshot" then safe_join([ "snapshot ", sha_tag(p["head_sha"]), " · #{Array(p["files"]).size} files" ])
156
+ when "git.worktree_created" then safe_join([ "created worktree on ", tag.code(p["branch"]) ])
157
+ when "verification.passed" then safe_join([ "✓ ", tag.strong(p["name"]), " passed at ", sha_tag(p["commit_sha"]) ])
158
+ when "verification.failed" then safe_join([ "✗ ", tag.strong(p["name"]), " #{p["status"]} at ", sha_tag(p["commit_sha"]) ])
159
+ when "approval.requested" then "requested approval: #{p["action_kind"].to_s.humanize}"
160
+ when /\Aapproval\.(approved|rejected|invalidated)\z/ then "approval #{$1}"
161
+ when "agent.session.attached" then "session attached via #{p["source"]}#{p["confidence"] == "needs_confirmation" ? " (needs confirmation)" : ""}"
162
+ when "agent.execution.message" then p["text"].to_s.truncate(240)
163
+ when "agent.tool.started" then safe_join([ "→ ", tag.code(p["tool"].to_s), " ", p["target"].to_s ])
164
+ when "agent.tool.completed" then safe_join([ "✓ ", tag.code(p["tool"].to_s), " ", p["target"].to_s, Array(p["files"]).first(3).join(", ") ])
165
+ when "security.secret_redacted" then "redacted #{p["redactions"]} secret(s) from #{p["where"]}"
166
+ when /\Aagent\.execution\.(\w+)\z/ then "execution #{$1.humanize.downcase}#{p["reason"] ? " — #{p["reason"]}" : ""}"
167
+ when /\Aworkflow\.(.+)\z/ then "workflow #{$1.tr("_", " ")}#{p["step"] ? " · #{p["step"]}" : ""}"
168
+ else event.event_type
169
+ end
170
+ end
171
+
172
+ def event_icon(event)
173
+ case event.event_type
174
+ when /\Averification\.passed/ then [ "check", "ok" ]
175
+ when /\Averification\.failed|failed|rejected|denied/ then [ "x", "bad" ]
176
+ when /\Agit\./ then [ "commit", "git" ]
177
+ when /\Aapproval\./ then [ "shield", "approval" ]
178
+ when /\Aagent\.tool/ then [ "terminal", "tool" ]
179
+ when /\Aagent\./ then [ "bot", "agent" ]
180
+ when /\Asecurity\./ then [ "lock", "bad" ]
181
+ when /commented/ then [ "edit", "comment" ]
182
+ else [ "dot", "neutral" ]
183
+ end
184
+ end
185
+
186
+ def actor_label(event)
187
+ return event.source.sub("provider.", "") if event.actor_type.nil?
188
+
189
+ event.actor_type == "agent" ? "agent" : event.actor_id
190
+ end
191
+
192
+ def asset_url(name)
193
+ engine_asset_path(name: name, v: AssetsController.fingerprint(name))
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,227 @@
1
+ // AgentBoard dashboard behaviour. Loaded as an ES module through an import map that points
2
+ // at the Turbo and Stimulus builds shipped inside their gems (no Node, no bundler).
3
+ // turbo-rails' ESM build exports { Turbo, cable } (not individual functions).
4
+ import { Turbo } from "@hotwired/turbo"
5
+ import { Application, Controller } from "@hotwired/stimulus"
6
+
7
+ const application = Application.start()
8
+ window.Stimulus = application
9
+
10
+ const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content
11
+
12
+ async function postJSON(url, body) {
13
+ const response = await fetch(url, {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken() },
16
+ body: JSON.stringify(body),
17
+ credentials: "same-origin"
18
+ })
19
+ const data = await response.json().catch(() => ({}))
20
+ return { ok: response.ok && data.ok !== false, data }
21
+ }
22
+
23
+ function toast(message, kind = "alert") {
24
+ const host = document.querySelector(".toasts")
25
+ if (!host) return
26
+ const el = document.createElement("div")
27
+ el.className = `toast toast-${kind}`
28
+ el.setAttribute("data-controller", "toast")
29
+ el.innerHTML = `<span>${kind === "alert" ? "⚠︎" : "✓"}</span><div></div>`
30
+ el.querySelector("div").textContent = message
31
+ host.appendChild(el)
32
+ }
33
+
34
+ function refresh() {
35
+ // Don't yank the page while the user is typing or has a dialog open.
36
+ const active = document.activeElement
37
+ if (document.querySelector("dialog[open], details.popover[open]")) return
38
+ if (active && ["TEXTAREA", "INPUT", "SELECT"].includes(active.tagName) && active.value) return
39
+ // Turbo 8 page refresh: morphs the current page in place and preserves scroll.
40
+ if (Turbo.session?.refresh) Turbo.session.refresh(window.location.href)
41
+ else Turbo.visit(window.location.href, { action: "replace" })
42
+ }
43
+
44
+ // Live updates: poll the event cursor and morph-refresh when something changed.
45
+ application.register("pulse", class extends Controller {
46
+ static values = { url: String, interval: { type: Number, default: 3000 } }
47
+ connect() {
48
+ this.cursor = undefined
49
+ this.timer = setInterval(() => this.check(), this.intervalValue)
50
+ this.onVisibility = () => document.visibilityState === "visible" && this.check()
51
+ document.addEventListener("visibilitychange", this.onVisibility)
52
+ }
53
+ disconnect() { clearInterval(this.timer); document.removeEventListener("visibilitychange", this.onVisibility) }
54
+ async check() {
55
+ if (document.visibilityState !== "visible") return
56
+ try {
57
+ const res = await fetch(this.urlValue, { headers: { Accept: "application/json" }, credentials: "same-origin" })
58
+ if (!res.ok) return
59
+ const data = await res.json()
60
+ const live = document.getElementById("runtime-active")
61
+ if (live) live.textContent = data.active_executions
62
+ const changed = this.cursor !== undefined && data.cursor !== this.cursor
63
+ this.cursor = data.cursor
64
+ if (changed && !document.querySelector(".tcard.is-dragging")) refresh()
65
+ } catch (error) {
66
+ console.warn("[agentboard] live update failed", error)
67
+ }
68
+ }
69
+ })
70
+
71
+ // Kanban drag & drop. Transitions are validated server-side; invalid drops revert.
72
+ application.register("board", class extends Controller {
73
+ static values = { moveUrl: String }
74
+ connect() {
75
+ this.element.addEventListener("dragstart", this.start)
76
+ this.element.addEventListener("dragend", this.end)
77
+ this.element.querySelectorAll("[data-column]").forEach((col) => {
78
+ col.addEventListener("dragover", this.over)
79
+ col.addEventListener("dragleave", this.leave)
80
+ col.addEventListener("drop", this.drop)
81
+ })
82
+ }
83
+ start = (e) => {
84
+ const card = e.target.closest(".tcard")
85
+ if (!card) return
86
+ this.dragged = card
87
+ card.classList.add("is-dragging")
88
+ e.dataTransfer.effectAllowed = "move"
89
+ e.dataTransfer.setData("text/plain", card.dataset.key)
90
+ const allowed = (card.dataset.allowed || "").split(" ")
91
+ this.element.querySelectorAll("[data-column]").forEach((col) => {
92
+ if (col.dataset.column !== card.dataset.status && !allowed.includes(col.dataset.column)) col.classList.add("is-invalid")
93
+ })
94
+ }
95
+ end = () => {
96
+ this.dragged?.classList.remove("is-dragging")
97
+ this.element.querySelectorAll(".is-over, .is-invalid").forEach((c) => c.classList.remove("is-over", "is-invalid"))
98
+ }
99
+ over = (e) => {
100
+ e.preventDefault()
101
+ const col = e.currentTarget
102
+ col.classList.add("is-over")
103
+ const body = col.querySelector(".column-body")
104
+ const after = [...body.querySelectorAll(".tcard:not(.is-dragging)")].find((c) => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2)
105
+ if (this.dragged) after ? body.insertBefore(this.dragged, after) : body.appendChild(this.dragged)
106
+ }
107
+ leave = (e) => { if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.classList.remove("is-over") }
108
+ drop = async (e) => {
109
+ e.preventDefault()
110
+ const card = this.dragged
111
+ const col = e.currentTarget
112
+ col.classList.remove("is-over")
113
+ if (!card) return
114
+ const to = col.dataset.column
115
+ const prev = card.previousElementSibling?.dataset.position
116
+ const next = card.nextElementSibling?.dataset.position
117
+ let position
118
+ if (prev && next) position = (parseFloat(prev) + parseFloat(next)) / 2
119
+ else if (prev) position = parseFloat(prev) + 1
120
+ else if (next) position = parseFloat(next) - 1
121
+ let reason
122
+ if (to === "blocked" || to === "cancelled") {
123
+ reason = prompt(`Reason for ${to}?`)
124
+ if (!reason) return refresh()
125
+ }
126
+ const url = card.dataset.moveUrl
127
+ const { ok, data } = await postJSON(url, { to, position, reason })
128
+ if (!ok) toast(data?.error?.message || "Move rejected")
129
+ refresh()
130
+ }
131
+ })
132
+
133
+ // Execution console: tails the redacted log while the execution is active.
134
+ application.register("log", class extends Controller {
135
+ static values = { url: String, active: Boolean }
136
+ static targets = ["body"]
137
+ connect() { this.load(); if (this.activeValue) this.timer = setInterval(() => this.load(), 1500) }
138
+ disconnect() { clearInterval(this.timer) }
139
+ async load() {
140
+ const res = await fetch(this.urlValue, { credentials: "same-origin" })
141
+ if (!res.ok) return
142
+ const text = await res.text()
143
+ const body = this.bodyTarget
144
+ const atBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 40
145
+ if (body.textContent !== text) {
146
+ body.textContent = text
147
+ if (atBottom) body.scrollTop = body.scrollHeight
148
+ }
149
+ }
150
+ })
151
+
152
+ application.register("theme", class extends Controller {
153
+ connect() { this.apply(localStorage.getItem("agentboard-theme")) }
154
+ toggle() {
155
+ const current = document.documentElement.dataset.theme ||
156
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
157
+ const next = current === "dark" ? "light" : "dark"
158
+ localStorage.setItem("agentboard-theme", next)
159
+ this.apply(next)
160
+ }
161
+ apply(theme) { if (theme) document.documentElement.dataset.theme = theme }
162
+ })
163
+
164
+ application.register("toast", class extends Controller {
165
+ connect() { this.timer = setTimeout(() => this.dismiss(), this.element.classList.contains("toast-alert") ? 9000 : 4500) }
166
+ disconnect() { clearTimeout(this.timer) }
167
+ dismiss() { this.element.classList.add("is-leaving"); setTimeout(() => this.element.remove(), 200) }
168
+ })
169
+
170
+ application.register("modal", class extends Controller {
171
+ static values = { id: String }
172
+ open(e) { e?.preventDefault(); document.getElementById(this.idValue)?.showModal(); document.getElementById(this.idValue)?.querySelector("[autofocus]")?.focus() }
173
+ close(e) { e?.preventDefault(); this.element.closest("dialog")?.close() }
174
+ })
175
+
176
+ // Live YAML validation for the workflow editor.
177
+ application.register("workflow-validator", class extends Controller {
178
+ static values = { url: String }
179
+ static targets = ["input", "output"]
180
+ connect() { this.validate() }
181
+ schedule() { clearTimeout(this.timer); this.timer = setTimeout(() => this.validate(), 350) }
182
+ async validate() {
183
+ const { data } = await postJSON(this.urlValue, { yaml: this.inputTarget.value })
184
+ const out = this.outputTarget
185
+ if (data.ok) {
186
+ out.className = "validator ok"
187
+ out.textContent = `✓ ${data.name} — valid DAG: ${data.steps.join(" → ")}`
188
+ } else {
189
+ out.className = "validator bad"
190
+ out.textContent = "✗ " + (data.errors || ["invalid"]).join("\n✗ ")
191
+ }
192
+ }
193
+ indent(e) {
194
+ if (e.key !== "Tab") return
195
+ e.preventDefault()
196
+ const t = e.target, s = t.selectionStart
197
+ t.setRangeText(" ", s, t.selectionEnd, "end")
198
+ this.schedule()
199
+ }
200
+ })
201
+
202
+ // Keyboard shortcuts: c = new ticket, / = search, g b / g e / g a = navigation.
203
+ application.register("hotkeys", class extends Controller {
204
+ connect() { this.onKey = (e) => this.handle(e); document.addEventListener("keydown", this.onKey) }
205
+ disconnect() { document.removeEventListener("keydown", this.onKey) }
206
+ handle(e) {
207
+ if (e.metaKey || e.ctrlKey || e.altKey) return
208
+ const tag = (e.target.tagName || "").toLowerCase()
209
+ if (["input", "textarea", "select"].includes(tag) || e.target.isContentEditable) return
210
+ const link = (sel) => document.querySelector(sel)?.click()
211
+ if (this.pendingG) {
212
+ this.pendingG = false
213
+ const map = { b: "board", l: "backlog", e: "executions", a: "agents", w: "workflows", r: "approvals" }
214
+ if (map[e.key]) { e.preventDefault(); link(`[data-hotkey-target="${map[e.key]}"]`) }
215
+ return
216
+ }
217
+ if (e.key === "g") { this.pendingG = true; setTimeout(() => (this.pendingG = false), 800); return }
218
+ if (e.key === "c") { e.preventDefault(); link("[data-hotkey='new-ticket']") }
219
+ if (e.key === "/") { e.preventDefault(); document.querySelector("[data-hotkey='search']")?.focus() }
220
+ }
221
+ })
222
+
223
+ application.register("autosubmit", class extends Controller {
224
+ submit() { clearTimeout(this.t); this.t = setTimeout(() => this.element.requestSubmit(), 250) }
225
+ })
226
+
227
+ export { Turbo, application }