cardinal-ai 0.2.5 → 0.2.7
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 +30 -1
- data/app/controllers/boards_controller.rb +53 -4
- data/app/controllers/cards_controller.rb +18 -4
- data/app/controllers/columns_controller.rb +2 -1
- 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/jobs/summary_job.rb +88 -0
- data/app/models/board.rb +21 -3
- data/app/models/card.rb +9 -1
- data/app/models/column.rb +31 -7
- data/app/services/agent/runner.rb +29 -2
- 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 +26 -14
- data/app/views/cards/_card.html.erb +13 -8
- data/app/views/cards/_detail.html.erb +14 -1
- data/app/views/cards/_summary_panel.html.erb +28 -0
- data/app/views/columns/edit.html.erb +9 -1
- 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 +3 -1
- data/db/cable_schema.rb +11 -0
- data/db/migrate/20260704130000_add_summary_to_cards.rb +7 -0
- data/db/queue_schema.rb +129 -0
- data/exe/cardinal +4 -1
- data/lib/cardinal/version.rb +1 -1
- metadata +38 -2
- data/config/credentials.yml.enc +0 -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.
|
|
@@ -334,10 +357,14 @@ module Agent
|
|
|
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}".
|
|
@@ -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", action: "turbo:submit-end->modal#closeOnSuccess" } },
|
|
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 %>
|
|
@@ -2,30 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
<header class="topbar">
|
|
4
4
|
<div class="topbar-left">
|
|
5
|
-
<h1>Cardinal AI <span class="sep">▸</span>
|
|
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" %>
|
|
6
8
|
<%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
|
|
7
9
|
form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
|
|
8
10
|
data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
|
|
9
|
-
<span id="repo-pull-status"></span>
|
|
10
|
-
</div>
|
|
11
|
-
<div class="topbar-right">
|
|
12
11
|
<%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
|
|
13
12
|
as a brief and injected into worker prompts. The button greys→reds as the
|
|
14
|
-
brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+.
|
|
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. %>
|
|
15
16
|
<% behind = @board.commits_behind_brief %>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
disabled: @board.brief_working?,
|
|
19
|
-
style: @board.brief? ? "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}" : nil,
|
|
20
|
-
title: @board.brief? ? "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date}" : "Map this repo into a brief agents reuse — saves exploration on every run" do %>
|
|
21
|
-
<% if @board.brief_working? %>
|
|
17
|
+
<% if @board.brief_working? %>
|
|
18
|
+
<button type="button" class="deep-dive working" disabled>
|
|
22
19
|
<span class="pulse-dot"></span> Deep dive · working…
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 %>
|
|
26
35
|
🔍 Deep dive
|
|
27
36
|
<% end %>
|
|
28
37
|
<% end %>
|
|
38
|
+
<span id="repo-pull-status"></span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="topbar-right">
|
|
29
41
|
<button type="button" class="theme-toggle"
|
|
30
42
|
data-controller="theme" data-action="theme#toggle">☀ Light</button>
|
|
31
43
|
<% if ENV["CARDINAL_AUTH"].present? %>
|
|
@@ -31,18 +31,23 @@
|
|
|
31
31
|
<% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
|
|
32
32
|
<span class="chip agent-chip">🪶 replied</span>
|
|
33
33
|
<% end %>
|
|
34
|
-
<% if (card.running? || card.needs_attention?) && (last_run = card.runs.order(:id).last) && (last_run.cost.to_f.positive? || last_run.output_tokens.positive?) %>
|
|
35
|
-
<span class="chip">$<%= last_run.cost.round(2) %><%= " · #{(last_run.output_tokens / 1000.0).round(1)}k out" if card.working? %></span>
|
|
36
|
-
<% end %>
|
|
37
34
|
<% if card.parent_id %><span class="chip">↑ sub</span><% end %>
|
|
38
35
|
<% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
|
|
39
36
|
<% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
40
37
|
</div>
|
|
41
38
|
<% end %>
|
|
42
|
-
<%
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<span class="footer-
|
|
46
|
-
|
|
39
|
+
<% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
|
|
40
|
+
<% if has_cost || card.pr_url.present? %>
|
|
41
|
+
<div class="card-footer">
|
|
42
|
+
<span class="footer-left"><%= card.column.model_label if has_cost %></span>
|
|
43
|
+
<span class="footer-right">
|
|
44
|
+
<% if has_cost %>
|
|
45
|
+
<span class="footer-cost">$<%= card.total_cost.round(2) %> · <%= card.total_output_tokens %> out</span>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% if card.pr_url.present? %>
|
|
48
|
+
<a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
|
|
49
|
+
<% end %>
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
47
52
|
<% end %>
|
|
48
53
|
</article>
|
|
@@ -31,12 +31,17 @@
|
|
|
31
31
|
<div class="detail-panes">
|
|
32
32
|
<section class="timeline" data-controller="scroll">
|
|
33
33
|
<nav class="zoom-tabs">
|
|
34
|
-
<% %w[conversation activity debug].each do |zoom| %>
|
|
34
|
+
<% %w[conversation activity debug summary].each do |zoom| %>
|
|
35
35
|
<%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
|
|
36
36
|
class: ("active" if @zoom == zoom) %>
|
|
37
37
|
<% end %>
|
|
38
38
|
</nav>
|
|
39
39
|
|
|
40
|
+
<% if @zoom == "summary" %>
|
|
41
|
+
<div class="timeline-scroll">
|
|
42
|
+
<%= render "cards/summary_panel", card: @card %>
|
|
43
|
+
</div>
|
|
44
|
+
<% else %>
|
|
40
45
|
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
41
46
|
<% if @card.description.present? %>
|
|
42
47
|
<div class="event event-description"><%= render_markdown @card.description %></div>
|
|
@@ -65,6 +70,7 @@
|
|
|
65
70
|
data: { controller: "composer", action: "keydown->composer#keydown" },
|
|
66
71
|
placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
|
|
67
72
|
<% end %>
|
|
73
|
+
<% end %>
|
|
68
74
|
</section>
|
|
69
75
|
|
|
70
76
|
<aside class="work-panel">
|
|
@@ -175,6 +181,13 @@
|
|
|
175
181
|
<p class="empty">No runs yet — drag the card into an execution column to assign an agent.</p>
|
|
176
182
|
<% end %>
|
|
177
183
|
|
|
184
|
+
<% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
|
|
185
|
+
<div class="work-footer">
|
|
186
|
+
<span class="footer-left"><%= @card.column.model_label %></span>
|
|
187
|
+
<span class="footer-cost">$<%= latest.cost.round(2) %> · <%= latest.output_tokens %> out</span>
|
|
188
|
+
</div>
|
|
189
|
+
<% end %>
|
|
190
|
+
|
|
178
191
|
<details class="advanced-rules panel-advanced">
|
|
179
192
|
<summary>Advanced</summary>
|
|
180
193
|
<p class="hint">Deleting removes the card and its entire history (events, runs, workspace). The remote branch and PR, if any, are left untouched.</p>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div id="card_summary" class="summary-panel" data-controller="autosave">
|
|
2
|
+
<div class="summary-head">
|
|
3
|
+
<h3>Customer summary <span class="autosave-status" data-autosave-target="status"></span></h3>
|
|
4
|
+
<% if card.summary_working? %>
|
|
5
|
+
<button type="button" class="deep-dive working summary-generate" disabled>
|
|
6
|
+
<span class="pulse-dot"></span> Generating…
|
|
7
|
+
</button>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= button_to summarize_card_path(card), class: "deep-dive summary-generate",
|
|
10
|
+
title: "Compress everything this card did into a couple of non-technical lines" do %>
|
|
11
|
+
✨ <%= card.summary.present? ? "Regenerate" : "Generate summary" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<p class="hint summary-blurb">A plain-language recap of what this card delivered — ready to drop into a customer chat. Fully editable; your edits are respected when you regenerate.</p>
|
|
17
|
+
|
|
18
|
+
<%= form_with model: card, class: "summary-form",
|
|
19
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
20
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
21
|
+
<%= f.text_area :summary, rows: 10, placeholder: "No summary yet — click Generate summary, or write your own.",
|
|
22
|
+
disabled: card.summary_working? %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% if card.summary_generated_at.present? %>
|
|
26
|
+
<p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.summary_generated_at) %> ago</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
@@ -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>
|
|
@@ -126,7 +134,7 @@
|
|
|
126
134
|
</div>
|
|
127
135
|
<% end %>
|
|
128
136
|
|
|
129
|
-
<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: \"
|
|
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, model (this column's active AI model), or blank for a static label. Example: \"Model: | model\".") %></label>
|
|
130
138
|
<%= f.text_area :footer_text, rows: 3, class: "mono",
|
|
131
139
|
value: footer_config_text(@column),
|
|
132
140
|
placeholder: "Total cost: | sum_cost" %>
|
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,14 +1,16 @@
|
|
|
1
1
|
Rails.application.routes.draw do
|
|
2
2
|
root "boards#show"
|
|
3
3
|
|
|
4
|
-
resource :board, only: :show do
|
|
4
|
+
resource :board, only: [:show, :edit, :update] do
|
|
5
5
|
post :deep_dive
|
|
6
6
|
post :pull
|
|
7
|
+
get :brief
|
|
7
8
|
end
|
|
8
9
|
resources :cards, only: [:new, :create, :show, :update, :destroy] do
|
|
9
10
|
member do
|
|
10
11
|
patch :move
|
|
11
12
|
post :approve
|
|
13
|
+
post :summarize
|
|
12
14
|
end
|
|
13
15
|
resources :messages, only: [:create]
|
|
14
16
|
end
|
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
|
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
|