# Workload Registry Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Agents enumerate what each Linux server actually runs — Docker containers, the compose stacks grouping them, and systemd services — and report it to the control plane. Containers and units can be started, stopped and restarted from the UI, and a bounded snapshot of their logs read without opening a console.

**Architecture:** A **workload** is one container or one systemd unit. The agent collects both on a 60-second ticker and reports through a `ReportWorkloads` RPC with a hash short-circuit. On-demand refresh does not return data — `RefreshWorkloadsCmd` makes the agent report immediately through that same RPC, so there is one writer for the collection. Control actions and log reads travel out through the existing `commandDispatcher` and answer back over the bus, mirroring `StepResults`.

**Tech Stack:** Go 1.26, gin, mongo-driver v2, gRPC, Redis (bus), Next.js 16 + TanStack Query, Tailwind 3.

**Spec:** `docs/superpowers/specs/2026-08-06-workload-registry-design.md`. Read it before starting. Where this plan and the spec disagree, the spec wins and the plan is wrong.

## Orientation for a fresh session

This is sub-project B of four. Sub-project A (package inventory and CVE findings) is independent and may or may not be built yet — **this plan shares no code with it** and can be executed first, second, or alone. If A exists, its `ServerPackages` model is the shape `ServerWorkloads` deliberately mirrors.

Key existing machinery this plan builds on, with the reasoning that must not be broken:

- `server/internal/services/dispatch.go` — `commandDispatcher.send()` publishes a command envelope addressed to the pod holding that agent's stream and waits for its ack. **Request/ack, not a queue:** a command whose owner died must fail loudly (503) rather than sit in a queue while the operator is told it worked.
- `server/internal/services/stepresults.go` — `StepResults.Await/Deliver`. `Await` subscribes to the result channel **before** the command is dispatched. Copy that ordering exactly; a fast agent otherwise answers into a channel nobody has joined.
- `agent/internal/sync/sync.go` — the command stream handler, with a `handleX` function per `ServerCommand` variant.
- `bus.RunAsLeader("housekeeping", …)` in `server/cmd/main.go` — nothing in this plan needs it. There is no scheduler here; the agent drives its own ticker.

## Global Constraints

- **Do not write tests.** No `*_test.go`, no npm test files, no test scaffolding. This is a deliberate instruction from the repository owner, not an oversight. Verification is `go build`, `go vet`, `npm run build` and the manual checks written into the tasks.
- **Linux only.** Windows agents must not collect workloads. systemd does not exist there and the container story differs; a separate spec covers it if ever.
- **Not gated by licence.** v1 ships to every instance. Do not add a `HasFeature` check.
- Every service query is scoped by `instance_id`. No unscoped lookups, ever.
- Every mutating API path writes an audit event via `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details string)`.
- Module path prefix is `gitea.hostxtra.co.uk/mrhid6/vantage/`.
- Mongo driver is **v2**: `go.mongodb.org/mongo-driver/v2/bson`; ObjectIDs are `bson.ObjectID`, not `primitive.ObjectID`.
- Collections are reached via `db.Col("name")`.
- No component in `web/` may carry a hex colour; use Tailwind token names only. `web/` is dark-only — do not add a light theme.
- Commit messages use the `feat:` / `fix:` / `docs:` prefixes already in `git log`.
- No new Go dependencies. Everything here shells out to binaries already on the host.

## Correctness risks carried without tests

Places where a mistake produces a **wrong answer or a silent cost rather than a crash**. Each has a manual check written into its task.

1. **The protected set (Task 4).** Wrong in the permissive direction and a server can stop its own agent — it goes offline and the only way back is SSH or physical access, which is what this feature exists to avoid needing.
2. **Hash order-independence (Task 3).** `docker ps` output ordering is not stable. An ordering-sensitive hash resends the full list every 60 seconds forever, visible only as traffic.
3. **Log caps (Task 5).** A line count alone does not bound size. 500 lines of 4KB JSON is 2MB through the bus.
4. **`DockerOK` vs empty list (Tasks 2 and 9).** "Docker not installed" and "Docker running nothing" must not render alike.

---

## File Structure

**Create — agent:**
- `agent/internal/workloads/workloads.go` — `Collect()`, orchestrating both collectors
- `agent/internal/workloads/docker.go` — Docker collection and parsing
- `agent/internal/workloads/systemd.go` — systemd collection and parsing
- `agent/internal/workloads/control.go` — start/stop/restart, and the protected set
- `agent/internal/workloads/logs.go` — bounded log reads

**Create — server:**
- `server/internal/models/workloads.go` — `ServerWorkloads`, `Workload`
- `server/internal/services/workloads.go` — storage, hash compare, fleet search, dispatch wrappers
- `server/internal/services/workloadresults.go` — `WorkloadResults` registry over the bus
- `server/internal/api/workloads.go` — REST handlers

**Create — web:**
- `web/app/(app)/workloads/page.tsx` — fleet-wide view and search
- `web/components/workloads/WorkloadList.tsx` — grouped stacks, containers, units
- `web/components/workloads/WorkloadRow.tsx` — one row with its actions
- `web/components/workloads/LogDialog.tsx` — bounded log snapshot

**Modify:**
- `proto/vantage/v1/vantage.proto` — `ReportWorkloads` RPC, three `ServerCommand` variants, one `AgentMessage` variant
- `agent/internal/sync/sync.go` — the 60s report loop and three command handlers
- `agent/internal/grpc/client.go` — `ReportWorkloads` client method
- `server/internal/grpc/server.go` — `ReportWorkloads` handler; route `WorkloadLogsResult` in `CommandStream`
- `server/internal/services/coreindexes.go` — workload indexes
- `server/internal/api/handlers.go` — register routes
- `web/lib/api.ts` — types and client methods
- `web/components/Sidebar.tsx` — Workloads entry
- `web/app/(app)/servers/[id]/page.tsx` — Workloads tab
- The control plane's instance-deletion collection list — add `server_workloads`
- `CLAUDE.md`, `docsite/docs/vantage/` — document the subsystem

---

### Task 1: Models and indexes

