From 328cb9cc3021bc3183b2c810f6146d9a56df815e Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Wed, 29 Jul 2026 17:01:49 -0300 Subject: [PATCH 1/3] fix: enforce canonical VM deploy environment --- .claude/skills/deploy-to-vm/SKILL.md | 65 +++++++++++++++++----------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/.claude/skills/deploy-to-vm/SKILL.md b/.claude/skills/deploy-to-vm/SKILL.md index 4f4e5dc4..45f3e922 100644 --- a/.claude/skills/deploy-to-vm/SKILL.md +++ b/.claude/skills/deploy-to-vm/SKILL.md @@ -7,9 +7,9 @@ trigger: /deploy-to-vm # deploy-to-vm -Deploy one immutable web image to the private-LAN VM. This skill never builds -on the host, pulls source into a VM checkout, starts a worker, or deploys a -floating tag. +Deploy one immutable web image to the private-LAN VM through the canonical +host control plane. This skill never builds on the host, replaces the guarded +Compose descriptor, starts a worker, or deploys a floating tag. ## Required input and host state @@ -17,12 +17,14 @@ floating tag. - Image: `ghcr.io/wolverin0/clawtrol:${REVISION}`. - Local repository: clean and checked out at `REVISION`. - Host deployment directory: `$HOME/.local/share/clawtrol-deploy`. -- Host secret environment: `$HOME/.config/clawtrol/runtime.env`, mode `0600`. +- Canonical host environment: `$HOME/.local/share/clawtrol-deploy/release.env`, + mode `0600`; it contains both runtime values and `CLAWTROL_IMAGE`. - Restored-database migration evidence for this exact image revision. - Both retired systemd web units and the Solid Queue worker remain stopped. -Never print, copy into the repository, or place the host runtime environment in -command arguments. Docker Compose reads it from the host-side environment file. +The retired `$HOME/.config/clawtrol/runtime.env` must not exist and must never +be consumed. Never print or copy the canonical environment into the repository. +An image promotion may change only its `CLAWTROL_IMAGE` line. ## Hard preflight @@ -46,36 +48,50 @@ Run read-only host checks: ```bash ssh -o BatchMode=yes -o ConnectTimeout=8 "$CLAWTROL_VM" 'set -eu -test -f "$HOME/.config/clawtrol/runtime.env" -test "$(stat -c %a "$HOME/.config/clawtrol/runtime.env")" = 600 +deploy_dir="$HOME/.local/share/clawtrol-deploy" +test -f "$deploy_dir/docker-compose.yml" +test -f "$deploy_dir/release.env" +test "$(stat -c %a "$deploy_dir/release.env")" = 600 +test ! -e "$HOME/.config/clawtrol/runtime.env" +test "$(grep -c "^CLAWTROL_IMAGE=" "$deploy_dir/release.env")" = 1 +test "$(cd "$deploy_dir" && docker compose \ + --env-file release.env -f docker-compose.yml config --services)" = clawdeck for unit in clawdeck-web.service clawtrol.service clawtrol-worker.service; do ! systemctl --user is-active --quiet "$unit" done' ``` -If the runtime environment is absent, permissions are broader than `0600`, a -retired unit is active, CI is not green for the exact SHA, or restore evidence -is missing, stop. Do not repair host secrets or start/stop services implicitly. +If canonical files are absent, permissions are broader than `0600`, the retired +environment still exists, the descriptor resolves to anything except the one +`clawdeck` service, a retired unit is active, CI is not green for the exact SHA, +or restore evidence is missing, stop. Do not repair host secrets, replace the +guarded descriptor, or start/stop services implicitly. ## Stage the immutable release -Copy only the Compose descriptor from the tested checkout: +Pull the exact image and create a candidate environment by copying the current +canonical file and changing only its image line: ```bash -ssh "$CLAWTROL_VM" 'mkdir -p "$HOME/.local/share/clawtrol-deploy"' -scp docker-compose.yml \ - "$CLAWTROL_VM:~/.local/share/clawtrol-deploy/docker-compose.yml.next" ssh "$CLAWTROL_VM" "set -eu cd \"\$HOME/.local/share/clawtrol-deploy\" -printf 'CLAWTROL_IMAGE=ghcr.io/wolverin0/clawtrol:%s\n' '$REVISION' > release.env.next +test \"\$(grep -c '^CLAWTROL_IMAGE=' release.env)\" = 1 +cp release.env release.env.next +sed -i 's|^CLAWTROL_IMAGE=.*$|CLAWTROL_IMAGE=ghcr.io/wolverin0/clawtrol:$REVISION|' \ + release.env.next chmod 600 release.env.next +before_nonimage=\"\$(grep -v '^CLAWTROL_IMAGE=' release.env | sha256sum | cut -d' ' -f1)\" +after_nonimage=\"\$(grep -v '^CLAWTROL_IMAGE=' release.env.next | sha256sum | cut -d' ' -f1)\" +test \"\$before_nonimage\" = \"\$after_nonimage\" docker pull 'ghcr.io/wolverin0/clawtrol:$REVISION' test \"\$(docker image inspect 'ghcr.io/wolverin0/clawtrol:$REVISION' \ --format '{{ index .Config.Labels \"org.opencontainers.image.revision\" }}')\" = '$REVISION' docker compose \ --env-file release.env.next \ - --env-file \"\$HOME/.config/clawtrol/runtime.env\" \ - -f docker-compose.yml.next config --quiet" + -f docker-compose.yml config --quiet +test \"\$(docker compose --env-file release.env.next \ + -f docker-compose.yml config --images)\" = \ + 'ghcr.io/wolverin0/clawtrol:$REVISION'" ``` Do not continue if the image label, Compose image, or tested SHA differ. @@ -96,23 +112,22 @@ migrations wait until the compatibility soak and a separate release. ## Cut over one web container -Back up the previous non-secret release descriptor, atomically activate the new -descriptor, and recreate only the web service: +Back up the canonical environment, atomically activate the candidate, and +recreate only the web service. The guarded Compose descriptor stays untouched: ```bash ssh "$CLAWTROL_VM" "set -eu cd \"\$HOME/.local/share/clawtrol-deploy\" -test ! -f release.env || cp release.env release.env.previous -test ! -f docker-compose.yml || cp docker-compose.yml docker-compose.yml.previous +cp release.env release.env.previous +chmod 600 release.env.previous mv release.env.next release.env -mv docker-compose.yml.next docker-compose.yml docker compose \ --env-file release.env \ - --env-file \"\$HOME/.config/clawtrol/runtime.env\" \ + -f docker-compose.yml \ pull clawdeck docker compose \ --env-file release.env \ - --env-file \"\$HOME/.config/clawtrol/runtime.env\" \ + -f docker-compose.yml \ up -d --no-build --no-deps clawdeck" ``` From 732e06f3d56952bae3690e09015b9bd7f94461bc Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Wed, 29 Jul 2026 17:01:56 -0300 Subject: [PATCH 2/3] feat: redesign control room as focus inbox --- app/controllers/control_room_controller.rb | 44 ++- app/views/control_room/_fleet.html.erb | 36 +-- app/views/control_room/_live_payload.html.erb | 2 +- .../control_room/_request_status.html.erb | 51 ++-- .../_selected_task_summary.html.erb | 47 +++ .../control_room/_source_health.html.erb | 43 ++- app/views/control_room/_task_board.html.erb | 168 +++++----- app/views/control_room/_task_list.html.erb | 67 ++-- app/views/control_room/show.html.erb | 287 +++++++++--------- .../control_room_controller_test.rb | 75 +++-- test/system/control_room_test.rb | 53 +++- 11 files changed, 502 insertions(+), 371 deletions(-) create mode 100644 app/views/control_room/_selected_task_summary.html.erb diff --git a/app/controllers/control_room_controller.rb b/app/controllers/control_room_controller.rb index 3102e244..08ec7b77 100644 --- a/app/controllers/control_room_controller.rb +++ b/app/controllers/control_room_controller.rb @@ -5,19 +5,28 @@ class ControlRoomController < ApplicationController QUEUED_STATES = %w[queued ready].freeze WAITING_STATES = %w[failed review].freeze TERMINAL_STATES = %w[done cancelled].freeze + WORKSPACE_LANES = { + "waiting" => { title: "Needs you", collection: :waiting_on_you }, + "running" => { title: "In progress", collection: :running_now }, + "queued" => { title: "Scheduled", collection: :queued_next }, + "program" => { title: "Programs", collection: :programs } + }.freeze before_action :set_task, only: %i[thread message approve retry cancel] helper_method :intent_result_summary, :pane_display_status, :orchestration_items, - :task_needs_attention? + :task_needs_attention?, :workspace_lanes, :active_lane def show @full_width_page = true load_control_room_state @selected_task = selected_task + @active_lane = selected_lane(@selected_task) end def live load_control_room_state + @selected_task = selected_task + @active_lane = selected_lane(@selected_task) render partial: "live_payload" end @@ -43,7 +52,7 @@ def message record end - redirect_to control_room_path(task_id: @task.id), + redirect_to control_room_path(task_id: @task.id, lane: params[:lane]), notice: "Message queued as intent ##{intent.id}." rescue Orchestration::InvalidRequest => e redirect_to control_room_path(task_id: @task.id), alert: e.message @@ -53,7 +62,7 @@ def message define_method(action) do source = source_for_task!(@task) intent = create_intent(source, action.to_s, @task, {}) - redirect_to control_room_path(task_id: @task.id), + redirect_to control_room_path(task_id: @task.id, lane: params[:lane]), notice: "#{action.to_s.titleize} queued as intent ##{intent.id}." rescue Orchestration::InvalidRequest => e redirect_to control_room_path(task_id: @task.id), alert: e.message @@ -127,7 +136,7 @@ def categorize_tasks @queued_next = grouped.fetch(:queued, []) @programs = grouped.fetch(:program, []) @unclassified_tasks = grouped.fetch(:unclassified, []) - @recent = grouped.fetch(:recent, []).first(25) + @recent = grouped.fetch(:recent, []) @project_work_counts = project_work_counts end @@ -171,6 +180,33 @@ def selected_task @tasks.find { |task| task.id == params[:task_id].to_i } end + def workspace_lanes + WORKSPACE_LANES.transform_values do |definition| + definition.merge(tasks: instance_variable_get(:"@#{definition[:collection]}")) + end + end + + def active_lane + @active_lane || "waiting" + end + + def selected_lane(task) + requested = params[:lane].to_s + return requested if WORKSPACE_LANES.key?(requested) + return lane_key(task_lane(task)) if task + + WORKSPACE_LANES.keys.find { |key| workspace_lanes.dig(key, :tasks)&.any? } || "waiting" + end + + def lane_key(lane) + { + waiting: "waiting", + running: "running", + queued: "queued", + program: "program" + }.fetch(lane, "waiting") + end + def create_task_intent(kind:, default_project: nil) source = source_for_create! brief = required_param(:brief, maximum: 2_000) diff --git a/app/views/control_room/_fleet.html.erb b/app/views/control_room/_fleet.html.erb index de6856fa..167c758f 100644 --- a/app/views/control_room/_fleet.html.erb +++ b/app/views/control_room/_fleet.html.erb @@ -1,12 +1,12 @@ <% panes = Array(@source&.panes) %> <% status_counts = panes.map { |pane| pane_display_status(pane) }.tally %> -
- -
- Fleet · <%= panes.length %> panes + + Fleet · <%= panes.length %> panes +
<% { "working" => ["bg-green-400", "text-green-300"], "waiting" => ["bg-yellow-400", "text-yellow-200"], @@ -17,26 +17,17 @@ }.each do |status, classes| %> <% count = status_counts.fetch(status, 0) %> <% next unless count.positive? %> - - "> + + " aria-hidden="true"> <%= count %> <%= status %> <% end %>
- - <% if @source&.last_seen_at %> - <% a2a_updates = @source.health.to_h.dig("fleet", "a2a_envelopes").to_i %> - synced <%= time_ago_in_words(@source.last_seen_at) %> ago · - <%= a2a_updates.positive? ? "#{a2a_updates} A2A updates in latest sync" : "No A2A updates in latest sync" %> · - expand pane details - <% else %> - no bridge telemetry · expand - <% end %> - + Pane details
<% if panes.any? %> -
+
<% panes.each do |pane| %> <% status = pane_display_status(pane) %> <% status_style = case status @@ -50,9 +41,9 @@ data-pane-status="<%= status %>" aria-label="pane <%= pane["pane_id"] || pane["id"] %>, <%= pane["project"] %>, <%= status %>">
- pane <%= pane["pane_id"] || pane["id"] %> · <%= pane["project"] %> - - "> + pane <%= pane["pane_id"] || pane["id"] %> · <%= pane["project"] %> + + " aria-hidden="true"> <%= status %>
@@ -61,11 +52,6 @@ <%= [pane["title"], pane["persona"], pane["model"], pane["ctx"]].compact_blank.join(" · ") %>

