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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '079e999400eeaa39a8e7f7d8efdc4b3b114923cf0aaf190dcd15a6117a97d7b2'
|
|
4
|
+
data.tar.gz: 61343cc2e5c42fbf43f3eeb223da83af60d1e634f8424e69d032c9afd287aa02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1acb0d147c37615d1daf946a8e3aebbbdec636511cf6781a835ed4b73b61b1f3b57aff903273b160b75df66b263857cc83e7ef38f8b71841a7f8f3ebea8877e6
|
|
7
|
+
data.tar.gz: 5ab7bddf1b68f55466825914a1d0eb7a697e02a5c7e0e3a4d0dcfe306ee673c65a17f9f5e2bf09425c002ccd02809d32ce30e2f0a2ececdd2c93b8d0139932c5
|
data/README.md
CHANGED
|
@@ -43,12 +43,26 @@ That's the whole setup. Now:
|
|
|
43
43
|
4. **Review** — read the final report and the pull request. Say what's wrong in the
|
|
44
44
|
card's conversation to send it back, or approve.
|
|
45
45
|
5. **QA** — the pull request goes live for formal review on GitHub.
|
|
46
|
-
6. **Drag to Done** — the pull request merges. Shipped.
|
|
46
|
+
6. **Drag to Done** — the pull request merges. Shipped. (If the project has CI and it's
|
|
47
|
+
red or still running, Cardinal refuses to merge and tells you why on the card.)
|
|
47
48
|
|
|
48
49
|
Every column has a ⚙ gear where you can change the rules — which AI model works there,
|
|
49
50
|
how many cards can run at once, spending limits, and what happens when a card arrives
|
|
50
51
|
(written in plain English; Cardinal figures out the rest).
|
|
51
52
|
|
|
53
|
+
### The deep dive
|
|
54
|
+
|
|
55
|
+
The **🔍 Deep dive** button in the topbar sends a read-only agent (it can look, never
|
|
56
|
+
touch) through your repo once and saves what it learns as a **repo brief** — what the
|
|
57
|
+
project is, where things live, how to build and test it, the traps to avoid. Every worker
|
|
58
|
+
agent gets the brief with its assignment, so agents skip re-exploring your codebase on
|
|
59
|
+
every single card. It costs one AI call.
|
|
60
|
+
|
|
61
|
+
Once a brief exists the button shows **🔍 Repo brief** — click it to read exactly what
|
|
62
|
+
agents are being told, and to regenerate it. The button drifts from grey toward red as
|
|
63
|
+
commits land that the brief hasn't seen; Cardinal won't silently re-run a dive that's
|
|
64
|
+
already current.
|
|
65
|
+
|
|
52
66
|
## Good to know
|
|
53
67
|
|
|
54
68
|
- Everything Cardinal knows about a project lives in a `.cardinal/` folder inside it,
|
|
@@ -57,6 +71,11 @@ how many cards can run at once, spending limits, and what happens when a card ar
|
|
|
57
71
|
`cardinal logout` to unlink).
|
|
58
72
|
- Agents can only push to their own card branches — merging is always your drag.
|
|
59
73
|
- AI usage bills the Claude account you linked, the same as using Claude Code.
|
|
74
|
+
- The board is only reachable from **your own machine** (localhost). To browse it from
|
|
75
|
+
another device on your network — a phone or tablet — start with `CARDINAL_HOST=0.0.0.0
|
|
76
|
+
cardinal`, and know that anyone on that network can then drive your board.
|
|
77
|
+
- In a worker column's ⚙ gear you can turn off **Shell access**: the agent can then only
|
|
78
|
+
read and edit files — it can't run commands — and Cardinal commits its work for it.
|
|
60
79
|
|
|
61
80
|
## For developers
|
|
62
81
|
|
|
@@ -52,6 +52,7 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
52
52
|
}
|
|
53
53
|
.topbar h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
54
54
|
.topbar h1 .sep { color: var(--accent); margin: 0 4px; }
|
|
55
|
+
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
|
55
56
|
.topbar-right { display: flex; align-items: center; gap: 14px; }
|
|
56
57
|
|
|
57
58
|
.theme-toggle {
|
|
@@ -60,6 +61,37 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
60
61
|
}
|
|
61
62
|
.theme-toggle:hover { color: var(--text); border-color: var(--text-dim); }
|
|
62
63
|
|
|
64
|
+
/* Repo deep dive button (card #12). Inline color/border come from the board's
|
|
65
|
+
staleness gradient (grey → red over 10 commits); .stale-critical flashes to
|
|
66
|
+
nag a refresh once the brief is too far behind HEAD to trust. */
|
|
67
|
+
.topbar-right form.button_to { display: inline-flex; margin: 0; }
|
|
68
|
+
.deep-dive {
|
|
69
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
70
|
+
background: transparent; border: 1px solid var(--border); border-radius: 6px;
|
|
71
|
+
color: var(--text-dim); font-weight: 600; padding: 5px 10px; line-height: 1;
|
|
72
|
+
cursor: pointer; white-space: nowrap;
|
|
73
|
+
}
|
|
74
|
+
.deep-dive:hover:not(:disabled) { filter: brightness(1.25); }
|
|
75
|
+
.deep-dive:disabled { cursor: default; opacity: .8; }
|
|
76
|
+
.deep-dive.stale-critical { animation: stale-flash 1.1s ease-in-out infinite; }
|
|
77
|
+
@keyframes stale-flash {
|
|
78
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(212, 51, 51, .55); }
|
|
79
|
+
50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.brief-meta { font-size: 12px; color: var(--text-dim); }
|
|
83
|
+
.brief-behind { color: var(--amber); }
|
|
84
|
+
.brief-content {
|
|
85
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
86
|
+
padding: 4px 14px; margin: 10px 0; max-height: 50vh; overflow-y: auto;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.pull-form { display: inline; }
|
|
91
|
+
#repo-pull-status { font-size: 0.85rem; }
|
|
92
|
+
#repo-pull-status .pull-ok { color: var(--green); }
|
|
93
|
+
#repo-pull-status .pull-err { color: var(--red); }
|
|
94
|
+
|
|
63
95
|
.attention summary {
|
|
64
96
|
cursor: pointer;
|
|
65
97
|
color: var(--amber);
|
|
@@ -154,6 +186,10 @@ button, input[type="submit"] {
|
|
|
154
186
|
body.dragging .drop-hint { display: block; }
|
|
155
187
|
.ticker { font-size: 11px; color: var(--text-dim); margin: 0 4px 8px; }
|
|
156
188
|
|
|
189
|
+
.column-footer { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-dim); }
|
|
190
|
+
.footer-row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 4px; }
|
|
191
|
+
.footer-value { font-variant-numeric: tabular-nums; color: var(--text); }
|
|
192
|
+
|
|
157
193
|
.cards { min-height: 40px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto; }
|
|
158
194
|
.cards-clickable { cursor: pointer; }
|
|
159
195
|
.agent-chip { color: var(--blue); }
|
|
@@ -2,4 +2,103 @@ class BoardsController < ApplicationController
|
|
|
2
2
|
def show
|
|
3
3
|
@board = Board.includes(columns: :cards).first!
|
|
4
4
|
end
|
|
5
|
+
|
|
6
|
+
# Kick off the repo deep dive (card #12). Non-blocking: flip the board into
|
|
7
|
+
# its "Working" state, morph the topbar so the button reflects it, and let
|
|
8
|
+
# DeepDiveJob do the read-only exploration in the background. Skipped when a
|
|
9
|
+
# dive is already running, or when the brief already matches HEAD — nothing
|
|
10
|
+
# changed, so a re-dive would just burn a run (the brief modal's Regenerate
|
|
11
|
+
# button passes force=1 to override).
|
|
12
|
+
def deep_dive
|
|
13
|
+
board = Board.first!
|
|
14
|
+
fresh = board.brief? && board.commits_behind_brief == 0
|
|
15
|
+
unless board.brief_working? || (fresh && params[:force].blank?)
|
|
16
|
+
board.update!(brief_status: "working")
|
|
17
|
+
board.broadcast_refresh_to board
|
|
18
|
+
DeepDiveJob.perform_later(board)
|
|
19
|
+
end
|
|
20
|
+
redirect_to root_path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Board settings gear (the board-level analog of the column gear): name and
|
|
24
|
+
# default branch — the branch agents fork from and Done merges toward.
|
|
25
|
+
def edit
|
|
26
|
+
@board = Board.first!
|
|
27
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
board = Board.first!
|
|
32
|
+
attrs = params.require(:board).permit(:name, :default_branch)
|
|
33
|
+
board.update!(
|
|
34
|
+
name: attrs[:name].presence || board.name,
|
|
35
|
+
default_branch: attrs[:default_branch].presence || board.default_branch
|
|
36
|
+
)
|
|
37
|
+
board.broadcast_refresh_to board
|
|
38
|
+
if params[:autosave]
|
|
39
|
+
render turbo_stream: [
|
|
40
|
+
turbo_stream.update("board-name", board.name),
|
|
41
|
+
turbo_stream.update("board-form-errors", "")
|
|
42
|
+
]
|
|
43
|
+
else
|
|
44
|
+
redirect_to root_path
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Inspect the repo brief: what the deep dive wrote, when, from which SHA.
|
|
49
|
+
def brief
|
|
50
|
+
@board = Board.first!
|
|
51
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
52
|
+
|
|
53
|
+
render :brief
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
|
|
57
|
+
# against falls behind. The topbar Pull button fast-forwards it. --ff-only
|
|
58
|
+
# on purpose: never invent merge commits or rebase local work — if the tree
|
|
59
|
+
# has diverged, say so and let the human sort it out in a real terminal.
|
|
60
|
+
def pull
|
|
61
|
+
board = Board.first!
|
|
62
|
+
message, ok = pull_repo(board)
|
|
63
|
+
render turbo_stream: turbo_stream.update(
|
|
64
|
+
"repo-pull-status",
|
|
65
|
+
helpers.tag.span(message, class: ok ? "pull-ok" : "pull-err")
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def pull_repo(board)
|
|
72
|
+
repo = board.local_path.presence
|
|
73
|
+
return ["No local repo path on this board", false] unless repo && Dir.exist?(File.join(repo, ".git"))
|
|
74
|
+
|
|
75
|
+
before, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
|
|
76
|
+
out, status = Open3.capture2e("git", "-C", repo, "pull", "--ff-only")
|
|
77
|
+
unless status.success?
|
|
78
|
+
# Surface git's own reason (diverged, offline, auth) — the last
|
|
79
|
+
# non-blank line is usually the one that matters.
|
|
80
|
+
return [out.lines.map(&:strip).reject(&:blank?).last.to_s.truncate(120), false]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
after, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
|
|
84
|
+
if before.strip == after.strip
|
|
85
|
+
["Already up to date", true]
|
|
86
|
+
else
|
|
87
|
+
count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
|
|
88
|
+
migrated = run_pending_migrations
|
|
89
|
+
note = migrated.positive? ? " · ran #{helpers.pluralize(migrated, "migration")}" : ""
|
|
90
|
+
["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}#{note}", true]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# When the board's repo IS this Cardinal instance (dogfooding) a pull can
|
|
95
|
+
# bring schema changes; without this the running server 500s until someone
|
|
96
|
+
# runs db:migrate by hand. A no-op everywhere else — `cardinal up` already
|
|
97
|
+
# covers cold boots via db:prepare.
|
|
98
|
+
def run_pending_migrations
|
|
99
|
+
context = ActiveRecord::Base.connection_pool.migration_context
|
|
100
|
+
pending = context.migrations.map(&:version) - context.get_all_versions
|
|
101
|
+
context.migrate if pending.any?
|
|
102
|
+
pending.size
|
|
103
|
+
end
|
|
5
104
|
end
|
|
@@ -26,7 +26,7 @@ class ColumnsController < ApplicationController
|
|
|
26
26
|
:name, :archetype, :instructions, :model, :effort,
|
|
27
27
|
:concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
|
|
28
28
|
:on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
|
|
29
|
-
accepts_from: []
|
|
29
|
+
:shell, :footer_text, accepts_from: []
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
policy = @column.policy.dup
|
|
@@ -38,6 +38,7 @@ class ColumnsController < ApplicationController
|
|
|
38
38
|
policy["plan_approval"] = attrs[:plan_approval] == "1"
|
|
39
39
|
policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
|
|
40
40
|
policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
|
|
41
|
+
policy["shell"] = (attrs[:shell] == "1") if attrs.key?(:shell) # worker shell access (execution gear only)
|
|
41
42
|
# Accept policy (card #15): store allowed source column ids as strings.
|
|
42
43
|
# EXPLICIT ONLY — an empty list means the column accepts from nowhere.
|
|
43
44
|
policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
|
|
@@ -74,6 +75,14 @@ class ColumnsController < ApplicationController
|
|
|
74
75
|
policy.delete("on_entry_text")
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# Footer (card #18): one row per line as "Label | compute". A blank compute
|
|
79
|
+
# is a static label; a compute must be one Column knows how to aggregate.
|
|
80
|
+
begin
|
|
81
|
+
policy["footer"] = parse_footer(attrs[:footer_text])
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
return column_error(e.message)
|
|
84
|
+
end
|
|
85
|
+
|
|
77
86
|
@column.update!(
|
|
78
87
|
name: attrs[:name].presence || @column.name,
|
|
79
88
|
archetype: new_archetype,
|
|
@@ -126,5 +135,21 @@ class ColumnsController < ApplicationController
|
|
|
126
135
|
|
|
127
136
|
private
|
|
128
137
|
|
|
138
|
+
# "Label | compute" lines → [{"label" =>, "compute" =>}]. Returns nil when
|
|
139
|
+
# empty so the key is dropped by policy.compact. Raises ArgumentError on an
|
|
140
|
+
# unknown compute key, mirroring the on_entry validation path.
|
|
141
|
+
def parse_footer(text)
|
|
142
|
+
rows = text.to_s.lines.filter_map do |line|
|
|
143
|
+
next if line.strip.blank?
|
|
144
|
+
label, compute = line.split("|", 2).map(&:strip)
|
|
145
|
+
compute = compute.to_s
|
|
146
|
+
if compute.present? && !Column::FOOTER_COMPUTES.include?(compute)
|
|
147
|
+
raise ArgumentError, "Footer compute \"#{compute}\" is not one of: #{Column::FOOTER_COMPUTES.join(', ')}"
|
|
148
|
+
end
|
|
149
|
+
{ "label" => label.to_s, "compute" => compute.presence }.compact
|
|
150
|
+
end
|
|
151
|
+
rows.presence
|
|
152
|
+
end
|
|
153
|
+
|
|
129
154
|
def set_column = @column = Column.find(params[:id])
|
|
130
155
|
end
|
|
@@ -26,6 +26,14 @@ module ApplicationHelper
|
|
|
26
26
|
MARKDOWN.render(text.to_s).html_safe
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Render a column's stored footer config (array of {label, compute} hashes)
|
|
30
|
+
# back into the "Label | compute" line format the gear textarea edits.
|
|
31
|
+
def footer_config_text(column)
|
|
32
|
+
Array(column.footer).map do |row|
|
|
33
|
+
[row["label"], row["compute"].presence].compact.join(" | ")
|
|
34
|
+
end.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
def info_tip(text)
|
|
30
38
|
tag.span("i", class: "info",
|
|
31
39
|
data: { controller: "tooltip", tooltip_text_value: text,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Opt-in repo deep dive (card #12): a one-shot, read-only agent (the same
|
|
2
|
+
# cheap ClaudeCli tier the planning assistant uses — Read/Glob/Grep, no
|
|
3
|
+
# workspace) that maps the board's repo into a compact "repo brief". The brief
|
|
4
|
+
# is written to .cardinal/repo-brief.md and injected into every worker prompt,
|
|
5
|
+
# converting the per-run exploration tax into a one-time cost.
|
|
6
|
+
class DeepDiveJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
# Enough turns to walk the tree and read a handful of key files, not enough
|
|
10
|
+
# to wander. ClaudeCli wraps up from context if it hits the cap.
|
|
11
|
+
MAX_TURNS = 30
|
|
12
|
+
FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
|
|
13
|
+
|
|
14
|
+
PROMPT = <<~PROMPT.freeze
|
|
15
|
+
Map this repository as a concise "repo brief" for other AI agents who will
|
|
16
|
+
work in it. The whole point is to save tokens later, so be dense and skip
|
|
17
|
+
the obvious — every line must earn its place.
|
|
18
|
+
|
|
19
|
+
Explore with your read-only tools, then output ONLY flat markdown with these
|
|
20
|
+
sections (drop any that genuinely don't apply):
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
One short paragraph: what this project is and its shape.
|
|
24
|
+
## Directory Structure
|
|
25
|
+
The top-level directories and what each is for (one line each).
|
|
26
|
+
## Key Directories
|
|
27
|
+
The few places most work actually happens, and what lives there.
|
|
28
|
+
## Build & Test
|
|
29
|
+
The exact commands to install, build, run, and test.
|
|
30
|
+
## Key Conventions
|
|
31
|
+
Naming, patterns, and idioms an agent must follow to match the codebase.
|
|
32
|
+
## Tech Stack
|
|
33
|
+
Languages, frameworks, and notable libraries with their role.
|
|
34
|
+
## Gotchas
|
|
35
|
+
Non-obvious traps, footguns, and constraints worth knowing before editing.
|
|
36
|
+
|
|
37
|
+
Do not include a preamble, closing remarks, or anything outside these sections.
|
|
38
|
+
PROMPT
|
|
39
|
+
|
|
40
|
+
def perform(board)
|
|
41
|
+
repo = board.local_path.presence
|
|
42
|
+
return clear_working(board) unless ClaudeCli.available? && repo
|
|
43
|
+
|
|
44
|
+
sha = board.head_sha
|
|
45
|
+
model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
|
|
46
|
+
|
|
47
|
+
brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
|
|
48
|
+
|
|
49
|
+
File.write(board.brief_path, brief.to_s)
|
|
50
|
+
board.update!(brief_sha: sha, brief_generated_at: Time.current,
|
|
51
|
+
brief_model: model, brief_status: nil)
|
|
52
|
+
board.broadcast_refresh_to board
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# A failed dive must not leave the button stuck on "Working" forever.
|
|
55
|
+
clear_working(board)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def clear_working(board)
|
|
61
|
+
board.update!(brief_status: nil)
|
|
62
|
+
board.broadcast_refresh_to board
|
|
63
|
+
end
|
|
64
|
+
end
|
data/app/jobs/merge_pr_job.rb
CHANGED
|
@@ -6,6 +6,7 @@ class MergePrJob < ApplicationJob
|
|
|
6
6
|
def perform(card_id)
|
|
7
7
|
card = Card.find(card_id)
|
|
8
8
|
return if card.pr_url.blank? || card.pr_state == "merged"
|
|
9
|
+
return unless checks_green?(card)
|
|
9
10
|
|
|
10
11
|
# Best-effort undraft — a QA column may already have done it, and gh
|
|
11
12
|
# errors on an already-ready PR; the merge step is the real gate.
|
|
@@ -18,6 +19,27 @@ class MergePrJob < ApplicationJob
|
|
|
18
19
|
|
|
19
20
|
private
|
|
20
21
|
|
|
22
|
+
# The merge gate: never ship over failing CI. A repo with no checks
|
|
23
|
+
# configured passes (nothing to gate on); failing or still-running checks
|
|
24
|
+
# park the card as blocked with the reason — drag it out and back into Done
|
|
25
|
+
# to retry once CI is green.
|
|
26
|
+
def checks_green?(card)
|
|
27
|
+
out, status = Open3.capture2e("gh", "pr", "checks", card.pr_url)
|
|
28
|
+
return true if status.success?
|
|
29
|
+
return true if out.match?(/no checks reported/i)
|
|
30
|
+
|
|
31
|
+
reason =
|
|
32
|
+
if status.exitstatus == 8
|
|
33
|
+
"CI checks are still running — not merged. Drag out of Done and back once they finish."
|
|
34
|
+
else
|
|
35
|
+
failing = out.lines.map(&:strip).grep(/fail/i).first(3).join("; ").presence || out.strip.truncate(160)
|
|
36
|
+
"CI checks failing — not merged. #{failing}"
|
|
37
|
+
end
|
|
38
|
+
card.log!("error", text: reason)
|
|
39
|
+
card.update!(status: "blocked")
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
21
43
|
def run_step(card, cmd)
|
|
22
44
|
out, status = Open3.capture2e(*cmd)
|
|
23
45
|
return true if status.success?
|
data/app/jobs/resume_run_job.rb
CHANGED
|
@@ -20,6 +20,13 @@ class ResumeRunJob < ApplicationJob
|
|
|
20
20
|
return
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Atomic claim (§ races): two finishing runs can both kick the queue and
|
|
24
|
+
# double-fire this job — exactly one claimer resumes the session. The
|
|
25
|
+
# claim value is "running", which is what a resume sets anyway.
|
|
26
|
+
return unless Run.where(id: run.id, status: "needs_input")
|
|
27
|
+
.update_all(status: "running", updated_at: Time.current) == 1
|
|
28
|
+
run.reload
|
|
29
|
+
|
|
23
30
|
if (pending = run.briefing["pending_resume"])
|
|
24
31
|
run.update!(briefing: run.briefing.except("pending_resume"))
|
|
25
32
|
message = [pending["message"], message].compact_blank.join("\n\n")
|
data/app/jobs/start_run_job.rb
CHANGED
|
@@ -3,11 +3,26 @@ class StartRunJob < ApplicationJob
|
|
|
3
3
|
|
|
4
4
|
def perform(card_id)
|
|
5
5
|
card = Card.find(card_id)
|
|
6
|
-
|
|
7
|
-
return
|
|
6
|
+
column = card.column
|
|
7
|
+
return unless column.execution? && column.ai?
|
|
8
|
+
return if column.at_wip_limit? # stays queued; kicked when a slot frees
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
# Atomic claim (§ races): two kicks can enqueue this job twice for the
|
|
11
|
+
# same card; exactly one claimer flips queued→working, the rest no-op.
|
|
12
|
+
return unless Card.where(id: card.id, status: "queued")
|
|
13
|
+
.update_all(status: "working", updated_at: Time.current) == 1
|
|
14
|
+
card.reload.touch # update_all skips callbacks — nudge the board broadcast
|
|
15
|
+
|
|
16
|
+
# Re-check AFTER claiming: claims are atomic, so an over-subscribed slot
|
|
17
|
+
# shows up as strictly more running than allowed and the loser un-claims
|
|
18
|
+
# back into the queue (the next kick retries it).
|
|
19
|
+
if column.at_wip_limit_exceeded?
|
|
20
|
+
card.update!(status: "queued")
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
session = card.agent_sessions.create!(status: "provisioning", model: column.model)
|
|
25
|
+
run = session.runs.create!(status: "queued", briefing: { "card" => card.title, "column" => column.name })
|
|
11
26
|
Agent::Runner.start(run)
|
|
12
27
|
end
|
|
13
28
|
end
|
data/app/models/board.rb
CHANGED
|
@@ -60,18 +60,30 @@ class Board < ApplicationRecord
|
|
|
60
60
|
# Raw configured URL (get-url applies insteadOf rewrites, which can embed
|
|
61
61
|
# credential-helper tokens); strip any userinfo defensively either way.
|
|
62
62
|
origin, origin_ok = Open3.capture2e("git", "-C", repo_path, "config", "--get", "remote.origin.url")
|
|
63
|
-
branch, branch_ok = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
64
63
|
|
|
65
64
|
board = create!(
|
|
66
65
|
name: File.basename(repo_path),
|
|
67
66
|
repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil,
|
|
68
|
-
default_branch:
|
|
67
|
+
default_branch: detect_default_branch(repo_path),
|
|
69
68
|
local_path: repo_path
|
|
70
69
|
)
|
|
71
70
|
board.install_default_columns!
|
|
72
71
|
board
|
|
73
72
|
end
|
|
74
73
|
|
|
74
|
+
# The REMOTE's default branch, not whatever happened to be checked out when
|
|
75
|
+
# `cardinal up` first ran — launching from a feature branch must not make
|
|
76
|
+
# Done merge toward that feature branch forever. Fallback chain: origin's
|
|
77
|
+
# HEAD → current branch → "main". Editable later in board settings.
|
|
78
|
+
def self.detect_default_branch(repo_path)
|
|
79
|
+
head, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD")
|
|
80
|
+
return head.strip.delete_prefix("refs/remotes/origin/") if ok.success? && head.strip.present?
|
|
81
|
+
|
|
82
|
+
# symbolic-ref, not rev-parse: works even on an unborn branch (fresh init).
|
|
83
|
+
branch, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
84
|
+
ok.success? && branch.strip.present? ? branch.strip : "main"
|
|
85
|
+
end
|
|
86
|
+
|
|
75
87
|
def self.sanitize_remote_url(url)
|
|
76
88
|
# Drop any userinfo (tokens from credential-helper rewrites). Regex, not
|
|
77
89
|
# URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter.
|
|
@@ -83,6 +95,67 @@ class Board < ApplicationRecord
|
|
|
83
95
|
cards.pluck(:tags).flatten.compact.uniq.sort
|
|
84
96
|
end
|
|
85
97
|
|
|
98
|
+
# --- Repo brief (card #12) ---------------------------------------------
|
|
99
|
+
# A one-time deep dive that maps the repo, stored as flat markdown in
|
|
100
|
+
# .cardinal/ (never the host repo) and injected into worker prompts to
|
|
101
|
+
# spare each run the exploration tax. Metadata (which SHA/model/when) lives
|
|
102
|
+
# on the board so staleness can be judged against the current HEAD.
|
|
103
|
+
#
|
|
104
|
+
# Storage is a file + metadata, not one text column, so a structure
|
|
105
|
+
# provider (the Graphify child card) can slot a richer representation in
|
|
106
|
+
# underneath later without a migration.
|
|
107
|
+
BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
|
|
108
|
+
|
|
109
|
+
# Honor CARDINAL_DATA_DIR: in gem mode Rails.root is the installed gem
|
|
110
|
+
# (read-only); the instance's data lives in the target repo's .cardinal/.
|
|
111
|
+
def brief_path
|
|
112
|
+
Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def repo_brief
|
|
116
|
+
File.read(brief_path) if File.exist?(brief_path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def brief?
|
|
120
|
+
brief_sha.present? && File.exist?(brief_path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def brief_working? = brief_status == "working"
|
|
124
|
+
|
|
125
|
+
# HEAD of the board's repo right now — the yardstick staleness measures against.
|
|
126
|
+
def head_sha
|
|
127
|
+
return nil if local_path.blank?
|
|
128
|
+
out, ok = Open3.capture2e("git", "-C", local_path, "rev-parse", "HEAD")
|
|
129
|
+
ok.success? ? out.strip : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# How many commits landed since the brief was generated. nil when there's
|
|
133
|
+
# no brief (nothing to be stale against) or the SHA is unknown to the repo.
|
|
134
|
+
def commits_behind_brief
|
|
135
|
+
return @commits_behind_brief if defined?(@commits_behind_brief)
|
|
136
|
+
@commits_behind_brief =
|
|
137
|
+
if brief_sha.blank? || local_path.blank?
|
|
138
|
+
nil
|
|
139
|
+
else
|
|
140
|
+
out, ok = Open3.capture2e("git", "-C", local_path, "rev-list", "--count", "#{brief_sha}..HEAD")
|
|
141
|
+
ok.success? ? out.strip.to_i : nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT
|
|
146
|
+
|
|
147
|
+
# Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted
|
|
148
|
+
# as a validated hex into the button's inline style (mirrors Column#safe_color).
|
|
149
|
+
# Deep red once the brief is stale enough to over-anchor on.
|
|
150
|
+
def brief_staleness_color
|
|
151
|
+
behind = commits_behind_brief || 0
|
|
152
|
+
grey = [0x8a, 0x8a, 0x8a]
|
|
153
|
+
red = [0xd4, 0x33, 0x33]
|
|
154
|
+
t = [behind.to_f / BRIEF_STALE_AT, 1.0].min
|
|
155
|
+
rgb = grey.zip(red).map { |g, r| (g + (r - g) * t).round }
|
|
156
|
+
format("#%02x%02x%02x", *rgb)
|
|
157
|
+
end
|
|
158
|
+
|
|
86
159
|
# Cards currently waiting on the human, ordered by urgency — feeds the
|
|
87
160
|
# attention inbox in the board header.
|
|
88
161
|
def attention_cards
|
data/app/models/card.rb
CHANGED
|
@@ -11,7 +11,7 @@ class Card < ApplicationRecord
|
|
|
11
11
|
"planning" => %w[draft discussing archived],
|
|
12
12
|
"execution" => %w[queued working needs_input blocked failed work_complete archived],
|
|
13
13
|
"review" => %w[in_review changes_requested approved archived],
|
|
14
|
-
"terminal" => %w[done archived]
|
|
14
|
+
"terminal" => %w[done blocked archived] # blocked: merge gate refused (CI red/pending)
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
17
|
belongs_to :board
|
|
@@ -29,6 +29,9 @@ class Card < ApplicationRecord
|
|
|
29
29
|
validate :status_legal_for_column
|
|
30
30
|
|
|
31
31
|
before_validation :assign_number_and_position, on: :create
|
|
32
|
+
# Optional git fields left blank on the new-card form arrive as "" — store
|
|
33
|
+
# them as nil so "no PR/branch" is one state, not two ("" renders footers).
|
|
34
|
+
normalizes :branch_name, :pr_url, with: ->(v) { v.strip.presence }
|
|
32
35
|
|
|
33
36
|
after_commit -> { broadcast_refresh_to board }
|
|
34
37
|
|
data/app/models/column.rb
CHANGED
|
@@ -35,7 +35,12 @@ class Column < ApplicationRecord
|
|
|
35
35
|
store_accessor :policy, :instructions, :model, :effort, :concurrency_limit,
|
|
36
36
|
:plan_approval, :budget_per_run_cents, :timeout_minutes,
|
|
37
37
|
:max_turns, :tools, :on_entry, :on_success, :color, :arrivals,
|
|
38
|
-
:accepts_from
|
|
38
|
+
:accepts_from, :footer
|
|
39
|
+
|
|
40
|
+
# Aggregations a footer row may compute over the column's cards (card #18).
|
|
41
|
+
# A compute key not listed here renders blank, so config that outruns the
|
|
42
|
+
# code degrades gracefully instead of erroring.
|
|
43
|
+
FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards].freeze
|
|
39
44
|
|
|
40
45
|
# Only ever emit a validated hex color into inline styles.
|
|
41
46
|
def safe_color
|
|
@@ -51,6 +56,15 @@ class Column < ApplicationRecord
|
|
|
51
56
|
policy["ai"] != false
|
|
52
57
|
end
|
|
53
58
|
|
|
59
|
+
# Worker shell access (execution columns). ON (default): the agent gets the
|
|
60
|
+
# full toolset — shell, git, everything — inside its workspace clone, which
|
|
61
|
+
# means it can also touch the host beyond the clone. OFF: file tools only
|
|
62
|
+
# (read/search/edit); it physically cannot execute commands, and Cardinal
|
|
63
|
+
# commits and pushes its edits for it.
|
|
64
|
+
def shell_access?
|
|
65
|
+
policy["shell"] != false
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
# Which columns may move cards INTO this one (§ accept policy, card #15).
|
|
55
69
|
# Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
|
|
56
70
|
# means this column accepts from nowhere — there is no permissive default.
|
|
@@ -58,6 +72,24 @@ class Column < ApplicationRecord
|
|
|
58
72
|
Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
|
|
59
73
|
end
|
|
60
74
|
|
|
75
|
+
# Rows rendered under the cards (card #18). Footer config is an array of
|
|
76
|
+
# {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
|
|
77
|
+
# static label text with an optional computed aggregate over this column's
|
|
78
|
+
# cards. Returns [] when unconfigured, so existing columns render no footer.
|
|
79
|
+
def footer_rows
|
|
80
|
+
rows = Array(footer).filter_map do |row|
|
|
81
|
+
label = row["label"].to_s
|
|
82
|
+
value = footer_value(row["compute"])
|
|
83
|
+
next if label.blank? && value.blank?
|
|
84
|
+
{ label:, value: }
|
|
85
|
+
end
|
|
86
|
+
# AI columns advertise their active model as a final auto-row (card #32).
|
|
87
|
+
# Guarded on model presence so an AI column without one adds nothing,
|
|
88
|
+
# rather than emitting a "Model:" row with a blank value.
|
|
89
|
+
rows << { label: "Model:", value: model_short } if ai? && model.present?
|
|
90
|
+
rows
|
|
91
|
+
end
|
|
92
|
+
|
|
61
93
|
# Start the next queued card when a run slot frees up. A queued card whose
|
|
62
94
|
# run parked and already has its answer recorded resumes instead of
|
|
63
95
|
# starting fresh.
|
|
@@ -90,6 +122,27 @@ class Column < ApplicationRecord
|
|
|
90
122
|
execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
|
|
91
123
|
end
|
|
92
124
|
|
|
125
|
+
# Post-claim variant (§ races): a starter first claims working atomically,
|
|
126
|
+
# THEN checks — over-subscription reads as strictly more running than allowed.
|
|
127
|
+
def at_wip_limit_exceeded?
|
|
128
|
+
execution? && concurrency_limit.present? && running_count > concurrency_limit.to_i
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Aggregate a single footer row over the runs/cards in this column (card #18).
|
|
132
|
+
# Unknown keys return "" so the row shows just its static label.
|
|
133
|
+
def footer_value(compute)
|
|
134
|
+
case compute.to_s
|
|
135
|
+
when "sum_cost"
|
|
136
|
+
"$%.2f" % column_runs.sum(:cost)
|
|
137
|
+
when "sum_tokens"
|
|
138
|
+
ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
|
|
139
|
+
when "count_cards"
|
|
140
|
+
cards.count.to_s
|
|
141
|
+
else
|
|
142
|
+
""
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
93
146
|
# The built-in role contract for AI servicing this archetype — shown
|
|
94
147
|
# read-only in the gear modal so the Instructions field is understood as
|
|
95
148
|
# ADDING to this, never replacing it. Enforced in code, not editable.
|
|
@@ -131,4 +184,11 @@ class Column < ApplicationRecord
|
|
|
131
184
|
when "terminal" then "Closes it — PR merged and branch deleted, if there is one"
|
|
132
185
|
end
|
|
133
186
|
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Every run belonging to a card in this column, for footer aggregation.
|
|
191
|
+
def column_runs
|
|
192
|
+
Run.joins(agent_session: :card).where(cards: { column_id: id })
|
|
193
|
+
end
|
|
134
194
|
end
|