slice 10A: hub desired-state serving + signed-jobs queue (Down channel) (hub v0.9.0)

Serve operator intent to authenticated hosts: PUT /admin/hosts/{id}/desired-state
(global key) bumps desired_generation; GET /hosts/{id}/desired-state + /jobs are
per-host self-scoped; the host-report envelope now carries the real generation +
has_signed_ops. New signed_jobs table + store methods. Desired-state stored/served
opaquely (agent owns the schema). Cross-repo golden (envelope + desired-state)
byte-identical with felhom-agent; doc 03 §4/§9 updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:03:14 +02:00
parent f9af3243b9
commit e54f882e70
8 changed files with 669 additions and 30 deletions
+39
View File
@@ -1,5 +1,44 @@
# Felhom Hub — Changelog
## v0.9.0 — slice 10A: desired-state serving + signed-jobs queue (the "Down" channel) (2026-06-10)
The hub half of slice 10A: the hub now **serves operator intent** down to already-authenticated
hosts. The control envelope (the host-report response) stops returning placeholder
`desired_generation:0 / has_signed_ops:false` and carries the host's **real** generation + a
signed-jobs flag — the cheap change-notification the agent (v0.15.0) acts on. The heavy
desired-state moves only on a dedicated, self-scoped fetch.
### Added
- **`PUT /api/v1/admin/hosts/{host_id}/desired-state`** (global/operator key only) — sets a host's
desired-state and **atomically bumps `desired_generation`**. The body is JSON the hub stores +
serves **opaquely** (it validates only that it is well-formed JSON; the agent/CLI owns the
schema). Unknown host → 404; malformed JSON → 400. Minimal admin path; rich editing UX is later.
- **`GET /api/v1/hosts/{host_id}/desired-state`** (per-host key, **self-scoped** — a host reads
only its own; the global key may read any) — returns `{generation, desired_state}`. The agent
fetches it when the envelope's generation advances past its cache.
- **`GET /api/v1/hosts/{host_id}/jobs`** (per-host key, self-scoped) — serves the host's pending
**opaque** signed-op blobs (oldest first). The hub never forged, opened, or executes them
(verify + run is slice 10B; this only serves the queue).
- **`POST /api/v1/admin/hosts/{host_id}/jobs`** (global key only) — enqueues a pre-signed opaque
job blob. The minimal operator path to seed the queue; the hub holds no signing key.
- **Store**: a new `signed_jobs` table (per-host opaque blob queue); `SetHostDesired` (set + bump
generation, atomic), `EnqueueSignedJob` / `GetSignedJobs` / `CountSignedJobs`. The `hosts` table's
previously-inert `desired_json` / `desired_generation` columns are now live.
### Changed
- The host-report **control envelope** now reports the host's actual `desired_generation` and
`has_signed_ops` (queue non-empty), both degrading safely to their old defaults on a store error
(a heartbeat never fails on the control channel). `poll_interval_seconds` / `blocked` unchanged.
### Tests
- admin-set bumps the generation each write + the served state reflects the latest body; admin-set
is global-key-only (per-host → 403, malformed → 400, unknown host → 404).
- `GET /desired-state` is **self-scoped** (host A's key → host B → 403; global → any; no token → 401).
- the envelope carries the current generation + `has_signed_ops` flips on enqueue; `GET /jobs` is
self-scoped + serves the blobs oldest-first; admin enqueue is global-key-only.
- cross-repo golden round-trip: `testdata/desired-state.golden.json` set → fetched back unchanged
(the opaque pass-through), **byte-identical** with felhom-agent's copy.
## (no version bump) — slice 9 cross-repo wire-contract: `host.cpu_temp_c` (2026-06-10)
Slice 9 adds a nullable **`cpu_temp_c`** field to the shared `HostMetrics` wire struct (the agent's