<% end %> - <% if pane["session_pct"].present? || pane["weekly_pct"].present? %> -

- Session <%= pane["session_pct"].presence || "—" %>% · week <%= pane["weekly_pct"].presence || "—" %>% -

- <% end %> <% end %>
diff --git a/app/views/control_room/_live_payload.html.erb b/app/views/control_room/_live_payload.html.erb index fcb034b6..eca4a3f0 100644 --- a/app/views/control_room/_live_payload.html.erb +++ b/app/views/control_room/_live_payload.html.erb @@ -1,4 +1,4 @@ <%= render "source_health" %> <%= render "fleet" %> -<%= render "request_status" %> <%= render "task_board" %> +<%= render "selected_task_summary" if @selected_task %> diff --git a/app/views/control_room/_request_status.html.erb b/app/views/control_room/_request_status.html.erb index b986e2a7..1ceb8073 100644 --- a/app/views/control_room/_request_status.html.erb +++ b/app/views/control_room/_request_status.html.erb @@ -1,38 +1,29 @@ -
- -
- Delivery log - Receipts only · expand -
+ + Delivery receipts <% if @pending_intents.any? %> - - <%= pluralize(@pending_intents.size, "pending") %> - + <%= @pending_intents.size %> pending <% else %> - <%= @recent_intents.size %> recent + <%= @recent_intents.size %> <% end %> - - <% if @recent_intents.any? %> -
- <% @recent_intents.each do |intent| %> - <% tone = { "applied" => "border-green-400/25 text-green-400", "rejected" => "border-red-400/25 text-red-400" }.fetch(intent.status, "border-accent/25 text-accent") %> -
-
- #<%= intent.id %> <%= intent.kind.humanize %> - <%= intent.status.humanize %> -
-

