Class: Column

Inherits:
ApplicationRecord show all
Includes:
ModelLabeling
Defined in:
app/models/column.rb

Constant Summary collapse

ARCHETYPES =
%w[inbox planning execution review terminal].freeze
ARCHETYPE_TEMPLATES =

Archetypes are TEMPLATES, not magic: choosing one stamps concrete, editable values into the policy fields. Nothing falls back to these at runtime — what the gear modal shows is everything there is.

{
  "inbox"     => {},
  "planning"  => {
    "on_entry"      => [{ "action" => "assistant_greeting" }],
    "on_entry_text" => "The planning assistant reads the card and opens the discussion.",
    "instructions"  => "Drive toward crisp acceptance criteria. Open with the 2-3 sharpest questions."
  },
  "execution" => {
    "on_entry"      => [{ "action" => "start_agent_run" }],
    "on_entry_text" => "Assign a dedicated worker agent to the card and start a run."
  },
  "review"    => {},
  "terminal"  => {
    "on_entry"      => [{ "action" => "merge_pr" }],
    "on_entry_text" => "Merge the card's PR and ship it."
  }
}.freeze
%w[sum_cost sum_tokens count_cards model].freeze
BUILT_IN_ROLES =

The built-in role contract for AI servicing this archetype — shown read-only in the gear modal so the Instructions field is understood as ADDING to this, never replacing it. Enforced in code, not editable.

{
  "planning"  => "Plans only, never implements: read-only tools (physically cannot change files), " \
                 "drives toward a Ready-for-execution brief, and hands off — approval means " \
                 "\"finalize the brief\", not \"do it\".",
  "execution" => "Full toolset in an isolated checkout of the card's branch. Commits as it goes but " \
                 "never pushes (the runner pushes); merges the default branch itself on conflict; " \
                 "parks with a QUESTION: when genuinely blocked; ends with a final report."
}.freeze
AI_MODES =

What "Use AI" concretely means here — the §5 tier distinction, visible.

{
  "planning"  => "a shared planning assistant joins each card's conversation",
  "execution" => "a dedicated worker agent is assigned to each card",
  "review"    => "allow AI on-entry rules (ai_task) in this column",
  "terminal"  => "allow AI on-entry rules (ai_task) in this column"
}.freeze

Instance Method Summary collapse

Methods included from ModelLabeling

#model_label_of, #model_short_of

Instance Method Details

#accepts?(source_column) ⇒ Boolean