**Files:**
- Create: `server/internal/models/workloads.go`
- Modify: `server/internal/services/coreindexes.go`, `server/cmd/main.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `models.ServerWorkloads`, `models.Workload`, `services.EnsureWorkloadIndexes() error`.

- [ ] **Step 1: Write the models**

Create `server/internal/models/workloads.go`:

```go
package models

import (
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
)

// Workload kinds.
const (
	WorkloadContainer = "container"
	WorkloadUnit      = "unit"
)

// Control actions.
const (
	WorkloadStart   = "start"
	WorkloadStop    = "stop"
	WorkloadRestart = "restart"
)

// Workload is one container or one systemd unit.
type Workload struct {
	Kind string `bson:"kind" json:"kind"` // container | unit
	ID   string `bson:"id" json:"id"`     // container id, or unit name
	Name string `bson:"name" json:"name"`

	// State is deliberately NOT collapsed into a shared vocabulary across the
	// two kinds. Containers report running/exited/paused/restarting/created;
	// units report active/inactive/failed/activating. A failed unit and an
	// exited container mean different things, and flattening them loses the
	// distinction the operator needs.
	State  string `bson:"state" json:"state"`
	Health string `bson:"health,omitempty" json:"health,omitempty"`

	Image string   `bson:"image,omitempty" json:"image,omitempty"`
	Stack string   `bson:"stack,omitempty" json:"stack,omitempty"` // compose project label
	Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`

	Restarts  int       `bson:"restarts,omitempty" json:"restarts,omitempty"`
	StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`

	// Protected is computed agent-side and reported so the UI can render the
	// action disabled with a reason rather than offering a button whose refusal
	// is already known. The field is the courtesy; the agent's own check is the
	// boundary.
	Protected bool `bson:"protected" json:"protected"`
}

// ServerWorkloads holds one server's whole workload list in ONE document.
type ServerWorkloads struct {
	ID          bson.ObjectID `bson:"_id,omitempty" json:"-"`
	InstanceID  string        `bson:"instance_id" json:"-"`
	ServerID    string        `bson:"server_id" json:"server_id"`
	Hash        string        `bson:"hash" json:"hash"`
	Workloads   []Workload    `bson:"workloads" json:"workloads"`
	CollectedAt time.Time     `bson:"collected_at" json:"collected_at"`

	// A host with no Docker and a host with Docker running nothing both produce
	// an empty list. One should read "not in use here", the other "nothing
	// running", and only the second deserves any alarm.
	//
	// The error strings separate a third case the booleans cannot: installed
	// with the daemon down. "Not installed" and "installed but not responding"
	// are different problems with different fixes.
	DockerOK     bool   `bson:"docker_ok" json:"docker_ok"`
	DockerError  string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
	SystemdOK    bool   `bson:"systemd_ok" json:"systemd_ok"`
	SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
}
```

- [ ] **Step 2: Add the index builder**

Append to `server/internal/services/coreindexes.go` (or create `server/internal/services/workloadindexes.go` if that file's conventions do not fit):

```go
// EnsureWorkloadIndexes declares the indexes for the workload registry.
//
// Warns rather than being fatal, matching EnsureSecretIndexes: a missing index
// degrades these queries to a collection scan, which is no reason to refuse to
// serve the fleet.
func EnsureWorkloadIndexes() error {
	ctx := context.Background()

	idx := []mongo.IndexModel{
		{
			Keys:    bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
			Options: options.Index().SetUnique(true),
		},
		// Multikey, for the fleet-wide "which servers run image X" query, which
		// is the reason the snapshot is stored rather than fetched and discarded.
		{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "workloads.image", Value: 1}}},
	}
	if _, err := db.Col("server_workloads").Indexes().CreateMany(ctx, idx); err != nil {
		log.Printf("warning: server_workloads indexes: %v", err)
	}
	return nil
}
```

- [ ] **Step 3: Wire it into schema setup**

In `server/cmd/main.go`, find `runSchemaSetup` (near line 75) and the block calling the other `Ensure*Indexes` functions. Add alongside the non-fatal ones:

```go
	if err := services.EnsureWorkloadIndexes(); err != nil {
		log.Printf("warning: workload indexes: %v", err)
	}
```

- [ ] **Step 4: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/models/workloads.go server/internal/services/ server/cmd/main.go
git commit -m "feat: models and indexes for the workload registry"
```

---

### Task 2: Docker collector

**Files:**
- Create: `agent/internal/workloads/docker.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `workloads.Workload` (the agent-side struct), `workloads.collectDocker() ([]Workload, bool, string)` returning workloads, ok, and an error string.

- [ ] **Step 1: Write the collector**

Create `agent/internal/workloads/docker.go`:

```go
package workloads

import (
	"context"
	"encoding/json"
	"os/exec"
	"strings"
	"time"
)

// Workload is one container or one systemd unit, agent-side. It mirrors
// models.Workload on the server.
type Workload struct {
	Kind      string
	ID        string
	Name      string
	State     string
	Health    string
	Image     string
	Stack     string
	Ports     []string
	Restarts  int
	StartedAt time.Time
	Protected bool
}

const dockerTimeout = 30 * time.Second

// dockerInspect is the subset of `docker inspect` output we read.
//
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
// reports health and uptime inside a human Status string — "Up 2 hours
// (healthy)" — and anything built on that is parsing English that is
// localised, reworded between releases, and silently different for a paused or
// restarting container. inspect gives typed fields instead.
type dockerInspect struct {
	ID    string `json:"Id"`
	Name  string `json:"Name"`
	State struct {
		Status     string `json:"Status"`
		StartedAt  string `json:"StartedAt"`
		Restarting bool   `json:"Restarting"`
		Health     *struct {
			Status string `json:"Status"`
		} `json:"Health"`
	} `json:"State"`
	Config struct {
		Image  string            `json:"Image"`
		Labels map[string]string `json:"Labels"`
	} `json:"Config"`
	RestartCount int `json:"RestartCount"`
	NetworkSettings struct {
		Ports map[string][]struct {
			HostIP   string `json:"HostIp"`
			HostPort string `json:"HostPort"`
		} `json:"Ports"`
	} `json:"NetworkSettings"`
}

