slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)

The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:59 +02:00
parent aa4dfb75ea
commit 8ecf8929fb
19 changed files with 836 additions and 59 deletions
+55 -6
View File
@@ -1,5 +1,7 @@
package hub
import "encoding/json"
// HostReport is the wire contract shared with the hub's ingest
// (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub
// field-for-field. Encoding is ordinary encoding/json (no canonicalization —
@@ -233,15 +235,62 @@ type PBSSnapshot struct {
type AuditEntry struct{} // audit-log tail entry fields TBD
// ControlEnvelope is the hub's 200 response to a host-report. This slice the agent
// adopts ONLY PollIntervalSeconds; the rest are reserved/forward-compat fields it
// logs at most and never acts on (reconcile, slice 4, consumes them).
// ControlEnvelope is the hub's 200 response to a host-report — the "Down" channel (slice 10A).
// It is a cheap change-notification on every heartbeat: the agent adopts PollIntervalSeconds,
// and when DesiredGeneration ADVANCES past its cached one it fetches the full desired-state from
// GET /hosts/{id}/desired-state (the heavy state moves only on change). HasSignedOps flags a
// non-empty signed-jobs queue (the agent fetches/executes them in 10B). Blocked stays reserved.
type ControlEnvelope struct {
Status string `json:"status"`
// PollIntervalSeconds is a pointer so a missing field (keep current interval) is
// distinguishable from an explicit 0.
PollIntervalSeconds *int `json:"poll_interval_seconds"`
Blocked bool `json:"blocked"` // reserved — ignored (slice 4)
DesiredGeneration int64 `json:"desired_generation"` // reserved — ignored (slice 4)
HasSignedOps bool `json:"has_signed_ops"` // reserved — ignored (slice 4)
Blocked bool `json:"blocked"` // reserved — ignored
DesiredGeneration int64 `json:"desired_generation"` // slice 10A: the cached-vs-current change signal
HasSignedOps bool `json:"has_signed_ops"` // slice 10A: signed-jobs queue non-empty (exec 10B)
}
// DesiredStateResponse is GET /hosts/{host_id}/desired-state (slice 10A — the "Down" channel's
// heavy payload, fetched only when the envelope's generation advances). Generation is the
// generation this state corresponds to, so the agent caches state+generation atomically. This is
// a cross-repo wire contract (DUPLICATED in felhom.eu/hub until a shared module exists); the
// desired-state golden stays byte-identical across the two repos.
type DesiredStateResponse struct {
Generation int64 `json:"generation"`
DesiredState WireDesiredState `json:"desired_state"`
}
// WireDesiredState is the hub's authoritative per-host target (slice 10A). The agent reconciles the
// parts it can today (guests: benign deltas reconciled, an explicit decommission gated
// pending_signature); the rest are FORWARD-COMPAT — carried + cached, NOT acted on in 10A. The
// restore_directive is consumed in 10D (host/guest-loss DR); storage_manifest / backup_policy /
// pbs_namespace are placeholders kept opaque so the wire is stable as those land.
type WireDesiredState struct {
Guests []WireDesiredGuest `json:"guests"`
StorageManifest json.RawMessage `json:"storage_manifest,omitempty"`
BackupPolicy json.RawMessage `json:"backup_policy,omitempty"`
PBSNamespace string `json:"pbs_namespace,omitempty"`
RestoreDirective *WireRestoreDirective `json:"restore_directive,omitempty"` // slice 10D (forward-compat)
}
// WireDesiredGuest is one guest's target (slice 10A). Every field is optional ("unmanaged"); the
// agent's planner acts only on the fields that are set. Run is running|stopped|""; Spec reuses
// GuestSpec (cores/memory_bytes/disk_bytes); Decommission is the EXPLICIT destructive delta (gated
// pending_signature in 10A — executor is 10B).
type WireDesiredGuest struct {
VMID int `json:"vmid"`
Run string `json:"run,omitempty"`
Spec *GuestSpec `json:"spec,omitempty"`
Description *string `json:"description,omitempty"`
Decommission bool `json:"decommission,omitempty"`
}
// WireRestoreDirective is the forward-compat restore directive (slice 10D — host/guest-loss DR).
// Defined now so the wire contract is stable; 10A carries it through to the cache but does NOT
// consume it (no restore is initiated from desired-state in 10A).
type WireRestoreDirective struct {
Mode string `json:"mode,omitempty"` // guest_loss | host_loss (10D vocabulary)
Archive string `json:"archive,omitempty"` // source archive/snapshot to restore from
VMID int `json:"vmid,omitempty"`
}