Merge pull request 'Grant a bar one key of the state it shows, and per-machine state its own machine's key (hq ADR 0260)' (#151) from fix/state-grants-per-key into main

This commit was merged in pull request #151.
This commit is contained in:
2026-10-08 14:09:25 +00:00
12 changed files with 242 additions and 51 deletions
+7 -5
View File
@@ -136,16 +136,18 @@ var barShows = map[string]map[string]barOption{
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
}
// barBlockReads is the state a block shows, if any.
func barBlockReads(data map[string]any) []string {
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
func barBlockReads(data map[string]any) []StateRead {
if data["shows"] != "state" {
return nil
}
options, _ := data["options"].(map[string]any)
if s, ok := options["state"].(string); ok && s != "" {
return []string{s}
s, ok := options["state"].(string)
if !ok || s == "" {
return nil
}
return nil
key, _ := options["key"].(string)
return []StateRead{{Read: s, Key: key}}
}
type barOption struct {
+43 -16
View File
@@ -38,10 +38,10 @@ type Shape struct {
// Examples are pieces every holder's template must render, one for each variant the shape has,
// so a holder that cannot render one is refused at registration and not found on a machine.
Examples []map[string]any
// Reads is the state on the bus a piece shows, as `<module>.<name>`, or nothing (novox/hq ADR
// 0255): a contributor offers only state it keeps itself, and the holder on the same machine is
// granted to read it, so the holder's manifest names no contributor.
Reads func(data map[string]any) []string `json:"-"`
// Reads is the state on the bus a piece shows, each a state and the one key of it, or nothing
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
// machine is granted to read that key, so the holder's manifest names no contributor.
Reads func(data map[string]any) []StateRead `json:"-"`
}
// PlaceField is one field a holder places by, and the values it takes.
@@ -120,25 +120,38 @@ func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []str
keeps[s.Name] = true
}
var problems []string
for _, read := range r.Shape.Reads(c.Data) {
module, local, err := ReadState(read)
for _, sr := range r.Shape.Reads(c.Data) {
module, local, err := ReadState(sr.Read)
switch {
case err != nil:
problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err))
case module != m.Module || !keeps[local]:
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+
"module offers its own state only (novox/hq ADR 0255)", m.Module, i+1, read, m.Module))
"module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module))
}
if sr.Key != "" && !stateKeyName.MatchString(sr.Key) {
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+
"digits and hyphens", m.Module, i+1, sr.Key))
}
}
return problems
}
// StateRead is one key of another module's state that a holder reads: Read is `<module>.<name>`,
// and Key the one key, empty for the holder's own machine until ReadsGranted names it.
type StateRead struct {
Read, Key string
}
// stateKeyName is a key a piece may name: a machine's name is one.
var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a
// kind its seat receives as data (novox/hq ADR 0255), sorted and once each. The bus grants the holder
// these beside its own `reads`: the holder names no contributor, and reads only what is offered to it
// on the machine it runs on.
func ReadsGranted(holder Manifest, onMachine []Manifest) []string {
seen := map[string]bool{}
// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
seen := map[StateRead]bool{}
for _, claim := range holder.Claims {
s, known := SeatNamed(claim.Name)
if !known {
@@ -153,16 +166,30 @@ func ReadsGranted(holder Manifest, onMachine []Manifest) []string {
if cs, ok := SeatNamed(c.Seat); !ok || cs.Name != s.Name || c.Kind != r.Kind {
continue
}
for _, read := range r.Shape.Reads(c.Data) {
if module, _, err := ReadState(read); err == nil && module == m.Module && module != holder.Module {
seen[read] = true
for _, sr := range r.Shape.Reads(c.Data) {
if sr.Key == "" {
sr.Key = machine
}
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
stateKeyName.MatchString(sr.Key) {
seen[sr] = true
}
}
}
}
}
}
return sortedKeys(seen)
out := make([]StateRead, 0, len(seen))
for sr := range seen {
out = append(out, sr)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Read != out[j].Read {
return out[i].Read < out[j].Read
}
return out[i].Key < out[j].Key
})
return out
}
// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float.
+15 -3
View File
@@ -364,14 +364,15 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes
if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) {
t.Fatalf("the block does not read this machine's key:\n%s", bar)
}
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}); len(got) != 1 || got[0] != "power.draw" {
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 ||
got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) {
t.Fatalf("the holder is granted %v", got)
}
// Only the holder, and only for what the modules on its machine offer.
if got := ReadsGranted(power, []Manifest{barHolder(), power}); len(got) != 0 {
if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 {
t.Fatalf("a contributor is granted %v", got)
}
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}); len(got) != 0 {
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 {
t.Fatalf("a holder alone is granted %v", got)
}
}
@@ -393,3 +394,14 @@ func TestAModuleOffersItsOwnStateOnly(t *testing.T) {
t.Errorf("its own state was refused: %v", err)
}
}
func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`))
if err != nil || len(m.State) != 1 || !m.State[0].PerMachine {
t.Fatalf("%+v %v", m.State, err)
}
raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true})
if string(raw) != `{"name":"draw","per-machine":true}` {
t.Fatalf("%s", raw)
}
}
+5 -2
View File
@@ -33,6 +33,9 @@ type StateDeclaration struct {
History int `json:"history,omitempty"`
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
TTLSeconds int `json:"ttl-seconds,omitempty"`
// PerMachine says the state is keyed by machine: each machine's instance writes the key named for
// its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260).
PerMachine bool `json:"per-machine,omitempty"`
}
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
@@ -46,7 +49,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err)
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine}: %w", err)
}
*s = StateDeclaration(full)
return nil
@@ -54,7 +57,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
// MarshalJSON writes back the short form when there is nothing else to say.
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
if s.History == 0 && s.TTLSeconds == 0 {
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine {
return json.Marshal(s.Name)
}
type plain StateDeclaration
+1 -1
View File
@@ -39,7 +39,7 @@ func TestAStateNameIsLocalAndOneToken(t *testing.T) {
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`},
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine}`},
} {
_, err := ParseManifest([]byte(c.manifest))
if err == nil {