// collectDocker enumerates containers. It returns ok=false with an empty error
// string when Docker is simply not installed — the common case on this fleet,
// and not a fault.
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
	if _, err := exec.LookPath("docker"); err != nil {
		return nil, false, "" // not installed; not an error
	}

	ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
	defer cancel()

	idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
	if err != nil {
		// Installed but not answering: a different problem with a different
		// fix, so it carries a message where "not installed" does not.
		return nil, false, "docker ps failed: " + errText(err)
	}

	ids := strings.Fields(string(idsOut))
	if len(ids) == 0 {
		return []Workload{}, true, "" // Docker present, nothing running
	}

	args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
	out, err := exec.CommandContext(ctx, "docker", args...).Output()
	if err != nil {
		return nil, false, "docker inspect failed: " + errText(err)
	}

	var wls []Workload
	for _, line := range strings.Split(string(out), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		var di dockerInspect
		if err := json.Unmarshal([]byte(line), &di); err != nil {
			continue
		}
		wls = append(wls, dockerToWorkload(di))
	}
	return wls, true, ""
}

func dockerToWorkload(di dockerInspect) Workload {
	w := Workload{
		Kind:     "container",
		ID:       di.ID,
		Name:     strings.TrimPrefix(di.Name, "/"),
		State:    di.State.Status,
		Image:    di.Config.Image,
		Restarts: di.RestartCount,
	}
	if di.State.Health != nil {
		w.Health = strings.ToLower(di.State.Health.Status)
	}
	// The compose project label is what Docker itself treats as authoritative.
	// No YAML is read from disk: a compose file there may not be what is running.
	if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
		w.Stack = v
	}
	if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
		w.StartedAt = t
	}
	for container, bindings := range di.NetworkSettings.Ports {
		for _, b := range bindings {
			w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
		}
	}
	return w
}

func errText(err error) string {
	if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
		return strings.TrimSpace(string(ee.Stderr))
	}
	return err.Error()
}
```

- [ ] **Step 2: Verify against a real Docker host**

Confirm the field names by eye before trusting the struct tags:

```bash
docker run -d --name plantest --restart=always nginx:alpine
docker inspect --format '{{json .}}' plantest | python3 -m json.tool | head -60
docker rm -f plantest
```

Check that `Id`, `Name`, `State.Status`, `State.StartedAt`, `RestartCount`, `Config.Image`, `Config.Labels` and `NetworkSettings.Ports` all appear with those exact spellings and shapes. If you have a compose project handy, confirm `com.docker.compose.project` is present on its containers.

- [ ] **Step 3: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/workloads/docker.go
git commit -m "feat: agent enumerates docker containers"
```

---

### Task 3: systemd collector and hashing

**Files:**
- Create: `agent/internal/workloads/systemd.go`, `agent/internal/workloads/workloads.go`

**Interfaces:**
- Consumes: `workloads.Workload`, `workloads.collectDocker` (Task 2).
- Produces: `workloads.Collect(ctx) Result`, `workloads.Hash([]Workload) string`, `workloads.Result{Workloads, DockerOK, DockerError, SystemdOK, SystemdError}`.

- [ ] **Step 1: Write the systemd collector**

Create `agent/internal/workloads/systemd.go`:

```go
package workloads

import (
	"context"
	"os/exec"
	"strings"
	"time"
)

const systemdTimeout = 30 * time.Second

// excludedPrefixes drops the platform's own units. A typical host carries 300+
// units and systemd accounts for most of them; listing all of them buries the
// ten anyone cares about.
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}

// collectSystemd enumerates services in two passes, because "running or
// failed" and "enabled but stopped" are different questions — and an enabled
// unit that is not running is exactly the one worth seeing.
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
	if _, err := exec.LookPath("systemctl"); err != nil {
		return nil, false, ""
	}

	ctx, cancel := context.WithTimeout(ctx, systemdTimeout)
	defer cancel()

	// Column output rather than --output=json: the JSON flag needs systemd
	// 246+, and this fleet includes older stable distributions. The columns
	// have been stable considerably longer than the JSON has existed.
	unitsOut, err := exec.CommandContext(ctx, "systemctl",
		"list-units", "--type=service", "--state=running,failed",
		"--no-legend", "--plain", "--no-pager").Output()
	if err != nil {
		return nil, false, "systemctl list-units failed: " + errText(err)
	}

	seen := map[string]bool{}
	var wls []Workload

	for _, line := range strings.Split(string(unitsOut), "\n") {
		f := strings.Fields(line)
		// UNIT LOAD ACTIVE SUB DESCRIPTION…
		if len(f) < 4 {
			continue
		}
		name := f[0]
		if excluded(name) || seen[name] {
			continue
		}
		seen[name] = true
		wls = append(wls, Workload{
			Kind:  "unit",
			ID:    name,
			Name:  strings.TrimSuffix(name, ".service"),
			State: f[2], // ACTIVE: active | failed | activating | inactive
		})
	}

	filesOut, err := exec.CommandContext(ctx, "systemctl",
		"list-unit-files", "--type=service", "--state=enabled",
		"--no-legend", "--plain", "--no-pager").Output()
	if err == nil {
		for _, line := range strings.Split(string(filesOut), "\n") {
			f := strings.Fields(line)
			// UNIT FILE  STATE  [PRESET]
			if len(f) < 2 {
				continue
			}
			name := f[0]
			if excluded(name) || seen[name] {
				continue
			}
			seen[name] = true
			wls = append(wls, Workload{
				Kind:  "unit",
				ID:    name,
				Name:  strings.TrimSuffix(name, ".service"),
				State: "inactive", // enabled but not currently running
			})
		}
	}

	return wls, true, ""
}

func excluded(name string) bool {
	for _, p := range excludedPrefixes {
		if strings.HasPrefix(name, p) {
			return true
		}
	}
	return false
}
```

- [ ] **Step 2: Write the orchestrator and hash**

Create `agent/internal/workloads/workloads.go`:

