cardinal-ai 0.2.4 → 0.2.6
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 +4 -4
- data/README.md +20 -1
- data/app/assets/stylesheets/cardinal.css +36 -0
- data/app/controllers/boards_controller.rb +99 -0
- data/app/controllers/columns_controller.rb +26 -1
- data/app/helpers/application_helper.rb +8 -0
- data/app/jobs/deep_dive_job.rb +64 -0
- data/app/jobs/merge_pr_job.rb +22 -0
- data/app/jobs/resume_run_job.rb +7 -0
- data/app/jobs/start_run_job.rb +19 -4
- data/app/models/board.rb +75 -2
- data/app/models/card.rb +4 -1
- data/app/models/column.rb +61 -1
- data/app/services/agent/runner.rb +39 -4
- data/app/services/agent/workspace.rb +9 -0
- data/app/views/boards/brief.html.erb +48 -0
- data/app/views/boards/edit.html.erb +29 -0
- data/app/views/boards/show.html.erb +36 -1
- data/app/views/cards/_card.html.erb +2 -2
- data/app/views/cards/_detail.html.erb +4 -4
- data/app/views/columns/_column.html.erb +11 -0
- data/app/views/columns/edit.html.erb +13 -0
- data/config/application.rb +1 -1
- data/config/cable.yml +9 -1
- data/config/database.yml +11 -2
- data/config/environments/development.rb +5 -0
- data/config/environments/production.rb +2 -1
- data/config/puma.rb +1 -1
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +5 -1
- data/db/cable_schema.rb +11 -0
- data/db/migrate/20260704120000_add_repo_brief_to_boards.rb +8 -0
- data/db/queue_schema.rb +129 -0
- data/exe/cardinal +4 -1
- data/lib/cardinal/version.rb +1 -1
- metadata +37 -1
|
@@ -30,6 +30,21 @@ module Agent
|
|
|
30
30
|
- Finish with a concise report: what you did, what to check, any open questions.
|
|
31
31
|
RULES
|
|
32
32
|
|
|
33
|
+
# Column shell access OFF: the agent can read, search, and edit — nothing
|
|
34
|
+
# else. Enforced by the CLI tool list, not just these words.
|
|
35
|
+
RESTRICTED_EXECUTE_RULES = <<~RULES.freeze
|
|
36
|
+
## Rules
|
|
37
|
+
- You have FILE TOOLS ONLY: read, search, edit, write. You cannot run shell
|
|
38
|
+
commands or git — do not attempt to; Cardinal commits and pushes your edits for you.
|
|
39
|
+
- Work only inside this repository checkout (you are already on the card's branch).
|
|
40
|
+
- Stay strictly within the card's scope. Prefer the smallest reasonable interpretation and note assumptions.
|
|
41
|
+
- If something must be RUN to finish the job (tests, generators, installs), do the file
|
|
42
|
+
work, then list the exact commands in your final report for the user to run.
|
|
43
|
+
- If you are blocked on a decision only the user can make, output a single line starting with
|
|
44
|
+
"QUESTION:" followed by the question, then stop immediately. Do not guess on genuinely ambiguous choices.
|
|
45
|
+
- Finish with a concise report: what you did, what to check, any open questions.
|
|
46
|
+
RULES
|
|
47
|
+
|
|
33
48
|
def self.start(run) = new(run).start
|
|
34
49
|
def self.resume(run, message, approve: false) = new(run).resume(message, approve: approve)
|
|
35
50
|
|
|
@@ -59,7 +74,7 @@ module Agent
|
|
|
59
74
|
begin_segment!
|
|
60
75
|
if run.phase == "plan" && approve
|
|
61
76
|
run.update!(phase: "execute")
|
|
62
|
-
stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{
|
|
77
|
+
stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{execute_rules}",
|
|
63
78
|
mode: "execute", resuming: true)
|
|
64
79
|
elsif run.phase == "plan"
|
|
65
80
|
stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
|
|
@@ -106,6 +121,9 @@ module Agent
|
|
|
106
121
|
cmd += ["--max-turns", "3", "--tools", ""]
|
|
107
122
|
else
|
|
108
123
|
cmd += ["--max-turns", (column.max_turns.presence || DEFAULT_EXECUTE_TURNS).to_s]
|
|
124
|
+
# Shell access off: restrict to file tools — the sandbox is the tool
|
|
125
|
+
# list itself, not a request in the prompt.
|
|
126
|
+
cmd += ["--tools", "Read,Glob,Grep,Edit,Write"] unless column.shell_access?
|
|
109
127
|
end
|
|
110
128
|
cmd += ["--model", column.model] if column.model.present?
|
|
111
129
|
cmd += ["--effort", column.effort] if column.effort.present?
|
|
@@ -205,6 +223,11 @@ module Agent
|
|
|
205
223
|
|
|
206
224
|
def conclude_execute(workspace, result)
|
|
207
225
|
accumulate_usage(result)
|
|
226
|
+
# A shell-less agent cannot commit its own edits — do it for it before
|
|
227
|
+
# any commit counting or salvage below.
|
|
228
|
+
unless column.shell_access?
|
|
229
|
+
workspace.commit_all!("Card ##{card.number}: #{card.title.truncate(60)}\n\nCommitted by Cardinal for a shell-less agent.")
|
|
230
|
+
end
|
|
208
231
|
unless result[:success]
|
|
209
232
|
# Budget exhaustion isn't failure — park and offer to continue (§8).
|
|
210
233
|
# The session survives; an answer resumes it with a fresh turn budget.
|
|
@@ -329,15 +352,19 @@ module Agent
|
|
|
329
352
|
## Brief
|
|
330
353
|
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
331
354
|
|
|
332
|
-
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
355
|
+
#{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
333
356
|
## Card conversation so far
|
|
334
357
|
#{conversation_excerpt.presence || "(none)"}
|
|
335
358
|
|
|
336
359
|
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
337
|
-
#{
|
|
360
|
+
#{execute_rules}
|
|
338
361
|
PROMPT
|
|
339
362
|
end
|
|
340
363
|
|
|
364
|
+
def execute_rules
|
|
365
|
+
column.shell_access? ? EXECUTE_RULES : RESTRICTED_EXECUTE_RULES
|
|
366
|
+
end
|
|
367
|
+
|
|
341
368
|
def plan_prompt
|
|
342
369
|
<<~PROMPT
|
|
343
370
|
You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
|
|
@@ -346,7 +373,7 @@ module Agent
|
|
|
346
373
|
## Brief
|
|
347
374
|
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
348
375
|
|
|
349
|
-
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
376
|
+
#{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
350
377
|
## Card conversation so far
|
|
351
378
|
#{conversation_excerpt.presence || "(none)"}
|
|
352
379
|
|
|
@@ -363,6 +390,14 @@ module Agent
|
|
|
363
390
|
PROMPT
|
|
364
391
|
end
|
|
365
392
|
|
|
393
|
+
# The one-time repo deep dive (card #12), injected ahead of the planning
|
|
394
|
+
# brief so the agent starts oriented instead of re-exploring the tree each
|
|
395
|
+
# run. Empty string (not nil) when there's no brief, so the heredoc stays clean.
|
|
396
|
+
def repo_brief_section
|
|
397
|
+
brief = card.board.repo_brief.presence or return ""
|
|
398
|
+
"## Repo brief\n#{brief.strip}\n\n"
|
|
399
|
+
end
|
|
400
|
+
|
|
366
401
|
# The planning assistant's distilled "Ready for execution" brief, if the
|
|
367
402
|
# conversation produced one — the most load-bearing artifact of planning.
|
|
368
403
|
def planning_brief
|
|
@@ -74,6 +74,15 @@ module Agent
|
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Shell-less agents (column shell access off) edit files but cannot run
|
|
78
|
+
# git — the runner commits on their behalf when a segment ends.
|
|
79
|
+
def commit_all!(message)
|
|
80
|
+
return false if git_out(path, "status", "--porcelain").strip.empty?
|
|
81
|
+
git!(path, "add", "-A")
|
|
82
|
+
git!(path, "commit", "--quiet", "-m", message)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
77
86
|
# How the runner should spawn the agent process for this workspace.
|
|
78
87
|
def agent_spawn(cmd) = [cmd, { chdir: path.to_s }]
|
|
79
88
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
|
+
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
3
|
+
<div class="modal modal-sm">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>🔍 Repo brief</h1>
|
|
6
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="modal-body">
|
|
9
|
+
<p class="hint">
|
|
10
|
+
A deep dive is a one-shot, read-only agent (Read/Glob/Grep only — it cannot change
|
|
11
|
+
anything) that maps this repo into the brief below. Every worker agent gets the brief
|
|
12
|
+
in its prompt, so runs skip re-exploring the codebase. It costs one bounded AI call
|
|
13
|
+
using the planning column's model.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<% if @board.brief? %>
|
|
17
|
+
<p class="brief-meta">
|
|
18
|
+
Generated <%= @board.brief_generated_at&.strftime("%b %-d, %H:%M") %>
|
|
19
|
+
from <code><%= @board.brief_sha&.first(7) %></code>
|
|
20
|
+
with <%= @board.brief_model %>
|
|
21
|
+
<% behind = @board.commits_behind_brief %>
|
|
22
|
+
<% if behind&.positive? %>
|
|
23
|
+
· <span class="brief-behind"><%= pluralize(behind, "commit") %> behind HEAD</span>
|
|
24
|
+
<% else %>
|
|
25
|
+
· current with HEAD
|
|
26
|
+
<% end %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div class="brief-content">
|
|
30
|
+
<%= render_markdown(@board.repo_brief.to_s) %>
|
|
31
|
+
</div>
|
|
32
|
+
<% else %>
|
|
33
|
+
<p class="brief-meta">No brief yet — run a deep dive from the topbar.</p>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<div class="card-edit-actions">
|
|
37
|
+
<%= button_to "↻ Regenerate brief", deep_dive_board_path(force: 1),
|
|
38
|
+
form: { data: { turbo_frame: "_top" } },
|
|
39
|
+
disabled: @board.brief_working? %>
|
|
40
|
+
</div>
|
|
41
|
+
<p class="hint">
|
|
42
|
+
Regenerating replaces the brief. When the brief already matches HEAD the deep dive
|
|
43
|
+
button won't re-run it — only this button forces a fresh dive.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
|
+
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
3
|
+
<div class="modal modal-sm" data-controller="autosave">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>⚙ Board settings
|
|
6
|
+
<span class="autosave-status" data-autosave-target="status"></span></h1>
|
|
7
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
8
|
+
</header>
|
|
9
|
+
<div class="modal-body">
|
|
10
|
+
<div id="board-form-errors"></div>
|
|
11
|
+
|
|
12
|
+
<%= form_with model: @board, url: board_path(autosave: 1), class: "card-edit",
|
|
13
|
+
data: { autosave_target: "form" } do |f| %>
|
|
14
|
+
<label>Board name</label>
|
|
15
|
+
<%= f.text_field :name %>
|
|
16
|
+
|
|
17
|
+
<label>Default branch <%= info_tip("The branch agents fork card branches from and Done merges pull requests toward. Detected from the repo at first boot; fix it here if that guess was wrong.") %></label>
|
|
18
|
+
<%= f.text_field :default_branch, placeholder: "main" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<label>Repository</label>
|
|
22
|
+
<p class="locked-field"><code><%= @board.repo_url.presence || "(no remote)" %></code></p>
|
|
23
|
+
|
|
24
|
+
<label>Local checkout</label>
|
|
25
|
+
<p class="locked-field"><code><%= @board.local_path %></code></p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
<%= turbo_stream_from @board %>
|
|
2
2
|
|
|
3
3
|
<header class="topbar">
|
|
4
|
-
<
|
|
4
|
+
<div class="topbar-left">
|
|
5
|
+
<h1>Cardinal AI <span class="sep">▸</span> <span id="board-name"><%= @board.name %></span></h1>
|
|
6
|
+
<%= link_to "⚙", edit_board_path, data: { turbo_frame: "modal" }, class: "gear",
|
|
7
|
+
title: "Board settings — name, default branch" %>
|
|
8
|
+
<%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
|
|
9
|
+
form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
|
|
10
|
+
data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
|
|
11
|
+
<%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
|
|
12
|
+
as a brief and injected into worker prompts. The button greys→reds as the
|
|
13
|
+
brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+.
|
|
14
|
+
Once a brief exists the button opens it for inspection (the modal holds
|
|
15
|
+
the Regenerate action) — a fresh brief is never silently re-run. %>
|
|
16
|
+
<% behind = @board.commits_behind_brief %>
|
|
17
|
+
<% if @board.brief_working? %>
|
|
18
|
+
<button type="button" class="deep-dive working" disabled>
|
|
19
|
+
<span class="pulse-dot"></span> Deep dive · working…
|
|
20
|
+
</button>
|
|
21
|
+
<% elsif @board.brief? %>
|
|
22
|
+
<%= link_to brief_board_path, data: { turbo_frame: "modal" },
|
|
23
|
+
class: "deep-dive#{' stale-critical' if @board.brief_stale?}",
|
|
24
|
+
style: "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}",
|
|
25
|
+
title: "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date} — click to inspect" do %>
|
|
26
|
+
<% if behind&.positive? %>
|
|
27
|
+
🔍 Repo brief · <%= behind %> commit<%= "s" if behind != 1 %> behind
|
|
28
|
+
<% else %>
|
|
29
|
+
🔍 Repo brief
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= button_to deep_dive_board_path, class: "deep-dive",
|
|
34
|
+
title: "Map this repo into a brief agents reuse — saves exploration on every run" do %>
|
|
35
|
+
🔍 Deep dive
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<span id="repo-pull-status"></span>
|
|
39
|
+
</div>
|
|
5
40
|
<div class="topbar-right">
|
|
6
41
|
<button type="button" class="theme-toggle"
|
|
7
42
|
data-controller="theme" data-action="theme#toggle">☀ Light</button>
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
<% end %>
|
|
37
37
|
<% if card.parent_id %><span class="chip">↑ sub</span><% end %>
|
|
38
38
|
<% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
|
|
39
|
-
<% if card.branch_name && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
39
|
+
<% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
40
40
|
</div>
|
|
41
41
|
<% end %>
|
|
42
|
-
<% if card.pr_url %>
|
|
42
|
+
<% if card.pr_url.present? %>
|
|
43
43
|
<a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
|
|
44
44
|
<span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
|
|
45
45
|
<span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<span class="card-number-sub" title="Card number — how branches, PRs, and other cards refer to this card">#<%= @card.number %></span>
|
|
9
9
|
</h1>
|
|
10
10
|
<div class="modal-header-right">
|
|
11
|
-
<% if @card.branch_name %>
|
|
11
|
+
<% if @card.branch_name.present? %>
|
|
12
12
|
<span class="git-line">
|
|
13
13
|
<span class="branch-base"><%= @card.board.default_branch %></span>
|
|
14
14
|
<span class="git-arrow">→</span>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<button type="button" class="copy-btn" data-clipboard-target="button"
|
|
18
18
|
data-action="clipboard#copy" title="Copy branch name">⧉</button>
|
|
19
19
|
</span>
|
|
20
|
-
<% if @card.pr_url %>
|
|
20
|
+
<% if @card.pr_url.present? %>
|
|
21
21
|
<span class="git-arrow">→</span>
|
|
22
22
|
<%= link_to "##{@card.pr_url[%r{/pull/(\d+)}, 1]}", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-link" %>
|
|
23
23
|
<% if @card.pr_state.present? %><span class="pr-state">(<%= @card.pr_state %>)</span><% end %>
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
|
|
114
114
|
<% if @card.column.review? %>
|
|
115
115
|
<h3>Review</h3>
|
|
116
|
-
<% if @card.pr_url %>
|
|
116
|
+
<% if @card.pr_url.present? %>
|
|
117
117
|
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
118
118
|
<% end %>
|
|
119
119
|
<% if @card.in_review? %>
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
</li>
|
|
169
169
|
<% end %>
|
|
170
170
|
</ul>
|
|
171
|
-
<% if @card.pr_url %>
|
|
171
|
+
<% if @card.pr_url.present? %>
|
|
172
172
|
<p>🌿 <%= link_to "View pull request", @card.pr_url, target: "_blank" %><%= " (#{@card.pr_state})" if @card.pr_state.present? %></p>
|
|
173
173
|
<% end %>
|
|
174
174
|
<% else %>
|
|
@@ -22,4 +22,15 @@
|
|
|
22
22
|
<%= render "cards/card", card: card %>
|
|
23
23
|
<% end %>
|
|
24
24
|
</div>
|
|
25
|
+
<% rows = column.footer_rows %>
|
|
26
|
+
<% if rows.any? %>
|
|
27
|
+
<footer class="column-footer">
|
|
28
|
+
<% rows.each do |row| %>
|
|
29
|
+
<div class="footer-row">
|
|
30
|
+
<span class="footer-label"><%= row[:label] %></span>
|
|
31
|
+
<span class="footer-value"><%= row[:value] %></span>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</footer>
|
|
35
|
+
<% end %>
|
|
25
36
|
</section>
|
|
@@ -105,6 +105,14 @@
|
|
|
105
105
|
<%= info_tip("The agent's first pass is read-only: it explores and proposes a plan, then waits. You approve with one click or reply to redirect. Your main safety rail.") %>
|
|
106
106
|
</label>
|
|
107
107
|
|
|
108
|
+
<% if @column.execution? %>
|
|
109
|
+
<label class="check-row">
|
|
110
|
+
<%= f.check_box :shell, checked: @column.shell_access? %>
|
|
111
|
+
Shell access — the agent may run commands (tests, builds, git)
|
|
112
|
+
<%= info_tip("Unchecked: the agent gets file tools only (read, search, edit, write) and physically cannot execute anything — Cardinal commits and pushes its edits for it. Safer, but it can't run your tests or generators, and the agent process runs on this machine either way.") %>
|
|
113
|
+
</label>
|
|
114
|
+
<% end %>
|
|
115
|
+
|
|
108
116
|
</div>
|
|
109
117
|
|
|
110
118
|
<label>On-entry rules <%= info_tip("What happens when a card lands in this column, in plain English — e.g. \"start the worker agent\" or \"have an AI suggest tags and a better title\". Cardinal compiles it to rule actions when you save. Blank = the archetype's default behavior shown below.") %></label>
|
|
@@ -125,6 +133,11 @@
|
|
|
125
133
|
</details>
|
|
126
134
|
</div>
|
|
127
135
|
<% end %>
|
|
136
|
+
|
|
137
|
+
<label>Footer <%= info_tip("A summary strip under the cards. One row per line as \"Label | compute\", where compute is sum_cost (total run cost), sum_tokens (total input+output tokens), count_cards, or blank for a static label. Example: \"Total cost: | sum_cost\".") %></label>
|
|
138
|
+
<%= f.text_area :footer_text, rows: 3, class: "mono",
|
|
139
|
+
value: footer_config_text(@column),
|
|
140
|
+
placeholder: "Total cost: | sum_cost" %>
|
|
128
141
|
<% end %>
|
|
129
142
|
|
|
130
143
|
<details class="advanced-rules panel-advanced">
|
data/config/application.rb
CHANGED
|
@@ -22,7 +22,7 @@ require "securerandom"
|
|
|
22
22
|
|
|
23
23
|
if ENV["CARDINAL_GEM"] == "1"
|
|
24
24
|
# Installed-gem mode: no Bundler — load what Bundler.require would have.
|
|
25
|
-
%w[propshaft importmap-rails turbo-rails stimulus-rails redcarpet sqlite3].each { |g| require g }
|
|
25
|
+
%w[propshaft importmap-rails turbo-rails stimulus-rails redcarpet sqlite3 solid_queue solid_cable].each { |g| require g }
|
|
26
26
|
else
|
|
27
27
|
# Require the gems listed in Gemfile, including any gems
|
|
28
28
|
# you've limited to :test, :development, or :production.
|
data/config/cable.yml
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
# Jobs run in SolidQueue worker processes (forked by the puma plugin), so
|
|
2
|
+
# Turbo broadcasts cross process boundaries — the async adapter can't carry
|
|
3
|
+
# them. SolidCable (SQLite-backed pubsub) works in dev and prod alike.
|
|
1
4
|
development:
|
|
2
|
-
adapter:
|
|
5
|
+
adapter: solid_cable
|
|
6
|
+
connects_to:
|
|
7
|
+
database:
|
|
8
|
+
writing: cable
|
|
9
|
+
polling_interval: 0.1.seconds
|
|
10
|
+
message_retention: 1.day
|
|
3
11
|
|
|
4
12
|
test:
|
|
5
13
|
adapter: test
|
data/config/database.yml
CHANGED
|
@@ -6,8 +6,17 @@ default: &default
|
|
|
6
6
|
timeout: 5000
|
|
7
7
|
|
|
8
8
|
development:
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
primary:
|
|
10
|
+
<<: *default
|
|
11
|
+
database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/cardinal.db
|
|
12
|
+
queue:
|
|
13
|
+
<<: *default
|
|
14
|
+
database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/queue.sqlite3
|
|
15
|
+
migrations_paths: db/queue_migrate
|
|
16
|
+
cable:
|
|
17
|
+
<<: *default
|
|
18
|
+
database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/cable.sqlite3
|
|
19
|
+
migrations_paths: db/cable_migrate
|
|
11
20
|
|
|
12
21
|
test:
|
|
13
22
|
<<: *default
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
require "active_support/core_ext/integer/time"
|
|
2
2
|
|
|
3
3
|
Rails.application.configure do
|
|
4
|
+
# Durable jobs even in dev (dogfooding runs real agent work): SolidQueue in
|
|
5
|
+
# its own SQLite db; the puma plugin supervises workers — no extra service.
|
|
6
|
+
config.active_job.queue_adapter = :solid_queue
|
|
7
|
+
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
8
|
+
|
|
4
9
|
# Settings specified here will take precedence over those in config/application.rb.
|
|
5
10
|
|
|
6
11
|
# Make code changes take effect immediately without server restart.
|
|
@@ -50,7 +50,8 @@ Rails.application.configure do
|
|
|
50
50
|
# config.cache_store = :mem_cache_store
|
|
51
51
|
|
|
52
52
|
# Replace the default in-process and non-durable queuing backend for Active Job.
|
|
53
|
-
|
|
53
|
+
config.active_job.queue_adapter = :solid_queue
|
|
54
|
+
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
54
55
|
|
|
55
56
|
# Ignore bad email addresses and do not raise email delivery errors.
|
|
56
57
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
data/config/puma.rb
CHANGED
|
@@ -35,7 +35,7 @@ port ENV.fetch("PORT", 3000)
|
|
|
35
35
|
plugin :tmp_restart
|
|
36
36
|
|
|
37
37
|
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
|
38
|
-
plugin :solid_queue if ENV
|
|
38
|
+
plugin :solid_queue if ENV.fetch("SOLID_QUEUE_IN_PUMA", "1") == "1" && !ENV["RAILS_ENV"].to_s.start_with?("test")
|
|
39
39
|
|
|
40
40
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
|
41
41
|
# In other environments, only set the PID file if requested.
|
data/config/queue.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
default: &default
|
|
2
|
+
dispatchers:
|
|
3
|
+
- polling_interval: 1
|
|
4
|
+
batch_size: 500
|
|
5
|
+
workers:
|
|
6
|
+
- queues: "*"
|
|
7
|
+
threads: 3
|
|
8
|
+
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
|
9
|
+
polling_interval: 0.1
|
|
10
|
+
|
|
11
|
+
development:
|
|
12
|
+
<<: *default
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
<<: *default
|
|
16
|
+
|
|
17
|
+
production:
|
|
18
|
+
<<: *default
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# examples:
|
|
2
|
+
# periodic_cleanup:
|
|
3
|
+
# class: CleanSoftDeletedRecordsJob
|
|
4
|
+
# queue: background
|
|
5
|
+
# args: [ 1000, { batch_size: 500 } ]
|
|
6
|
+
# schedule: every hour
|
|
7
|
+
# periodic_cleanup_with_command:
|
|
8
|
+
# command: "SoftDeletedRecord.due.delete_all"
|
|
9
|
+
# priority: 2
|
|
10
|
+
# schedule: at 5am every day
|
|
11
|
+
|
|
12
|
+
production:
|
|
13
|
+
clear_solid_queue_finished_jobs:
|
|
14
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
15
|
+
schedule: every hour at minute 12
|
data/config/routes.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
Rails.application.routes.draw do
|
|
2
2
|
root "boards#show"
|
|
3
3
|
|
|
4
|
-
resource :board, only: :show
|
|
4
|
+
resource :board, only: [:show, :edit, :update] do
|
|
5
|
+
post :deep_dive
|
|
6
|
+
post :pull
|
|
7
|
+
get :brief
|
|
8
|
+
end
|
|
5
9
|
resources :cards, only: [:new, :create, :show, :update, :destroy] do
|
|
6
10
|
member do
|
|
7
11
|
patch :move
|
data/db/cable_schema.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ActiveRecord::Schema[7.1].define(version: 1) do
|
|
2
|
+
create_table "solid_cable_messages", force: :cascade do |t|
|
|
3
|
+
t.binary "channel", limit: 1024, null: false
|
|
4
|
+
t.binary "payload", limit: 536870912, null: false
|
|
5
|
+
t.datetime "created_at", null: false
|
|
6
|
+
t.integer "channel_hash", limit: 8, null: false
|
|
7
|
+
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
|
|
8
|
+
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
|
|
9
|
+
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddRepoBriefToBoards < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :boards, :brief_sha, :string
|
|
4
|
+
add_column :boards, :brief_generated_at, :datetime
|
|
5
|
+
add_column :boards, :brief_model, :string
|
|
6
|
+
add_column :boards, :brief_status, :string
|
|
7
|
+
end
|
|
8
|
+
end
|
data/db/queue_schema.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
ActiveRecord::Schema[7.1].define(version: 1) do
|
|
2
|
+
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
|
3
|
+
t.bigint "job_id", null: false
|
|
4
|
+
t.string "queue_name", null: false
|
|
5
|
+
t.integer "priority", default: 0, null: false
|
|
6
|
+
t.string "concurrency_key", null: false
|
|
7
|
+
t.datetime "expires_at", null: false
|
|
8
|
+
t.datetime "created_at", null: false
|
|
9
|
+
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
|
|
10
|
+
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
|
|
11
|
+
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
|
15
|
+
t.bigint "job_id", null: false
|
|
16
|
+
t.bigint "process_id"
|
|
17
|
+
t.datetime "created_at", null: false
|
|
18
|
+
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
|
19
|
+
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
|
23
|
+
t.bigint "job_id", null: false
|
|
24
|
+
t.text "error"
|
|
25
|
+
t.datetime "created_at", null: false
|
|
26
|
+
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
create_table "solid_queue_jobs", force: :cascade do |t|
|
|
30
|
+
t.string "queue_name", null: false
|
|
31
|
+
t.string "class_name", null: false
|
|
32
|
+
t.text "arguments"
|
|
33
|
+
t.integer "priority", default: 0, null: false
|
|
34
|
+
t.string "active_job_id"
|
|
35
|
+
t.datetime "scheduled_at"
|
|
36
|
+
t.datetime "finished_at"
|
|
37
|
+
t.string "concurrency_key"
|
|
38
|
+
t.datetime "created_at", null: false
|
|
39
|
+
t.datetime "updated_at", null: false
|
|
40
|
+
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
|
|
41
|
+
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
|
|
42
|
+
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
|
|
43
|
+
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
|
|
44
|
+
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
create_table "solid_queue_pauses", force: :cascade do |t|
|
|
48
|
+
t.string "queue_name", null: false
|
|
49
|
+
t.datetime "created_at", null: false
|
|
50
|
+
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
create_table "solid_queue_processes", force: :cascade do |t|
|
|
54
|
+
t.string "kind", null: false
|
|
55
|
+
t.datetime "last_heartbeat_at", null: false
|
|
56
|
+
t.bigint "supervisor_id"
|
|
57
|
+
t.integer "pid", null: false
|
|
58
|
+
t.string "hostname"
|
|
59
|
+
t.text "metadata"
|
|
60
|
+
t.datetime "created_at", null: false
|
|
61
|
+
t.string "name", null: false
|
|
62
|
+
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
|
63
|
+
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
|
64
|
+
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
|
68
|
+
t.bigint "job_id", null: false
|
|
69
|
+
t.string "queue_name", null: false
|
|
70
|
+
t.integer "priority", default: 0, null: false
|
|
71
|
+
t.datetime "created_at", null: false
|
|
72
|
+
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
|
73
|
+
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
|
|
74
|
+
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
|
78
|
+
t.bigint "job_id", null: false
|
|
79
|
+
t.string "task_key", null: false
|
|
80
|
+
t.datetime "run_at", null: false
|
|
81
|
+
t.datetime "created_at", null: false
|
|
82
|
+
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
|
83
|
+
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
|
87
|
+
t.string "key", null: false
|
|
88
|
+
t.string "schedule", null: false
|
|
89
|
+
t.string "command", limit: 2048
|
|
90
|
+
t.string "class_name"
|
|
91
|
+
t.text "arguments"
|
|
92
|
+
t.string "queue_name"
|
|
93
|
+
t.integer "priority", default: 0
|
|
94
|
+
t.boolean "static", default: true, null: false
|
|
95
|
+
t.text "description"
|
|
96
|
+
t.datetime "created_at", null: false
|
|
97
|
+
t.datetime "updated_at", null: false
|
|
98
|
+
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
|
99
|
+
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
|
103
|
+
t.bigint "job_id", null: false
|
|
104
|
+
t.string "queue_name", null: false
|
|
105
|
+
t.integer "priority", default: 0, null: false
|
|
106
|
+
t.datetime "scheduled_at", null: false
|
|
107
|
+
t.datetime "created_at", null: false
|
|
108
|
+
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
|
109
|
+
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
create_table "solid_queue_semaphores", force: :cascade do |t|
|
|
113
|
+
t.string "key", null: false
|
|
114
|
+
t.integer "value", default: 1, null: false
|
|
115
|
+
t.datetime "expires_at", null: false
|
|
116
|
+
t.datetime "created_at", null: false
|
|
117
|
+
t.datetime "updated_at", null: false
|
|
118
|
+
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
|
|
119
|
+
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
|
|
120
|
+
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
124
|
+
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
125
|
+
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
126
|
+
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
127
|
+
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
128
|
+
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
129
|
+
end
|
data/exe/cardinal
CHANGED
|
@@ -106,6 +106,9 @@ end
|
|
|
106
106
|
# scaffolding inside the engine/gem directory — read-only for sudo installs).
|
|
107
107
|
ENV["PIDFILE"] = File.join(TARGET, ".cardinal", "tmp", "server.pid")
|
|
108
108
|
ENV["CARDINAL_SWEEPER"] = "1" # the sweeper initializer keys off Rails::Server otherwise
|
|
109
|
+
# Localhost only: the board drives your logged-in gh/claude sessions, so it
|
|
110
|
+
# must not be reachable by others on the network. CARDINAL_HOST=0.0.0.0
|
|
111
|
+
# deliberately exposes it (e.g. to browse from a phone on your LAN).
|
|
109
112
|
exec(RbConfig.ruby, "-S", "puma",
|
|
110
|
-
"-b", "tcp
|
|
113
|
+
"-b", "tcp://#{ENV.fetch("CARDINAL_HOST", "127.0.0.1")}:#{ENV["PORT"]}",
|
|
111
114
|
File.join(HOME_DIR, "config.ru"))
|
data/lib/cardinal/version.rb
CHANGED