carlos / platform Public

settings-4 task 3: the registry's build_state column

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev aca319ee5e715f0c999e6a6a859c1f5288d935d6 parent 10f721d
5 files changed, +200 −12
  • internal/registry/buildstate_test.go +146 −0
  • internal/registry/coldhandover.go +1 −1
  • internal/registry/registry.go +49 −11
  • internal/registry/registry_test.go +1 −0
  • internal/registry/remote_dispatch.go +3 −0
diff --git a/internal/registry/buildstate_test.go b/internal/registry/buildstate_test.go
new file mode 100644
index 000000000..819644036
--- /dev/null
+++ b/internal/registry/buildstate_test.go
@@ -0,0 +1,146 @@
+package registry
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+)
+
+const buildStateCol = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:00Z"}`
+
+func TestBuildStateColumn(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ := reg.Get("a.example.com")
+ if rt.BuildState != "" {
+ t.Fatalf("a new row has build state %q", rt.BuildState)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ col := buildStateCol
+ if err := reg.SetBuildState("a.example.com", col); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.BuildState != col {
+ t.Fatalf("column = %q", rt.BuildState)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after != before {
+ t.Fatal("build state changed the edge's fingerprint")
+ }
+ // A full-row Upsert that does not carry the column keeps it.
+ if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.BuildState != col {
+ t.Fatalf("Upsert cleared the column: %q", rt.BuildState)
+ }
+ if err := reg.SetBuildState("a.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.BuildState != "" {
+ t.Fatal("the narrow setter could not clear the column")
+ }
+ if err := reg.SetBuildState("nobody.example.com", col); err == nil {
+ t.Fatal("an unknown host was not an error")
+ }
+}
+
+// build_state is rewritten (ReadAt) on every reconcile refresh with nothing an
+// operator did behind it, so it must not trigger a registry snapshot (#207).
+func TestBuildStateIsChurnForTheSnapshotGate(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const host = "a.example.com"
+ if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState(host, buildStateCol); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before != after {
+ t.Fatal("SetBuildState moved the snapshot fingerprint")
+ }
+}
+
+// A cold handover INSERTs the whole row positionally: the column must be in
+// that list or every handover fails on column count, and its value must
+// survive.
+func TestBuildStateSurvivesColdHandover(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ ctx := context.Background()
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"}
+ newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"}
+ route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner}
+ if err = reg.Upsert(route); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.SetBuildState(route.Host, buildStateCol); err != nil {
+ t.Fatal(err)
+ }
+ current, err := reg.Get(route.Host)
+ if err != nil || current == nil {
+ t.Fatalf("Get: %v", err)
+ }
+ old := ColdRows{Route: *current}
+ next := ColdRows{Route: *current}
+ next.Route.ClaimOwnership = newOwner
+ targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}}
+ if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
+ t.Fatalf("handover refused: %v", err)
+ }
+ got, err := reg.Get(route.Host)
+ if err != nil || got == nil {
+ t.Fatalf("Get after handover: %v", err)
+ }
+ if got.BuildState != buildStateCol {
+ t.Fatalf("build state after handover = %q", got.BuildState)
+ }
+}
+
+// The host's registry child carries the writer across the transport.
+func TestRemoteSetBuildState(t *testing.T) {
+ r, _ := remoteTestRegistry(t)
+ const host = "remote.example.com"
+ if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.SetBuildState(host, buildStateCol); err != nil {
+ t.Fatal(err)
+ }
+ got, err := r.Get(host)
+ if err != nil || got == nil || got.BuildState != buildStateCol {
+ t.Fatalf("route = %+v, %v", got, err)
+ }
+ if err := r.SetBuildState("nobody.example.com", buildStateCol); err == nil {
+ t.Fatal("an unknown host was not an error")
+ }
+}
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go
index 4d82e992d..d3514a109 100644
--- a/internal/registry/coldhandover.go
+++ b/internal/registry/coldhandover.go
@@ -135,7 +135,7 @@ func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string,
return ErrClaimOwnership
}
t := next.Route
- values := []any{t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings}
+ values := []any{t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState}
if _, err = tx.ExecContext(ctx, `INSERT INTO routes (`+routeCols+`) VALUES (`+strings.TrimSuffix(strings.Repeat("?,", len(values)), ",")+`)`, values...); err != nil {
return err
}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index fe3457f65..0ac7f9d62 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -299,6 +299,14 @@ type Route struct {
// changes what root installs, not what the edge serves.
UpdateSettings string
+ // BuildState is the agent's copy of the build-state object (JSON of
+ // release.BuildStateCopy; "" = never read), refreshed at reconcile and
+ // before every wake. Root reads it. Deliberately NOT in Fingerprint():
+ // it changes what root decides, not what the edge serves. The registry
+ // holds it as an opaque string (release imports registry, not the other
+ // way round).
+ BuildState string
+
// Replicas is the account's replica placements, comma-joined placement
// codes in canonical (sorted, deduplicated) order — the registry half of
// #162. "" on every row that predates the column = no replicas =
@@ -582,6 +590,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN update_settings TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -848,18 +857,22 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 51 names, in this exact order. scanRoute's destinations, Upsert's
-// placeholder row and Upsert's argument list all count from here: adding a
-// column means adding it in FOUR places, and a miscount is a silent column
-// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
-// one).
+// share, 53 names, in this exact order. scanRoute's destinations, Upsert's
+// placeholder row and Upsert's argument list all count from here, as does
+// coldhandover.go's positional values list; adding a column means adding it
+// in SIX places: the Route struct, the migration list, routeCols with
+// scanRoute, Upsert (placeholders, CASE arm and arguments),
+// coldhandover.go's values, and remote_dispatch.go's remoteRequest and
+// dispatch case for its narrow setter. A miscount is a silent column shift
+// rather than an error (TestUpsertRoundTripsAllColumns is what catches one;
+// a missing cold-handover value fails the INSERT on column count).
const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at,
args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op,
-good_version, good_digest, good_demand, serving_version, update_settings`
+good_version, good_digest, good_demand, serving_version, update_settings, build_state`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -871,7 +884,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox,
&t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt,
&t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp,
- &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings)
+ &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings, &t.BuildState)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -1028,7 +1041,7 @@ func (r *Registry) Delete(host string) error {
// SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route
// -revoke`), SetTunnel (`carlos route -tunnel -`), SetUnit (`carlos route
// -unit -`), SetReplication (`carlos route -replication -`), SetSeed,
-// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, and for
+// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, SetBuildState, and for
// `provisioned` the row's own deletion.
//
// `replication` is the one column with a SECOND clearing door, and it is not
@@ -1117,7 +1130,7 @@ func (r *Registry) Upsert(t Route) error {
}
res, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr,
@@ -1146,7 +1159,8 @@ ON CONFLICT(host) DO UPDATE SET
args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END,
idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END,
max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END,
- update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END
+ update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END,
+ build_state=CASE WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END
WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp
AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
@@ -1157,7 +1171,7 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla
b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health,
t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds,
t.Replicas, b2i(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp,
- t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings)
+ t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState)
if err != nil {
return err
}
@@ -1869,6 +1883,29 @@ func (r *Registry) SetUpdateSettings(host, col string) error {
return nil
}
+// SetBuildState stores the agent's copy of a route's build-state object as a
+// narrow UPDATE: reconcile and the wake path refresh it, in both directions,
+// including down to "". Errors on an unmatched host, like SetUpdateSettings.
+func (r *Registry) SetBuildState(host, col string) error {
+ if r.remote != nil {
+ _, err := r.remote.call(nil, remoteRequest{Method: "SetBuildState", Host: host, BuildState: col}, nil)
+ return err
+ }
+
+ res, err := r.sql.Exec(`UPDATE routes SET build_state = ? WHERE host = ?`, col, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetReplicas repoints a route's replica placement set (#162) as a narrow
// UPDATE: the provision pass converges this column every tick from the
// record, and the record's value is authoritative in BOTH directions —
@@ -3131,6 +3168,7 @@ var snapshotChurnCols = map[string]bool{
"next_due": true, // activator scheduling (SetNextDue, every pass)
"dns_checked_at": true, // DNS sweep stamp (SetDNSStatus, every sweep)
"serving_version": true, // rollout stamp (SetServingVersion, every wake and swap)
+ "build_state": true, // the agent's build-state copy (SetBuildState, ReadAt moves every refresh)
}
// snapshotClaimCols are the ownership columns, hashed only when non-default.
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index ca70219d2..089a512d4 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -84,6 +84,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
GoodVersion: "good789", GoodDigest: "sha256:good", GoodDemand: "swap-demand",
ServingVersion: "serve012",
UpdateSettings: `{"updates":"manual","channel":"stable"}`,
+ BuildState: `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}`,
}
if err := reg.Upsert(want); err != nil {
t.Fatal(err)
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go
index 9d84778b3..dcfdfa556 100644
--- a/internal/registry/remote_dispatch.go
+++ b/internal/registry/remote_dispatch.go
@@ -53,6 +53,7 @@ type remoteRequest struct {
Health string
Steering string
UpdateSettings string
+ BuildState string
Replicas string
Replica bool
Hash string
@@ -212,6 +213,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback
err = r.SetSteering(q.Host, q.Steering)
case "SetUpdateSettings":
err = r.SetUpdateSettings(q.Host, q.UpdateSettings)
+ case "SetBuildState":
+ err = r.SetBuildState(q.Host, q.BuildState)
case "SetReplicas":
err = r.SetReplicas(q.Host, q.Replicas)
case "SetReplica":