```go
package workloads

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"runtime"
	"sort"
	"strconv"
)

// Result is one collection pass.
type Result struct {
	Workloads    []Workload
	DockerOK     bool
	DockerError  string
	SystemdOK    bool
	SystemdError string
}

// Collect enumerates every workload on this host. Linux only.
func Collect(ctx context.Context) Result {
	if runtime.GOOS != "linux" {
		return Result{}
	}

	var r Result
	containers, dockerOK, dockerErr := collectDocker(ctx)
	units, systemdOK, systemdErr := collectSystemd(ctx)

	r.DockerOK, r.DockerError = dockerOK, dockerErr
	r.SystemdOK, r.SystemdError = systemdOK, systemdErr
	r.Workloads = append(append([]Workload{}, containers...), units...)

	markProtected(r.Workloads)
	return r
}

// Hash fingerprints a workload set so an unchanged set never has to be sent.
//
// It sorts first: `docker ps` output ordering is not stable, and an
// ordering-sensitive hash would resend the full list every 60 seconds forever
// — a cost visible only as traffic.
//
// StartedAt is deliberately excluded: it does not change while a container
// runs, and including it would add nothing. Restarts IS included, because a
// container cycling is exactly the change worth reporting.
func Hash(wls []Workload) string {
	lines := make([]string, 0, len(wls))
	for _, w := range wls {
		lines = append(lines, strings.Join([]string{
			w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack,
			strconv.Itoa(w.Restarts),
		}, "\x00"))
	}
	sort.Strings(lines)
	h := sha256.New()
	for _, l := range lines {
		h.Write([]byte(l))
		h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil))
}
```

Add `"strings"` to the import block. `markProtected` arrives in Task 4 — add a temporary empty `func markProtected(_ []Workload) {}` to this file now so it builds, and delete it when Task 4 lands.

- [ ] **Step 3: Verify the systemd parsing and the hash**

On a Linux host, confirm the column positions the parser assumes:

```bash
systemctl list-units --type=service --state=running,failed --no-legend --plain --no-pager | head -5
systemctl list-unit-files --type=service --state=enabled --no-legend --plain --no-pager | head -5
```

The first must have the unit name in column 1 and `active`/`failed` in column 3. The second must have the unit name in column 1. Confirm `systemd-journald.service` appears in the raw output and would be dropped by `excluded`, while `sshd.service` or `nginx.service` survives.

For the hash, confirm by inspection that two slices holding the same workloads in different order produce the same string, and that changing one `Restarts` value changes it.

- [ ] **Step 4: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/workloads/
git commit -m "feat: agent enumerates systemd services"
```

---

### Task 4: Control actions and the protected set

**Files:**
- Create: `agent/internal/workloads/control.go`
- Modify: `agent/internal/workloads/workloads.go` — remove the temporary `markProtected` stub

**Interfaces:**
- Consumes: `workloads.Workload`.
- Produces: `workloads.Control(ctx, kind, id, action string) error`, `workloads.ErrProtected`, `workloads.markProtected([]Workload)`.

**This is the task with the unrecoverable failure mode.** A server that stops its own agent goes offline, and the only way back is SSH or physical access — which is what this feature exists to avoid needing.

- [ ] **Step 1: Write the control layer**

Create `agent/internal/workloads/control.go`:

```go
package workloads

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"time"
)

// ErrProtected is returned for a workload the agent will not act on.
var ErrProtected = errors.New("workload is protected")

// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"

// controlTimeout bounds a stop that may never finish on its own. `docker stop`
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
// with a long TimeoutStopSec blocks for exactly as long as that says. A
// timeout must return a real error rather than an ack implying success.
const controlTimeout = 90 * time.Second

// ownContainerID is read once: the container this agent runs in, if any.
var ownContainerID = detectOwnContainer()

var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)

// detectOwnContainer returns this process's container ID, or "" on a host
// install. The agent is normally a systemd service, so "" is the common case;
// this exists so containerising it later cannot silently remove the guard.
func detectOwnContainer() string {
	b, err := os.ReadFile("/proc/self/cgroup")
	if err != nil {
		return ""
	}
	if m := cgroupContainerRe.FindString(string(b)); m != "" {
		return m
	}
	return ""
}

// isProtected reports whether the agent refuses to act on this workload.
//
// The refusal lives here, in the agent, and not in the control plane. As with
// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name
// a target, but the agent decides what it will do to itself. A server-side
// denylist alone would be bypassed by the next dispatch path someone adds.
func isProtected(kind, id, name string) bool {
	if kind == "unit" {
		return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
	}
	if ownContainerID == "" {
		return false
	}
	// Container IDs are commonly abbreviated to 12 characters; compare on the
	// shorter of the two so a short id still matches a full one.
	return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID)
}

// markProtected stamps the flag onto a collected list so the UI can render the
// action disabled with a reason.
func markProtected(wls []Workload) {
	for i := range wls {
		wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name)
	}
}

// Control starts, stops or restarts a workload.
func Control(ctx context.Context, kind, id, action string) error {
	switch action {
	case "start", "stop", "restart":
	default:
		return fmt.Errorf("unknown action %q", action)
	}

	// Checked before anything else happens, and checked here rather than only
	// on the server. See isProtected.
	if isProtected(kind, id, strings.TrimSuffix(id, ".service")) {
		return fmt.Errorf("%w: %s", ErrProtected, id)
	}

	ctx, cancel := context.WithTimeout(ctx, controlTimeout)
	defer cancel()

	var cmd *exec.Cmd
	switch kind {
	case "container":
		cmd = exec.CommandContext(ctx, "docker", action, id)
	case "unit":
		cmd = exec.CommandContext(ctx, "systemctl", action, id)
	default:
		return fmt.Errorf("unknown workload kind %q", kind)
	}

	if out, err := cmd.CombinedOutput(); err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
		}
		return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
	}
	return nil
}
```

Delete the temporary `markProtected` stub from `workloads.go`.

- [ ] **Step 2: Verify the guard**

**Do not skip this.** From a scratch `main.go` on a dev machine, confirm:

| Call | Expected |
| ---- | -------- |
| `Control(ctx, "unit", "vantage-agent.service", "restart")` | error wrapping `ErrProtected`, and **no restart happens** |
| `Control(ctx, "unit", "vantage-agent", "restart")` | error wrapping `ErrProtected` — the name form must be caught too |
| `Control(ctx, "unit", "nginx.service", "restart")` | proceeds |
| `Control(ctx, "container", "<some other container>", "restart")` | proceeds |
| `Control(ctx, "unit", "nginx.service", "reload")` | error, unknown action |

Then run `markProtected` over a collected list and confirm `vantage-agent.service` comes back `Protected: true` and everything else `false`.

- [ ] **Step 3: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/workloads/
git commit -m "feat: agent control actions with self-protection"
```

