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,299 @@
1
+ // AgentBoard capture widget — injected into the host app's pages by Agentboard::Capture::Middleware.
2
+ // Select elements on any screen and turn them into an AgentBoard ticket. No dependencies.
3
+ // Everything lives in a closed Shadow DOM so the host app's CSS and ours never collide.
4
+ (() => {
5
+ if (window.__agentboardCapture) return
6
+ window.__agentboardCapture = true
7
+ const script = document.currentScript
8
+ const cfg = JSON.parse(script?.dataset.agentboardCapture || "{}")
9
+ const consoleErrors = []
10
+
11
+ // --- console error collection (messages only, capped) ----------------------------------
12
+ const push = (msg) => { consoleErrors.push(String(msg).slice(0, 500)); if (consoleErrors.length > 20) consoleErrors.shift() }
13
+ const origError = console.error
14
+ console.error = function (...args) { push(args.map(a => (a && a.message) || a).join(" ")); return origError.apply(this, args) }
15
+ window.addEventListener("error", e => push(`${e.message} @ ${e.filename || ""}:${e.lineno || ""}`))
16
+ window.addEventListener("unhandledrejection", e => push(`Unhandled rejection: ${e.reason && (e.reason.message || e.reason)}`))
17
+
18
+ // --- helpers ----------------------------------------------------------------------------
19
+ const cssEscape = (s) => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/[^\w-]/g, "\\$&")
20
+ function selectorFor(el) {
21
+ if (el.id) return `#${cssEscape(el.id)}`
22
+ const parts = []
23
+ let node = el
24
+ while (node && node.nodeType === 1 && parts.length < 5 && node !== document.body) {
25
+ const testid = node.getAttribute("data-testid") || node.getAttribute("data-test")
26
+ if (testid) { parts.unshift(`[data-testid="${testid}"]`); break }
27
+ if (node.id) { parts.unshift(`#${cssEscape(node.id)}`); break }
28
+ let part = node.tagName.toLowerCase()
29
+ const cls = [...node.classList].filter(c => !/^(is-|has-|active|hover|focus|ab-)/.test(c)).slice(0, 2)
30
+ if (cls.length) part += "." + cls.map(cssEscape).join(".")
31
+ const same = node.parentElement ? [...node.parentElement.children].filter(c => c.tagName === node.tagName) : []
32
+ if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`
33
+ parts.unshift(part)
34
+ node = node.parentElement
35
+ }
36
+ return parts.join(" > ")
37
+ }
38
+
39
+ // Rails' annotate_rendered_view_with_filenames wraps each template in
40
+ // <!-- BEGIN app/views/x.html.erb --> … <!-- END app/views/x.html.erb -->. Walk backwards
41
+ // through siblings and ancestors; the first BEGIN without a matching END encloses `el`.
42
+ function templateFor(el) {
43
+ const closed = new Set()
44
+ let node = el
45
+ while (node && node !== document) {
46
+ let sib = node.previousSibling
47
+ while (sib) {
48
+ if (sib.nodeType === 8) {
49
+ const text = sib.nodeValue.trim()
50
+ if (text.startsWith("END ")) closed.add(text.slice(4))
51
+ else if (text.startsWith("BEGIN ")) { const path = text.slice(6); if (!closed.has(path)) return path }
52
+ }
53
+ sib = sib.previousSibling
54
+ }
55
+ node = node.parentNode
56
+ }
57
+ return null
58
+ }
59
+
60
+ function htmlSnippet(el) {
61
+ const clone = el.cloneNode(true)
62
+ clone.querySelectorAll?.("input, textarea, select").forEach(i => { i.removeAttribute("value"); if (i.tagName === "TEXTAREA") i.textContent = "" })
63
+ if (clone.tagName === "INPUT") clone.removeAttribute("value")
64
+ return clone.outerHTML.slice(0, 2000)
65
+ }
66
+
67
+ const describe = (el) => {
68
+ const r = el.getBoundingClientRect()
69
+ return { el, selector: selectorFor(el), template: templateFor(el), text: (el.innerText || "").trim().slice(0, 300),
70
+ html: htmlSnippet(el), rect: { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height } }
71
+ }
72
+
73
+ // --- UI ---------------------------------------------------------------------------------
74
+ const host = document.createElement("div")
75
+ host.setAttribute("data-agentboard-capture-root", "")
76
+ document.documentElement.appendChild(host)
77
+ const root = host.attachShadow({ mode: "closed" })
78
+ root.innerHTML = `
79
+ <style>
80
+ :host { all: initial; }
81
+ * { box-sizing: border-box; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
82
+ .fab { position: fixed; right: 20px; bottom: 20px; z-index: 2147483646; display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 16px 0 12px;
83
+ border: 0; border-radius: 21px; cursor: pointer; color: #fff; font-size: 13.5px; font-weight: 650;
84
+ background: linear-gradient(120deg,#6d6aff,#a855f7 55%,#ec4899); box-shadow: 0 10px 30px rgba(109,106,255,.45), 0 0 0 1px rgba(255,255,255,.15) inset; }
85
+ .fab:hover { filter: brightness(1.08); }
86
+ .fab kbd { font-size: 10.5px; opacity: .8; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: 0 4px; }
87
+ .mark { width: 18px; height: 18px; border-radius: 5px; background: rgba(255,255,255,.25); display: grid; place-items: center; }
88
+ .hl { position: absolute; z-index: 2147483645; pointer-events: none; border: 2px solid #7c7aff; background: rgba(124,122,255,.12); border-radius: 4px; transition: all .06s; }
89
+ .hl.sel { border-color: #ec4899; background: rgba(236,72,153,.12); }
90
+ .hl .lbl { position: absolute; left: -2px; top: -24px; white-space: nowrap; font-size: 11px; font-weight: 600; color: #fff; background: #7c7aff; padding: 3px 7px; border-radius: 5px; font-family: ui-monospace, Menlo, monospace; }
91
+ .hl.sel .lbl { background: #ec4899; }
92
+ .bar { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); z-index: 2147483646; display: flex; gap: 10px; align-items: center; padding: 8px 10px 8px 14px;
93
+ border-radius: 12px; background: #111118; color: #ededf2; font-size: 13px; box-shadow: 0 12px 40px rgba(0,0,0,.4); border: 1px solid #2a2a35; }
94
+ .btn { height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid #33333f; background: #1b1b24; color: #ededf2; font-size: 13px; font-weight: 600; cursor: pointer; }
95
+ .btn:hover { background: #24242f; }
96
+ .btn.primary { border: 0; color: #fff; background: linear-gradient(120deg,#6d6aff,#a855f7); }
97
+ .btn[disabled] { opacity: .5; cursor: default; }
98
+ .panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100vw); z-index: 2147483647; background: #101016; color: #ededf2; border-left: 1px solid #262630;
99
+ box-shadow: -20px 0 60px rgba(0,0,0,.45); display: flex; flex-direction: column; animation: slide .22s cubic-bezier(.2,.8,.2,1); }
100
+ @keyframes slide { from { transform: translateX(30px); opacity: 0 } }
101
+ .ph { display: flex; align-items: center; gap: 10px; padding: 16px 18px; border-bottom: 1px solid #262630; font-weight: 700; font-size: 15px; }
102
+ .ph .x { margin-left: auto; background: none; border: 0; color: #8d8d9c; font-size: 20px; cursor: pointer; }
103
+ .pb { padding: 16px 18px; overflow: auto; flex: 1; display: grid; gap: 14px; align-content: start; }
104
+ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #a9a9b8; }
105
+ input, textarea, select { width: 100%; font-size: 14px; color: #ededf2; background: #17171f; border: 1px solid #2f2f3b; border-radius: 9px; padding: 9px 11px; }
106
+ textarea { min-height: 84px; resize: vertical; }
107
+ input:focus, textarea:focus, select:focus { outline: none; border-color: #7c7aff; box-shadow: 0 0 0 3px rgba(124,122,255,.25); }
108
+ .seg { display: flex; gap: 6px; }
109
+ .seg button { flex: 1; height: 32px; border-radius: 8px; border: 1px solid #2f2f3b; background: #17171f; color: #a9a9b8; font-size: 13px; font-weight: 600; cursor: pointer; }
110
+ .seg button.on { border-color: #7c7aff; color: #fff; background: rgba(124,122,255,.18); }
111
+ .chips { display: grid; gap: 8px; }
112
+ .chip { display: grid; grid-template-columns: 1fr auto; gap: 4px 8px; padding: 9px 10px; border-radius: 9px; background: #17171f; border: 1px solid #2a2a35; font-size: 12px; }
113
+ .chip code { font-family: ui-monospace, Menlo, monospace; color: #c4c3ff; word-break: break-all; }
114
+ .chip .tpl { grid-column: 1 / -1; color: #86efac; font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
115
+ .chip .rm { background: none; border: 0; color: #8d8d9c; cursor: pointer; font-size: 15px; }
116
+ .meta { font-size: 12px; color: #8d8d9c; line-height: 1.6; }
117
+ .meta code { color: #c9c9d3; font-family: ui-monospace, Menlo, monospace; }
118
+ .check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #c9c9d3; font-weight: 500; }
119
+ .check input { width: auto; }
120
+ .shot { width: 100%; border-radius: 9px; border: 1px solid #2a2a35; }
121
+ .pro { font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 5px; background: rgba(245,158,11,.18); color: #fbbf24; }
122
+ .pf { padding: 14px 18px; border-top: 1px solid #262630; display: flex; gap: 10px; }
123
+ .pf .btn { flex: 1; height: 40px; }
124
+ .toast { position: fixed; right: 20px; bottom: 74px; z-index: 2147483647; padding: 12px 14px; border-radius: 12px; background: #111118; color: #ededf2; font-size: 13.5px;
125
+ border: 1px solid #2a2a35; box-shadow: 0 12px 40px rgba(0,0,0,.4); max-width: 340px; }
126
+ .toast a { color: #a9a7ff; font-weight: 650; }
127
+ .err { color: #fda4af; font-size: 12.5px; }
128
+ </style>
129
+ <button class="fab" part="fab" title="Capture elements into an AgentBoard ticket (Alt+Shift+A)"><span class="mark">◎</span>AgentBoard <kbd>⌥⇧A</kbd></button>`
130
+ const fab = root.querySelector(".fab")
131
+ const state = { selecting: false, selected: [], hover: null, screenshot: null, kind: "bug" }
132
+ let hoverBox = null, bar = null, panel = null
133
+ const boxes = new Map()
134
+
135
+ function box(el, cls, label) {
136
+ const b = document.createElement("div")
137
+ b.className = "hl " + cls
138
+ const r = el.getBoundingClientRect()
139
+ Object.assign(b.style, { left: `${r.x + scrollX}px`, top: `${r.y + scrollY}px`, width: `${r.width}px`, height: `${r.height}px`, position: "absolute" })
140
+ b.innerHTML = `<span class="lbl"></span>`
141
+ b.querySelector(".lbl").textContent = label
142
+ root.appendChild(b)
143
+ return b
144
+ }
145
+ const labelFor = (el) => { const t = templateFor(el); return t ? t.replace(/^app\/views\//, "") : el.tagName.toLowerCase() }
146
+ const isOurs = (el) => el === host || host.contains(el)
147
+
148
+ function onMove(e) {
149
+ const el = document.elementFromPoint(e.clientX, e.clientY)
150
+ if (!el || isOurs(el) || el === state.hover) return
151
+ state.hover = el
152
+ hoverBox?.remove()
153
+ hoverBox = box(el, "", labelFor(el))
154
+ }
155
+ function onClick(e) {
156
+ if (isOurs(e.target)) return
157
+ e.preventDefault(); e.stopPropagation()
158
+ const el = state.hover || e.target
159
+ const existing = state.selected.findIndex(s => s.el === el)
160
+ if (existing >= 0) { boxes.get(el)?.remove(); boxes.delete(el); state.selected.splice(existing, 1) }
161
+ else { if (!e.shiftKey) clearSelection(); state.selected.push(describe(el)); boxes.set(el, box(el, "sel", labelFor(el))) }
162
+ renderBar()
163
+ if (!e.shiftKey && state.selected.length) openPanel()
164
+ }
165
+ const blockEvent = (e) => { if (!isOurs(e.target)) { e.preventDefault(); e.stopPropagation() } }
166
+ function clearSelection() { boxes.forEach(b => b.remove()); boxes.clear(); state.selected = [] }
167
+
168
+ function startSelecting() {
169
+ if (state.selecting) return
170
+ state.selecting = true
171
+ document.addEventListener("mousemove", onMove, true)
172
+ document.addEventListener("click", onClick, true)
173
+ ;["mousedown", "mouseup", "submit"].forEach(t => document.addEventListener(t, blockEvent, true))
174
+ document.documentElement.style.cursor = "crosshair"
175
+ bar = document.createElement("div"); bar.className = "bar"; root.appendChild(bar); renderBar()
176
+ }
177
+ function stopSelecting() {
178
+ state.selecting = false
179
+ document.removeEventListener("mousemove", onMove, true)
180
+ document.removeEventListener("click", onClick, true)
181
+ ;["mousedown", "mouseup", "submit"].forEach(t => document.removeEventListener(t, blockEvent, true))
182
+ document.documentElement.style.cursor = ""
183
+ hoverBox?.remove(); hoverBox = null; bar?.remove(); bar = null
184
+ }
185
+ function renderBar() {
186
+ if (!bar) return
187
+ bar.innerHTML = `<span>Click an element · <b>Shift</b>+click to add more · <b>Esc</b> to cancel</span>
188
+ <button class="btn primary" data-next ${state.selected.length ? "" : "disabled"}>Continue (${state.selected.length})</button>`
189
+ bar.querySelector("[data-next]").onclick = () => openPanel()
190
+ }
191
+
192
+ function openPanel() {
193
+ stopSelecting()
194
+ panel?.remove()
195
+ panel = document.createElement("div"); panel.className = "panel"
196
+ const route = cfg.route || location.pathname
197
+ panel.innerHTML = `
198
+ <div class="ph">New AgentBoard ticket<button class="x" title="Close">×</button></div>
199
+ <div class="pb">
200
+ <label>Title<input data-title maxlength="200" value=""></label>
201
+ <div class="seg">${["bug", "feature", "task"].map(k => `<button type="button" data-kind="${k}" class="${k === state.kind ? "on" : ""}">${k[0].toUpperCase() + k.slice(1)}</button>`).join("")}</div>
202
+ <label>What's wrong or what should change?<textarea data-desc placeholder="Describe the problem or the change you want…"></textarea></label>
203
+ <label>Expected result <span style="font-weight:400;color:#6e6e7c">(becomes an acceptance criterion)</span><input data-expected placeholder="e.g. The total shows 2 decimals"></label>
204
+ <label>Priority<select data-priority><option value="0">Urgent</option><option value="1">High</option><option value="2" selected>Medium</option><option value="3">Low</option></select></label>
205
+ <div><div class="meta" style="margin-bottom:8px">Selected elements</div><div class="chips" data-chips></div>
206
+ <button class="btn" type="button" data-more style="margin-top:8px">+ Select more</button></div>
207
+ <label class="check"><input type="checkbox" data-console ${consoleErrors.length ? "checked" : ""}> Include console errors (${consoleErrors.length})</label>
208
+ <div data-shot-area>${cfg.screenshots ? `<button class="btn" type="button" data-shot>Attach screenshot of the selection</button>` :
209
+ `<div class="meta">Screenshots of the selection <span class="pro">PRO</span></div>`}</div>
210
+ <div class="meta">Page <code>${location.pathname}</code> · route <code>${route}</code><br>${(cfg.templates || []).length} templates rendered on this page</div>
211
+ <div class="err" data-error></div>
212
+ </div>
213
+ <div class="pf"><button class="btn" data-cancel>Cancel</button><button class="btn primary" data-submit>Create ticket</button></div>`
214
+ root.appendChild(panel)
215
+ const first = state.selected[0]
216
+ panel.querySelector("[data-title]").value = first ? `${state.kind === "bug" ? "Fix" : "Change"} ${first.text ? `“${first.text.slice(0, 50)}”` : first.selector.split(" > ").pop()} on ${route}` : `Issue on ${route}`
217
+ renderChips()
218
+ panel.querySelectorAll("[data-kind]").forEach(b => b.onclick = () => { state.kind = b.dataset.kind; panel.querySelectorAll("[data-kind]").forEach(x => x.classList.toggle("on", x === b)) })
219
+ panel.querySelector(".x").onclick = panel.querySelector("[data-cancel]").onclick = close
220
+ panel.querySelector("[data-more]").onclick = () => { panel.remove(); panel = null; startSelecting() }
221
+ panel.querySelector("[data-submit]").onclick = submit
222
+ panel.querySelector("[data-shot]")?.addEventListener("click", takeScreenshot)
223
+ setTimeout(() => panel.querySelector("[data-desc]").focus(), 50)
224
+ }
225
+ function renderChips() {
226
+ const list = panel.querySelector("[data-chips]")
227
+ list.innerHTML = ""
228
+ state.selected.forEach((s, i) => {
229
+ const c = document.createElement("div"); c.className = "chip"
230
+ c.innerHTML = `<code></code><button class="rm" title="Remove">×</button><span class="tpl"></span>`
231
+ c.querySelector("code").textContent = s.selector
232
+ c.querySelector(".tpl").textContent = s.template ? `↳ ${s.template}` : "↳ template unknown (enable annotate_rendered_view_with_filenames)"
233
+ c.querySelector(".rm").onclick = () => { boxes.get(s.el)?.remove(); boxes.delete(s.el); state.selected.splice(i, 1); renderChips() }
234
+ list.appendChild(c)
235
+ })
236
+ }
237
+
238
+ // Pro: capture the current tab once (the browser asks for permission) and crop to the
239
+ // union of the selected elements.
240
+ async function takeScreenshot() {
241
+ const area = panel.querySelector("[data-shot-area]")
242
+ try {
243
+ panel.style.visibility = "hidden"; boxes.forEach(b => b.style.visibility = "hidden")
244
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: "browser" }, preferCurrentTab: true, audio: false })
245
+ const video = document.createElement("video"); video.srcObject = stream; await video.play()
246
+ await new Promise(r => setTimeout(r, 250))
247
+ const scale = video.videoWidth / innerWidth
248
+ const rects = state.selected.map(s => s.el.getBoundingClientRect())
249
+ const pad = 16
250
+ const x = Math.max(0, Math.min(...rects.map(r => r.left)) - pad), y = Math.max(0, Math.min(...rects.map(r => r.top)) - pad)
251
+ const w = Math.min(innerWidth, Math.max(...rects.map(r => r.right)) + pad) - x, h = Math.min(innerHeight, Math.max(...rects.map(r => r.bottom)) + pad) - y
252
+ const canvas = document.createElement("canvas"); canvas.width = w * scale; canvas.height = h * scale
253
+ canvas.getContext("2d").drawImage(video, x * scale, y * scale, w * scale, h * scale, 0, 0, canvas.width, canvas.height)
254
+ stream.getTracks().forEach(t => t.stop())
255
+ state.screenshot = canvas.toDataURL("image/png")
256
+ area.innerHTML = `<img class="shot" alt="Screenshot of the selection"><button class="btn" type="button" data-unshot style="margin-top:8px">Remove screenshot</button>`
257
+ area.querySelector("img").src = state.screenshot
258
+ area.querySelector("[data-unshot]").onclick = () => { state.screenshot = null; openPanel() }
259
+ } catch (err) {
260
+ area.insertAdjacentHTML("beforeend", `<div class="err">Screenshot cancelled or not supported in this browser.</div>`)
261
+ } finally {
262
+ if (panel) panel.style.visibility = ""; boxes.forEach(b => b.style.visibility = "")
263
+ }
264
+ }
265
+
266
+ async function submit() {
267
+ const btn = panel.querySelector("[data-submit]"); btn.disabled = true; btn.textContent = "Creating…"
268
+ const payload = { capture: {
269
+ title: panel.querySelector("[data-title]").value, description: panel.querySelector("[data-desc]").value,
270
+ expected: panel.querySelector("[data-expected]").value, kind: state.kind, priority: panel.querySelector("[data-priority]").value,
271
+ url: location.href, route: cfg.route, viewport: { width: innerWidth, height: innerHeight }, user_agent: navigator.userAgent,
272
+ templates: cfg.templates || [], console_errors: panel.querySelector("[data-console]").checked ? consoleErrors : [],
273
+ elements: state.selected.map(({ el, ...rest }) => rest), screenshot: state.screenshot || undefined
274
+ } }
275
+ try {
276
+ const res = await fetch(cfg.endpoint, { method: "POST", credentials: "same-origin",
277
+ headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || "" },
278
+ body: JSON.stringify(payload) })
279
+ const data = await res.json().catch(() => ({}))
280
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`)
281
+ close()
282
+ const t = document.createElement("div"); t.className = "toast"
283
+ t.innerHTML = `✓ Created <a target="_blank" rel="noopener"></a> in AgentBoard`
284
+ t.querySelector("a").textContent = data.key; t.querySelector("a").href = data.url
285
+ root.appendChild(t); setTimeout(() => t.remove(), 7000)
286
+ } catch (err) {
287
+ panel.querySelector("[data-error]").textContent = `Could not create the ticket: ${err.message}`
288
+ btn.disabled = false; btn.textContent = "Create ticket"
289
+ }
290
+ }
291
+
292
+ function close() { panel?.remove(); panel = null; stopSelecting(); clearSelection(); state.screenshot = null }
293
+
294
+ fab.addEventListener("click", () => state.selecting ? stopSelecting() : startSelecting())
295
+ document.addEventListener("keydown", (e) => {
296
+ if (e.key === "Escape" && (state.selecting || panel)) { e.preventDefault(); close() }
297
+ if (e.altKey && e.shiftKey && (e.key === "A" || e.key === "a" || e.code === "KeyA")) { e.preventDefault(); state.selecting ? stopSelecting() : startSelecting() }
298
+ }, true)
299
+ })()
@@ -0,0 +1,4 @@
1
+ module Agentboard
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,23 @@
1
+ module Agentboard
2
+ class AcceptanceCriterion < ApplicationRecord
3
+ self.table_name = "agentboard_acceptance_criteria"
4
+ VERIFICATION_KINDS = %w[manual test command review].freeze
5
+ STATES = %w[pending verified failed waived].freeze
6
+
7
+ belongs_to :ticket
8
+ validates :description, presence: true
9
+ validates :verification_kind, inclusion: { in: VERIFICATION_KINDS }
10
+ validates :state, inclusion: { in: STATES }
11
+ validates :position, uniqueness: { scope: :ticket_id }
12
+
13
+ before_validation :assign_position, on: :create
14
+
15
+ def satisfied? = %w[verified waived].include?(state)
16
+
17
+ private
18
+
19
+ def assign_position
20
+ self.position ||= (ticket&.acceptance_criteria&.maximum(:position) || 0) + 1
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ module Agentboard
2
+ class AgentProfile < ApplicationRecord
3
+ belongs_to :project
4
+ has_many :executions, dependent: :nullify
5
+ validates :name, presence: true, uniqueness: { scope: :project_id }
6
+ validates :provider_key, presence: true
7
+
8
+ def provider = Agentboard::Providers.registry.fetch(provider_key)
9
+
10
+ def self.default_for(project, provider_key)
11
+ find_or_create_by!(project: project, name: "#{provider_key}-default") do |p|
12
+ p.provider_key = provider_key.to_s
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ module Agentboard
2
+ # A provider coding conversation or process identity.
3
+ #
4
+ # trust_level:
5
+ # "launched" – started by the AgentBoard runtime (we own the process)
6
+ # "hook" – corroborated by a provider hook payload
7
+ # "mcp_stdio" – bound to a local MCP stdio process started by the user
8
+ # "untrusted" – label supplied by an agent; never authorizes anything
9
+ class AgentSession < ApplicationRecord
10
+ TRUST_LEVELS = %w[launched hook mcp_stdio untrusted].freeze
11
+
12
+ belongs_to :project
13
+ has_many :ticket_links, class_name: "Agentboard::SessionTicketLink", dependent: :destroy
14
+ has_many :tickets, through: :ticket_links
15
+ has_many :leases, class_name: "Agentboard::TicketLease", dependent: :nullify
16
+ has_many :executions, dependent: :nullify
17
+
18
+ validates :provider_key, presence: true
19
+ validates :trust_level, inclusion: { in: TRUST_LEVELS }
20
+
21
+ scope :active, -> { where(state: "active") }
22
+
23
+ def active_link = ticket_links.find_by(ended_at: nil)
24
+ def active_ticket = active_link&.ticket
25
+ def actor = Agentboard::Authorization.agent_actor(id, name: display_name)
26
+ def display_name = label.presence || "#{provider_key} #{(external_session_id || id)[0, 8]}"
27
+
28
+ def touch_seen! = update_column(:last_seen_at, Time.current)
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module Agentboard
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ before_validation :assign_uuid, on: :create
6
+
7
+ private
8
+
9
+ def assign_uuid
10
+ self.id ||= Agentboard::Ids.uuid if has_attribute?(:id)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ module Agentboard
2
+ # Human authorization bound to an exact action digest, code state (head SHA),
3
+ # execution, requester, expiry and required role (spec §28.5).
4
+ class Approval < ApplicationRecord
5
+ DECISIONS = %w[pending approved rejected expired invalidated].freeze
6
+ ACTION_KINDS = %w[
7
+ review_completion destructive_migration privileged_shell protected_branch_merge
8
+ credential_access production_deploy security_policy_change workflow_gate custom
9
+ ].freeze
10
+
11
+ belongs_to :project
12
+ belongs_to :execution, optional: true
13
+ validates :decision, inclusion: { in: DECISIONS }
14
+ validates :action_kind, inclusion: { in: ACTION_KINDS }
15
+ validates :action_digest, :requested_by, presence: true
16
+
17
+ scope :pending, -> { where(decision: "pending") }
18
+
19
+ def ticket = ticket_id && Agentboard::Ticket.find_by(id: ticket_id)
20
+ def expired?(now = Time.current) = expires_at.present? && expires_at <= now
21
+ def pending? = decision == "pending"
22
+
23
+ # Approved, unexpired, and still bound to the same action and code state.
24
+ def valid_for?(action_digest:, head_sha: nil, now: Time.current)
25
+ decision == "approved" && !expired?(now) &&
26
+ self.action_digest == action_digest && (self.head_sha.nil? || self.head_sha == head_sha)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ module Agentboard
2
+ class Artifact < ApplicationRecord
3
+ KINDS = %w[commit diff log test_report pull_request summary context file link handoff capture screenshot].freeze
4
+
5
+ belongs_to :ticket
6
+ belongs_to :execution, optional: true
7
+ validates :kind, inclusion: { in: KINDS }
8
+ validates :uri, presence: true
9
+ validates :provenance, inclusion: { in: Event::PROVENANCE }
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Agentboard
2
+ class Comment < ApplicationRecord
3
+ belongs_to :ticket
4
+ validates :body, presence: true, length: { maximum: 20_000 }
5
+ validates :actor_type, inclusion: { in: %w[human agent system] }
6
+
7
+ def agent? = actor_type == "agent"
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ module Agentboard
2
+ # Application-append-only event log (spec §11). Rows are never updated by the
3
+ # application; privacy retention is enforced separately.
4
+ class Event < ApplicationRecord
5
+ PROVENANCE = %w[confirmed provider_reported git_observed inferred].freeze
6
+ PROVENANCE_LABELS = {
7
+ "confirmed" => "Confirmed by AgentBoard",
8
+ "provider_reported" => "Reported by provider",
9
+ "git_observed" => "Observed in Git",
10
+ "inferred" => "Inferred"
11
+ }.freeze
12
+
13
+ validates :provenance, inclusion: { in: PROVENANCE }
14
+ validates :event_type, :aggregate_type, :aggregate_id, :source, :correlation_id, presence: true
15
+
16
+ before_update { raise ActiveRecord::ReadOnlyRecord, "events are append-only" }
17
+
18
+ scope :chronological, -> { order(:received_at, :id) }
19
+
20
+ def family = event_type.split(".").first
21
+
22
+ # Payload in the current schema version (upcast on read; the stored row is untouched).
23
+ def data = EventSchema.upcast(event_type, schema_version, payload)
24
+
25
+ def envelope
26
+ {
27
+ event_id: id, schema_version: EventSchema.current_version(event_type), type: event_type, source: source,
28
+ provenance: provenance, project_id: project_id, ticket_id: ticket_id,
29
+ execution_id: execution_id, session_id: session_id, correlation_id: correlation_id,
30
+ causation_id: causation_id, aggregate: { type: aggregate_type, id: aggregate_id, sequence: sequence },
31
+ occurred_at: occurred_at&.utc&.iso8601(3), received_at: received_at&.utc&.iso8601(3),
32
+ stored_schema_version: schema_version,
33
+ payload: data
34
+ }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ module Agentboard
2
+ # One AgentBoard-managed attempt to perform work. Execution state is independent of
3
+ # ticket state (ADR-004); retries create new rows with incremented `attempt`.
4
+ class Execution < ApplicationRecord
5
+ belongs_to :ticket
6
+ belongs_to :agent_profile, optional: true
7
+ belongs_to :agent_session, optional: true
8
+ belongs_to :workflow_step, optional: true
9
+ has_many :verification_results, -> { order(:started_at) }, dependent: :destroy
10
+ has_many :artifacts, dependent: :nullify
11
+ has_many :usage_records, dependent: :destroy
12
+ has_many :approvals, dependent: :nullify
13
+
14
+ validates :status, inclusion: { in: StateMachines::Execution::STATES }
15
+ validates :provider_key, presence: true
16
+ validates :attempt, uniqueness: { scope: :ticket_id }
17
+
18
+ scope :active, -> { where(status: StateMachines::Execution::ACTIVE) }
19
+ scope :terminal, -> { where(status: StateMachines::Execution::TERMINAL) }
20
+ scope :recent, -> { order(created_at: :desc) }
21
+
22
+ def terminal? = StateMachines::Execution.terminal?(status)
23
+ def active? = !terminal?
24
+ def short_id = id.to_s.split("-").last[0, 8]
25
+ def label = "#{ticket.key} · exec-#{attempt.to_s.rjust(3, "0")}"
26
+
27
+ def duration
28
+ return unless started_at
29
+
30
+ (finished_at || Time.current) - started_at
31
+ end
32
+
33
+ def events
34
+ Agentboard::Event.where(execution_id: id).order(:occurred_at, :sequence)
35
+ end
36
+
37
+ def log_tail(bytes: 64 * 1024)
38
+ return "" unless log_path && File.file?(log_path)
39
+
40
+ File.open(log_path, "rb") do |f|
41
+ size = f.size
42
+ f.seek([ size - bytes, 0 ].max)
43
+ f.read.to_s.force_encoding("UTF-8").scrub
44
+ end
45
+ end
46
+
47
+ def self.next_attempt_for(ticket)
48
+ (where(ticket_id: ticket.id).maximum(:attempt) || 0) + 1
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ module Agentboard
2
+ class IdempotencyKey < ApplicationRecord
3
+ validates :scope, :key, :request_digest, presence: true
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module Agentboard
2
+ class Milestone < ApplicationRecord
3
+ belongs_to :project
4
+ has_many :tickets, dependent: :nullify
5
+ validates :name, presence: true
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ module Agentboard
2
+ class OutboxMessage < ApplicationRecord
3
+ STATES = %w[pending delivered dead].freeze
4
+ MAX_ATTEMPTS = 8
5
+
6
+ belongs_to :event, class_name: "Agentboard::Event"
7
+ validates :state, inclusion: { in: STATES }
8
+
9
+ scope :due, ->(now = Time.current) { where(state: "pending").where("available_at <= ?", now).order(:available_at) }
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ module Agentboard
2
+ class Project < ApplicationRecord
3
+ has_many :tickets, dependent: :restrict_with_exception
4
+ has_many :milestones, dependent: :destroy
5
+ has_many :agent_profiles, dependent: :destroy
6
+ has_many :agent_sessions, dependent: :restrict_with_exception
7
+ has_many :workflow_definitions, dependent: :restrict_with_exception
8
+ has_many :approvals, dependent: :restrict_with_exception
9
+
10
+ validates :key, presence: true, uniqueness: true, format: { with: /\A[A-Z][A-Z0-9_]{0,15}\z/, message: "must be uppercase letters/digits, e.g. AB" }
11
+ validates :name, :root_path, :default_branch, presence: true
12
+
13
+ # Resolves (or bootstraps) the project for the configured repository root.
14
+ def self.current(root: Agentboard.config.root_path)
15
+ find_by(root_path: root.to_s) || first || bootstrap!(root: root)
16
+ end
17
+
18
+ def self.bootstrap!(root: Agentboard.config.root_path)
19
+ name = Agentboard.config.dig("project", "name") || File.basename(root.to_s).tr("_-", " ").split.map(&:capitalize).join(" ")
20
+ key = Agentboard.config.dig("project", "key") || derive_key(name)
21
+ branch = Agentboard::Git::Repository.new(root).default_branch rescue "main"
22
+ create!(name: name, key: key, root_path: root.to_s, default_branch: branch || "main")
23
+ rescue ActiveRecord::RecordNotUnique
24
+ find_by!(root_path: root.to_s)
25
+ end
26
+
27
+ def self.derive_key(name)
28
+ letters = name.to_s.scan(/[A-Za-z0-9]+/).map { |w| w[0] }.join.upcase
29
+ letters = name.to_s.gsub(/[^A-Za-z]/, "").upcase[0, 3] if letters.length < 2
30
+ letters = "AB" if letters.blank?
31
+ letters = "P#{letters}" unless letters.match?(/\A[A-Z]/)
32
+ letters[0, 6]
33
+ end
34
+
35
+ def repository = Agentboard::Git::Repository.new(root_path)
36
+
37
+ def next_ticket_number!
38
+ # Atomic counter increment; safe under concurrent creators.
39
+ self.class.where(id: id).update_all("ticket_counter = ticket_counter + 1")
40
+ reload.ticket_counter
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ module Agentboard
2
+ class RepositorySnapshot < ApplicationRecord
3
+ belongs_to :project
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ module Agentboard
2
+ class SessionTicketLink < ApplicationRecord
3
+ # Association precedence (spec §7): lower number wins; automatic sources never
4
+ # replace an explicit human choice.
5
+ SOURCES = { "mcp" => 1, "cli_attach" => 2, "dashboard" => 3, "branch_heuristic" => 4, "provider_metadata" => 5, "runtime" => 0 }.freeze
6
+ EXPLICIT = %w[mcp cli_attach dashboard runtime].freeze
7
+
8
+ belongs_to :agent_session
9
+ belongs_to :ticket
10
+ validates :source, inclusion: { in: SOURCES.keys }
11
+ validates :confidence, inclusion: { in: %w[confirmed probable needs_confirmation] }
12
+
13
+ scope :active, -> { where(ended_at: nil) }
14
+
15
+ def explicit? = EXPLICIT.include?(source)
16
+ def precedence = SOURCES.fetch(source)
17
+ end
18
+ end