feat: v0.9.2 Ask Claude on an alert starts an investigation session
build-deb / Lint, test, package (push) Successful in 2m23s
build-deb / Deploy to CT 126 (push) Successful in 30s
build-deb / Notify on failure (push) Skipped

- Ops Alerts → Ask Claude now calls `devdesk attach --investigate SESSION UID`.
  The server looks the alert up, writes the prompt and starts Claude in a new
  alert-<name> session with it as the first prompt, already sent. Prompts are
  stored in prompts/<session>.txt and consumed once by devdesk-claude-run.
  A session that is already running is just opened.
- The prompt has the rule, query, summary, details and labels, and where it
  runs: the CT/VM id, the node it is on now, status and HA state, and
  `ssh <node> pct exec <id> --`. It reminds Claude that HA guests move
  (`devdesk guests <id>`), and gives aggregate rules a hint.
- `devdesk guests`: Proxmox inventory over SSH (DEVDESK_PVE_NODES). pvesh
  gives the cluster view; each node gives lxc-info and guest-agent IPs.
  Cached in guests.json, keeping last-seen IPs for stopped guests; --alerts
  refreshes it in the background every 5 minutes.
- Alerts carry uid, query, description and targets, matched by instance
  IP/host or by name; each card shows the target.
- cmd_alerts moves to libexec/devdesk-ops.sh.

Closes #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 11:24:49 -04:00
co-authored by Claude Opus 5.5
parent afdbf6eaec
commit c0f475bffe
14 changed files with 556 additions and 98 deletions
+129
View File
@@ -927,3 +927,132 @@ fake_convo() {
run devdesk attach --presence alpha off
[ ! -e "$TMP/presence/${sid#\$}" ]
}
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
fake_pve() {
export FAKE_PVE="$TMP/pve" DEVDESK_PVE_SSH="$TMP/fake-ssh" DEVDESK_PVE_NODES="down-node large1"
mkdir -p "$FAKE_PVE"
cat >"$DEVDESK_PVE_SSH" <<'EOF'
#!/bin/bash
while [ "${1:-}" = -o ]; do shift 2; done
case $1:$2 in
down-node:*) exit 255 ;;
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
esac
EOF
chmod +x "$DEVDESK_PVE_SSH"
pve_state running
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >"$FAKE_PVE/node-large1"
}
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
pve_state() {
printf '%s\n' \
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"'"$1"'","type":"lxc","hastate":"'"${1/running/started}"'"},' \
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >"$FAKE_PVE/inventory"
if [ "$1" = running ]; then
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >"$FAKE_PVE/node-monitor-02"
else
printf 'monitor-02\t10.10.10.9\n' >"$FAKE_PVE/node-monitor-02"
fi
}
# Grafana's rules API, served from a file.
fake_grafana() {
export DEVDESK_GRAFANA_URL="file://$TMP/grafana" DEVDESK_GRAFANA_PUBLIC_URL=https://grafana.example
mkdir -p "$TMP/grafana/api/prometheus/grafana/api/v1"
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
annotations: {summary: "status={{ $value }}"},
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >"$TMP/grafana/api/prometheus/grafana/api/v1/rules"
}
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
fake_pve
run devdesk guests
[ "$status" -eq 0 ]
[[ $output == *"110"*"draupnir"*"monitor-02"*"running"*"started"*"10.10.10.24"* ]]
[[ $output == *"102"*"vm"*"homeAssistant"*"large1"*"10.10.10.23"* ]]
[[ $output != *"127.0.0.1"* ]]
run devdesk guests --json 10.10.10.27
[ "$(jq -r '[.guests[].id] | join(",")' <<<"$output")" = 139 ]
[ "$(jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<<"$output")" = 10.10.10.2,10.10.90.10 ]
# Draupnir stops: its IP is remembered, marked as last seen.
pve_state stopped
run devdesk guests drau
[[ $output == *"stopped"*"10.10.10.24 (last seen)"* ]]
# No node answers: the cache, with a warning.
run env DEVDESK_PVE_NODES=down-node devdesk guests draupnir
[ "$status" -eq 0 ]
[[ $output == *"no Proxmox node answered"*"10.10.10.24"* ]]
run env DEVDESK_PVE_NODES= devdesk guests
[ "$status" -ne 0 ]
[[ $output == *"DEVDESK_PVE_NODES"* ]]
}
@test "alerts name the guest or node each one is about (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
pve_state stopped
devdesk guests >/dev/null
run devdesk alerts
[ "$status" -eq 0 ]
t() { jq -c --arg u "$1" '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<<"$output"; }
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
[ "$(t u-ceph)" = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
[ "$(t u-count)" = '[]' ]
[ "$(t u-lan)" = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
[ "$(jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<<"$output")" = "status=1" ]
[ "$(jq -r '[.alerts[].uid] | join(",")' <<<"$output")" = u-draupnir,u-ceph,u-count,u-lan ]
# Never seen running: matched by the name in its job label instead.
rm "$DEVDESK_STATE_DIR/guests.json"
devdesk guests >/dev/null
run devdesk alerts
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
}
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
start_server
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ "$status" -eq 0 ]
[ "$(jq -c . <<<"$output")" = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
claude_args alert-draupnir-down >/dev/null
log="$FAKE_LOG/alert-draupnir-down"
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' "$log"
grep -q 'Alert: Draupnir Down (critical, firing since' "$log"
grep -q 'Query: `up{job="draupnir-node"}`' "$log"
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' "$log"
grep -q '`devdesk guests 110` shows where it is now' "$log"
# The prompt is sent once: not again when Claude is resumed.
[ ! -e "$DEVDESK_STATE_DIR/prompts/alert-draupnir-down.txt" ]
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ "$(jq -r .started <<<"$output")" = false ]
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
run devdesk attach --investigate exporters u-count
claude_args exporters >/dev/null
grep -q 'The rule aggregates' "$FAKE_LOG/exporters"
run grep -c 'Where it runs' "$FAKE_LOG/exporters"
[ "$output" = 0 ]
run devdesk attach --investigate gone u-ok
[ "$status" -ne 0 ]
[[ $output == *"no longer firing"* ]]
run devdesk attach --investigate 'bad name' u-ok
[[ $output == *"invalid session name"* ]]
}