Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 40 additions & 25 deletions .claude/skills/deploy-to-vm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,24 @@ 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

- `REVISION`: the exact 40-character lowercase Git SHA that passed hosted CI.
- 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

Expand All @@ -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.
Expand All @@ -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"
```

Expand Down
44 changes: 40 additions & 4 deletions app/controllers/control_room_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
36 changes: 11 additions & 25 deletions app/views/control_room/_fleet.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<% panes = Array(@source&.panes) %>
<% status_counts = panes.map { |pane| pane_display_status(pane) }.tally %>
<details class="group rounded-xl border border-border bg-bg-surface"
<details class="group shrink-0 border-b border-border bg-bg-base/35"
data-control-room-live-region="fleet"
data-source-count="<%= panes.length %>"
data-rendered-count="<%= panes.length %>">
<summary class="flex cursor-pointer list-none flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm font-semibold text-content">Fleet · <%= panes.length %> panes</span>
<summary class="flex min-h-11 cursor-pointer list-none flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 lg:px-5">
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-content-muted">Fleet · <%= panes.length %> panes</span>
<div class="flex flex-1 flex-wrap items-center gap-3">
<% {
"working" => ["bg-green-400", "text-green-300"],
"waiting" => ["bg-yellow-400", "text-yellow-200"],
Expand All @@ -17,26 +17,17 @@
}.each do |status, classes| %>
<% count = status_counts.fetch(status, 0) %>
<% next unless count.positive? %>
<span class="inline-flex items-center gap-1.5 text-xs <%= classes.last %>">
<span class="size-2 rounded-full <%= classes.first %> <%= "animate-pulse" if status == "working" %>"></span>
<span class="inline-flex items-center gap-1.5 text-xs font-medium <%= classes.last %>">
<span class="size-2 rounded-full <%= classes.first %> <%= "animate-pulse" if status == "working" %>" aria-hidden="true"></span>
<%= count %> <%= status %>
</span>
<% end %>
</div>
<span class="text-xs text-content-muted">
<% 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 %>
</span>
<span class="text-xs text-content-muted">Pane details</span>
</summary>

<% if panes.any? %>
<div class="grid max-h-64 gap-2 overflow-y-auto border-t border-border p-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="grid max-h-52 gap-2 overflow-y-auto border-t border-border p-3 sm:grid-cols-2 xl:grid-cols-4">
<% panes.each do |pane| %>
<% status = pane_display_status(pane) %>
<% status_style = case status
Expand All @@ -50,9 +41,9 @@
data-pane-status="<%= status %>"
aria-label="pane <%= pane["pane_id"] || pane["id"] %>, <%= pane["project"] %>, <%= status %>">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-content">pane <%= pane["pane_id"] || pane["id"] %> · <%= pane["project"] %></span>
<span class="inline-flex items-center gap-1">
<span class="size-2 rounded-full bg-current <%= "animate-pulse" if status == "working" %>"></span>
<span class="truncate font-semibold text-content">pane <%= pane["pane_id"] || pane["id"] %> · <%= pane["project"] %></span>
<span class="inline-flex shrink-0 items-center gap-1">
<span class="size-2 rounded-full bg-current <%= "animate-pulse" if status == "working" %>" aria-hidden="true"></span>
<%= status %>
</span>
</div>
Expand All @@ -61,11 +52,6 @@
<%= [pane["title"], pane["persona"], pane["model"], pane["ctx"]].compact_blank.join(" · ") %>
</p>
<% end %>
<% if pane["session_pct"].present? || pane["weekly_pct"].present? %>
<p class="mt-1 text-[11px] text-content-muted">
Session <%= pane["session_pct"].presence || "—" %>% · week <%= pane["weekly_pct"].presence || "—" %>%
</p>
<% end %>
</article>
<% end %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/control_room/_live_payload.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%= render "source_health" %>
<%= render "fleet" %>
<%= render "request_status" %>
<%= render "task_board" %>
<%= render "selected_task_summary" if @selected_task %>
51 changes: 21 additions & 30 deletions app/views/control_room/_request_status.html.erb
Original file line number Diff line number Diff line change
@@ -1,38 +1,29 @@
<details class="rounded-xl border border-border bg-bg-surface"
data-control-room-live-region="requests"
<details data-control-room-live-region="requests"
data-source-count="<%= @recent_intents.size %>"
data-rendered-count="<%= @recent_intents.size %>">
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3">
<div>
<span class="text-sm font-semibold text-content">Delivery log</span>
<span class="ml-2 text-xs text-content-muted">Receipts only · expand</span>
</div>
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between px-4 py-2 text-xs font-semibold text-content">
Delivery receipts
<% if @pending_intents.any? %>
<span class="rounded-full bg-accent/15 px-2 py-1 text-xs font-semibold text-accent">
<%= pluralize(@pending_intents.size, "pending") %>
</span>
<span class="rounded-md bg-accent/15 px-2 py-0.5 text-accent"><%= @pending_intents.size %> pending</span>
<% else %>
<span class="text-xs text-content-muted"><%= @recent_intents.size %> recent</span>
<span class="font-normal text-content-muted"><%= @recent_intents.size %></span>
<% end %>
</summary>

<% if @recent_intents.any? %>
<div class="grid max-h-64 gap-2 overflow-y-auto border-t border-border p-3 md:grid-cols-2 xl:grid-cols-3">
<% @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") %>
<div class="rounded-lg border bg-bg-elevated px-3 py-2 <%= tone %>">
<div class="flex items-center justify-between gap-3 text-xs">
<span class="font-semibold">#<%= intent.id %> <%= intent.kind.humanize %></span>
<span><%= intent.status.humanize %></span>
</div>
<p class="mt-1 text-xs text-content-muted">
<%= intent_result_summary(intent) || (intent.status == "pending" ? "Waiting for Wezbridge" : "Processed by Wezbridge") %>
· <%= time_ago_in_words(intent.processed_at || intent.created_at) %> ago
</p>
<div class="max-h-44 space-y-1 overflow-y-auto border-t border-border p-2">
<% @recent_intents.each do |intent| %>
<% tone = { "applied" => "text-green-400", "rejected" => "text-red-400" }.fetch(intent.status, "text-accent") %>
<div class="rounded-md bg-bg-elevated px-2 py-2 text-xs">
<div class="flex items-center justify-between gap-2">
<strong class="<%= tone %>">#<%= intent.id %> <%= intent.kind.humanize %></strong>
<span class="text-content-muted"><%= intent.status.humanize %></span>
</div>
<% end %>
</div>
<% else %>
<p class="border-t border-border p-4 text-sm text-content-muted">No requests have been sent yet.</p>
<% end %>
<p class="mt-1 truncate text-content-muted">
<%= intent_result_summary(intent) || (intent.status == "pending" ? "Waiting for Wezbridge" : "Processed by Wezbridge") %>
</p>
</div>
<% end %>
<% if @recent_intents.empty? %>
<p class="px-2 py-3 text-content-muted">No requests have been sent yet.</p>
<% end %>
</div>
</details>
Loading
Loading