267 lines
11 KiB
Go
267 lines
11 KiB
Go
// Command mesh-delivery-forge-bridge-tools serves the forge bridge's tools (novox/hq ADR 0633 §4, issue 1585): the runtime
|
|
// launches it for a call. bridge_groups reads the delivery groups the gate holds and what the bridge kept of their ends;
|
|
// bridge_group_end_again writes an ask into the bridge's state end-asks and waits for the bridge's answer there, since the
|
|
// bridge is the other program and serves no request subject (ADR 0437 §4).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
|
|
|
"mesh-delivery-forge-bridge/internal/bridge"
|
|
"mesh-delivery-forge-bridge/internal/groups"
|
|
)
|
|
|
|
// Tool names.
|
|
const (
|
|
GroupsTool = "bridge_groups"
|
|
EndAgainTool = "bridge_group_end_again"
|
|
// MergedTool answers the merged message of a merge on request (issue 1585).
|
|
MergedTool = "bridge_merged_message"
|
|
)
|
|
|
|
// asker writes an end-asks value and waits for the bridge's answer to it.
|
|
type asker func(key string, value []byte) (groups.EndAsk, error)
|
|
|
|
// Tools are the bridge's tools: groupsRead reads the groups the gate holds with what the bridge kept of them.
|
|
func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []string, error), merged func(repository, merge string) (json.RawMessage, error)) []stdio.Tool {
|
|
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
|
return []stdio.Tool{
|
|
{Name: MergedTool,
|
|
Description: "The merged message of one merge, asked again (issue 1585): the message the bridge wrote on its stream FORGE when the " +
|
|
"pull request was merged into the trunk, built the same way from the forge (the merge commit's trees and the manifests " +
|
|
"and lock files of its module directories) and from the gate's merges state (the pull request's number, head and " +
|
|
"group). For the indexer, which asks it when it takes in a held merge it does not hold in memory (a restart, a new " +
|
|
"build), and the ask-again a lost message lacked. About 0.5 MB for a repository of many modules. Answers {message} " +
|
|
"with the forge message whole, or why it cannot: the gate holds no merge with that commit, or the forge did not answer.",
|
|
Input: map[string]any{"type": "object", "required": []string{"repository", "merge"}, "properties": map[string]any{
|
|
"repository": map[string]any{"type": "string", "description": "the repository, owner/name, as the forge names it"},
|
|
"merge": map[string]any{"type": "string", "description": "the merge commit, 40 hex digits"}}},
|
|
Run: func(args map[string]any) (any, error) {
|
|
raw, err := merged(str(args, "repository"), str(args, "merge"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"message": raw}, nil
|
|
}},
|
|
{Name: GroupsTool,
|
|
Description: "The delivery groups the gate holds (more than one member), each with its members' merge state and whether each " +
|
|
"merged message is written on the forge stream, and whether the group's end (forge.group.merged) is written: ended, " +
|
|
"or open with why (gathered, or merged and its end not written yet). An open group with every member merged is " +
|
|
"what bridge_group_end_again says the end of. Each group names its last end ask (who, why, when) and the bridge's " +
|
|
"answer to it, from the state end-asks. A group's name narrows it to that group; open lists only open ones.",
|
|
Input: map[string]any{"type": "object", "properties": map[string]any{
|
|
"group": map[string]any{"type": "string", "description": "only this group, by the name the gate keys it"},
|
|
"open": map[string]any{"type": "boolean", "description": "only the groups whose end is not written"}}},
|
|
Run: func(args map[string]any) (any, error) {
|
|
rows, unread, err := groupsRead()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
only, _ := args["open"].(bool)
|
|
group := str(args, "group")
|
|
kept := rows[:0]
|
|
for _, r := range rows {
|
|
if (group != "" && r.Group != group) || (only && !r.Open) {
|
|
continue
|
|
}
|
|
kept = append(kept, r)
|
|
}
|
|
return map[string]any{"groups": kept, "unread": unread}, nil
|
|
}},
|
|
{Name: EndAgainTool,
|
|
Description: "Say a delivery group's end (forge.group.merged) again, by hand, for a group whose end the indexer did not take " +
|
|
"in (a group bridge_groups names as merged with its end not written, or one the indexer waits for). The bridge " +
|
|
"writes it again from the gate's merges, with the time appended to the message id so the stream does not drop it as " +
|
|
"a duplicate; the indexer takes an end for a group it already took in without changing anything. Refused while " +
|
|
"a member is not merged or its merged message is not written. Answers whether it was written.",
|
|
Input: map[string]any{"type": "object", "required": []string{"group"}, "properties": map[string]any{
|
|
"group": map[string]any{"type": "string", "description": "the group, by the name the gate keys it"},
|
|
"why": map[string]any{"type": "string", "description": "why its end is said again, in plain words"}}},
|
|
Run: func(args map[string]any) (any, error) {
|
|
key, value, err := groups.NewEndAsk(str(args, "group"), str(args, "why"), stdio.CallerOf(args), now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a, err := ask(key, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"group": a.Group, "written": a.Written, "answer": a.Answer, "by": a.By, "why": a.Why}, nil
|
|
}},
|
|
}
|
|
}
|
|
|
|
// MergedOf is the merged message of the merge of repository at commit merge, as the bridge writes it: the pull request's
|
|
// number, head and group from the gate's merges (raw, by key), the rest from the forge through trees and files
|
|
// (bridge.MergedMessage), and, for a member of a delivery group, the group and its members as the process sets them.
|
|
func MergedOf(raws map[string][]byte, repository, merge string, trees bridge.Trees, files bridge.Files) (json.RawMessage, error) {
|
|
owner, repo, ok := strings.Cut(repository, "/")
|
|
if !ok || owner == "" || repo == "" {
|
|
return nil, fmt.Errorf("%q is not owner/name", repository)
|
|
}
|
|
for _, key := range slices.Sorted(maps.Keys(raws)) {
|
|
m, err := delivery.DecodeMerge(raws[key])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
at := slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool {
|
|
return delivery.SameRepository(p.Repository, repository) && p.Commit == merge
|
|
})
|
|
if at < 0 {
|
|
continue
|
|
}
|
|
p := m.Pulls[at]
|
|
msg, err := bridge.MergedMessage(owner, repo, p.Number, m.Group, p.Head.Head, merge, trees, files)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the merged message of %s at %s cannot be built: %w", repository, merge, err)
|
|
}
|
|
f, err := forgemsg.DecodeForge(msg.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, approved := m.Approved[delivery.ApprovedKey(f.Repository, f.Number)]; approved && len(m.Approved) >= 2 {
|
|
f.Group, f.Members = m.Group, groups.MembersOf(m)
|
|
}
|
|
out, err := forgemsg.EncodeForge(f)
|
|
return out, err
|
|
}
|
|
return nil, fmt.Errorf("the gate's merges hold no merge of %s with the commit %s, so its pull request is not known", repository, merge)
|
|
}
|
|
|
|
// read is the groups the gate holds with what the bridge kept of them: their ends in performed, and the last ask of
|
|
// each in end-asks with the bridge's answer.
|
|
func read(merges, performed, asks stdio.KeptState) ([]groups.Row, []string, error) {
|
|
keys, err := merges.Keys()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
|
}
|
|
raws := map[string][]byte{}
|
|
for _, k := range keys {
|
|
e, err := merges.Get(k)
|
|
if err != nil || e == nil {
|
|
continue
|
|
}
|
|
raws[k] = e.Value
|
|
}
|
|
pkeys, err := performed.Keys()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("the bridge's performed state cannot be listed: %w", err)
|
|
}
|
|
published, ended := map[string]bool{}, map[string]uint64{}
|
|
for _, k := range pkeys {
|
|
switch {
|
|
case strings.HasPrefix(k, "published."):
|
|
published[k] = true
|
|
case strings.HasPrefix(k, "groups."):
|
|
if e, err := performed.Get(k); err == nil && e != nil {
|
|
rev, _ := strconv.ParseUint(strings.TrimSpace(string(e.Value)), 10, 64)
|
|
ended[strings.TrimPrefix(k, "groups.")] = rev
|
|
}
|
|
}
|
|
}
|
|
akeys, err := asks.Keys()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("the bridge's state %s cannot be listed: %w", groups.EndAsksState, err)
|
|
}
|
|
askRaws := map[string][]byte{}
|
|
for _, k := range akeys {
|
|
if e, err := asks.Get(k); err == nil && e != nil {
|
|
askRaws[k] = e.Value
|
|
}
|
|
}
|
|
rows, unread := groups.Rows(raws, published, ended, askRaws)
|
|
return rows, unread, nil
|
|
}
|
|
|
|
// askEnd writes the end-asks value at key and waits, on a watch of the key, at most bound for the bridge's answer.
|
|
func askEnd(s stdio.KeptState, bound time.Duration) asker {
|
|
return func(key string, value []byte) (groups.EndAsk, error) {
|
|
answered := make(chan groups.EndAsk, 1)
|
|
var rev uint64
|
|
var mu sync.Mutex
|
|
take := func(revision uint64, v []byte) {
|
|
mu.Lock()
|
|
mine := rev
|
|
mu.Unlock()
|
|
if mine == 0 || revision <= mine {
|
|
return
|
|
}
|
|
if a, err := groups.DecodeEndAsk(v); err == nil && a.Done() {
|
|
select {
|
|
case answered <- a:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
if err := s.Watch(key, func(c stdio.StateChange) error {
|
|
if c.Op == "put" {
|
|
take(c.Revision, c.Value)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return groups.EndAsk{}, fmt.Errorf("watching %s.%s: %w", groups.EndAsksState, key, err)
|
|
}
|
|
r, err := s.Put(key, json.RawMessage(value))
|
|
if err != nil {
|
|
return groups.EndAsk{}, fmt.Errorf("the ask could not be written to %s: %w", groups.EndAsksBucket, err)
|
|
}
|
|
mu.Lock()
|
|
rev = r
|
|
mu.Unlock()
|
|
if e, err := s.Get(key); err == nil && e != nil {
|
|
take(e.Revision, e.Value)
|
|
}
|
|
wait, cancel := context.WithTimeout(context.Background(), bound) // a context's bound, no timer (ADR 0437 §8)
|
|
defer cancel()
|
|
select {
|
|
case a := <-answered:
|
|
return a, nil
|
|
case <-wait.Done():
|
|
return groups.EndAsk{}, fmt.Errorf("the bridge did not answer within %s: its answer will be in %s.%s", bound, groups.EndAsksBucket, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// askCaller calls a tool as this bundle's module, through the runtime (stdio.Ask), the module's own invokes granting it.
|
|
func askCaller(_ context.Context, key string, body any) (json.RawMessage, error) {
|
|
return stdio.Ask(key, body)
|
|
}
|
|
|
|
func main() {
|
|
merges, performed, asks := stdio.State(delivery.GateModule+".merges"), stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
|
|
tools := Tools(askEnd(asks, 25*time.Second), time.Now,
|
|
func() ([]groups.Row, []string, error) { return read(merges, performed, asks) },
|
|
func(repository, merge string) (json.RawMessage, error) {
|
|
keys, err := merges.Keys()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
|
}
|
|
raws := map[string][]byte{}
|
|
for _, k := range keys {
|
|
if e, err := merges.Get(k); err == nil && e != nil {
|
|
raws[k] = e.Value
|
|
}
|
|
}
|
|
ctx := context.Background()
|
|
return MergedOf(raws, repository, merge, bridge.TreesVia(ctx, askCaller), bridge.FilesVia(ctx, askCaller))
|
|
})
|
|
if err := stdio.Serve("", tools); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|