- <%= intent_result_summary(intent) || (intent.status == "pending" ? "Waiting for Wezbridge" : "Processed by Wezbridge") %> - · <%= time_ago_in_words(intent.processed_at || intent.created_at) %> ago -

+
+ <% @recent_intents.each do |intent| %> + <% tone = { "applied" => "text-green-400", "rejected" => "text-red-400" }.fetch(intent.status, "text-accent") %> +
+
+ #<%= intent.id %> <%= intent.kind.humanize %> + <%= intent.status.humanize %>
- <% end %> -
- <% else %> -

No requests have been sent yet.

- <% end %> +

+ <%= intent_result_summary(intent) || (intent.status == "pending" ? "Waiting for Wezbridge" : "Processed by Wezbridge") %> +

+
+ <% end %> + <% if @recent_intents.empty? %> +

No requests have been sent yet.

+ <% end %> +
diff --git a/app/views/control_room/_selected_task_summary.html.erb b/app/views/control_room/_selected_task_summary.html.erb new file mode 100644 index 00000000..7b443384 --- /dev/null +++ b/app/views/control_room/_selected_task_summary.html.erb @@ -0,0 +1,47 @@ +<% state = @selected_task.state_data.fetch("orchestration", {}) %> +<% contract = state["contract"].to_h %> +<% gate = contract["gate"].presence || contract["kind"].presence %> +<% latest_reply = @selected_task.agent_messages.reject { |message| message.metadata["provenance"] == "operator" }.max_by(&:created_at) %> +
+
+
+
Blocked by
+
+ <%= state["blocker"].presence || "No blocker was supplied." %> +
+ <% if gate %> + + Gate · <%= gate %> + + <% end %> +
+
+
Next action
+
+ <%= state["next_action"].presence || "Reply with your direction, or open the full task for complete context." %> +
+
+
+ +
+
+