---

### Task 5: Bounded log reads

**Files:**
- Create: `agent/internal/workloads/logs.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `workloads.Logs(ctx, kind, id string, tail int) (text string, truncated bool, err error)`, `workloads.MaxLogLines`, `workloads.MaxLogBytes`.

- [ ] **Step 1: Write the log reader**

Create `agent/internal/workloads/logs.go`:

```go
package workloads

import (
	"context"
	"fmt"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

const (
	// MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first.
	//
	// A line count alone does not bound size: 500 lines of a container printing
	// 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning
	// that gave workflow logs a per-line cap as well as a per-run one.
	MaxLogLines = 500
	MaxLogBytes = 256 * 1024

	logTimeout = 60 * time.Second
)

// Logs returns a bounded snapshot of a workload's recent output.
//
// There is no follow mode. The browser console already offers a real terminal
// on the same server where `docker logs -f` works properly, with its own
// scrollback and cancellation. A snapshot answers "why did this restart",
// which is the question that sends people to the console in the first place.
func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) {
	if tail <= 0 || tail > MaxLogLines {
		tail = MaxLogLines
	}

	ctx, cancel := context.WithTimeout(ctx, logTimeout)
	defer cancel()

	var cmd *exec.Cmd
	switch kind {
	case "container":
		cmd = exec.CommandContext(ctx, "docker", "logs",
			"--tail", strconv.Itoa(tail), "--timestamps", id)
	case "unit":
		cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
			"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
	default:
		return "", false, fmt.Errorf("unknown workload kind %q", kind)
	}

	// docker logs writes container stderr to our stderr, so both streams must
	// be captured or half the output silently disappears.
	out, err := cmd.CombinedOutput()
	if err != nil && len(out) == 0 {
		return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
	}

	return cap(string(out))
}

// cap enforces both limits, trimming from the FRONT: the most recent lines are
// the ones worth keeping.
func cap(s string) (string, bool, error) {
	truncated := false

	lines := strings.Split(s, "\n")
	if len(lines) > MaxLogLines {
		lines = lines[len(lines)-MaxLogLines:]
		truncated = true
	}
	s = strings.Join(lines, "\n")

	if len(s) > MaxLogBytes {
		s = s[len(s)-MaxLogBytes:]
		// Drop the leading partial line left by a byte-wise cut.
		if i := strings.IndexByte(s, '\n'); i >= 0 {
			s = s[i+1:]
		}
		truncated = true
	}

	return s, truncated, nil
}
```

`cap` shadows the builtin. Rename it to `capLog` if that bothers the linter; the behaviour is what matters.

- [ ] **Step 2: Verify both caps**

Both directions, because a line-count-only implementation passes the first and fails the second silently:

```bash
# Line cap: 600 lines in, expect 500 out and truncated=true
docker run -d --name captest alpine sh -c 'for i in $(seq 1 600); do echo line-$i; done; sleep 3600'

# Byte cap: few lines, each large. Expect truncated=true well under 500 lines.
docker run -d --name bigtest alpine sh -c 'for i in $(seq 1 100); do head -c 4000 /dev/zero | tr "\0" "x"; echo; done; sleep 3600'
```

Call `Logs(ctx, "container", "captest", 0)` and `Logs(ctx, "container", "bigtest", 0)` from a scratch `main.go`. Confirm the first returns 500 lines with `truncated` true, and the second returns ≤256KB with `truncated` true. Then `docker rm -f captest bigtest`.

- [ ] **Step 3: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/workloads/logs.go
git commit -m "feat: agent reads bounded workload logs"
```

---

### Task 6: Proto

**Files:**
- Modify: `proto/vantage/v1/vantage.proto`
- Regenerate: `agent/internal/grpc/pb/`, `server/internal/grpc/pb/`

- [ ] **Step 1: Add the RPC and messages**

Add to the `Vantage` service block:

```protobuf
  rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
```

Add these messages at the end of the file:

```protobuf
// ReportWorkloads carries what a server is running.
//
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
// with workloads empty, and resends with the body only if need_full is set.
message ReportWorkloadsRequest {
  string server_id     = 1;
  string agent_token   = 2;
  string hash          = 3;
  bool   docker_ok     = 4;
  string docker_error  = 5;
  bool   systemd_ok    = 6;
  string systemd_error = 7;
  repeated Workload workloads = 8;  // empty on the offer call
}

message ReportWorkloadsResponse {
  bool need_full = 1;
}

message Workload {
  string kind        = 1;  // "container" | "unit"
  string id          = 2;
  string name        = 3;
  string state       = 4;
  string health      = 5;
  string image       = 6;
  string stack       = 7;
  repeated string ports = 8;
  int32  restarts    = 9;
  string started_at  = 10;  // RFC3339, empty when not running
  bool   protected   = 11;
}

// RefreshWorkloadsCmd carries no payload back. It makes the agent report
// immediately through ReportWorkloads, so there is exactly one writer for the
// server_workloads collection rather than two arriving by different routes.
message RefreshWorkloadsCmd {}

message ControlWorkloadCmd {
  string kind   = 1;
  string id     = 2;
  string action = 3;  // "start" | "stop" | "restart"
}

message WorkloadLogsCmd {
  string kind = 1;
  string id   = 2;
  int32  tail = 3;
}

message WorkloadLogsResult {
  string command_id = 1;
  string text       = 2;
  bool   truncated  = 3;
  string error      = 4;
}
```

- [ ] **Step 2: Add the command and result variants**

In `message ServerCommand`, add three variants to the `oneof command` block using the next free field numbers — **check the file, do not reuse a number**:

```protobuf
    RefreshWorkloadsCmd refresh_workloads = <next>;
    ControlWorkloadCmd  control_workload  = <next+1>;
    WorkloadLogsCmd     workload_logs     = <next+2>;
```

In `message AgentMessage`, add one variant to its `oneof` using the next free number there:

