From 5094abded9d47a49acdc8d57828ce1fa9b340edd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:40:36 +0100 Subject: [PATCH] C1c-3: the delivery SDK gives askers one reader of a run's answer from a step-ended StepEnded.ReadOutput takes the inline `output`, else asks the seat's verb output on the node that ran the ask for the object `output-object` names (the runtime hands the chunks as the member `file`, A-249); a step-ended with both is refused. Builds: design 47 G A-254, A-240, A-247 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-delivery-sdk/buildstep/stepoutput.go | 60 ++++++++++++++++++ .../buildstep/stepoutput_test.go | 63 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 modules/mesh-delivery-sdk/buildstep/stepoutput.go create mode 100644 modules/mesh-delivery-sdk/buildstep/stepoutput_test.go diff --git a/modules/mesh-delivery-sdk/buildstep/stepoutput.go b/modules/mesh-delivery-sdk/buildstep/stepoutput.go new file mode 100644 index 00000000..9cf25f4b --- /dev/null +++ b/modules/mesh-delivery-sdk/buildstep/stepoutput.go @@ -0,0 +1,60 @@ +package buildstep + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" +) + +// OutputVerb is the verb of a build seat that answers a run's answer kept as an object (design 47 G A-254). +const OutputVerb = "output" + +// AskVerb asks a seat verb as the runtime calls it (stdio.Ask): the key `seat:.[@]` and the body in, the +// verb's answer out. A verb marked chunks is asked by the plain call, and the runtime hands the whole file in `file` (A-249). +type AskVerb func(key string, body any) (json.RawMessage, error) + +// ReadOutput is the answer a build seat's step-ended carries (design 47 G A-254): its inline `output`, else the object +// `output-object` names, read through the verb `output` of the seat of language on the node that ran the ask. Every asker of a +// run reads the answer this way, whichever build seat ran it; none reads the seat's step state. +func (e StepEnded) ReadOutput(language string, ask AskVerb) ([]byte, error) { + if e.OutputObject == "" { + return []byte(e.Output), nil + } + seat, err := BuildSeat(language) + if err != nil { + return nil, err + } + if ask == nil { + return nil, errors.New("buildstep: the answer is in the output object " + e.OutputObject + ", which this reader has no verb to read") + } + var call [16]byte + if _, err := rand.Read(call[:]); err != nil { + return nil, err + } + raw, err := ask("seat:"+seat+"."+OutputVerb+"@"+e.Node, map[string]any{"ask": e.Ask, "call": hex.EncodeToString(call[:])}) + if err != nil { + return nil, fmt.Errorf("buildstep: asking %s for the answer %s of the ask %s: %w", seat, e.OutputObject, e.Ask, err) + } + var got struct { + Ask string `json:"ask"` + File string `json:"file"` + Chunks int `json:"chunks"` + } + if err := json.Unmarshal(raw, &got); err != nil { + return nil, fmt.Errorf("buildstep: the verb output of %s answered no answer for %s: %w", seat, e.Ask, err) + } + if got.Ask != e.Ask { + return nil, fmt.Errorf("buildstep: the verb output of %s answered the ask %q, asked %s", seat, got.Ask, e.Ask) + } + if got.File == "" { + return nil, fmt.Errorf("buildstep: %s on %s keeps no answer of the ask %s", seat, e.Node, e.Ask) + } + body, err := base64.StdEncoding.DecodeString(got.File) + if err != nil { + return nil, fmt.Errorf("buildstep: the answer of %s in no base64: %w", e.Ask, err) + } + return body, nil +} diff --git a/modules/mesh-delivery-sdk/buildstep/stepoutput_test.go b/modules/mesh-delivery-sdk/buildstep/stepoutput_test.go new file mode 100644 index 00000000..d5b6cbb1 --- /dev/null +++ b/modules/mesh-delivery-sdk/buildstep/stepoutput_test.go @@ -0,0 +1,63 @@ +package buildstep + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// Design 47 G A-254: one reader of a run's answer from a step-ended, inline or through the seat's verb output on the node that ran it. +func TestStepEndedReadOutput(t *testing.T) { + const ask = "m2kx9a1b3c4d5f" + var key string + var body map[string]any + verb := func(answer string, err error) AskVerb { + return func(k string, b any) (json.RawMessage, error) { + key = k + body, _ = b.(map[string]any) + return json.RawMessage(answer), err + } + } + for _, c := range []struct { + name string + end StepEnded + ask AskVerb + want string + wantKey string + fail string + }{ + {name: "inline", end: StepEnded{Ask: ask, Node: "ace", Output: `{"a":1}`}, want: `{"a":1}`}, + {name: "an object is read through the verb on its node", end: StepEnded{Ask: ask, Node: "ace", OutputObject: OutputObjectName(ask)}, + ask: verb(`{"ask":"`+ask+`","file":"eyJhIjoxfQ=="}`, nil), want: `{"a":1}`, wantKey: "seat:node-build-go.output@ace"}, + {name: "no answer kept", end: StepEnded{Ask: ask, Node: "ace", OutputObject: "o"}, ask: verb(`{"ask":"`+ask+`","chunks":0}`, nil), fail: "keeps no answer"}, + {name: "another ask's answer", end: StepEnded{Ask: ask, Node: "ace", OutputObject: "o"}, ask: verb(`{"ask":"x","file":"e30="}`, nil), fail: "answered the ask"}, + {name: "the verb fails", end: StepEnded{Ask: ask, Node: "ace", OutputObject: "o"}, ask: verb(``, errors.New("no seat")), fail: "no seat"}, + {name: "no verb", end: StepEnded{Ask: ask, Node: "ace", OutputObject: "o"}, fail: "no verb"}, + } { + t.Run(c.name, func(t *testing.T) { + key, body = "", nil + got, err := c.end.ReadOutput("go", c.ask) + if c.fail != "" { + if err == nil || !strings.Contains(err.Error(), c.fail) { + t.Fatalf("err %v, want %q", err, c.fail) + } + return + } + if err != nil || string(got) != c.want || key != c.wantKey { + t.Fatalf("got %q, %v on %q, want %q on %q", got, err, key, c.want, c.wantKey) + } + if c.wantKey != "" && (body["ask"] != ask || len(body["call"].(string)) != 32) { + t.Fatalf("asked with %v, want the ask and 128 random bits as call", body) + } + }) + } +} + +// A step-ended names its answer inline or as an object, never both. +func TestStepEndedRefusesAnswerTwice(t *testing.T) { + e := StepEnded{ID: "m2kx9a1b3c4d5f", Ask: "m2kx9a1b3c4d5f", Asker: "a", Node: "ace", Outcome: OutcomePassed, Output: "1", OutputObject: "o"} + if _, err := EncodeStepEnded(e); err == nil || !strings.Contains(err.Error(), "inline and as an object") { + t.Fatalf("err %v", err) + } +}