Latest from pane 0

+ Full history lives in the task panel +
+ <% if latest_reply %> +
+
+ <%= latest_reply.display_sender %> + +
+

<%= latest_reply.content %>

+
+ <% else %> +
+ Pane 0 has not replied in this task yet. +
+ <% end %> +
+
diff --git a/app/views/control_room/_source_health.html.erb b/app/views/control_room/_source_health.html.erb index 6d000704..da29cd0e 100644 --- a/app/views/control_room/_source_health.html.erb +++ b/app/views/control_room/_source_health.html.erb @@ -4,38 +4,33 @@ <% last_error = @source&.last_error.presence %> <% warning = stale || last_error.present? %>
" + class="flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-3 py-2 <%= warning ? "border-red-400/40 bg-red-400/10" : "border-green-400/25 bg-green-400/5" %>" role="<%= warning ? "alert" : "status" %>"> -
-
- <% if @source %> - "> - <%= @source.profile %> · <%= warning ? "attention" : @source.display_status %> - - - bridge <%= @source.last_seen_at ? "synced #{time_ago_in_words(@source.last_seen_at)} ago" : "has not synced yet" %> - - <% else %> - Waiting for the Wezbridge bridge - <% end %> -
- " - data-control-room-live-target="status" - aria-live="polite">Page refresh connecting… -
+ <% if @source %> + "> + + <%= warning ? "Bridge needs attention" : "Fleet healthy" %> + + + <%= @source.last_seen_at ? "synced #{time_ago_in_words(@source.last_seen_at)} ago" : "not synced yet" %> + + <% else %> + Waiting for Wezbridge + <% end %> + Page refresh connecting… <% if warning %> -

- This board may be showing old state. + + State may be stale. <% if health["stale_seconds"].present? %> No progress for <%= distance_of_time_in_words(health["stale_seconds"].to_i) %>. <% elsif @source&.last_seen_at %> Last sync was <%= time_ago_in_words(@source.last_seen_at) %> ago. - <% else %> - No successful sync has been recorded. <% end %> <% if last_error %> - Bridge error: <%= last_error %> + Bridge error: <%= last_error %> <% end %> -

+ <% end %>
diff --git a/app/views/control_room/_task_board.html.erb b/app/views/control_room/_task_board.html.erb index 61226e14..52c22802 100644 --- a/app/views/control_room/_task_board.html.erb +++ b/app/views/control_room/_task_board.html.erb @@ -1,100 +1,116 @@ -
-
-
-
-

Work by project

-

Counts describe the actual lane, not a generic “active” state.

-
-

- <%= @waiting_on_you.length + @running_now.length + @queued_next.length + @programs.length + @unclassified_tasks.length %> open source rows · - <%= @unclassified_tasks.length %> unclassified -

-
-
- <% @project_work_counts.each do |project, counts| %> - <% details = counts.filter_map { |lane, count| "#{count} #{lane}" if count.positive? } %> - - <%= project %>: <%= details.join(" · ") %> - - <% end %> - <% if @project_work_counts.empty? %> -

No open work is mirrored.

+
+ <% if @unclassified_tasks.any? %> -
- - <%= pluralize(@unclassified_tasks.length, "task") %> could not be classified · show + + <%= pluralize(@unclassified_tasks.length, "task") %> could not be classified · inspect -

- This is a cockpit classification gap, not work you owe. The tasks remain visible here until their source state is understood. -

-
+

This is a cockpit classification gap, not work you owe.