```protobuf
    WorkloadLogsResult workload_logs_result = <next>;
```

- [ ] **Step 3: Regenerate**

Run the project's existing protoc generation command. Find it with:

```bash
grep -rn "protoc" --include=Makefile --include="*.sh" --include="*.yml" . | head
```

Output must land in both `agent/internal/grpc/pb/` and `server/internal/grpc/pb/`.

- [ ] **Step 4: Build both modules and commit**

Run: `cd agent && go build ./... && cd ../server && go build ./...`

```bash
git add proto/ agent/internal/grpc/pb/ server/internal/grpc/pb/
git commit -m "feat: workload registry proto messages"
```

---

### Task 7: Server storage and the result registry

**Files:**
- Create: `server/internal/services/workloads.go`, `server/internal/services/workloadresults.go`
- Modify: `server/internal/grpc/server.go`

**Interfaces:**
- Consumes: `models.ServerWorkloads` (Task 1), the proto types (Task 6).
- Produces: `services.HasWorkloadHash`, `services.StoreWorkloads`, `services.GetWorkloads`, `services.SearchWorkloads`, `services.WorkloadResults.Await/Deliver`, `services.DispatchRefreshWorkloads`, `services.DispatchControlWorkload`, `services.DispatchWorkloadLogs`.

- [ ] **Step 1: Write the storage service**

Create `server/internal/services/workloads.go`:

```go
package services

import (
	"context"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) {
	err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
		"instance_id": instanceID,
		"server_id":   serverID,
		"hash":        hash,
	}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
	if err == mongo.ErrNoDocuments {
		return false, nil
	}
	return err == nil, err
}

// StoreWorkloads replaces a server's workload list.
func StoreWorkloads(instanceID, serverID, hash string, wls []models.Workload,
	dockerOK bool, dockerErr string, systemdOK bool, systemdErr string) error {

	_, err := db.Col("server_workloads").UpdateOne(context.Background(),
		bson.M{"instance_id": instanceID, "server_id": serverID},
		bson.M{"$set": bson.M{
			"hash":          hash,
			"workloads":     wls,
			"collected_at":  time.Now(),
			"docker_ok":     dockerOK,
			"docker_error":  dockerErr,
			"systemd_ok":    systemdOK,
			"systemd_error": systemdErr,
		}},
		options.UpdateOne().SetUpsert(true),
	)
	return err
}

func GetWorkloads(instanceID, serverID string) (*models.ServerWorkloads, error) {
	var sw models.ServerWorkloads
	err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
		"instance_id": instanceID,
		"server_id":   serverID,
	}).Decode(&sw)
	if err == mongo.ErrNoDocuments {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &sw, nil
}

type WorkloadHit struct {
	ServerID string          `json:"server_id"`
	Workload models.Workload `json:"workload"`
}

// SearchWorkloads answers "which servers run image X" — the reason the snapshot
// is stored rather than fetched on demand and discarded.
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
	ctx := context.Background()

	filter := bson.M{"instance_id": instanceID}
	if image != "" {
		filter["workloads.image"] = image
	}

	cur, err := db.Col("server_workloads").Find(ctx, filter)
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	var docs []models.ServerWorkloads
	if err := cur.All(ctx, &docs); err != nil {
		return nil, err
	}

	hits := []WorkloadHit{}
	for _, d := range docs {
		for _, w := range d.Workloads {
			if image != "" && w.Image != image {
				continue
			}
			if stack != "" && w.Stack != stack {
				continue
			}
			if state != "" && w.State != state {
				continue
			}
			hits = append(hits, WorkloadHit{ServerID: d.ServerID, Workload: w})
		}
	}
	return hits, nil
}
```

- [ ] **Step 2: Write the result registry**

Create `server/internal/services/workloadresults.go`, mirroring `stepresults.go` exactly:

```go
package services

import (
	"context"
	"encoding/json"
	"log"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
)

// Workload log results travel back over the bus for the same reason commands
// travel out over it: the pod serving the HTTP request and the pod holding the
// agent's stream are two different processes, and a map in one cannot be read
// by the other.
//
// Await MUST be called before the command is dispatched, or a fast agent
// answers into a channel nobody is listening on yet. See stepresults.go.

type workloadResultRegistry struct{}

var WorkloadResults = &workloadResultRegistry{}

func (r *workloadResultRegistry) Await(commandID string) (<-chan *pb.WorkloadLogsResult, func()) {
	out := make(chan *pb.WorkloadLogsResult, 1)

	ctx, cancel := context.WithCancel(context.Background())
	raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
	if err != nil {
		log.Printf("workload results: subscribe for %s: %v", commandID, err)
		cancel()
		close(out)
		return out, func() {}
	}

	go func() {
		defer close(out)
		select {
		case <-ctx.Done():
			return
		case b, ok := <-raw:
			if !ok {
				return
			}
			var res pb.WorkloadLogsResult
			if err := json.Unmarshal(b, &res); err != nil {
				log.Printf("workload results: undecodable result for %s: %v", commandID, err)
				return
			}
			out <- &res
		}
	}()

	return out, func() {
		cancel()
		unsub()
	}
}

// Deliver publishes a result received from an agent. Called on the pod holding
// that agent's stream, which is not usually the pod waiting for it.
func (r *workloadResultRegistry) Deliver(res *pb.WorkloadLogsResult) {
	if res == nil || res.CommandId == "" {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
	defer cancel()
	if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
		log.Printf("workload results: publish for %s: %v", res.CommandId, err)
	}
}
```

- [ ] **Step 3: Write the dispatch wrappers**

Append to `server/internal/services/workloads.go`, following how `DispatchRunStep` in `dispatch.go` builds a command and how `dispatchAndWait` orders subscription before dispatch:

```go
// DispatchRefreshWorkloads asks an agent to report immediately. It returns as
// soon as the agent acks; the caller refetches the stored document.
func DispatchRefreshWorkloads(serverID string) error { … }

// DispatchControlWorkload runs a control action and waits for the result.
func DispatchControlWorkload(serverID, kind, id, action string) error { … }

// DispatchWorkloadLogs fetches a bounded log snapshot.
//
// Await is called BEFORE dispatch. Reversing those two lines introduces a race
// that only shows under load, on a fast agent.
func DispatchWorkloadLogs(serverID, kind, id string, tail int) (text string, truncated bool, err error) { … }
```