Which columns may move cards INTO this one (§ accept policy, card #15). Stored as an array of column-id strings. EXPLICIT ONLY: an empty list means this column accepts from nowhere — there is no permissive default.

Returns:

  • (Boolean)


73
74
75
# File 'app/models/column.rb', line 73

def accepts?(source_column)
  Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
end

#ai?Boolean

Does any AI service this column? Explicit per-column switch (default ON for back-compat); the inbox/Tasks intake is never AI, unconditionally. When false the column is inert AI-wise: no assistant, no worker runs, no ai_task rules — cards there are human work.

Returns:

  • (Boolean)


56
57
58
59
# File 'app/models/column.rb', line 56

def ai?
  return false if inbox?
  policy["ai"] != false
end

#ai_mode_descriptionObject



174
# File 'app/models/column.rb', line 174

def ai_mode_description = AI_MODES[archetype]

#archetype_templateObject



34
# File 'app/models/column.rb', line 34

def archetype_template = ARCHETYPE_TEMPLATES.fetch(archetype, {})

#at_wip_limit?Boolean

Returns:

  • (Boolean)


122
123
124
# File 'app/models/column.rb', line 122

def at_wip_limit?
  execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
end

#at_wip_limit_exceeded?Boolean

Post-claim variant (§ races): a starter first claims working atomically, THEN checks — over-subscription reads as strictly more running than allowed.

Returns:

  • (Boolean)


128
129
130
# File 'app/models/column.rb', line 128

def at_wip_limit_exceeded?
  execution? && concurrency_limit.present? && running_count > concurrency_limit.to_i
end

#built_in_roleObject



164
# File 'app/models/column.rb', line 164

def built_in_role = BUILT_IN_ROLES[archetype]

#drag_hintObject

One-line consequence shown while dragging a card over this column (§14.1).



184
185
186
187
188
189
190
191
192
# File 'app/models/column.rb', line 184

def drag_hint
  case archetype
  when "inbox"     then "Parked — no agent activity"
  when "planning"  then "The board assistant will join the discussion"
  when "execution" then "An agent will be assigned and start work"
  when "review"    then "Work stops — ready for your verdict"
  when "terminal"  then "Closes it — PR merged and branch deleted, if there is one"
  end
end

Rows rendered under the cards (card #18). Footer config is an array of => "Total cost:", "compute" => "sum_cost" hashes; each row pairs static label text with an optional computed aggregate over this column's cards. Returns [] when unconfigured, so existing columns render no footer. No auto-rows (de-magic): the model row that used to be hardcoded for AI columns is now the "model" compute — visible in the gear, deletable.



83
84
85
86
87
88
89
90
# File 'app/models/column.rb', line 83

def footer_rows
  Array(footer).filter_map do |row|
    label = row["label"].to_s
    value = footer_value(row["compute"])
    next if label.blank? && value.blank?
    { label:, value: }
  end
end

Aggregate a single footer row over the runs/cards in this column (card #18). Unknown keys return "" so the row shows just its static label.



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'app/models/column.rb', line 134

def footer_value(compute)
  case compute.to_s
  when "sum_cost"
    "$%.2f" % (column_runs.sum(:cost) + column_ai_calls.sum(:cost))
  when "sum_tokens"
    ActiveSupport::NumberHelper.number_to_delimited(
      column_runs.sum("input_tokens + output_tokens") + column_ai_calls.sum("input_tokens + output_tokens"))
  when "count_cards"
    cards.active.count.to_s
  when "model"
    # The column's active AI model, short form. Blank when AI is off or no
    # model is set — the row then shows just its label, telling the truth.
    ai? ? model_short.to_s : ""
  else
    ""
  end
end

#kick_queueObject

Start the next queued card when a run slot frees up. A queued card whose run parked and already has its answer recorded resumes instead of starting fresh.



95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'app/models/column.rb', line 95

def kick_queue
  return unless ai?
  return if at_wip_limit?
  next_card = cards.where(status: "queued").order(:position).first
  return unless next_card

  parked = next_card.runs.where(status: "needs_input").order(:id).last
  if parked&.briefing&.key?("pending_resume")
    ResumeRunJob.perform_later(parked.id, "")
  else
    StartRunJob.perform_later(next_card.id)
  end
end

#model_labelObject

"Opus - High" — human label for cost footers (card #20). Effort is optional, so a model with no configured effort renders just "Opus".



114
# File 'app/models/column.rb', line 114

def model_label = model_label_of(model, effort)

#model_shortObject

"claude-sonnet-4-6" → "sonnet", for compact chips on card faces.



110
# File 'app/models/column.rb', line 110

def model_short = model_short_of(model)

#queued_countObject



120
# File 'app/models/column.rb', line 120

def queued_count  = cards.where(status: "queued").count

#running_countObject



119
# File 'app/models/column.rb', line 119

def running_count = cards.where(status: "working").count

#safe_colorObject

Only ever emit a validated hex color into inline styles.



48
49
50
# File 'app/models/column.rb', line 48

def safe_color
  color if color.to_s.match?(/\A#\h{6}\z/)
end

#seed_archetype_templateObject

Stamp template values into any policy field the creator left blank.



177
178
179
180
181
# File 'app/models/column.rb', line 177

def seed_archetype_template
  archetype_template.each do |key, value|
    policy[key] = value if policy[key].blank?
  end
end

#shell_access?Boolean

Worker shell access (execution columns). ON (default): the agent gets the full toolset — shell, git, everything — inside its workspace clone, which means it can also touch the host beyond the clone. OFF: file tools only (read/search/edit); it physically cannot execute commands, and Cardinal commits and pushes its edits for it.

Returns:

  • (Boolean)


66
67
68
# File 'app/models/column.rb', line 66

def shell_access?
  policy["shell"] != false
end