+
<% @unclassified_tasks.first(20).each do |task| %> <% state = task.state_data.fetch("orchestration", {}) %> - <%= link_to control_room_path(task_id: task.id), - class: "rounded-lg border border-yellow-500/30 bg-bg-elevated p-3 text-sm text-content hover:border-yellow-400/60", + <%= link_to control_room_path(task_id: task.id, lane: active_lane), + class: "block rounded-md border border-yellow-500/25 bg-bg-elevated px-3 py-2 text-xs text-content", data: { task_origin_id: task.origin_session_id } do %> - <%= task.name %> - - <%= state["project"] %> · source state “<%= state["source_state"].presence || "missing" %>” - + <%= task.name %> + <%= state["project"] %> · “<%= state["source_state"].presence || "missing" %>” <% end %> <% end %>
<% end %> -
- <%= render "task_list", - title: "Waiting on You", - description: "Decisions, replies, reviews, and retries that genuinely need you.", - empty_message: "Nothing needs your input.", - tasks: @waiting_on_you, - show_action: true %> - <%= render "task_list", - title: "Running Now", - description: "Work with an active executor right now.", - empty_message: "No executor is running a task.", - tasks: @running_now, - show_action: false %> - <%= render "task_list", - title: "Queued Next", - description: "Accepted work waiting for an executor.", - empty_message: "Nothing is waiting to start.", - tasks: @queued_next, - show_action: false %> - <%= render "task_list", - title: "Programs", - description: "Long-running coordination and oversight.", - empty_message: "No ongoing programs.", - tasks: @programs, - show_action: false %> +
+ <% workspace_lanes.each do |key, lane| %> + <%= render "task_list", + title: lane[:title], + tasks: lane[:tasks], + lane: key, + active: key == active_lane %> + <% end %>
-
- - Recently completed · <%= @recent.length %> - history · expand - -
- <%= render "task_list", - title: "Recently Completed", - description: "Finished and cancelled work, kept for reference.", - empty_message: "No recent completed work.", - tasks: @recent, - show_action: false %> +
+
+ + <%= @waiting_on_you.length + @running_now.length + @queued_next.length + @programs.length + @unclassified_tasks.length %> + open items · "><%= @unclassified_tasks.length %> unclassified + +
+ <%= @recent.length %> completed + <%= @recent_intents.length %> receipts +
+
+ +
+
+ + Work by project <%= @project_work_counts.length %> + +
+
+ <% @project_work_counts.each do |project, counts| %> + <% details = counts.filter_map { |lane_name, count| "#{count} #{lane_name}" if count.positive? } %> + + <%= project %>: <%= details.join(" · ") %> + + <% end %> + <% if @project_work_counts.empty? %> + No open work is mirrored. + <% end %> +
+
+
+ +
+ + Recently completed <%= @recent.length > 20 ? "20 of #{@recent.length}" : @recent.length %> + +
+ <% @recent.first(20).each do |task| %> + <% state = task.state_data.fetch("orchestration", {}) %> + <%= link_to control_room_path(task_id: task.id, lane: active_lane), + class: "flex items-center justify-between gap-2 rounded-md px-2 py-2 text-xs text-content-secondary hover:bg-bg-elevated", + data: { task_origin_id: task.origin_session_id } do %> + <%= task.name %> + <%= state["source_state"] %> + <% end %> + <% end %> + <% if @recent.empty? %> +

No completed work yet.

+ <% end %> +
+
+ + <%= render "request_status" %>
-
+
diff --git a/app/views/control_room/_task_list.html.erb b/app/views/control_room/_task_list.html.erb index 2850b6f1..1e1feb6e 100644 --- a/app/views/control_room/_task_list.html.erb +++ b/app/views/control_room/_task_list.html.erb @@ -1,35 +1,54 @@ -
" data-board-section="<%= title.parameterize %>" data-source-count="<%= tasks.length %>" - data-rendered-count="<%= [tasks.length, 20].min %>"> -
-
-

<%= title %>

-

<%= description %>

-
- - <%= tasks.length > 20 ? "#{[tasks.length, 20].min} of #{tasks.length}" : tasks.length %> - + data-rendered-count="<%= [tasks.length, 50].min %>" + data-workspace-panel="<%= lane %>"> +
+ Project · work + Status + Updated
-
- <% tasks.first(20).each do |task| %> +
+ <% tasks.first(50).each do |task| %> <% state = task.state_data.fetch("orchestration", {}) %> - <%= link_to control_room_path(task_id: task.id), - class: "block rounded-lg border border-border bg-bg-elevated p-3 transition-colors hover:border-accent/40", + <% selected = @selected_task&.id == task.id %> + <% tone = case lane + when "waiting" then "text-yellow-300" + when "running" then "text-green-300" + when "queued" then "text-sky-300" + else "text-violet-300" + end %> + <%= link_to control_room_path(task_id: task.id, lane: lane), + class: "grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_7rem_4.5rem] items-center gap-3 border-b border-border px-4 py-3 transition-colors hover:bg-bg-elevated/70 #{selected ? "bg-accent/10 shadow-[inset_3px_0_0_var(--color-accent)]" : ""}", + aria: { current: selected ? "true" : nil }, data: { task_origin_id: task.origin_session_id } do %> -

<%= task.name %>

-
- <%= state["project"] %> - <%= state["source_state"] %> -
- <%= render "task_context", state:, compact: true %> - <% if show_action %> -

Open and answer

- <% end %> + + <%= state["project"].presence || "Unassigned" %> + <%= task.name %> + <% if lane == "waiting" && state["blocker"].present? %> + <%= state["blocker"] %> + <% end %> + + + " aria-hidden="true"> + <%= { "waiting" => "Needs you", "running" => "Working", "queued" => "Scheduled", "program" => "Program" }.fetch(lane) %> + + <% end %> <% end %> <% if tasks.empty? %> -

<%= empty_message %>

+
+ + + +