Fill each in against the actual `commandDispatcher` API in `dispatch.go` — generate a command ID the same way `DispatchRunStep` does, and reuse its ack-error handling so an offline agent surfaces as `ErrAgentNotConnected`.

- [ ] **Step 4: Add the gRPC handler and route the result**

In `server/internal/grpc/server.go`, add a `ReportWorkloads` handler following `ReportUpdates`: validate the agent token, answer `need_full: !known` on the offer call (empty `workloads`), otherwise convert to `[]models.Workload` and call `StoreWorkloads`.

Then in `CommandStream` (near line 165), where `CommandResult` and `StepResult` are already routed, add the new variant:

```go
		if r := msg.GetWorkloadLogsResult(); r != nil {
			services.WorkloadResults.Deliver(r)
			continue
		}
```

Match the surrounding switch or if-chain style exactly.

- [ ] **Step 5: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/services/workloads.go server/internal/services/workloadresults.go server/internal/grpc/server.go
git commit -m "feat: store workload reports and route log results"
```

---

### Task 8: Agent wiring

**Files:**
- Modify: `agent/internal/sync/sync.go`, `agent/internal/grpc/client.go`

**Interfaces:**
- Consumes: `workloads.Collect`, `workloads.Hash`, `workloads.Control`, `workloads.Logs` (Tasks 2–5); the proto types (Task 6).
- Produces: a 60s report loop and three command handlers.

- [ ] **Step 1: Add the client method**

In `agent/internal/grpc/client.go`, following `ReportUpdates`:

```go
// ReportWorkloads sends a workload report and returns whether the server wants
// the full list.
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	resp, err := c.client.ReportWorkloads(ctx, req)
	if err != nil {
		return false, err
	}
	return resp.GetNeedFull(), nil
}
```

- [ ] **Step 2: Add the report loop**

In `agent/internal/sync/sync.go`, add a `runWorkloads(ctx, cfg)` following the shape of the existing `runInventory` (line 413), on a 60-second ticker, calling a `reportWorkloads(cfg)` that:

1. returns immediately on non-Linux
2. calls `workloads.Collect(ctx)`
3. computes `workloads.Hash(result.Workloads)`
4. calls `ReportWorkloads` with `Workloads` empty
5. on `need_full`, calls again with the list populated

Convert `workloads.Workload` to `*pb.Workload`, formatting `StartedAt` as RFC3339 and sending `""` for the zero time.

Start it from the same place the other goroutines are started (`Run`, near line 31):

```go
	go runWorkloads(ctx, cfg)
```

- [ ] **Step 3: Add the three command handlers**

In the command stream's dispatch switch, alongside `handleApplyUpdates` and `handleOpenProxy`:

```go
// handleRefreshWorkloads makes the agent report immediately. It sends nothing
// back beyond the ack: the refresh is a nudge, not a channel, so there is one
// writer for the collection rather than two.
func handleRefreshWorkloads(cfg *config.Config) {
	go reportWorkloads(cfg)
}

func handleControlWorkload(stream pb.Vantage_CommandStreamClient, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) {
	err := workloads.Control(context.Background(), cmd.GetKind(), cmd.GetId(), cmd.GetAction())
	// Report through the existing CommandResult path, matching how
	// handleApplyUpdates replies. On success, report immediately so the UI's
	// refetch shows the new state rather than the old one.
	if err == nil {
		go reportWorkloads(cfg)
	}
	// … send CommandResult{CommandId: commandID, Ok: err == nil, Error: …}
}

func handleWorkloadLogs(stream pb.Vantage_CommandStreamClient, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) {
	text, truncated, err := workloads.Logs(context.Background(), cmd.GetKind(), cmd.GetId(), int(cmd.GetTail()))
	res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated}
	if err != nil {
		res.Error = err.Error()
	}
	// … send AgentMessage{WorkloadLogsResult: res}
}
```

Fill in the send calls against the exact `AgentMessage` construction the neighbouring handlers use — the stream send is not the same shape in every file.

- [ ] **Step 4: Verify end to end against a dev instance**

With a dev server and one Linux agent:

1. Confirm a `server_workloads` document appears within 60s.
2. Confirm a second cycle with nothing changed sends no body — add a temporary log line at the `need_full` branch if the traffic is not otherwise visible, then remove it.
3. Start a container and confirm the next report includes it.
4. Dispatch a control action and confirm the state changes and a fresh report follows.
5. Dispatch a control action against `vantage-agent.service` and **confirm it is refused and the agent stays up**.

- [ ] **Step 5: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/sync/sync.go agent/internal/grpc/client.go
git commit -m "feat: agent reports workloads and handles workload commands"
```

---

### Task 9: REST API

**Files:**
- Create: `server/internal/api/workloads.go`
- Modify: `server/internal/api/handlers.go`

- [ ] **Step 1: Write the handlers**

Create `server/internal/api/workloads.go`, following the conventions in `handlers.go` — `auth.InstanceID(c)` for scoping, `actorFromCtx(c)` for the audit actor:

- `getServerWorkloads` — `GET /api/servers/:id/workloads`. Returns the stored document. A server with no document yet returns an empty list with `docker_ok: false, systemd_ok: false` rather than 404 — the agent may simply not have reported yet.
- `refreshServerWorkloads` — `POST /api/servers/:id/workloads/refresh`. Calls `DispatchRefreshWorkloads`. Answers **503** when the dispatcher reports the agent is not connected; the client then shows the stored snapshot as stale rather than pretending.
- `controlWorkload` — `POST /api/servers/:id/workloads/:wid/action`, body `{"action":"start|stop|restart"}`. Owner or admin. **Answers 409 when the agent refuses a protected workload**, with the reason in the message — not 500, since nothing failed. Audit event `workload.<action>` naming the target.
- `getWorkloadLogs` — `GET /api/servers/:id/workloads/:wid/logs?tail=`. Owner or admin. Clamps `tail` to 500 server-side rather than erroring. Audit event `workload.logs_read`.
- `listWorkloads` — `GET /api/workloads?image=&stack=&state=`. Fleet-wide, calls `SearchWorkloads`.

