cardinal-ai 0.2.5 → 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 +8 -0
- data/app/controllers/boards_controller.rb +53 -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/models/board.rb +19 -3
- data/app/models/card.rb +1 -1
- data/app/models/column.rb +15 -0
- 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/columns/edit.html.erb +8 -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 +2 -1
- data/db/cable_schema.rb +11 -0
- data/db/queue_schema.rb +129 -0
- data/exe/cardinal +4 -1
- data/lib/cardinal/version.rb +1 -1
- metadata +35 -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
|
|
|
@@ -79,6 +79,14 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
79
79
|
50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
|
|
80
80
|
}
|
|
81
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
|
+
|
|
82
90
|
.pull-form { display: inline; }
|
|
83
91
|
#repo-pull-status { font-size: 0.85rem; }
|
|
84
92
|
#repo-pull-status .pull-ok { color: var(--green); }
|
|
@@ -5,11 +5,14 @@ class BoardsController < ApplicationController
|
|
|
5
5
|
|
|
6
6
|
# Kick off the repo deep dive (card #12). Non-blocking: flip the board into
|
|
7
7
|
# its "Working" state, morph the topbar so the button reflects it, and let
|
|
8
|
-
# DeepDiveJob do the read-only exploration in the background.
|
|
9
|
-
# dive is already running
|
|
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).
|
|
10
12
|
def deep_dive
|
|
11
13
|
board = Board.first!
|
|
12
|
-
|
|
14
|
+
fresh = board.brief? && board.commits_behind_brief == 0
|
|
15
|
+
unless board.brief_working? || (fresh && params[:force].blank?)
|
|
13
16
|
board.update!(brief_status: "working")
|
|
14
17
|
board.broadcast_refresh_to board
|
|
15
18
|
DeepDiveJob.perform_later(board)
|
|
@@ -17,6 +20,39 @@ class BoardsController < ApplicationController
|
|
|
17
20
|
redirect_to root_path
|
|
18
21
|
end
|
|
19
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
|
+
|
|
20
56
|
# Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
|
|
21
57
|
# against falls behind. The topbar Pull button fast-forwards it. --ff-only
|
|
22
58
|
# on purpose: never invent merge commits or rebase local work — if the tree
|
|
@@ -49,7 +85,20 @@ class BoardsController < ApplicationController
|
|
|
49
85
|
["Already up to date", true]
|
|
50
86
|
else
|
|
51
87
|
count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
|
|
52
|
-
|
|
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]
|
|
53
91
|
end
|
|
54
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
|
|
55
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
|
-
:footer_text, 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
|
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.
|
|
@@ -94,7 +106,11 @@ class Board < ApplicationRecord
|
|
|
94
106
|
# underneath later without a migration.
|
|
95
107
|
BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
|
|
96
108
|
|
|
97
|
-
|
|
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
|
|
98
114
|
|
|
99
115
|
def repo_brief
|
|
100
116
|
File.read(brief_path) if File.exist?(brief_path)
|
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
|
data/app/models/column.rb
CHANGED
|
@@ -56,6 +56,15 @@ class Column < ApplicationRecord
|
|
|
56
56
|
policy["ai"] != false
|
|
57
57
|
end
|
|
58
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
|
+
|
|
59
68
|
# Which columns may move cards INTO this one (§ accept policy, card #15).
|
|
60
69
|
# Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
|
|
61
70
|
# means this column accepts from nowhere — there is no permissive default.
|
|
@@ -113,6 +122,12 @@ class Column < ApplicationRecord
|
|
|
113
122
|
execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
|
|
114
123
|
end
|
|
115
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
|
+
|
|
116
131
|
# Aggregate a single footer row over the runs/cards in this column (card #18).
|
|
117
132
|
# Unknown keys return "" so the row shows just its static label.
|
|
118
133
|
def footer_value(compute)
|
|
@@ -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" } },
|
|
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? %>
|
|
@@ -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>
|
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,9 +1,10 @@
|
|
|
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
|
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
|
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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cardinal-ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Ellis
|
|
@@ -121,6 +121,34 @@ dependencies:
|
|
|
121
121
|
- - "~>"
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: '3.6'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: solid_queue
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '1.0'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '1.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: solid_cable
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '3.0'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '3.0'
|
|
124
152
|
description: 'Cardinal AI is a local, per-repo AI Kanban tool: columns are policies,
|
|
125
153
|
cards become Claude-powered worker agents, and work ships as pull requests. Run
|
|
126
154
|
`cardinal` inside any git repository to get a board for it at http://localhost:4000.'
|
|
@@ -178,6 +206,8 @@ files:
|
|
|
178
206
|
- app/services/rules.rb
|
|
179
207
|
- app/services/rules/compiler.rb
|
|
180
208
|
- app/services/run_sweeper.rb
|
|
209
|
+
- app/views/boards/brief.html.erb
|
|
210
|
+
- app/views/boards/edit.html.erb
|
|
181
211
|
- app/views/boards/show.html.erb
|
|
182
212
|
- app/views/cards/_card.html.erb
|
|
183
213
|
- app/views/cards/_detail.html.erb
|
|
@@ -217,13 +247,17 @@ files:
|
|
|
217
247
|
- config/initializers/run_sweeper.rb
|
|
218
248
|
- config/locales/en.yml
|
|
219
249
|
- config/puma.rb
|
|
250
|
+
- config/queue.yml
|
|
251
|
+
- config/recurring.yml
|
|
220
252
|
- config/routes.rb
|
|
221
253
|
- config/storage.yml
|
|
254
|
+
- db/cable_schema.rb
|
|
222
255
|
- db/migrate/20260703000001_create_cardinal_schema.rb
|
|
223
256
|
- db/migrate/20260703000002_add_agent_runner_fields.rb
|
|
224
257
|
- db/migrate/20260704000001_add_parent_to_cards.rb
|
|
225
258
|
- db/migrate/20260704000002_add_assistant_session_to_cards.rb
|
|
226
259
|
- db/migrate/20260704120000_add_repo_brief_to_boards.rb
|
|
260
|
+
- db/queue_schema.rb
|
|
227
261
|
- db/seeds.rb
|
|
228
262
|
- docker/agent/Dockerfile
|
|
229
263
|
- exe/cardinal
|