+ <%= { "waiting" => "Nothing needs you", "running" => "No work is running", "queued" => "Nothing is scheduled", "program" => "No programs are active" }.fetch(lane) %> +

+

The count above is sourced directly from the mirrored ledger.

+
<% end %>
diff --git a/app/views/control_room/show.html.erb b/app/views/control_room/show.html.erb index 0e7e1bd9..4b14c804 100644 --- a/app/views/control_room/show.html.erb +++ b/app/views/control_room/show.html.erb @@ -1,162 +1,157 @@ <% content_for :title, "Control Room" %> <% content_for :breadcrumb_standalone, "Control Room" %> -
" +
-
-
-

Personal orchestration

-

Control Room

-
-

- See what needs you, what is running, and what pane 0 reported—without watching every terminal. -

-
- - <%= render "source_health" %> - <%= render "fleet" %> - <%= render "task_board" %> - -
-
-
-
-

Primary action

-

Talk to pane 0

-

- Ask about one project or the whole fleet. The answer arrives in the task drawer. -

-
- Creates a question thread -
- <%= form_with url: control_room_ask_path, class: "mt-4 grid gap-3 lg:grid-cols-[minmax(12rem,18rem)_minmax(0,1fr)_auto]" do |form| %> - <%= form.hidden_field :source_id, value: @source&.id, id: "question_source_id" %> -
- <%= form.label :project, "Question context", for: "question_project", class: "mb-1 block text-xs font-medium text-content-secondary" %> - <%= form.select :project, @question_options, { include_blank: false }, - id: "question_project", - class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> -
-
- <%= form.label :title, "Question title", for: "question_title", class: "mb-1 block text-xs font-medium text-content-secondary" %> - <%= form.text_field :title, id: "question_title", placeholder: "What is this about?", - class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> +
+
+
+
+
- <%= form.submit "Ask pane 0", disabled: @source.blank?, - class: "mt-5 cursor-pointer self-start rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> -
- <%= form.label :brief, "Question", for: "question_brief", class: "sr-only" %> - <%= form.text_area :brief, id: "question_brief", required: true, rows: 3, - placeholder: "What do you want pane 0 to assess, explain, or decide?", - class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> +
+

Control Room

+

One place to decide, reply, and see what moves next.

- <% end %> -
+
+ <%= render "source_health" %> +
+ + <%= render "fleet" %> -
-
- - Create work - secondary action · expand - -
-

Record validated work in the durable ledger for pane 0 to dispatch.

