The operator, 2026-11-04: logic one module owns lives inside that module as a package of its own, so the take-in step changes no mesh-sdk. TakeIn, its keys and event names, PendingWait and PendingAnswer move to modules/mesh-delivery-indexer/takein (its merges a takein.Merge of its own); Forge whole, with its group fields, ForgeGroupEnd and GroupMerge, moves to modules/mesh-delivery-forge-bridge/forgemsg. Both are nested Go modules that their readers take by replace, as buildrun is. Every importer moves in this commit: the indexer, the bridge, the watchman and the view. Forge's private checks (the repository name, the version) are copied small; mesh-sdk main still holds an unused Forge until its own fold removes it. The indexer, bridge, watchman, view and checker pin mesh-sdk 5a38a12 (origin/main), no feat/take-in commit. The bridge's integration bus now grants the state end-asks that bridge_group_end_again added: without it every test of the package failed on a permissions violation. Test budget: two new test functions (TestForge, TestTakeInEvents) and the two the join added are paid by merging four pairs in internal/bridge; the repository stays at 806 unit tests. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
414 lines
17 KiB
Go
414 lines
17 KiB
Go
// Package forgemsg is the forge bridge's contract on its own stream `forge` (novox/hq ADR 0633 §4): its messages (Forge,
|
|
// one per pull request, merge, refusal or list of open pull requests) and a delivery group's end (ForgeGroupEnd). The
|
|
// forge bridge writes them; the indexer and the gate read them. It is the forge bridge's own package: one module owns
|
|
// these types, so they live with it and not in mesh-sdk (the operator, 2026-11-04).
|
|
package forgemsg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// Version is the version of every value this package writes and the only one it reads: mesh-sdk's delivery values'.
|
|
const Version = delivery.Version
|
|
|
|
// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names.
|
|
const ForgeStream = "forge"
|
|
|
|
// The kinds of forge message, the last part of a forge event's name.
|
|
const (
|
|
ForgePull = "pull"
|
|
ForgeMerged = "merged"
|
|
ForgeRefused = "refused"
|
|
ForgeOpen = "open"
|
|
)
|
|
|
|
// OpenPull names an open pull request into the trunk: the repository as `<owner>/<name>` and the number.
|
|
type OpenPull struct {
|
|
Repository string `json:"repository"`
|
|
Number int `json:"number"`
|
|
}
|
|
|
|
// OpenEvent is the name of an open message: `forge.open`. It names no repository: it says every open pull request of
|
|
// every repository the forge bridge reads (novox/hq ADR 0443 §5).
|
|
func OpenEvent() string { return "forge." + ForgeOpen }
|
|
|
|
// The actions of a pull message.
|
|
const (
|
|
PullOpened = "opened"
|
|
PullUpdated = "updated"
|
|
PullClosed = "closed"
|
|
)
|
|
|
|
// checkRepository refuses a repository that is not `<owner>/<name>`: exactly one slash, each part a valid name.
|
|
func checkRepository(repository string) error {
|
|
owner, name, ok := strings.Cut(repository, "/")
|
|
if !ok || strings.Contains(name, "/") {
|
|
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
|
}
|
|
if err := checkName("repository owner", owner); err != nil {
|
|
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
|
}
|
|
if err := checkName("repository name", name); err != nil {
|
|
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
|
}
|
|
if strings.Contains(owner, "/") {
|
|
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkForgeKind(kind string) error {
|
|
switch kind {
|
|
case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen:
|
|
return nil
|
|
}
|
|
return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused or open", kind)
|
|
}
|
|
|
|
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
|
func ForgeEvent(repository, kind string) (string, error) {
|
|
if err := checkForgeKind(kind); err != nil {
|
|
return "", err
|
|
}
|
|
if kind == ForgeOpen {
|
|
return "", fmt.Errorf("delivery: an open message names no repository, its event is %s", OpenEvent())
|
|
}
|
|
if err := checkRepository(repository); err != nil {
|
|
return "", err
|
|
}
|
|
owner, name, _ := strings.Cut(repository, "/")
|
|
return "forge." + owner + "." + name + "." + kind, nil
|
|
}
|
|
|
|
// ParseForgeEvent is the inverse of ForgeEvent.
|
|
func ParseForgeEvent(event string) (repository, kind string, err error) {
|
|
if event == OpenEvent() {
|
|
return "", ForgeOpen, nil
|
|
}
|
|
parts := strings.Split(event, ".")
|
|
if len(parts) != 4 || parts[0] != "forge" {
|
|
return "", "", fmt.Errorf("delivery: %q is not a forge event, forge.<owner>.<name>.<kind>", event)
|
|
}
|
|
repository = parts[1] + "/" + parts[2]
|
|
if _, err := ForgeEvent(repository, parts[3]); err != nil {
|
|
return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err)
|
|
}
|
|
return repository, parts[3], nil
|
|
}
|
|
|
|
// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull
|
|
// message says Action; a merged message carries the merge commit, the tree of every directory holding a
|
|
// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own
|
|
// words. A pull message may also carry the module directories its head changes (Dirs, in the order the forge said) and
|
|
// DirsKnown, true when the forge could say them: known and empty is DirsKnown with no Dirs, not known is neither
|
|
// (the forge's module_dirs and module_dirs_said, mesh-catalog modules/gitea/trunk.ts). Other messages carry neither.
|
|
type Forge struct {
|
|
V int `json:"v"`
|
|
Kind string `json:"kind"`
|
|
Repository string `json:"repository"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Number int `json:"number,omitempty"`
|
|
Head string `json:"head,omitempty"`
|
|
Action string `json:"action,omitempty"`
|
|
Dirs []string `json:"dirs,omitempty"`
|
|
DirsKnown bool `json:"dirs-known,omitempty"`
|
|
Merge string `json:"merge,omitempty"`
|
|
Trees map[string]string `json:"trees,omitempty"`
|
|
Root string `json:"root,omitempty"`
|
|
// Manifests are the module.json bytes, exact, of each module directory: of the merge's trees on a merged message,
|
|
// of Dirs at the head on a pull message. Locks are, of each such directory, its lock files' hex sha256 by path
|
|
// relative to it (novox/hq ADR 0465 clause 1). Older writers send neither.
|
|
Manifests map[string]string `json:"manifests,omitempty"`
|
|
Locks map[string]map[string]string `json:"locks,omitempty"`
|
|
// Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object
|
|
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
|
|
// indexer names the paths; a message without them gives such a module none. Older writers send none.
|
|
Paths map[string]map[string]string `json:"paths,omitempty"`
|
|
// Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573
|
|
// §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its
|
|
// fingerprint. Absent for a directory that uses none, and from an older writer.
|
|
Packages map[string][]string `json:"packages,omitempty"`
|
|
Act string `json:"act,omitempty"`
|
|
Words string `json:"words,omitempty"`
|
|
Open []OpenPull `json:"open,omitempty"`
|
|
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
|
|
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
|
|
// refused, loudly, rather than read with them dropped.
|
|
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
|
|
RetiredRead bool `json:"placements-read,omitempty"`
|
|
// Predefined is the catalogue's predefined.json, exact, at the merge commit of a merged message (novox/hq ADR 0565
|
|
// §2): what the indexer keeps as its state `predefined`. PredefinedRead says the bridge read the merge and it holds
|
|
// no predefined.json. Carried on a merged message only; an older bridge sends neither, and the indexer keeps what it
|
|
// held.
|
|
Predefined json.RawMessage `json:"predefined,omitempty"`
|
|
PredefinedRead bool `json:"predefined-read,omitempty"`
|
|
// Group is the delivery group a merged message's pull request was merged in, and Members every approved member of
|
|
// that group with its approved head, this one included (novox/hq ADR 0633 §4): the forge bridge sets both from the
|
|
// gate's merges state, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge
|
|
// with no group carries neither, and an older bridge sends neither; an older reader ignores both.
|
|
Group string `json:"group,omitempty"`
|
|
Members []delivery.Head `json:"members,omitempty"`
|
|
}
|
|
|
|
// checkGroup refuses a group on anything but a merged message, a group without its members or members without a
|
|
// group, a member that is not a repository's pull request at a head, a member named twice, and members that leave out
|
|
// the message's own pull request.
|
|
func (f Forge) checkGroup() error {
|
|
if f.Group == "" && len(f.Members) == 0 {
|
|
return nil
|
|
}
|
|
if f.Kind != ForgeMerged {
|
|
return fmt.Errorf("delivery: only a merged message names its delivery group, not a %s message", f.Kind)
|
|
}
|
|
if f.Group == "" || len(f.Members) == 0 {
|
|
return fmt.Errorf("delivery: a merged message names its group and the group's members together, or neither")
|
|
}
|
|
self := false
|
|
seen := map[string]bool{}
|
|
for _, m := range f.Members {
|
|
if err := checkRepository(m.Repository); err != nil {
|
|
return err
|
|
}
|
|
if m.Number <= 0 || m.Head == "" {
|
|
return fmt.Errorf("delivery: the member %s of the group %q needs its pull request number and head", m.Repository, f.Group)
|
|
}
|
|
k := fmt.Sprintf("%s#%d", m.Repository, m.Number)
|
|
if seen[k] {
|
|
return fmt.Errorf("delivery: the group %q names its member %s twice", f.Group, k)
|
|
}
|
|
seen[k] = true
|
|
self = self || (delivery.SameRepository(m.Repository, f.Repository) && m.Number == f.Number)
|
|
}
|
|
if !self {
|
|
return fmt.Errorf("delivery: the members of the group %q leave out the merged pull request %s#%d", f.Group, f.Repository, f.Number)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
|
|
// a list said both present and absent.
|
|
func (f Forge) checkPredefined() error {
|
|
if len(f.Predefined) == 0 && !f.PredefinedRead {
|
|
return nil
|
|
}
|
|
if f.Kind != ForgeMerged {
|
|
return fmt.Errorf("delivery: only a merged message carries the predefined list, not a %s message", f.Kind)
|
|
}
|
|
if len(f.Predefined) > 0 && f.PredefinedRead {
|
|
return fmt.Errorf("delivery: a merged message carries the predefined list and says the merge holds none (predefined-read)")
|
|
}
|
|
var list map[string]json.RawMessage
|
|
if len(f.Predefined) > 0 && json.Unmarshal(f.Predefined, &list) != nil {
|
|
return fmt.Errorf("delivery: the predefined list a merged message carries is not one JSON object")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f Forge) check() error {
|
|
if err := checkForgeKind(f.Kind); err != nil {
|
|
return err
|
|
}
|
|
if f.Kind == ForgeOpen {
|
|
return f.checkOpen()
|
|
}
|
|
if len(f.Open) > 0 {
|
|
return fmt.Errorf("delivery: only an open message names open pull requests")
|
|
}
|
|
if len(f.RetiredPlacements) > 0 || f.RetiredRead {
|
|
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
|
|
"placement is the seat mesh-placement's state")
|
|
}
|
|
if err := f.checkPredefined(); err != nil {
|
|
return err
|
|
}
|
|
if err := f.checkGroup(); err != nil {
|
|
return err
|
|
}
|
|
if err := checkRepository(f.Repository); err != nil {
|
|
return err
|
|
}
|
|
switch f.Kind {
|
|
case ForgePull:
|
|
if f.Number <= 0 {
|
|
return fmt.Errorf("delivery: a pull message needs its pull request number")
|
|
}
|
|
if f.Head == "" {
|
|
return fmt.Errorf("delivery: a pull message needs its head")
|
|
}
|
|
switch f.Action {
|
|
case PullOpened, PullUpdated, PullClosed:
|
|
default:
|
|
return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action)
|
|
}
|
|
if len(f.Dirs) > 0 && !f.DirsKnown {
|
|
return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)")
|
|
}
|
|
dirs := map[string]bool{}
|
|
for _, d := range f.Dirs {
|
|
dirs[d] = true
|
|
}
|
|
if err := f.checkInputs(func(d string) bool { return dirs[d] }, "its module directories"); err != nil {
|
|
return err
|
|
}
|
|
case ForgeMerged:
|
|
if len(f.Dirs) > 0 || f.DirsKnown {
|
|
return fmt.Errorf("delivery: a merged message carries no module directories")
|
|
}
|
|
if f.Number <= 0 {
|
|
return fmt.Errorf("delivery: a merged message needs its pull request number")
|
|
}
|
|
if f.Head == "" {
|
|
return fmt.Errorf("delivery: a merged message needs its head")
|
|
}
|
|
if f.Merge == "" {
|
|
return fmt.Errorf("delivery: a merged message needs its merge commit")
|
|
}
|
|
if f.Root == "" {
|
|
return fmt.Errorf("delivery: a merged message needs the repository's root tree")
|
|
}
|
|
// No trees is a merge that leaves the repository with no module.json anywhere (a documents repository, or one
|
|
// whose last module was removed): it is written all the same, so the indexer takes the merge in and drops the
|
|
// repository's modules (novox/hq issue 1126). An older writer always sent at least one tree, so none is unambiguous;
|
|
// such a message carries no manifests, locks or paths (checkInputs below: each names a directory of its trees).
|
|
for dir, tree := range f.Trees {
|
|
if tree == "" {
|
|
return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir)
|
|
}
|
|
}
|
|
if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil {
|
|
return err
|
|
}
|
|
case ForgeRefused:
|
|
if len(f.Dirs) > 0 || f.DirsKnown {
|
|
return fmt.Errorf("delivery: a refused message carries no module directories")
|
|
}
|
|
if len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 {
|
|
return fmt.Errorf("delivery: a refused message carries no manifests, lock hashes or named paths")
|
|
}
|
|
if f.Act == "" {
|
|
return fmt.Errorf("delivery: a refused message needs the act it answers")
|
|
}
|
|
if f.Words == "" {
|
|
return fmt.Errorf("delivery: a refused message needs the forge's words")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkInputs refuses a manifest or a lock hash of a directory that known does not know (of is what it knows them
|
|
// by), an empty manifest and an empty lock path or hash, naming the directory.
|
|
func (f Forge) checkInputs(known func(dir string) bool, of string) error {
|
|
for dir, m := range f.Manifests {
|
|
if !known(dir) {
|
|
return fmt.Errorf("delivery: the manifest of the directory %q is not of %s", dir, of)
|
|
}
|
|
if m == "" {
|
|
return fmt.Errorf("delivery: the manifest of the directory %q is empty", dir)
|
|
}
|
|
}
|
|
for dir, locks := range f.Locks {
|
|
if !known(dir) {
|
|
return fmt.Errorf("delivery: the lock hashes of the directory %q are not of %s", dir, of)
|
|
}
|
|
for path, hash := range locks {
|
|
if path == "" || hash == "" {
|
|
return fmt.Errorf("delivery: the lock %q of the directory %q has an empty path or hash", path, dir)
|
|
}
|
|
}
|
|
}
|
|
for dir, paths := range f.Paths {
|
|
if !known(dir) {
|
|
return fmt.Errorf("delivery: the named paths of the directory %q are not of %s", dir, of)
|
|
}
|
|
for path, object := range paths {
|
|
if path == "" || object == "" {
|
|
return fmt.Errorf("delivery: the named path %q of the directory %q has an empty path or object", path, dir)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkOpen refuses an open message that names a repository, a branch, a head or anything of the other kinds, or
|
|
// an open pull request that is not `<owner>/<name>` and a number.
|
|
func (f Forge) checkOpen() error {
|
|
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
|
|
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" || f.Group != "" || len(f.Members) > 0 {
|
|
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
|
|
}
|
|
for _, p := range f.Open {
|
|
if err := checkRepository(p.Repository); err != nil {
|
|
return err
|
|
}
|
|
if p.Number <= 0 {
|
|
return fmt.Errorf("delivery: the open pull request of %s has no number", p.Repository)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs.
|
|
func EncodeForge(f Forge) ([]byte, error) {
|
|
if err := f.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
f.V = Version
|
|
return json.Marshal(f)
|
|
}
|
|
|
|
// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses.
|
|
func DecodeForge(data []byte) (Forge, error) {
|
|
var f Forge
|
|
if err := decode(data, &f); err != nil {
|
|
return Forge{}, err
|
|
}
|
|
if err := f.check(); err != nil {
|
|
return Forge{}, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// SameForge says whether two forge messages are the same value.
|
|
func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) }
|
|
|
|
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
|
|
|
|
// checkName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses
|
|
// (mesh-sdk delivery's rule for a name).
|
|
func checkName(kind, name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("delivery: the %s name is empty", kind)
|
|
}
|
|
if !nameOK.MatchString(name) {
|
|
return fmt.Errorf("delivery: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decode reads a value of Version, refusing another or none with delivery.ErrUnknownVersion, as mesh-sdk delivery's
|
|
// values are read.
|
|
func decode(data []byte, into any) error {
|
|
var probe struct {
|
|
V *int `json:"v"`
|
|
}
|
|
if err := json.Unmarshal(data, &probe); err != nil {
|
|
return fmt.Errorf("delivery: value is not JSON: %w", err)
|
|
}
|
|
if probe.V == nil {
|
|
return fmt.Errorf("%w: no version in the value, this reader reads %d", delivery.ErrUnknownVersion, Version)
|
|
}
|
|
if *probe.V != Version {
|
|
return fmt.Errorf("%w: the value is version %d, this reader reads %d", delivery.ErrUnknownVersion, *probe.V, Version)
|
|
}
|
|
if err := json.Unmarshal(data, into); err != nil {
|
|
return fmt.Errorf("delivery: value does not fit its shape: %w", err)
|
|
}
|
|
return nil
|
|
}
|