H-8: a want's parts are checked by the digests the composition fetches
A want with parts is applied only when every fetched archive, process and container digest is among the parts and every part is a fetched digest; otherwise the module keeps running what it has and its have is held with detail parts-differ naming the digests. A want with no parts is applied as before. Builds: design 47 G §524, §379 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -1601,6 +1601,24 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
|
||||
ranBefore = ranBefore || a.prior[module]
|
||||
a.mu.Unlock()
|
||||
|
||||
// A want with parts is applied only when they and the digests its composition fetches agree (design 47 G §524): the
|
||||
// module keeps running what it has, and the have says which digest differs.
|
||||
if words := partsDifference(w.Want.Parts, comp.C.Resources); words != "" {
|
||||
a.mu.Lock()
|
||||
prev, runs := a.running[module]
|
||||
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev}
|
||||
a.mu.Unlock()
|
||||
if !runs {
|
||||
if h, found, _ := a.readHave(ctx, module); found {
|
||||
prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed}
|
||||
}
|
||||
}
|
||||
a.env.Say(fmt.Sprintf("%s is held: %s", module, words))
|
||||
a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld),
|
||||
Detail: words})
|
||||
return
|
||||
}
|
||||
|
||||
// A data module is backed up before a new composition of it moves (ADR 0429 §14), unless the want says to go on
|
||||
// without one (ADR 0539 §16).
|
||||
if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip {
|
||||
|
||||
@@ -322,6 +322,50 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) {
|
||||
if got := b.appliedList(); len(got) != 1 {
|
||||
t.Fatalf("a want naming another digest applied the composition: %v", got)
|
||||
}
|
||||
|
||||
// Design 47 G section 524: a want with parts is applied only when its parts and the digests the composition fetches
|
||||
// agree; a want with no parts is applied as before.
|
||||
d1, d2, d3 := "sha256:"+strings.Repeat("1", 64), "sha256:"+strings.Repeat("2", 64), "sha256:"+strings.Repeat("3", 64)
|
||||
archive := func(id, digest string) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"id": "web." + id, "type": "archive", "path": "/tmp/r6/web/" + id,
|
||||
"source": "https://store/" + id, "digest": digest, "module": "web"})
|
||||
return raw
|
||||
}
|
||||
for i, row := range []struct {
|
||||
name string
|
||||
parts map[string]string
|
||||
applies bool
|
||||
detail string
|
||||
}{
|
||||
{"agree", map[string]string{"web:a": d1, "web:b": d2}, true, ""},
|
||||
{"no parts", nil, true, ""},
|
||||
{"fetched outside", map[string]string{"web:a": d1}, false, PartsDiffer + " fetched and not in the want's parts: " + d2},
|
||||
{"part not fetched", map[string]string{"web:a": d1, "web:b": d2, "web:c": d3}, false,
|
||||
PartsDiffer + " in the want's parts and not fetched: " + d3},
|
||||
} {
|
||||
before := len(b.appliedList())
|
||||
fpn := fpOf(string("5678"[i]))
|
||||
ckn, crawn, dign := composition(t, "web", fpn, false, archive("a", d1), archive("b", d2))
|
||||
b.comps.put(ckn, crawn)
|
||||
wkn := meshwants.WantsByWantNodeModuleKey(node, "web")
|
||||
wrawn, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpn, Composed: dign, Parts: row.parts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.wants.put(wkn, wrawn)
|
||||
b.idle()
|
||||
if got := len(b.appliedList()) - before; (got == 1) != row.applies {
|
||||
t.Fatalf("parts %q: applied %d times, want applies=%v", row.name, got, row.applies)
|
||||
}
|
||||
if h := b.haves.last("web"); !row.applies && (h.Phase != string(nodeengineclient.PhaseHeld) || h.Detail != row.detail) {
|
||||
t.Fatalf("parts %q: the have is %s %q, want held %q", row.name, h.Phase, h.Detail, row.detail)
|
||||
}
|
||||
}
|
||||
// A container's digest is the one after its image's @.
|
||||
container := json.RawMessage(`{"id":"web.c","type":"container","name":"web-c","image":"store/web@` + d3 + `","module":"web"}`)
|
||||
if got := partsDifference(map[string]string{"web:c": d3}, []json.RawMessage{container}); got != "" {
|
||||
t.Fatalf("a container whose digest is in the parts differs: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R6-A AC2: given a kept declaration and wants and compositions equal to it, when the agent starts, nothing is applied
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// PartsDiffer is the start of the detail of a have held because a want's parts and the digests its composition fetches
|
||||
// disagree (design 47 G section 524).
|
||||
const PartsDiffer = "parts-differ:"
|
||||
|
||||
// partsDifference checks a want's `parts` against the digests of the archive, process and container resources of the
|
||||
// composition, which are the digests the apply fetches and checks (design 47 G section 524). A want with no `parts` (an
|
||||
// assigner of before G, or a node whose record does not state `want-parts`) is applied as today: it returns "". With
|
||||
// `parts`, the want is applied only when the digest of every resource fetched is among the values of `parts` and every
|
||||
// value of `parts` is the digest of a resource fetched; otherwise it returns the detail naming each digest.
|
||||
func partsDifference(parts map[string]string, resources []json.RawMessage) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
fetched := map[string]bool{}
|
||||
for _, raw := range resources {
|
||||
r, err := declaration.ParseResource(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch res := r.(type) {
|
||||
case *declaration.Archive:
|
||||
if res.Source != "" {
|
||||
fetched[res.Digest] = true
|
||||
}
|
||||
case *declaration.Process:
|
||||
if res.Source != "" {
|
||||
fetched[res.Digest] = true
|
||||
}
|
||||
case *declaration.Container:
|
||||
if strings.HasPrefix(res.Image, "sha256:") {
|
||||
fetched[res.Image] = true
|
||||
} else if _, digest, ok := strings.Cut(res.Image, "@"); ok {
|
||||
fetched[digest] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
named := map[string]bool{}
|
||||
for _, d := range parts {
|
||||
named[d] = true
|
||||
}
|
||||
var outside, missing []string
|
||||
for d := range fetched {
|
||||
if !named[d] {
|
||||
outside = append(outside, d)
|
||||
}
|
||||
}
|
||||
for d := range named {
|
||||
if !fetched[d] {
|
||||
missing = append(missing, d)
|
||||
}
|
||||
}
|
||||
if len(outside) == 0 && len(missing) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(outside)
|
||||
sort.Strings(missing)
|
||||
var said []string
|
||||
if len(outside) > 0 {
|
||||
said = append(said, "fetched and not in the want's parts: "+strings.Join(outside, ", "))
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
said = append(said, "in the want's parts and not fetched: "+strings.Join(missing, ", "))
|
||||
}
|
||||
return fmt.Sprintf("%s %s", PartsDiffer, strings.Join(said, "; "))
|
||||
}
|
||||
Reference in New Issue
Block a user