- <%= form_with url: control_room_tasks_path, class: "mt-3 grid gap-3" do |form| %> - <%= form.hidden_field :source_id, value: @source&.id, id: "task_source_id" %> - <%= form.label :project, "Work context", for: "task_project", class: "sr-only" %> - <%= form.select :project, @project_options, { prompt: "Choose a live pane / project" }, - id: "task_project", - required: true, - class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :title, "Task title", for: "task_title", class: "sr-only" %> - <%= form.text_field :title, id: "task_title", placeholder: "Task title", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :brief, "Task brief", for: "task_brief", class: "sr-only" %> - <%= form.text_area :brief, id: "task_brief", required: true, rows: 3, placeholder: "What should be done?", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :acceptance, "Acceptance criteria", for: "task_acceptance", class: "sr-only" %> - <%= form.text_area :acceptance, id: "task_acceptance", rows: 2, placeholder: "What does done look like?", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> -
- <%= form.label :priority, "Priority", for: "task_priority", class: "sr-only" %> - <%= form.select :priority, [["Normal", "normal"], ["High", "high"], ["Low", "low"]], {}, - id: "task_priority", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.submit "Create task", disabled: @source.blank?, - class: "cursor-pointer rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> +
+
+ <%= render "task_board" %> +
+ +
-
+ - <%= render "request_status" %> -
-
+ <%= render "selected_task_summary" %> - <% if @selected_task %> - <% state = @selected_task.state_data.fetch("orchestration", {}) %> - <%# Cockpit boundary: quick decisions only. Rich task detail belongs exclusively to the existing Boards task panel. %> - - <% end %> +
+ + Create new work + expand + +
+ <%= form_with url: control_room_tasks_path, class: "grid gap-3" do |form| %> + <%= form.hidden_field :source_id, value: @source&.id, id: "task_source_id" %> + <%= form.label :project, "Work context", for: "task_project", class: "sr-only" %> + <%= form.select :project, @project_options, { prompt: "Choose a live pane / project" }, + id: "task_project", required: true, + class: "min-h-11 w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :title, "Task title", for: "task_title", class: "sr-only" %> + <%= form.text_field :title, id: "task_title", placeholder: "Task title", + class: "min-h-11 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :brief, "Task brief", for: "task_brief", class: "sr-only" %> + <%= form.text_area :brief, id: "task_brief", required: true, rows: 3, placeholder: "What should be done?", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :acceptance, "Acceptance criteria", for: "task_acceptance", class: "sr-only" %> + <%= form.text_area :acceptance, id: "task_acceptance", rows: 2, placeholder: "What does done look like?", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> +
+ <%= form.label :priority, "Priority", for: "task_priority", class: "sr-only" %> + <%= form.select :priority, [["Normal", "normal"], ["High", "high"], ["Low", "low"]], {}, + id: "task_priority", + class: "min-h-11 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.submit "Create task", disabled: @source.blank?, + class: "min-h-11 cursor-pointer rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:opacity-40" %> +
+ <% end %> +
+
+
+ <% end %> + +
+
<%= turbo_frame_tag "task_panel" %>
diff --git a/test/controllers/control_room_controller_test.rb b/test/controllers/control_room_controller_test.rb index bbd94cab..f639eea2 100644 --- a/test/controllers/control_room_controller_test.rb +++ b/test/controllers/control_room_controller_test.rb @@ -76,16 +76,8 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_select "main#main-content.w-full.max-w-none" assert_select "[data-controller='control-room-live']", count: 1 assert_select "[data-control-room-live-region='requests']", count: 1 - assert_select "summary", text: /Delivery log/ + assert_select "summary", text: /Delivery receipts/ assert_select "[data-controller='gateway-health']", count: 0 - assert_select "select#task_project", count: 1 - assert_select "select#question_project", count: 1 - assert_select "select#question_project" do |select| - assert_equal "_fleet", select.first.css("option").first["value"] - end - assert_select "option[value='whatsappbot']", text: /pane 5/, count: 2 - assert_select "option[value='omniremote']", text: /present/, count: 2 - assert_includes response.body, "No A2A updates in latest sync" assert_includes response.body, "Projected task" assert_select "[data-control-room-live-region='fleet'][data-source-count='3'][data-rendered-count='3']" assert_select "[data-pane-status='working']", count: 1 @@ -99,10 +91,19 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_select "#task-thread [data-task-context='full']", count: 0 assert_select "#task-thread #task-thread-messages", count: 0 projected_link = css_select("[data-task-origin-id='#{task.origin_session_id}']").sole - assert_not_includes projected_link["href"], "#task-thread" + assert_includes projected_link["href"], "task_id=#{task.id}" ids = css_select("[id]").map { |element| element["id"] } assert_equal ids.uniq, ids, "Control Room must not render duplicate HTML ids" + get control_room_path + assert_select "select#task_project", count: 1 + assert_select "select#question_project", count: 1 + assert_select "select#question_project" do |select| + assert_equal "_fleet", select.first.css("option").first["value"] + end + assert_select "option[value='whatsappbot']", text: /pane 5/, count: 2 + assert_select "option[value='omniremote']", text: /present/, count: 2 + other = Task.create!(user: users(:two), board: boards(:two), name: "Other", origin_session_key: "wezbridge:primary:task:T-OTHER") post control_room_task_messages_path(other), params: { content: "No access" } @@ -172,30 +173,20 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest get control_room_path(task_id: blocked.id) assert_response :success - attention = css_select("[data-board-section='waiting-on-you']").sole + attention = css_select("[data-board-section='needs-you']").sole assert_equal "3", attention["data-source-count"] assert_equal "3", attention["data-rendered-count"] assert_equal 1, attention.css("[data-task-origin-id='#{blocked.origin_session_id}']").count assert_equal 1, attention.css("[data-task-origin-id='#{decision.origin_session_id}']").count assert_equal 1, attention.css("[data-task-origin-id='#{question.origin_session_id}']").count assert_equal 0, attention.css("[data-task-origin-id='T-0025']").count - assert_includes attention.text, "Blocked by" assert_includes attention.text, "ARS 8,025,812.27" - assert_includes attention.text, "Gate" - assert_includes attention.text, "operator" - assert_includes attention.text, "Next action" - assert_includes attention.text, "Acceptance" - assert_includes attention.text, "Evidence" - blocked_card = attention.css("[data-task-origin-id='#{blocked.origin_session_id}']").sole - assert_includes blocked_card.text, "1 criterion" - assert_includes blocked_card.text, "1 evidence item" - assert_not_includes blocked_card.text, "Decision is recorded" - assert_not_includes blocked_card.text, "20 balances affect current members" - assert_equal 3, blocked_card.css("[data-task-context='compact'] .line-clamp-2").count assert_select "#task-thread [data-task-context='full']", count: 0 - assert_select "#task-thread", text: /Operator must decide whether to dispose ARS 8,025,812\.27/ - assert_select "#task-thread", text: /Answer with approve or retain/ + summary = css_select("[data-control-room-live-region='selected-task-summary']").sole + assert_includes summary.text, "Operator must decide whether to dispose ARS 8,025,812.27" + assert_includes summary.text, "Gate · operator" + assert_includes summary.text, "Answer with approve or retain" assert_not_includes css_select("#task-thread").sole.text, "Decision is recorded" assert_not_includes css_select("#task-thread").sole.text, "20 balances affect current members" assert_select "#task-thread form[action='#{control_room_task_messages_path(blocked)}']", count: 1 @@ -236,9 +227,9 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_response :success expected = { - "waiting-on-you" => [waiting], - "running-now" => [running], - "queued-next" => [queued], + "needs-you" => [waiting], + "in-progress" => [running], + "scheduled" => [queued], "programs" => [program, explicit_program], "unclassified" => [unclassified], "recently-completed" => [completed] @@ -279,9 +270,9 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "[data-control-room-live-region='source-health'][role='alert']", count: 1 do - assert_select "span", text: /primary · attention/ - assert_select "p", text: /board may be showing old state/i - assert_select "p", text: /sync HTTP 503/ + assert_select "span", text: /Bridge needs attention/ + assert_select "span", text: /State may be stale/i + assert_select "span", text: /sync HTTP 503/ end end @@ -312,6 +303,28 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_response :not_found end + test "renders a focus inbox with persistent detail and latest pane reply" do + task = projected_task + task.agent_messages.create!( + direction: "incoming", + message_type: "output", + content: "The canary passed and awaits your ruling.", + sender_name: "pane 0" + ) + sign_in_as(@user) + + get control_room_path(task_id: task.id) + + assert_response :success + assert_select "nav[aria-label='Work status'] [aria-current='page']", text: /Needs you/ + assert_select "[data-workspace-panel='waiting']:not(.hidden)", count: 1 + assert_select "[data-workspace-panel='running'].hidden", count: 1 + assert_select "aside#task-thread[data-persistent-drawer='true']", count: 1 + assert_select "[data-control-room-live-region='selected-task-summary']", text: /The canary passed/ + assert_select "#task-thread textarea[placeholder='Type your direction or answer…']", count: 1 + assert_select "#task-thread a", text: "Open full task", count: 1 + end + private def projected_task diff --git a/test/system/control_room_test.rb b/test/system/control_room_test.rb index 07d432d0..a39a34f8 100644 --- a/test/system/control_room_test.rb +++ b/test/system/control_room_test.rb @@ -36,17 +36,14 @@ class ControlRoomTest < ApplicationSystemTestCase end test "creates work and sends a task-scoped message from the cockpit" do - visit control_room_path(task_id: @task.id) + visit control_room_path assert_text "Control Room" assert_text "pane 0" - within("[data-board-section='waiting-on-you']") do + within("[data-board-section='needs-you']") do assert_text "Operator ruling required before execution." - assert_text(/gate/i) - assert_text "operator" end - assert_selector "#task-thread[role='complementary'][data-persistent-drawer='true']" - find("summary", text: "Create work").click + find("summary", text: "Create new work").click within("form[action='#{control_room_tasks_path}']") do select "pane 0 — wezbridge (idle)", from: "Work context" fill_in "title", with: "Ship a focused fix" @@ -56,9 +53,11 @@ class ControlRoomTest < ApplicationSystemTestCase assert_text "Task queued as intent" visit control_room_path(task_id: @task.id) + assert_selector "#task-thread[role='complementary'][data-persistent-drawer='true']" + assert_text "Gate · operator" within("#task-thread") do fill_in "content", with: "Please show the exact test evidence." - click_button "Send" + click_button "Reply" end assert_text "Message queued as intent" @@ -86,13 +85,20 @@ class ControlRoomTest < ApplicationSystemTestCase within("#task-thread") { click_link "Open full task" } - assert_selector "[data-controller~='task-modal']", wait: 8 - assert_text "Review the canary" + if ApplicationSystemTestCase::CHROME_AVAILABLE + assert_selector "[data-controller~='task-modal']", wait: 8 + assert_text "Review the canary" + else + assert_current_path board_task_path(@task.board, @task) + assert_text "Confirm the live evidence." + end end test "refreshes cockpit state without clearing a draft" do + skip "Requires JavaScript support" unless ApplicationSystemTestCase::CHROME_AVAILABLE + visit control_room_path - find("summary", text: "Create work").click + find("summary", text: "Create new work").click fill_in "Task title", with: "Keep this draft" @user.tasks.create!( @@ -115,4 +121,31 @@ class ControlRoomTest < ApplicationSystemTestCase assert_field "Task title", with: "Keep this draft" assert_text "Live cockpit · updated just now" end + + test "switches work lanes and keeps the selected detail beside the list" do + @user.tasks.create!( + board: boards(:one), + name: "Running live verification", + description: "Observe the live release.", + origin_session_id: "T-0002", + origin_session_key: "wezbridge:primary:task:T-0002", + status: :in_progress, + state_data: { + "orchestration" => { + "profile" => "primary", + "source_state" => "running", + "project" => "clawtrol" + } + } + ) + + visit control_room_path + click_link "In progress" + + assert_selector "[data-workspace-panel='running']:not(.hidden)" + click_link "Running live verification" + assert_selector "#task-thread[data-persistent-drawer='true']" + assert_text "Running live verification" + assert_selector "[data-workspace-panel='running']:not(.hidden)" + end end From 416741919a47279e609a082400434b8e32d89fca Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Wed, 29 Jul 2026 17:07:28 -0300 Subject: [PATCH 3/3] test: assert live updates across workspace lanes --- test/system/control_room_test.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/system/control_room_test.rb b/test/system/control_room_test.rb index a39a34f8..16c1f522 100644 --- a/test/system/control_room_test.rb +++ b/test/system/control_room_test.rb @@ -117,7 +117,8 @@ class ControlRoomTest < ApplicationSystemTestCase } ) - assert_text "Appeared without a reload", wait: 8 + assert_selector "[data-workspace-panel='running']", + text: "Appeared without a reload", visible: :all, wait: 8 assert_field "Task title", with: "Keep this draft" assert_text "Live cockpit · updated just now" end