cardinal-ai 0.0.1 → 0.2.4
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/LICENSE +21 -0
- data/README.md +50 -29
- data/Rakefile +6 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/cardinal.css +530 -0
- data/app/controllers/application_controller.rb +7 -0
- data/app/controllers/boards_controller.rb +5 -0
- data/app/controllers/cards_controller.rb +129 -0
- data/app/controllers/columns_controller.rb +130 -0
- data/app/controllers/messages_controller.rb +25 -0
- data/app/controllers/runs_controller.rb +58 -0
- data/app/helpers/application_helper.rb +35 -0
- data/app/javascript/application.js +2 -0
- data/app/javascript/controllers/application.js +7 -0
- data/app/javascript/controllers/autosave_controller.js +43 -0
- data/app/javascript/controllers/board_column_controller.js +96 -0
- data/app/javascript/controllers/clipboard_controller.js +18 -0
- data/app/javascript/controllers/composer_controller.js +10 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/modal_controller.js +45 -0
- data/app/javascript/controllers/reveal_controller.js +15 -0
- data/app/javascript/controllers/scroll_controller.js +44 -0
- data/app/javascript/controllers/tags_controller.js +49 -0
- data/app/javascript/controllers/theme_controller.js +43 -0
- data/app/javascript/controllers/tooltip_controller.js +37 -0
- data/app/jobs/ai_task_job.rb +26 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/assistant_reply_job.rb +132 -0
- data/app/jobs/mark_pr_ready_job.rb +18 -0
- data/app/jobs/merge_pr_job.rb +27 -0
- data/app/jobs/resume_run_job.rb +30 -0
- data/app/jobs/start_run_job.rb +13 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/agent_session.rb +8 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/artifact.rb +8 -0
- data/app/models/board.rb +92 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +134 -0
- data/app/models/event.rb +44 -0
- data/app/models/run.rb +28 -0
- data/app/services/agent/runner.rb +379 -0
- data/app/services/agent/workspace.rb +138 -0
- data/app/services/card_transition.rb +97 -0
- data/app/services/claude_cli.rb +89 -0
- data/app/services/rules/compiler.rb +55 -0
- data/app/services/rules.rb +92 -0
- data/app/services/run_sweeper.rb +53 -0
- data/app/views/boards/show.html.erb +79 -0
- data/app/views/cards/_card.html.erb +48 -0
- data/app/views/cards/_detail.html.erb +190 -0
- data/app/views/cards/_tag_picker.html.erb +12 -0
- data/app/views/cards/new.html.erb +35 -0
- data/app/views/cards/show.html.erb +3 -0
- data/app/views/columns/_column.html.erb +25 -0
- data/app/views/columns/edit.html.erb +146 -0
- data/app/views/events/_event.html.erb +29 -0
- data/app/views/layouts/application.html.erb +46 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/bin/rails +4 -0
- data/bin/rake +4 -0
- data/cardinal.md +695 -0
- data/config/application.rb +60 -0
- data/config/boot.rb +13 -0
- data/config/bundler-audit.yml +5 -0
- data/config/cable.yml +13 -0
- data/config/ci.rb +20 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +31 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +78 -0
- data/config/environments/production.rb +89 -0
- data/config/environments/test.rb +53 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/cardinal_bootstrap.rb +12 -0
- data/config/initializers/cardinal_instance.rb +20 -0
- data/config/initializers/content_security_policy.rb +29 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/initializers/run_sweeper.rb +17 -0
- data/config/locales/en.yml +31 -0
- data/config/puma.rb +42 -0
- data/config/routes.rb +22 -0
- data/config/storage.yml +27 -0
- data/config.ru +6 -0
- data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
- data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
- data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
- data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
- data/db/seeds.rb +13 -0
- data/docker/agent/Dockerfile +16 -0
- data/exe/cardinal +111 -0
- data/lib/cardinal/version.rb +1 -1
- data/public/400.html +135 -0
- data/public/404.html +135 -0
- data/public/406-unsupported-browser.html +135 -0
- data/public/422.html +135 -0
- data/public/500.html +135 -0
- data/public/icon.png +0 -0
- data/public/icon.svg +3 -0
- data/public/robots.txt +1 -0
- data/vendor/javascript/sortablejs.js +3378 -0
- metadata +236 -9
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class ApplicationController < ActionController::Base
|
|
2
|
+
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
|
3
|
+
allow_browser versions: :modern
|
|
4
|
+
|
|
5
|
+
# Changes to the importmap will invalidate the etag for HTML responses
|
|
6
|
+
stale_when_importmap_changes
|
|
7
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
class CardsController < ApplicationController
|
|
2
|
+
before_action :set_card, only: [:show, :update, :move, :approve, :destroy]
|
|
3
|
+
|
|
4
|
+
def new
|
|
5
|
+
@board = Board.first!
|
|
6
|
+
@parent = @board.cards.find_by(id: params[:parent_id])
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
board = Board.first!
|
|
11
|
+
column = board.columns.inbox.order(:position).first || board.columns.first
|
|
12
|
+
parent = board.cards.find_by(id: params.dig(:card, :parent_id))
|
|
13
|
+
card = board.cards.create!(column:, parent:, **card_params)
|
|
14
|
+
card.log!("status_change", actor: "user", text: parent ? "Card created as a child of ##{parent.number} #{parent.title}" : "Card created")
|
|
15
|
+
parent&.log!("status_change", actor: "user", text: "Child card added: #{card.title}")
|
|
16
|
+
redirect_to root_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Rarely needed, deliberately buried in the card modal. A working card must
|
|
20
|
+
# be cancelled first — no killing live agents by deleting their card.
|
|
21
|
+
def destroy
|
|
22
|
+
if @card.working?
|
|
23
|
+
redirect_to card_path(@card)
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
workspace_path = Agent::Workspace::Local.new(@card).path
|
|
27
|
+
@card.destroy!
|
|
28
|
+
FileUtils.rm_rf(workspace_path)
|
|
29
|
+
redirect_to root_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
@zoom = params[:zoom].presence_in(%w[conversation activity debug]) || "conversation"
|
|
34
|
+
@events = case @zoom
|
|
35
|
+
when "conversation" then @card.events.conversation
|
|
36
|
+
when "activity" then @card.events.activity
|
|
37
|
+
else @card.events
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A frame navigation (opening the modal from the board) only needs the modal
|
|
41
|
+
# frame. A direct visit — bookmark, reload, or history restore of /cards/:id —
|
|
42
|
+
# must render the whole board with the modal already open behind it.
|
|
43
|
+
unless turbo_frame_request?
|
|
44
|
+
@board = @card.board
|
|
45
|
+
render "boards/show"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update
|
|
50
|
+
attrs = card_params
|
|
51
|
+
attrs.delete(:title) if params[:autosave] && attrs[:title].blank? # mid-edit blank, not a delete
|
|
52
|
+
# branch_name and pr_url lock once set (by the user or the agent) — never
|
|
53
|
+
# let a later edit clobber a value that work may already depend on.
|
|
54
|
+
attrs.delete(:branch_name) if @card.branch_name.present?
|
|
55
|
+
attrs.delete(:pr_url) if @card.pr_url.present?
|
|
56
|
+
@card.update!(attrs)
|
|
57
|
+
log_changelog!
|
|
58
|
+
|
|
59
|
+
respond_to do |format|
|
|
60
|
+
# Explicitly patch the board face in this tab too — Turbo suppresses a
|
|
61
|
+
# tab's own refresh broadcasts, so the morph won't cover the originator.
|
|
62
|
+
# Autosave must NOT replace the modal (it would steal focus mid-typing).
|
|
63
|
+
format.turbo_stream do
|
|
64
|
+
streams = [turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card })]
|
|
65
|
+
unless params[:autosave]
|
|
66
|
+
@zoom = "conversation"
|
|
67
|
+
@events = @card.events.conversation
|
|
68
|
+
streams << turbo_stream.replace("modal", template: "cards/show", formats: [:html])
|
|
69
|
+
end
|
|
70
|
+
render turbo_stream: streams
|
|
71
|
+
end
|
|
72
|
+
format.html { redirect_to card_path(@card) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Review verdicts (§3, §14.2). Approve is reversible — the merge happens as
|
|
77
|
+
# Done's entry rule when the human drags the card there.
|
|
78
|
+
def approve
|
|
79
|
+
if @card.in_review?
|
|
80
|
+
@card.update!(status: "approved")
|
|
81
|
+
@card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
|
|
82
|
+
end
|
|
83
|
+
redirect_to card_path(@card)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def move
|
|
87
|
+
from_column = @card.column
|
|
88
|
+
to_column = @card.board.columns.find(params[:column_id])
|
|
89
|
+
result = CardTransition.new(@card, to_column: to_column, position: params[:position]&.to_i).call
|
|
90
|
+
if result.success?
|
|
91
|
+
# Fresh markup for the affected columns: Turbo suppresses this tab's own
|
|
92
|
+
# refresh broadcasts, so without this the dragged card keeps its stale
|
|
93
|
+
# face (no queued ghosting, no ticker bump) until a job-thread broadcast.
|
|
94
|
+
render turbo_stream: [from_column, to_column].uniq.map { |col|
|
|
95
|
+
turbo_stream.replace(helpers.dom_id(col), partial: "columns/column", locals: { column: col.reload })
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
render json: { error: result.error }, status: :unprocessable_entity
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def set_card
|
|
105
|
+
@card = Board.first!.cards.find_by!(number: params[:id])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def card_params
|
|
109
|
+
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url)
|
|
110
|
+
attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
|
|
111
|
+
attrs.to_h.symbolize_keys
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Changelog in the activity timeline (the mechanism already exists). A burst
|
|
115
|
+
# of autosaves coalesces into one entry instead of one per pause-in-typing.
|
|
116
|
+
def log_changelog!
|
|
117
|
+
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url]
|
|
118
|
+
return if changed.empty?
|
|
119
|
+
|
|
120
|
+
last = @card.events.order(:id).last
|
|
121
|
+
if last&.kind == "status_change" && last.payload["changelog"] && last.created_at > 10.minutes.ago
|
|
122
|
+
fields = (Array(last.payload["fields"]) | changed)
|
|
123
|
+
last.update!(payload: last.payload.merge("fields" => fields, "text" => "Details edited: #{fields.join(", ")}"))
|
|
124
|
+
else
|
|
125
|
+
@card.log!("status_change", actor: "user", changelog: true, fields: changed,
|
|
126
|
+
text: "Details edited: #{changed.join(", ")}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
class ColumnsController < ApplicationController
|
|
2
|
+
before_action :set_column, only: [:edit, :update, :destroy]
|
|
3
|
+
|
|
4
|
+
def create
|
|
5
|
+
board = Board.first!
|
|
6
|
+
attrs = params.require(:column).permit(:name, :archetype)
|
|
7
|
+
# Inbox is the board's single intake and can't be created a second time
|
|
8
|
+
# (card #17) — refuse it even from a crafted request, and default any
|
|
9
|
+
# blank/invalid archetype to a non-special stage rather than inbox.
|
|
10
|
+
archetype = (attrs[:archetype].presence_in(Column::ARCHETYPES - %w[inbox])) || "planning"
|
|
11
|
+
board.columns.create!(
|
|
12
|
+
name: attrs[:name],
|
|
13
|
+
archetype: archetype,
|
|
14
|
+
position: (board.columns.maximum(:position) || -1) + 1,
|
|
15
|
+
policy: {}
|
|
16
|
+
)
|
|
17
|
+
redirect_to root_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def edit
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The gear modal is the entire policy admin surface (§1, §14.3).
|
|
24
|
+
def update
|
|
25
|
+
attrs = params.require(:column).permit(
|
|
26
|
+
:name, :archetype, :instructions, :model, :effort,
|
|
27
|
+
:concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
|
|
28
|
+
:on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
|
|
29
|
+
accepts_from: []
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
policy = @column.policy.dup
|
|
33
|
+
%w[instructions model effort].each { |k| policy[k] = attrs[k].presence }
|
|
34
|
+
policy["color"] = attrs[:custom_color] == "1" && attrs[:color].to_s.match?(/\A#\h{6}\z/) ? attrs[:color] : nil
|
|
35
|
+
%w[concurrency_limit max_turns timeout_minutes].each do |k|
|
|
36
|
+
policy[k] = attrs[k].present? ? attrs[k].to_i : nil
|
|
37
|
+
end
|
|
38
|
+
policy["plan_approval"] = attrs[:plan_approval] == "1"
|
|
39
|
+
policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
|
|
40
|
+
policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
|
|
41
|
+
# Accept policy (card #15): store allowed source column ids as strings.
|
|
42
|
+
# EXPLICIT ONLY — an empty list means the column accepts from nowhere.
|
|
43
|
+
policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
|
|
44
|
+
|
|
45
|
+
# Archetype is a TEMPLATE: switching it re-stamps rules + instructions
|
|
46
|
+
# from the new archetype (the submitted fields belong to the old one).
|
|
47
|
+
new_archetype = @column.inbox? ? "inbox" : (attrs[:archetype].presence_in(Column::ARCHETYPES - %w[inbox]) || @column.archetype)
|
|
48
|
+
@archetype_changed = new_archetype != @column.archetype
|
|
49
|
+
if @archetype_changed
|
|
50
|
+
template = Column::ARCHETYPE_TEMPLATES.fetch(new_archetype, {})
|
|
51
|
+
%w[on_entry on_entry_text instructions].each { |k| policy[k] = template[k] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rules: plain English is the source of truth (compiled on change); the
|
|
55
|
+
# advanced JSON editor applies only when the English box is empty.
|
|
56
|
+
if !@archetype_changed && attrs[:on_entry_text].present?
|
|
57
|
+
if attrs[:on_entry_text].strip != policy["on_entry_text"].to_s.strip
|
|
58
|
+
begin
|
|
59
|
+
policy["on_entry"] = Rules::Compiler.compile(attrs[:on_entry_text])
|
|
60
|
+
rescue Rules::Compiler::Error => e
|
|
61
|
+
return column_error(e.message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
policy["on_entry_text"] = attrs[:on_entry_text].strip
|
|
65
|
+
elsif !@archetype_changed && attrs[:on_entry_json].present?
|
|
66
|
+
begin
|
|
67
|
+
policy["on_entry"] = JSON.parse(attrs[:on_entry_json])
|
|
68
|
+
policy.delete("on_entry_text")
|
|
69
|
+
rescue JSON::ParserError => e
|
|
70
|
+
return column_error("on_entry is not valid JSON: #{e.message.truncate(120)}")
|
|
71
|
+
end
|
|
72
|
+
elsif !@archetype_changed
|
|
73
|
+
policy.delete("on_entry")
|
|
74
|
+
policy.delete("on_entry_text")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@column.update!(
|
|
78
|
+
name: attrs[:name].presence || @column.name,
|
|
79
|
+
archetype: new_archetype,
|
|
80
|
+
policy: policy.compact
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if params[:autosave]
|
|
84
|
+
# Silent save: patch the board's column section + clear any prior error.
|
|
85
|
+
# No modal replace — it would steal focus mid-edit.
|
|
86
|
+
streams = [
|
|
87
|
+
turbo_stream.replace(helpers.dom_id(@column), partial: "columns/column", locals: { column: @column.reload }),
|
|
88
|
+
turbo_stream.update("column-form-errors", "")
|
|
89
|
+
]
|
|
90
|
+
# A re-stamped archetype must re-render the modal (its fields changed
|
|
91
|
+
# server-side); focus loss is fine — the user just picked from a select.
|
|
92
|
+
streams << turbo_stream.replace("modal", template: "columns/edit", formats: [:html]) if @archetype_changed
|
|
93
|
+
render turbo_stream: streams
|
|
94
|
+
else
|
|
95
|
+
redirect_to root_path
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Autosave-friendly error: surface in the modal without re-rendering the form.
|
|
100
|
+
def column_error(message)
|
|
101
|
+
if params[:autosave]
|
|
102
|
+
render turbo_stream: turbo_stream.update(
|
|
103
|
+
"column-form-errors",
|
|
104
|
+
helpers.tag.p("#{message} — this field was NOT saved.", class: "form-error")
|
|
105
|
+
), status: :unprocessable_entity
|
|
106
|
+
else
|
|
107
|
+
@json_error = message
|
|
108
|
+
render :edit, status: :unprocessable_entity
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def destroy
|
|
113
|
+
if @column.inbox?
|
|
114
|
+
# The Tasks/inbox column is the board's intake — cards enter the flow here
|
|
115
|
+
# to be triaged. It can never be deleted (card #17).
|
|
116
|
+
@json_error = "The Tasks column is the board's intake and can't be deleted."
|
|
117
|
+
render :edit, status: :unprocessable_entity
|
|
118
|
+
elsif @column.cards.none?
|
|
119
|
+
@column.destroy!
|
|
120
|
+
redirect_to root_path
|
|
121
|
+
else
|
|
122
|
+
@json_error = "Column still has cards — move them first."
|
|
123
|
+
render :edit, status: :unprocessable_entity
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def set_column = @column = Column.find(params[:id])
|
|
130
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class MessagesController < ApplicationController
|
|
2
|
+
def create
|
|
3
|
+
card = Board.first!.cards.find_by!(number: params[:card_id])
|
|
4
|
+
text = params.require(:message)[:text]
|
|
5
|
+
parked_run = card.runs.where(status: "needs_input").order(:id).last
|
|
6
|
+
|
|
7
|
+
if parked_run
|
|
8
|
+
# Answer / plan feedback: goes back into the same agent session.
|
|
9
|
+
kind = parked_run.phase == "plan" ? "user_message" : "answer"
|
|
10
|
+
card.log!(kind, actor: "user", run: parked_run, text: text)
|
|
11
|
+
ResumeRunJob.perform_later(parked_run.id, text)
|
|
12
|
+
elsif card.column.review? && %w[in_review approved].include?(card.status)
|
|
13
|
+
# Review is entirely human: feedback IS the conversation. A message on a
|
|
14
|
+
# card under review marks it changes_requested; dragging it back to a
|
|
15
|
+
# work column carries the feedback into the next run's briefing.
|
|
16
|
+
card.log!("user_message", actor: "user", text: text)
|
|
17
|
+
card.update!(status: "changes_requested")
|
|
18
|
+
card.log!("status_change", actor: "user", text: "Changes requested — drag the card back to a work column when ready")
|
|
19
|
+
else
|
|
20
|
+
card.log!("user_message", actor: "user", text: text)
|
|
21
|
+
AssistantReplyJob.perform_later(card) if card.column.planning? && card.column.ai?
|
|
22
|
+
end
|
|
23
|
+
redirect_to card_path(card)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
class RunsController < ApplicationController
|
|
2
|
+
before_action :set_run
|
|
3
|
+
|
|
4
|
+
# Kill switch (cardinal.md §9): TERM the agent subprocess; sweeper and the
|
|
5
|
+
# runner's failure path keep the records honest.
|
|
6
|
+
def cancel
|
|
7
|
+
if run.running? && (pid = run.agent_session.config["pid"])
|
|
8
|
+
Process.kill("TERM", pid) rescue Errno::ESRCH
|
|
9
|
+
end
|
|
10
|
+
if run.running? || run.needs_input?
|
|
11
|
+
run.update!(status: "cancelled", finished_at: Time.current)
|
|
12
|
+
card.update!(status: "failed")
|
|
13
|
+
card.log!("status_change", actor: "user", run: run, text: "Run cancelled by user")
|
|
14
|
+
end
|
|
15
|
+
redirect_to card_path(card)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Plan-approval gate (§4): one click sends the agent from plan to execute.
|
|
19
|
+
def approve
|
|
20
|
+
if run.needs_input? && run.phase == "plan"
|
|
21
|
+
card.log!("plan_approved", actor: "user", run: run, text: "Plan approved")
|
|
22
|
+
ResumeRunJob.perform_later(run.id, "", approve: true)
|
|
23
|
+
end
|
|
24
|
+
redirect_to card_path(card)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Restart a run that parked or failed on its turn budget / timeout. Mirrors
|
|
28
|
+
# Column#kick_queue's branch: resume the saved session for a fresh budget, or
|
|
29
|
+
# (no session left) re-queue for a clean run.
|
|
30
|
+
def restart
|
|
31
|
+
if run.restartable?
|
|
32
|
+
if run.needs_input?
|
|
33
|
+
card.log!("progress", actor: "user", run: run, text: "Restarting run — resuming with a fresh turn budget")
|
|
34
|
+
ResumeRunJob.perform_later(run.id, "")
|
|
35
|
+
elsif run.external_session_id.present?
|
|
36
|
+
# Failed but the session survived: flip back to needs_input so
|
|
37
|
+
# ResumeRunJob's guard passes, then resume it.
|
|
38
|
+
run.update!(status: "needs_input", finished_at: nil)
|
|
39
|
+
card.update!(status: "needs_input")
|
|
40
|
+
card.log!("progress", actor: "user", run: run, text: "Restarting failed run — resuming the saved session with a fresh turn budget")
|
|
41
|
+
ResumeRunJob.perform_later(run.id, "")
|
|
42
|
+
else
|
|
43
|
+
# No session to resume: a clean run from the queue.
|
|
44
|
+
card.update!(status: "queued")
|
|
45
|
+
card.log!("progress", actor: "user", run: run, text: "Restarting failed run — starting a fresh run")
|
|
46
|
+
StartRunJob.perform_later(card.id)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
redirect_to card_path(card)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :run
|
|
55
|
+
|
|
56
|
+
def set_run = @run = Run.find(params[:id])
|
|
57
|
+
def card = run.card
|
|
58
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module ApplicationHelper
|
|
2
|
+
# Curated model choices for column policies. A custom value already saved on
|
|
3
|
+
# the column stays selectable.
|
|
4
|
+
def model_options(current)
|
|
5
|
+
options = [
|
|
6
|
+
["(inherit default)", ""],
|
|
7
|
+
["Haiku 4.5 — fastest, cheapest", "claude-haiku-4-5-20251001"],
|
|
8
|
+
["Sonnet 4.6 — balanced, recommended for workers", "claude-sonnet-4-6"],
|
|
9
|
+
["Opus 4.8 — most capable", "claude-opus-4-8"],
|
|
10
|
+
["Fable 5 — frontier, expensive", "claude-fable-5"]
|
|
11
|
+
]
|
|
12
|
+
options << [current, current] if current.present? && options.none? { |_, v| v == current }
|
|
13
|
+
options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Timeline text is Markdown (agents write it constantly). escape_html turns
|
|
17
|
+
# any raw HTML in the text into visible text instead of live DOM — an agent
|
|
18
|
+
# pasting `<div class=...>` inside a code fence must never restyle the page.
|
|
19
|
+
MARKDOWN = Redcarpet::Markdown.new(
|
|
20
|
+
Redcarpet::Render::HTML.new(escape_html: true, hard_wrap: true, safe_links_only: true),
|
|
21
|
+
fenced_code_blocks: true, tables: true, autolink: true,
|
|
22
|
+
strikethrough: true, no_intra_emphasis: true, lax_spacing: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def render_markdown(text)
|
|
26
|
+
MARKDOWN.render(text.to_s).html_safe
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def info_tip(text)
|
|
30
|
+
tag.span("i", class: "info",
|
|
31
|
+
data: { controller: "tooltip", tooltip_text_value: text,
|
|
32
|
+
action: "mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide" },
|
|
33
|
+
tabindex: 0)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Silent autosave for the card details form: debounce edits, submit, whisper.
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["status", "form"]
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.onEnd = (event) => {
|
|
9
|
+
if (!this.hasStatusTarget) return
|
|
10
|
+
this.statusTarget.textContent = event.detail?.success === false ? "✗ not saved" : "Saved ✓"
|
|
11
|
+
clearTimeout(this.fade)
|
|
12
|
+
this.fade = setTimeout(() => (this.statusTarget.textContent = ""), 1500)
|
|
13
|
+
}
|
|
14
|
+
this.element.addEventListener("turbo:submit-end", this.onEnd)
|
|
15
|
+
|
|
16
|
+
// A closing modal must not eat a pending debounce — flush immediately.
|
|
17
|
+
this.onModalClose = () => this.flush()
|
|
18
|
+
document.addEventListener("cardinal:modal-closing", this.onModalClose)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
clearTimeout(this.timer)
|
|
23
|
+
clearTimeout(this.fade)
|
|
24
|
+
this.element.removeEventListener("turbo:submit-end", this.onEnd)
|
|
25
|
+
document.removeEventListener("cardinal:modal-closing", this.onModalClose)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
flush() {
|
|
29
|
+
if (!this.timer) return
|
|
30
|
+
clearTimeout(this.timer)
|
|
31
|
+
this.timer = null
|
|
32
|
+
this.formTarget.requestSubmit()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
save() {
|
|
36
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = "…"
|
|
37
|
+
clearTimeout(this.timer)
|
|
38
|
+
this.timer = setTimeout(() => {
|
|
39
|
+
this.timer = null
|
|
40
|
+
this.formTarget.requestSubmit()
|
|
41
|
+
}, 800)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
|
+
import Sortable from "sortablejs"
|
|
4
|
+
|
|
5
|
+
// Drag-and-drop for a column's card list. Moving a card PATCHes /cards/:id/move;
|
|
6
|
+
// the server is the authority — a 422 snaps the card back and shows why.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static values = { hint: String, newUrl: String }
|
|
9
|
+
|
|
10
|
+
// A bare click on the column background — not on a card — opens the New Card
|
|
11
|
+
// composer, mirroring the header's [+]. Cards keep their own click (open
|
|
12
|
+
// detail); we only act when the click lands on the container itself.
|
|
13
|
+
newCard(event) {
|
|
14
|
+
if (event.target !== this.element || !this.hasNewUrlValue) return
|
|
15
|
+
document.getElementById("modal").src = this.newUrlValue
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
this.sortable = Sortable.create(this.element, {
|
|
20
|
+
group: "cards",
|
|
21
|
+
animation: 150,
|
|
22
|
+
ghostClass: "card-ghost",
|
|
23
|
+
onStart: () => {
|
|
24
|
+
document.body.classList.add("dragging")
|
|
25
|
+
this.markBlockedColumns()
|
|
26
|
+
},
|
|
27
|
+
onEnd: (event) => {
|
|
28
|
+
document.body.classList.remove("dragging")
|
|
29
|
+
this.clearBlockedColumns()
|
|
30
|
+
this.move(event)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Accept policies made visible: while dragging, columns that won't take a
|
|
36
|
+
// card from THIS column gray out with a blocked hint. Server still rules.
|
|
37
|
+
markBlockedColumns() {
|
|
38
|
+
const sourceId = this.element.dataset.columnId
|
|
39
|
+
document.querySelectorAll(".column").forEach(section => {
|
|
40
|
+
if (section.dataset.colId === sourceId) return // reorder within is always fine
|
|
41
|
+
const allowed = (section.dataset.accepts || "").split(",").filter(Boolean)
|
|
42
|
+
if (!allowed.includes(sourceId)) section.classList.add("drop-blocked")
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearBlockedColumns() {
|
|
47
|
+
document.querySelectorAll(".column.drop-blocked").forEach(s => s.classList.remove("drop-blocked"))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
disconnect() {
|
|
51
|
+
this.sortable?.destroy()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async move(event) {
|
|
55
|
+
const cardId = event.item.dataset.cardId
|
|
56
|
+
const columnId = event.to.dataset.columnId
|
|
57
|
+
if (event.to === event.from && event.newIndex === event.oldIndex) return
|
|
58
|
+
|
|
59
|
+
const response = await fetch(`/cards/${cardId}/move`, {
|
|
60
|
+
method: "PATCH",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Accept": "text/vnd.turbo-stream.html, application/json",
|
|
64
|
+
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({ column_id: columnId, position: event.newIndex })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (response.ok) {
|
|
70
|
+
// Server-rendered truth for both columns: instant queued/working
|
|
71
|
+
// styling, ticker counts, queue positions.
|
|
72
|
+
Turbo.renderStreamMessage(await response.text())
|
|
73
|
+
} else {
|
|
74
|
+
// Server said no: snap the card back where it came from and flash a red
|
|
75
|
+
// border so the bounce reads as a rejection, not a glitch.
|
|
76
|
+
event.from.insertBefore(event.item, event.from.children[event.oldIndex])
|
|
77
|
+
const body = await response.json().catch(() => ({}))
|
|
78
|
+
this.flashRejected(event.item, body.error)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
flashRejected(card, message) {
|
|
83
|
+
// Surface the reason on hover for the life of the flash; the durable
|
|
84
|
+
// record lives in the card's event timeline.
|
|
85
|
+
const priorTitle = card.getAttribute("title")
|
|
86
|
+
if (message) card.setAttribute("title", message)
|
|
87
|
+
card.classList.remove("move-rejected")
|
|
88
|
+
// Force a reflow so re-adding the class restarts the animation.
|
|
89
|
+
void card.offsetWidth
|
|
90
|
+
card.classList.add("move-rejected")
|
|
91
|
+
card.addEventListener("animationend", () => {
|
|
92
|
+
card.classList.remove("move-rejected")
|
|
93
|
+
if (message) priorTitle ? card.setAttribute("title", priorTitle) : card.removeAttribute("title")
|
|
94
|
+
}, { once: true })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// GitHub-style copy button: copies textValue, flashes a checkmark.
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static values = { text: String }
|
|
6
|
+
static targets = ["button"]
|
|
7
|
+
|
|
8
|
+
async copy() {
|
|
9
|
+
await navigator.clipboard.writeText(this.textValue)
|
|
10
|
+
const original = this.buttonTarget.textContent
|
|
11
|
+
this.buttonTarget.textContent = "✓"
|
|
12
|
+
this.buttonTarget.classList.add("copied")
|
|
13
|
+
setTimeout(() => {
|
|
14
|
+
this.buttonTarget.textContent = original
|
|
15
|
+
this.buttonTarget.classList.remove("copied")
|
|
16
|
+
}, 1200)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Chat composer: Enter sends, Shift+Enter inserts a newline.
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
keydown(event) {
|
|
6
|
+
if (event.key !== "Enter" || event.shiftKey) return
|
|
7
|
+
event.preventDefault()
|
|
8
|
+
if (this.element.value.trim() !== "") this.element.form.requestSubmit()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Near-fullscreen card modal rendered into the "modal" turbo-frame.
|
|
4
|
+
// Closes on Esc, backdrop click, or the ✕ button — unless `sticky`, in
|
|
5
|
+
// which case only the explicit close/cancel buttons (and a successful save)
|
|
6
|
+
// dismiss it. Sticky guards the new-card form so a stray click or Esc can't
|
|
7
|
+
// throw away everything you typed.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static values = { sticky: Boolean }
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.escHandler = (e) => { if (e.key === "Escape" && !this.stickyValue) this.close() }
|
|
13
|
+
document.addEventListener("keydown", this.escHandler)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
document.removeEventListener("keydown", this.escHandler)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
backdrop(event) {
|
|
21
|
+
if (event.target === this.element && !this.stickyValue) this.close()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// For _top-targeted forms (create card, deletes): the frame is
|
|
25
|
+
// turbo-permanent, so it survives the follow-up page render — close it
|
|
26
|
+
// explicitly once the submission succeeds.
|
|
27
|
+
closeOnSuccess(event) {
|
|
28
|
+
if (event.detail.success) this.close()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
close() {
|
|
32
|
+
// Let autosave forms flush any pending debounce before the frame clears.
|
|
33
|
+
document.dispatchEvent(new CustomEvent("cardinal:modal-closing"))
|
|
34
|
+
const frame = this.element.closest("turbo-frame")
|
|
35
|
+
frame.removeAttribute("src")
|
|
36
|
+
frame.innerHTML = ""
|
|
37
|
+
// Opening a card advances the URL to /cards/:id; closing must return the
|
|
38
|
+
// address bar to the board. The board is still rendered behind the permanent
|
|
39
|
+
// frame, so just rewrite the URL — no navigation needed. Back/forward still
|
|
40
|
+
// work via Turbo's history snapshots.
|
|
41
|
+
if (window.location.pathname.startsWith("/cards/")) {
|
|
42
|
+
window.history.pushState({}, "", "/")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Show/hide dependent settings when a toggle changes (e.g. the column AI
|
|
4
|
+
// checkbox reveals model/effort/budget settings). Autosave still fires via
|
|
5
|
+
// the change event bubbling to the autosave controller.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ["toggle", "panel"]
|
|
8
|
+
|
|
9
|
+
connect() { this.sync() }
|
|
10
|
+
|
|
11
|
+
sync() {
|
|
12
|
+
const on = this.toggleTarget.checked
|
|
13
|
+
this.panelTargets.forEach(p => p.classList.toggle("hidden", !on))
|
|
14
|
+
}
|
|
15
|
+
}
|