agentboard 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- metadata +285 -0
|
@@ -0,0 +1,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,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,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,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,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,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
|