`:wid` arrives URL-encoded — decode it before use. Unit names carry dots and `@`.

- [ ] **Step 2: Register the routes**

In `server/internal/api/handlers.go`, inside the `apiGroup` block:

```go
		apiGroup.GET("/workloads", listWorkloads)
		apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
		apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
		apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
		apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
```

**Check `auth.RequireRole`'s actual variadic signature** before using it with two arguments. Note also that gin resolves static segments ahead of wildcards, so `/servers/:id/workloads/refresh` and `/servers/:id/workloads/:wid/action` coexist — the same arrangement `/servers/new` and `/servers/:id` already use.

- [ ] **Step 3: Verify by hand**

Against a dev instance: a member session gets 200 on the workload list and **403** on both the action and the logs endpoints; an action against the agent's own unit returns **409**; a refresh against a stopped agent returns **503**; `?tail=99999` returns at most 500 lines.

- [ ] **Step 4: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/api/
git commit -m "feat: workload registry REST API"
```

---

### Task 10: Web UI

**Files:**
- Create: `web/app/(app)/workloads/page.tsx`, `web/components/workloads/{WorkloadList,WorkloadRow,LogDialog}.tsx`
- Modify: `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/servers/[id]/page.tsx`

- [ ] **Step 1: Add API client types and methods**

In `web/lib/api.ts`, add `Workload`, `ServerWorkloads` and `WorkloadHit` types mirroring the Go JSON tags exactly, plus one client method per Task 9 route. Follow the file's existing conventions.

- [ ] **Step 2: Build the server-detail tab**

Add a **Workloads** tab to `web/app/(app)/servers/[id]/page.tsx`, following the existing tab pattern, rendering `WorkloadList`.

`WorkloadList` orders: **compose stacks first, grouped under the stack name**, then loose containers, then units. Not cosmetic — a stack is one thing to an operator even when it is six containers, and a flat list turns one decision into six rows.

Opening the tab dispatches a refresh, then refetches. Show the `collected_at` age while it is in flight.

Three rules from the model:
- **`docker_ok: false` with no `docker_error` reads "Docker not in use on this server"** — never an empty list, and never alarming. With a `docker_error`, show that instead: it is a real problem with a real fix.
- **`protected` rows render their action buttons disabled with the reason**, rather than offering a button whose refusal is already known.
- State never reads by colour alone: every pill carries a distinct shape and a text label, matching the existing monitor pills. Container and unit states use their own vocabularies — do not map them onto a shared one.

- [ ] **Step 3: Build the log dialog**

Create `web/components/workloads/LogDialog.tsx`: monospace, on the `--well` token (the floor beneath the ground, used for machine output), scrolled to the bottom on open. When `truncated`, show a banner saying the output was capped — the user must not read a truncated log as a complete one.

- [ ] **Step 4: Build the fleet view**

Create `web/app/(app)/workloads/page.tsx`: filters for image, stack and state; rows link to their server. Add a **Workloads** entry to `web/components/Sidebar.tsx`.

- [ ] **Step 5: Verify**

Run: `cd web && npm run lint && npm run build`
Expected: exit 0, no type errors.

Then against a dev instance confirm: stacks group, a protected row's buttons are disabled, a server without Docker says "not in use" rather than showing nothing, a truncated log shows its banner, and a restart updates the row without a manual reload.

- [ ] **Step 6: Commit**

```bash
git add web/
git commit -m "feat: workload registry UI"
```

---

### Task 11: Instance deletion and documentation

- [ ] **Step 1: Add the collection to instance deletion**

Find the instance-deletion routine — the one place that knows which collections carry `instance_id`:

```bash
cd server && grep -rn "instance_id" internal/services/ | grep -i "delete\|purge\|reap"
```

Add `server_workloads` to its collection list. Missing this orphans a tenant's data indefinitely.

- [ ] **Step 2: Verify the whole tree builds**

From the repo root:

```bash
go build ./... && go vet ./...
```

Expected: exit 0.

- [ ] **Step 3: Document it**

Add a **Workload registry** subsystem section to `CLAUDE.md` covering: why refresh does not return data (one writer); why the protected set lives agent-side; why there is no live log following; and the `DockerOK`/`DockerError` distinction. Add a user-facing page under `docsite/docs/vantage/`.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "docs: document the workload registry"
```

---

## Spec coverage

- Workload as the domain word, containers and units in one collection → Task 1.
- `DockerOK`/`DockerError` distinction → Tasks 1, 2, 10.
- Docker via `ps -aq` + `inspect`, no English parsing → Task 2.
- Compose stacks from the label, no YAML → Task 2.
- systemd two-pass with the exclusion filter, column output not JSON → Task 3.
- Hash order-independence → Task 3.
- Protected set agent-side, `vantage-agent.service` and own container → Task 4.
- Control timeouts returning real errors → Task 4.
- Log caps in both directions, no follow mode → Task 5.
- Refresh returns no data; one writer → Tasks 6, 8.
- `Await` before dispatch → Task 7.
- 503 offline, 409 protected, owner|admin on control and logs → Task 9.
- Stacks grouped first; protected rows disabled; truncation stated → Task 10.
- Instance-deletion collection list → Task 11.

## Known gaps the executing engineer must close

- **Three dispatch wrappers are specified by signature and doc comment, not body** (Task 7, Step 3). They must be written against the actual `commandDispatcher` API in `dispatch.go`, reusing how `DispatchRunStep` generates a command ID and handles ack errors. `DispatchWorkloadLogs` must call `Await` **before** dispatch.
- **The agent's stream-send calls are left to match their neighbours** (Task 8, Step 3). `AgentMessage` construction is not identical across handlers, so copy the adjacent one rather than the sketch.
- **`auth.RequireRole`'s variadic signature must be confirmed** before Task 9's two-argument use.
- **No automated tests, by instruction.** The manual checks in Tasks 2, 3, 4, 5, 8, 9 and 10 are the substitute. Task 4's is the one that matters most: get the protected set wrong in the permissive direction and a server can take itself offline in a way the UI cannot undo.
