diff --git a/.gitignore b/.gitignore index cdef30ce8..e8667e1b4 100644 --- a/.gitignore +++ b/.gitignore @@ -115,9 +115,6 @@ dist/ /modules/mailu/cmd/automx/automx /modules/memory-pressure/cmd/memory-pressure/memory-pressure /modules/memory-pressure/memory-pressure -/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack -/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen -/modules/mesh-build-go/buildstep/mesh-pack /modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go /modules/mesh-build-go/mesh-build-go /modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image diff --git a/checks/failurekit/failurekit.go b/checks/failurekit/failurekit.go index c5b7a7a45..e46f8cad7 100644 --- a/checks/failurekit/failurekit.go +++ b/checks/failurekit/failurekit.go @@ -145,7 +145,7 @@ func runsTheKit(dir, call string) (bool, error) { return err } found = strings.Contains(string(raw), call) && - strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`) + strings.Contains(string(raw), `"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance"`) return nil }) return found, err diff --git a/checks/failurekit/failurekit_test.go b/checks/failurekit/failurekit_test.go index f4467c85e..42049e0ef 100644 --- a/checks/failurekit/failurekit_test.go +++ b/checks/failurekit/failurekit_test.go @@ -12,7 +12,7 @@ import ( // claims no node-build-* seat is not judged. func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) { root := t.TempDir() - const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n" + const imp = "package main\n\nimport \"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance\"\n\n" const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n" kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep for path, body := range map[string]string{ diff --git a/checks/locks/main.go b/checks/locks/main.go index e0b5c5829..fb9c6b651 100644 --- a/checks/locks/main.go +++ b/checks/locks/main.go @@ -5,7 +5,8 @@ // go run . // // Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go -// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages. +// ownDependencies): the `dependencies` beyond the mesh's own packages (@novox/mesh-*, the SDK among them), typescript and the +// @types packages. package main import ( @@ -17,6 +18,10 @@ import ( "strings" ) +// meshPackages is the prefix of every package the mesh itself publishes: the build module's toolchain and the mesh's registry +// give them, as they give the SDK, so a module naming only them needs no lock (design 47 G A-56). +const meshPackages = "@novox/mesh-" + // Finding is one module refused: it names Packages beyond the SDK and has no lock file. type Finding struct { Module string @@ -70,7 +75,7 @@ func Check(root string) ([]Finding, error) { } var names []string for n := range pkg.Dependencies { - if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") { + if !strings.HasPrefix(n, meshPackages) && n != "typescript" && !strings.HasPrefix(n, "@types/") { names = append(names, n) } } diff --git a/checks/locks/main_test.go b/checks/locks/main_test.go index 11aab626d..6ab888974 100644 --- a/checks/locks/main_test.go +++ b/checks/locks/main_test.go @@ -35,6 +35,8 @@ func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) { root := t.TempDir() module(t, root, "plain", "typescript", sdkOnly, false) module(t, root, "none", "typescript", "", false) + // The mesh's own packages count as the SDK does (design 47 G A-56): a contracts package needs no lock. + module(t, root, "contracts", "typescript", `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","@novox/mesh-delivery-contracts-git":"^0.1.0"}}`, false) got, err := Check(root) if err != nil || len(got) != 0 { t.Fatalf("%v %v", got, err) diff --git a/checks/readable/go.mod b/checks/readable/go.mod index 87c149ca9..8aee3980f 100644 --- a/checks/readable/go.mod +++ b/checks/readable/go.mod @@ -11,4 +11,4 @@ replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../ replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood -require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/checks/readable/main.go b/checks/readable/main.go index b455f0f35..51671cb04 100644 --- a/checks/readable/main.go +++ b/checks/readable/main.go @@ -31,6 +31,7 @@ import ( "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" "git.novox.be/novox/mesh-catalog/modules/restic/backupgood" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // Known are the constants of the owners' published packages an announcement may name a state by, by the selector the @@ -53,6 +54,7 @@ func Findings(root string) ([]string, error) { } dirs := map[string]string{} reads := map[string][]string{} + holders := map[string][]string{} // seat -> the modules of the catalogue that claim it for _, path := range manifests { raw, err := os.ReadFile(path) if err != nil { @@ -61,11 +63,17 @@ func Findings(root string) ([]string, error) { var m struct { Module string `json:"module"` Reads []json.RawMessage `json:"reads"` + Claims []struct { + Name string `json:"name"` + } `json:"claims"` } if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } dirs[m.Module] = filepath.Dir(path) + for _, c := range m.Claims { + holders[c.Name] = append(holders[c.Name], m.Module) + } for _, r := range m.Reads { var s string if json.Unmarshal(r, &s) == nil { @@ -77,32 +85,45 @@ func Findings(root string) ([]string, error) { owners := map[string]Announced{} for reader, rs := range reads { for _, r := range rs { - owner, state, ok := strings.Cut(r, ".") - dir, inCatalogue := dirs[owner] - if !ok || !inCatalogue || owner == reader { - continue - } - a, seen := owners[owner] - if !seen { - var problems []string - a, problems, err = AnnouncedBy(dir) + // A read in the seat form `seat:.` (design 47 G A-116, A-128) is of the holder of the seat, found where it + // is read: each module of the catalogue claiming the seat is judged for the state, as a read of it by name is. + var targets []string + state := "" + if seat, local, isSeat, err := catalogue.SeatRead(r); isSeat { if err != nil { - return nil, err + continue // the manifest validator names a malformed seat read } - owners[owner] = a - for _, p := range problems { - out = append(out, owner+": "+p) + targets, state = holders[seat], local + } else if owner, local, ok := strings.Cut(r, "."); ok { + targets, state = []string{owner}, local + } + for _, owner := range targets { + dir, inCatalogue := dirs[owner] + if !inCatalogue || owner == reader { + continue + } + a, seen := owners[owner] + if !seen { + var problems []string + a, problems, err = AnnouncedBy(dir) + if err != nil { + return nil, err + } + owners[owner] = a + for _, p := range problems { + out = append(out, owner+": "+p) + } + } + if a == nil { + continue // announces nothing from code: read as before + } + readable, keeps := a[state] + switch { + case !keeps: + out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner)) + case !readable: + out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner)) } - } - if a == nil { - continue // announces nothing from code: read as before - } - readable, keeps := a[state] - switch { - case !keeps: - out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner)) - case !readable: - out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner)) } } } diff --git a/checks/readable/main_test.go b/checks/readable/main_test.go index 808b5326e..e0b6e069c 100644 --- a/checks/readable/main_test.go +++ b/checks/readable/main_test.go @@ -50,6 +50,15 @@ func TestFindings(t *testing.T) { "modules/r/module.json": `{"module":"r","reads":["o.bound"]}`, "modules/o/module.json": `{"module":"o"}`, "modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""}, + {"seat form, the holder's state not readable", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s"]}`, + "modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`, + "modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"}, + "r reads o.s, which o's code announces not readable"}, + {"seat form, the holder's state readable, and a seat no module of the catalogue holds", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s","seat:elsewhere.s"]}`, + "modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`, + "modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\", Readable: true}\n"}, ""}, {"an owner that announces nothing, and one outside the catalogue", map[string]string{ "modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`, "modules/o/module.json": `{"module":"o"}`, diff --git a/checks/removedpackages/main.go b/checks/removedpackages/main.go index d77f0d08b..9b8915da5 100644 --- a/checks/removedpackages/main.go +++ b/checks/removedpackages/main.go @@ -1,6 +1,6 @@ // Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that // narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left -// mesh-sdk's buildstep for mesh-build-go's package. +// mesh-sdk's buildstep for the build runner's package (now the delivery SDK's steprun). // // A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the // pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin. @@ -8,8 +8,8 @@ // // - an import of a package in Removed (or one under it); // - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in -// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is -// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep). +// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is the +// delivery SDK's package steprun (git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun). // // It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out). // @@ -35,8 +35,8 @@ const sdk = "git.novox.be/novox/mesh-sdk/go" // Removed are the packages of mesh-sdk that left it, each with where its logic is now. var Removed = map[string]string{ - "buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu", - "buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance", + "buildcpu": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/buildcpu", + "buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance", "delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules", "compose": "the node-engine, which alone composes (ADR 0593 §6)", "compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)", @@ -140,7 +140,7 @@ func fileFindings(path, rel string) ([]string, error) { return true } seen[sel.Sel.Name] = true - out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name)) + out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name)) return true }) return out, nil diff --git a/checks/removedpackages/main_test.go b/checks/removedpackages/main_test.go index c5c403c4e..5b4bb4dc7 100644 --- a/checks/removedpackages/main_test.go +++ b/checks/removedpackages/main_test.go @@ -18,11 +18,11 @@ func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) { {"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""}, {"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"}, {"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"}, - {"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"}, + {"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "steprun/buildcpu"}, {"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"}, {"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"}, {"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""}, - {"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""}, + {"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun\"\n)\n\nvar _ = steprun.RunAsk\n", ""}, {"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""}, } { t.Run(row.name, func(t *testing.T) { diff --git a/modules/adwaita/module.json b/modules/adwaita/module.json index bf81c8331..b47fbf37e 100644 --- a/modules/adwaita/module.json +++ b/modules/adwaita/module.json @@ -1,6 +1,6 @@ { "module": "adwaita", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go b/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go index cca4a96df..36cd52286 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go @@ -5,7 +5,7 @@ import ( "fmt" "sort" - sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep" ) // The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4). diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go index fc598c633..3490e8f4f 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go @@ -20,21 +20,21 @@ import ( "fmt" engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "sort" "strings" "sync" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -45,8 +45,6 @@ const ( HavesRead = "node-engine.have" // ManifestsRead is the indexer's manifests: the images a manifest pins by itself. ManifestsRead = indexerModule + "." + catalog.StateManifests - // FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing. - FindingsState = meshconditions.StateFindings // Module is this module, the observer its findings name. Module = "artifact-store-tools" ) @@ -83,8 +81,10 @@ type Door interface { // Holder is the seat's holder. type Holder struct { - Bus Bus - Door func() (Door, error) + Bus Bus + // Findings is the module's finding writer: S25's and the sweep's findings go through it. + Findings Findings + Door func() (Door, error) // Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected. Listed func(ctx context.Context) (*View, error) Acts meshhandactsclient.Recorder @@ -95,6 +95,7 @@ type Holder struct { sweep sync.Mutex // one sweep at a time mu sync.Mutex + fmu sync.Mutex // one pass of Observe at a time ended map[string]time.Time // a done key's end, read once from its history } @@ -852,16 +853,60 @@ func inStoreAt(image, address string) string { return r } -// Observe writes S25's findings (image-missing): each want whose build names an image the store does not hold, on the -// want's machine, and deletes each finding that no longer holds. Nothing is said without a listing. +// Observe says S25's findings (image-missing): each want whose build names an image the store does not hold, on the +// want's machine, written once and cleared once it no longer holds. Nothing is said without a listing. What stands is what +// the held writer holds (design 47 G A-32, A-38), so a restart neither drops nor rewrites it. func (h *Holder) Observe(ctx context.Context) error { - j, err := h.judge(ctx) + now, err := h.observed(ctx) if err != nil { return err } - if j.view == nil { + if now == nil { return nil } + h.fmu.Lock() + defer h.fmu.Unlock() + said := map[string]meshconditions.Finding{} + for _, f := range h.Findings.Held() { + if f.Kind == ImageMissing { + said[views.FindingKey(f.Scope, f.ID, f.Kind)] = f + } + } + for k, was := range said { + if _, still := now[k]; !still { + if err := h.Findings.Clear(ctx, was.Scope, was.ID, was.Kind, "the store holds the image again"); err != nil { + return err + } + } + } + for k, f := range now { + if was, ok := said[k]; ok && was.Summary == f.Summary { + continue // written only when what it says changes + } + if err := h.Findings.Write(ctx, f); err != nil { + return err + } + } + return nil +} + +// Findings is the module's held finding writer (views.HeldWriter): the holder writes and clears through it and reads what it +// holds (design 47 G §689, A-29, A-38). +type Findings interface { + Write(ctx context.Context, finding meshconditions.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error + Held() []meshconditions.Finding +} + +// observed is S25's findings now by key, nil without a listing. +func (h *Holder) observed(ctx context.Context) (map[string]meshconditions.Finding, error) { + j, err := h.judge(ctx) + if err != nil { + return nil, err + } + if j.view == nil { + return nil, nil + } address := "" if d, err := h.Door(); err == nil { address = d.Address() @@ -870,7 +915,7 @@ func (h *Holder) Observe(ctx context.Context) error { for _, b := range j.built { builds[b.Module+" "+b.FP] = b } - now := map[string]meshconditions.FindingsByKeyValue{} + now := map[string]meshconditions.Finding{} for _, w := range j.wants { b, ok := builds[w.Module+" "+w.FP] if !ok { @@ -895,63 +940,26 @@ func (h *Holder) Observe(ctx context.Context) error { continue } f := imageMissing(w.Node, w.Module, reference, h.now()) - now[f.Key] = f + now[views.FindingKey(f.Scope, f.ID, f.Kind)] = f break } } - keys, err := h.Bus.Keys(FindingsState) - if err != nil { - return fmt.Errorf("the findings: %w", err) - } - for _, k := range keys { - if k == sweepFindingKey { - continue // the sweep's own finding, which the sweep clears - } - if _, still := now[k]; !still { - // A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep. - if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found { - if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind { - continue - } - } - if err := h.Bus.Delete(FindingsState, k); err != nil { - return err - } - } - } - for k, f := range now { - raw, found, err := h.Bus.Get(FindingsState, k) - if err != nil { - return err - } - if found { - if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary { - continue // written only when what it says changes - } - } - if err := meshconditionsclient.CheckFinding(f); err != nil { - return err - } - if err := h.Bus.Put(FindingsState, k, f); err != nil { - return err - } - } - return nil + return now, nil } -// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages). -func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue { - id := module + "." + node - return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing, - Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node}, - Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator, +// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages); its id +// is the module's and the machine's, each escaped (design 47 G A-14). +func imageMissing(node, module, image string, at time.Time) meshconditions.Finding { + id := keys.EscapeName(module) + "." + keys.EscapeName(node) + return meshconditions.Finding{Scope: "module", ID: id, Kind: ImageMissing, + Subject: meshconditions.Subject{Scope: "module", ID: id, Machine: node}, + Severity: meshconditions.FindingSeverityWarning, Resolver: "operator", Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+ "not hold (the evidence names it); the running container is untouched", module, node), Headline: capital(module) + " is not updated on " + node, Explanation: fmt.Sprintf("The new version of %s names a container image that the mesh's store does not hold, so it "+ "was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node), Needs: fmt.Sprintf("build %s again; the details name the image.", module), - Resolved: "Resolved: the store holds the image of " + module + " again", Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}}, Source: "S25", Observer: Module, At: at.UTC()} } diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go index 575dfcc00..3963d3749 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go @@ -198,7 +198,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) { } return m.Data, nil } - h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf} + h := &Holder{Bus: findingsAt{realBus: bus}, Findings: findingsThrough{findingsAt{realBus: bus}}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf} h.Acts = meshhandactsclient.Recorder{Ask: ask} // A build's end heard from the builds bucket, as follow hands it over. diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go index 6aa10a50f..72374209f 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go @@ -6,10 +6,10 @@ import ( "errors" "fmt" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "slices" "sort" "strings" @@ -17,14 +17,14 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" - artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" - ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" + ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry" ) // memBus is the holder's bus in memory: states by name, each key's history, and what was published. @@ -229,7 +229,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) { want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true}) _ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want)) var acts []string - h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat, + h := &Holder{Bus: b, Findings: findingsThrough{b}, Door: func() (Door, error) { return door, nil }, Seats: door.seat, Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil }, Now: func() time.Time { return day.Add(48 * time.Hour) }, Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { @@ -370,7 +370,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) { t.Errorf("the seats were called %v", door.calls) } - _, finding, _ := b.Get(FindingsState, sweepFindingKey) + _, finding, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused)) if finding != (c.refuse != "") { t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "") } @@ -379,7 +379,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil { t.Fatal(err) } - if _, still, _ := b.Get(FindingsState, sweepFindingKey); still { + if _, still, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused)); still { t.Error("a clean sweep left the sweep's finding") } return @@ -410,13 +410,16 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi if err := h.Observe(context.Background()); err != nil { t.Fatal(err) } - key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing) + key := views.FindingKey("module", "letta.ace", ImageMissing) raw, found, _ := b.Get(FindingsState, key) - f, err := meshconditions.DecodeFindingsByKeyValue(raw) + var f meshconditions.Finding + err := json.Unmarshal(raw, &f) if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) { t.Fatalf("the finding is %+v (%v, %v)", f, found, err) } view.L.Repos[repo].Revisions[digest] = true + // A restart: a holder made afresh holds no finding of its own, and clears what the writer holds (design 47 G A-32, A-38). + h = &Holder{Bus: h.Bus, Findings: h.Findings, Door: h.Door, Listed: h.Listed, Seats: h.Seats, Now: h.Now, Acts: h.Acts, Logf: h.Logf} if err := h.Observe(context.Background()); err != nil { t.Fatal(err) } @@ -441,3 +444,41 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi t.Fatalf("the images of ace are %+v: %v", images, err) } } + +// FindingsState is the module's state `findings`, where the tests' writer keeps what the holder writes through it. +const FindingsState = "findings" + +// findingsThrough is the holder's finding writer in a test: it keeps a finding in its key and deletes it as the module's +// writer does (views.Writer), without the bus. +type findingsThrough struct { + bus interface { + Put(state, key string, value any) error + Delete(state, key string) error + } +} + +func (f findingsThrough) Write(_ context.Context, finding meshconditions.Finding) error { + return f.bus.Put(FindingsState, views.FindingKey(finding.Scope, finding.ID, finding.Kind), finding) +} + +// Held is what the writer holds: the findings in the module's state. +func (f findingsThrough) Held() []meshconditions.Finding { + var out []meshconditions.Finding + keys, _ := f.bus.(interface { + Keys(string) ([]string, error) + }).Keys(FindingsState) + for _, k := range keys { + raw, ok, _ := f.bus.(interface { + Get(string, string) ([]byte, bool, error) + }).Get(FindingsState, k) + var m meshconditions.Finding + if ok && json.Unmarshal(raw, &m) == nil { + out = append(out, m) + } + } + return out +} + +func (f findingsThrough) Clear(_ context.Context, scope, id, kind, _ string) error { + return f.bus.Delete(FindingsState, views.FindingKey(scope, id, kind)) +} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go b/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go index 062427320..e83e881ea 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go @@ -11,9 +11,9 @@ import ( "fmt" "sort" - ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" ) // Repo is one repository as the seats' list answers it. diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go index 3af7eacc5..acb1d80d5 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go @@ -13,8 +13,8 @@ import ( "os" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/views" @@ -24,12 +24,29 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } func main() { s := &Store{Ask: stdio.Ask} - v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}} + v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}} v.Follow(assignerModule, "wants", WantsTool) v.Follow(indexerModule, "manifests", ManifestsTool) h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View, Seats: stdio.Ask, Now: time.Now, Logf: logf} h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask} + // The module's one held writer (design 47 G §689, A-16, A-29, A-32): the holder's findings and the reader's go through it. + w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: stdio.State("findings"), Kinds: []string{ImageMissing, SweepRefused}}) + if err != nil { + logf("%v", err) + os.Exit(1) + } + h.Findings = w + v.Findings = w + // the runtime answers the writer's subscription only once Serve has its channel + go func() { + <-stdio.Served() + if err := w.Start(context.Background()); err != nil { + logf("%v", err) + os.Exit(1) + } + }() v.Start(context.Background()) go follow(h, v) // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools. diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go b/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go index 67d33e2c2..53b89085d 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep" stdio "git.novox.be/novox/mesh-sdk/go" ) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go index 3729944df..3af7f16e7 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go @@ -1,8 +1,8 @@ package main import ( - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" - sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" + sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" "git.novox.be/novox/mesh-sdk/go/views" @@ -474,14 +474,19 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { t.Errorf("invokes %v", m.Invokes) } // ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read. - if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) || + if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", "mesh-conditions.findings-asked"}) || slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) { t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads) } // The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no // provision artifact-store. - if len(m.Requires) != 0 { - t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires) + // What it requires is its finding state and stream alone, in their template form (design 47 G §529). + var required []string + for _, r := range m.Requires { + required = append(required, fmt.Sprint(r.(map[string]any)["name"])) + } + if !reflect.DeepEqual(required, []string{"findings", "finding-events"}) { + t.Errorf("requires %v, want its findings and finding-events alone: the store is reached through the registry seats", m.Requires) } // Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision. address, err := os.ReadFile("../../files/store-address") diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go b/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go index 568b62781..538783e31 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go @@ -10,25 +10,23 @@ package main // sweep's finding, sweep-refused; the next sweep with no refusal clears it. import ( + "context" "encoding/json" "fmt" "strings" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" - ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference" + ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" ) // SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked. const SweepRefused = "sweep-refused" -// sweepFindingKey is the one key of the sweep's finding in the module's findings. -var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused) - // canonical is a kept reference in the older form, `artifact-store://`, whichever form it came in; another is as // it was. func canonical(reference string) string { @@ -115,26 +113,28 @@ func (h *Holder) removeOnSeat(reference, why string) error { // sweepFinding writes the sweep's finding: what the seat refused, and why. func (h *Holder) sweepFinding(what string, err error) { at := h.now().UTC() - f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused, - Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module}, - Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator, + f := meshconditions.Finding{Scope: "module", ID: keys.EscapeName(Module), Kind: SweepRefused, + Subject: meshconditions.Subject{Scope: "module", ID: keys.EscapeName(Module)}, + Severity: meshconditions.FindingSeverityWarning, Resolver: "operator", Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what), Headline: "The artifact sweep stopped", Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " + "refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.", Needs: "read the seat holder's journal for the refusal; the next sweep asks again.", - Resolved: "Resolved: the artifact sweep ran through without a refusal", Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}}, Source: "CAT-AS1", Observer: Module, At: at} - if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil { + if werr := h.Findings.Write(context.Background(), f); werr != nil { h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr) } } -// sweepClean clears the sweep's finding after a sweep no seat refused. +// sweepClean clears the sweep's finding, when the held writer holds it, after a sweep no seat refused. func (h *Holder) sweepClean() { - if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found { - if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil { + for _, was := range h.Findings.Held() { + if was.Kind != SweepRefused { + continue + } + if err := h.Findings.Clear(context.Background(), was.Scope, was.ID, was.Kind, "a sweep ran through without a refusal"); err != nil { h.logf("the sweep's finding could not be cleared: %v", err) } } diff --git a/modules/artifact-store-tools/go.mod b/modules/artifact-store-tools/go.mod index 2e3263e2d..b6d9eba7b 100644 --- a/modules/artifact-store-tools/go.mod +++ b/modules/artifact-store-tools/go.mod @@ -5,18 +5,17 @@ go 1.26.0 require ( git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) @@ -35,4 +34,6 @@ require ( golang.org/x/time v0.16.0 // indirect ) -require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/artifact-store-tools/module.json b/modules/artifact-store-tools/module.json index 64f4349cf..5ed0d1b66 100644 --- a/modules/artifact-store-tools/module.json +++ b/modules/artifact-store-tools/module.json @@ -1,6 +1,6 @@ { "module": "artifact-store-tools", - "version": "1", + "version": "0.1", "packages": { "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1" }, @@ -39,10 +39,8 @@ ], "consumes": [ "mesh-delivery-assigner.views.wants", - "mesh-delivery-indexer.views.manifests" - ], - "state": [ - "findings" + "mesh-delivery-indexer.views.manifests", + "mesh-conditions.findings-asked" ], "tools": [ "artifact_store_repositories", @@ -108,5 +106,26 @@ "from": "files" } ] - } + }, + "requires": [ + { + "name": "findings", + "storage": "key-value", + "template": "state" + }, + { + "name": "finding-events", + "storage": "stream", + "template": "events", + "options": { + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + } + } + ] } diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json index d1ee8d3b8..b5152b6d7 100644 --- a/modules/asus-zephyrus-g14/module.json +++ b/modules/asus-zephyrus-g14/module.json @@ -1,6 +1,6 @@ { "module": "asus-zephyrus-g14", - "version": "1", + "version": "0.1", "moved": true, "templates": "templates", "capabilities": [ diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 122b94e75..2ed839dce 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -1,6 +1,6 @@ { "module": "audit-logger", - "version": "1", + "version": "0.1", "slug": "audit", "consumes": [ "**" diff --git a/modules/avahi/module.json b/modules/avahi/module.json index de99c7c9c..01731ee6e 100644 --- a/modules/avahi/module.json +++ b/modules/avahi/module.json @@ -1,6 +1,6 @@ { "module": "avahi", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/baserow/module.json b/modules/baserow/module.json index 811962180..f92a50402 100644 --- a/modules/baserow/module.json +++ b/modules/baserow/module.json @@ -1,6 +1,6 @@ { "module": "baserow", - "version": "1", + "version": "0.1", "capabilities": [ "container-runtime" ], diff --git a/modules/blueman/module.json b/modules/blueman/module.json index 73a764068..8a79ba2d6 100644 --- a/modules/blueman/module.json +++ b/modules/blueman/module.json @@ -1,6 +1,6 @@ { "module": "blueman", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/bluetooth/module.json b/modules/bluetooth/module.json index dfaabf10a..6ee44cb77 100644 --- a/modules/bluetooth/module.json +++ b/modules/bluetooth/module.json @@ -1,6 +1,6 @@ { "module": "bluetooth", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/brother/module.json b/modules/brother/module.json index df2bed169..e1c1f7ae1 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -1,6 +1,6 @@ { "module": "brother", - "version": "1", + "version": "0.1", "requires": [ { "name": "s3-bucket", diff --git a/modules/buildah/module.json b/modules/buildah/module.json index bdfbe96b2..4e97d7cdd 100644 --- a/modules/buildah/module.json +++ b/modules/buildah/module.json @@ -1,6 +1,6 @@ { "module": "buildah", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "user-namespaces" diff --git a/modules/ca-trust/module.json b/modules/ca-trust/module.json index b3c9dc930..88602b90a 100644 --- a/modules/ca-trust/module.json +++ b/modules/ca-trust/module.json @@ -1,6 +1,6 @@ { "module": "ca-trust", - "version": "1", + "version": "0.1", "slug": "catrust", "capabilities": [ "service-manager" diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 6bac2b7aa..068d73518 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -83,7 +83,7 @@ must see, a node that joins later included — kept, so it carries no secret eit | what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence..access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) | | an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.` for every node, `.` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal | | a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus | -| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open | +| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question | | a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `.`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text | | the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh..` for every node, `node...` for one, `home...` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) | @@ -233,11 +233,8 @@ key says waiting, chosen (the option, who answered, on which channel, when), end refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at -most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two -slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last, -and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one -is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal -not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue, +most once a minute, and nothing enforces it. No question is refused for how many wait: the router offers an asker at most three asks at once, holds the oldest back behind newer ones and offers it again when one ends (hq ADR 0666 §12). A question the router holds no record of, its refusal +not heard here, reads as waiting until it expires: the router may still take it from its queue, and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of its words. An answer is the operator's word to the session and authorises nothing; a question never defaults. diff --git a/modules/claude-code/cmd/claude-code/catalogfeed.go b/modules/claude-code/cmd/claude-code/catalogfeed.go new file mode 100644 index 000000000..9bc070e5c --- /dev/null +++ b/modules/claude-code/cmd/claude-code/catalogfeed.go @@ -0,0 +1,18 @@ +package main + +// claude-code's reading of which modules exist and of each placed module's manifest (design 47 G §196, §594, §614): the +// catalog half, taken through meshcatalogclient's follower of the watched branches on the part's one views reader. A +// module's manifest is the one its catalog graph entry carries whole (§443), never the indexer's state `modules` or +// `manifests`. + +import ( + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +// CatalogHalf is what this module reads of the catalog half. +type CatalogHalf interface { + // Entry is a module's entry at the trunk; an error while a watched branch has no first view yet, never "no module". + Entry(module string) (catalog.CatalogEntry, bool, error) + // Ready is nil once every watched branch has its first view (none-yet counting as one), else says what it waits for. + Ready() error +} diff --git a/modules/claude-code/cmd/claude-code/catalogfeed_test.go b/modules/claude-code/cmd/claude-code/catalogfeed_test.go new file mode 100644 index 000000000..6c1edee7e --- /dev/null +++ b/modules/claude-code/cmd/claude-code/catalogfeed_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// wiringOf is a part's reader in miniature: it keeps the followers adopted, so a test starts them as the reader's Start does. +type wiringOf struct { + mu sync.Mutex + fs []*views.Follower +} + +func (w *wiringOf) Adopt(f *views.Follower, _, _ string, _ func(context.Context, []byte) error) error { + w.mu.Lock() + defer w.mu.Unlock() + w.fs = append(w.fs, f) + return nil +} +func (w *wiringOf) Drop(string, string) error { return nil } + +// Design 47 G §196, §594: which modules exist, and the manifest of each, are the catalog half's; a branch with no first +// view yet is an error naming it, never no module, and an entry's manifest is the one it carries whole. +func TestCatalogHalfIsTheWatchedBranchesNewestRevision(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + answered := false + var mu sync.Mutex + call := func(key string, _ any) (json.RawMessage, error) { + mu.Lock() + defer mu.Unlock() + switch key { + case meshcatalogclient.RegistrationsVerb: + return json.Marshal(catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "m", Version: 1, Branches: []string{"main"}}}}) + case meshcatalogclient.SourceVerb: + return json.Marshal(delivery.SourceAnswer{}) + case meshcatalogclient.RevisionVerb: + if !answered { + return nil, fmt.Errorf("the holder is away") + } + return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk}, + Entries: []catalog.CatalogEntry{{Module: "pg", Manifest: map[string]any{"module": "pg"}}}}) + } + return nil, fmt.Errorf("asked %s", key) + } + w := &wiringOf{} + b, err := meshcatalogclient.NewBranches(w, call) + if err != nil { + t.Fatal(err) + } + h := b + if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no watched branch") { + t.Fatalf("before the registrations: %v, want an error naming the missing branch", err) + } + go func() { _ = w.fs[0].Start(ctx) }() // the registrations + for len(b.Followed()) == 0 { + select { + case <-ctx.Done(): + t.Fatal("the registrations' branch is never followed") + case <-time.After(10 * time.Millisecond): + } + } + if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no first view of main") { + t.Fatalf("with no first view: %v, want an error naming the branch, never no module", err) + } + mu.Lock() + answered = true + mu.Unlock() + c, _ := b.Catalog("main") + if err := c.Start(ctx); err != nil { + t.Fatal(err) + } + got, ok, err := h.Entry("pg") + if err != nil || !ok || got.Manifest["module"] != "pg" || h.Ready() != nil { + t.Fatalf("with the first view: %v, %v, %v", got, ok, err) + } + if _, ok, err := h.Entry("absent"); ok || err != nil { + t.Fatalf("a module the half does not list: %v, %v", ok, err) + } +} diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 2f75f5e0b..f514945e3 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "os" "path/filepath" @@ -9,6 +10,8 @@ import ( "strings" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/views" ) var now = time.Now().UnixMilli() @@ -595,6 +598,20 @@ func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) { if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet { t.Fatalf("a start asked %v", asked) } + // A-134: the first view is the follower the module's views reader adopts on binding.changed.; its answer + // applies the binding as FirstBinding does. + r := &views.Reader{Module: Module} + f := bindingsFollower(p, ask, writer(w), func(done string, err error) { + if err != nil || !strings.Contains(done, `"generation":9`) { + t.Errorf("the adopted first view applied %q, %v", done, err) + } + }) + if err := r.Adopt(f, Manager, BindingChangedEvent, func(context.Context, []byte) error { return nil }); err != nil || !r.Follows(Manager+".bindings") { + t.Fatalf("the bindings follower is not adopted: %v", err) + } + if err := f.Start(context.Background()); err != nil || len(asked) != 9 { + t.Fatalf("the adopted first view: %v, asked %v", err, asked) + } raw, _ := os.ReadFile("../../module.json") var m struct { Consumes, Invokes []string diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 701beb5df..f66d79469 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -277,10 +277,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { _ = json.Unmarshal(raw, &m) // The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The - // licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9). - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) || + // licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9); a + // notice goes out on the operator channel's notify (design 47 G GR8). + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", RevisionVerb, RegistrationsVerb, SourceVerb, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet, "seat:operator-channel.notify"}) || len(m.Reads) != 0 || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) { + !slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", HoldingsAsked, BindingChanged, SecretChanged}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index 405ca61cc..a403dddf0 100644 --- a/modules/claude-code/cmd/claude-code/insteadof_test.go +++ b/modules/claude-code/cmd/claude-code/insteadof_test.go @@ -2,14 +2,8 @@ package main import ( "context" - "crypto/sha256" "encoding/json" "errors" - "fmt" - assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" - mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "os" "reflect" "slices" @@ -17,6 +11,10 @@ import ( "testing" "time" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -57,7 +55,7 @@ func someSeats() []seatdef.Definition { // saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus. func aMesh(t *testing.T) aBus { t.Helper() - b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}} + b := aBus{AssignmentsRead: {}} for _, p := range []mesh.Assignments{ {V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"}, Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}}, @@ -71,21 +69,33 @@ func aMesh(t *testing.T) aBus { b[AssignmentsRead][k] = v } } + return b +} + +// fakeCatalog is the catalog half as the tests give it: docker and zsh, docker's manifest saying what its tool replaces. +type fakeCatalog struct { + entries map[string]catalog.CatalogEntry + notYet error +} + +func (f fakeCatalog) Entry(module string) (catalog.CatalogEntry, bool, error) { + e, ok := f.entries[module] + return e, ok, f.notYet +} + +func (f fakeCatalog) Ready() error { return f.notYet } + +func aCatalog() fakeCatalog { + f := fakeCatalog{entries: map[string]catalog.CatalogEntry{}} for module, manifest := range map[string]string{ "docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`, "zsh": `{"module":"zsh"}`, } { - digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest))) - mkey, _ := catalogclient.ManifestsKey(digest) - raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest}) - if err != nil { - t.Fatal(err) - } - b[ManifestsRead][mkey] = raw - key, _ := catalogclient.ModulesKey(module) - b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"}) + var whole map[string]any + _ = json.Unmarshal([]byte(manifest), &whole) + f.entries[module] = catalog.CatalogEntry{Module: module, Manifest: whole} } - return b + return f } func resolving(name string) []string { @@ -97,7 +107,7 @@ func resolving(name string) []string { // controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a // loopback address never one of them. func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { - m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) + m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving) if err != nil { t.Fatal(err) } @@ -134,7 +144,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { case <-time.After(10 * time.Second): t.Fatalf("the seat list was not read: %+v", seatList.Status()) } - got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving) + got, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), definedSeats(), resolving) var addrs []string for _, r := range got.Replacements { addrs = append(addrs, r.Address) @@ -156,7 +166,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { // **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row, // the machines' seats first, and nothing is rendered where the records say nothing. func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { - m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) + m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving) table := InsteadOf(&m) for _, want := range []string{ "| `/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |", @@ -206,7 +216,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { // Asked again with the same answer, nothing is written and nothing renders. func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) { p := Paths{State: t.TempDir()} - m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) + m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving) if !KeepMeshTools(p, m) { t.Fatal("the first answer was not kept") } @@ -243,7 +253,7 @@ func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) { func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { bus := aMesh(t) r := newMeshViews() - for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} { + for _, name := range []string{AssignmentsRead} { state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".") values := map[string]json.RawMessage{} for k, v := range bus[name] { @@ -257,16 +267,20 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { t.Fatal(err) } } - want, err := ReadMeshToolsFrom(bus, someSeats(), resolving) + want, err := ReadMeshToolsFrom(bus, aCatalog(), someSeats(), resolving) if err != nil { t.Fatal(err) } - got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving) + got, err := ReadMeshToolsFrom(viewStates{r}, aCatalog(), someSeats(), resolving) if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 { t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want) } // A reader not yet answered says so, and reads nothing. - if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { + // A catalog half with no first view says so, and reads no module as absent. + if _, err := ReadMeshToolsFrom(bus, fakeCatalog{notYet: errors.New("the catalog half has no first view of main yet")}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { + t.Fatalf("an unanswered catalog half: %v", err) + } + if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, aCatalog(), someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { t.Fatalf("an unanswered first view: %v", err) } } @@ -274,7 +288,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { // Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them, // name no address of the controller, which is not placed: each job is named by the seat that owns it now. func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { - m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) + m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving) if err != nil { t.Fatal(err) } diff --git a/modules/claude-code/cmd/claude-code/invokes_test.go b/modules/claude-code/cmd/claude-code/invokes_test.go new file mode 100644 index 000000000..e2c8bbd9d --- /dev/null +++ b/modules/claude-code/cmd/claude-code/invokes_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// The operator channel's notify, which notifyOperator calls, is in the manifest's invokes, which the grants part reads per +// verb (design 47 G GR8): without it the call is refused live. +func TestTheManifestInvokesTheNotifyTheModuleCalls(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"seat:operator-channel.notify"`) { + t.Error("the manifest does not invoke seat:operator-channel.notify") + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 39efa0332..407b94b61 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -23,9 +23,10 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -218,12 +219,19 @@ func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshVie // holds them equal). const ( assignerModule = "mesh-delivery-assigner" - indexerModule = "mesh-delivery-indexer" ) +// The seat verbs the follower of the watched branches asks (design 47 G §614), as the manifest's `invokes` names them. +const ( + RevisionVerb = "seat:mesh-catalog.revision" + RegistrationsVerb = "seat:mesh-catalog.registrations" + SourceVerb = "seat:mesh-delivery.source" +) + +// meshCatalog is this module's reading of which modules exist: the catalog half, set where the reader is built. +var meshCatalog CatalogHalf + const ( - ModulesTool = indexerModule + ".indexer_read_modules" - ManifestsTool = indexerModule + ".indexer_read_manifests" AssignmentsTool = assignerModule + ".assigner_read_assignments" ) @@ -231,12 +239,22 @@ const ( // once of the owner's read tool, then followed on the owner's views family. It reads none of their state. var meshViews *views.Reader +// newFindingsWriter is this module's finding writer (design 47 G §689, A-16): it states the findings of its views reader and +// observes none of its own. +func newFindingsWriter() (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { + return views.NewStdioWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Asked: condseat.SubjectFindingsAsked, Module: "claude-code", State: stdio.State("findings"), + Observe: func(context.Context) (views.Observed[condseat.Finding], error) { + return views.Observed[condseat.Finding]{}, nil + }}) +} + // meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again. var meshChanged = make(chan struct{}, 1) // newMeshViews builds the reader of those three states; Start asks them. func newMeshViews() *views.Reader { - v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, + v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Acts: handActs{}, Changed: func(string, string, json.RawMessage, bool) { select { @@ -244,8 +262,6 @@ func newMeshViews() *views.Reader { default: } }} - v.Follow(indexerModule, "modules", ModulesTool) - v.Follow(indexerModule, "manifests", ManifestsTool) v.Follow(assignerModule, "assignments", AssignmentsTool) return v } @@ -326,7 +342,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).", Run: func(map[string]any) (any, error) { answer := map[string]any{} - if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil { + if m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup); err != nil { answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered" } else { KeepMeshTools(p, m) @@ -460,6 +476,20 @@ func main() { } p, launched := PathsFrom(os.Getenv) meshViews = newMeshViews() + branches, err := catalogclient.FollowBranches(meshViews, func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) }) + if err != nil { + say("%v", err) + os.Exit(1) + } + branches.Logf = say + // A change in a branch's half is told as any followed state's: the instructions' table is read again. + branches.Changed = func(string, string, json.RawMessage, bool, time.Time) { + select { + case meshChanged <- struct{}{}: + default: + } + } + meshCatalog = branches if launched { fsys = FilesFor(p) } @@ -489,6 +519,20 @@ func main() { } } } + findings, err := newFindingsWriter() + if err != nil { + say("%v", err) + os.Exit(1) + } + meshViews.Findings = findings + // the runtime answers the writer's subscription only once Serve has its channel + go func() { + <-stdio.Served() + if err := findings.Start(context.Background()); err != nil { + say("%v", err) + os.Exit(1) + } + }() meshViews.Start(context.Background()) go refreshMeshTools(p) servers := stateOf{stdio.State("servers")} @@ -624,22 +668,15 @@ func run(p Paths, view *ServerView, configView *ConfigView) { } go report() } - go persist("hearing this node's licence binding", func() error { - err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error { - applied(OnBindingChanged(p, e.Body, ask, writeManaged)) + // Adopted by this module's views reader (design 47 G A-134): the reader hears binding.changed. and asks the + // first view once its subscription is taken, and raises and clears that view's findings through the module's writer. + if err := meshViews.Adopt(bindingsFollower(p, ask, writeManaged, applied), Manager, BindingChangedEvent, + func(_ context.Context, body []byte) error { + applied(OnBindingChanged(p, body, ask, writeManaged)) return nil - }) - if err == nil { - go persist("asking this node's licence binding", func() error { - done, err := FirstBinding(p, ask, writeManaged) - if err == nil { - applied(done, nil) - } - return err - }, func(int) {}) - } - return err - }, func(n int) { say("hearing this node's licence binding%s", refusals(n)) }) + }); err != nil { + say("this node's licence binding is not followed: %v", err) + } // The token of the licence this node holds, asked again when the vault says its access secret changed (§19). go persist("hearing the vault's changes of this node's licence token", func() error { return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error { diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index d543178d6..e8cd63359 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -16,10 +16,6 @@ import ( "encoding/json" "errors" "fmt" - assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" - mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "net" "os" "path/filepath" @@ -28,6 +24,9 @@ import ( "sync" "time" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" "git.novox.be/novox/mesh-sdk/go/seatlist" @@ -93,8 +92,6 @@ func ReadMeshTools(p Paths) *MeshTools { // The states read, each declared in the manifest's reads. const ( AssignmentsRead = (assignerModule + "." + mesh.StateAssignments) - ModulesRead = indexerModule + "." + catalog.StateModules - ManifestsRead = indexerModule + "." + catalog.StateManifests ) // States reads the module states the manifest declares in its reads: a state's keys, and one key's value. @@ -152,44 +149,28 @@ func NodesRunning(st States, module string) ([]string, error) { return runsOn(placed)[module], nil } -// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer -// keeps of it. A module whose manifest cannot be read is left out and said in the error. -func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { +// modulesTools is every placed module's own tools that replace a command, by module, from the manifest its catalog +// graph entry carries. A module whose manifest cannot be read is left out and said in the error. +func modulesTools(cat CatalogHalf, on map[string][]string) ([]ModuleTools, error) { var out []ModuleTools var problems []error + if err := cat.Ready(); err != nil { + return nil, err + } modules := make([]string, 0, len(on)) for m := range on { modules = append(modules, m) } sort.Strings(modules) for _, module := range modules { - key, err := catalogclient.ModulesKey(module) - if err != nil { - continue - } - raw, found, err := st.Get(ModulesRead, key) + entry, found, err := cat.Entry(module) if err != nil || !found { if err != nil { - problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err)) + problems = append(problems, fmt.Errorf("the catalog half's entry of %s: %w", module, err)) } continue } - m, err := catalogclient.DecodeModule(raw) - if err != nil || m.Manifest == "" { - continue - } - mkey, err := catalogclient.ManifestsKey(m.Manifest) - if err != nil { - continue - } - raw, found, err = st.Get(ManifestsRead, mkey) - if err != nil || !found { - if err != nil { - problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err)) - } - continue - } - kept, err := catalogclient.DecodeManifest(raw) + kept, err := json.Marshal(entry.Manifest) if err != nil { problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err)) continue @@ -197,7 +178,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { var man struct { Replaces map[string][]string `json:"replaces"` } - if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 { + if json.Unmarshal(kept, &man) != nil || len(man.Replaces) == 0 { continue } out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces}) @@ -263,14 +244,14 @@ func lookup(name string) []string { // ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names // with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept. -func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) { +func ReadMeshToolsFrom(st States, cat CatalogHalf, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) { var m MeshTools placed, err := assignments(st) if err != nil { m.Replacements = ReplacementsOf(defined, nil) return m, err } - modules, merr := modulesTools(st, runsOn(placed)) + modules, merr := modulesTools(cat, runsOn(placed)) m.Replacements = ReplacementsOf(defined, modules) for node, p := range placed { machine := Machine{Name: node, Domains: []string{node + InternalSuffix}} @@ -357,7 +338,7 @@ func refreshMeshTools(p Paths) { } startSeatList(seatlist.StdioBus()) for { - m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup) + m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup) if err != nil { say("reading what each tool replaces and which machines the mesh has: %v", err) } diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 6c2117ea5..011ebb8c8 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -18,6 +18,7 @@ package main // every node, `.` for one — which every node watches. import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -30,6 +31,8 @@ import ( "strings" "sync" "time" + + "git.novox.be/novox/mesh-sdk/go/views" ) // Seat is the licence manager's role, and Manager the module whose events this one consumes. @@ -481,7 +484,25 @@ func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, err // BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this // module consumes (§22). -const BindingChanged = Manager + ".binding.changed" +const BindingChanged = Manager + "." + BindingChangedEvent + +// BindingChangedEvent is that event's name as the manager says it, with the consumer as its further part +// (`binding.changed.`, design 47 G A-134). +const BindingChangedEvent = "binding.changed" + +// bindingsFollower is this node's first view of its licence binding, adopted by the module's views reader (A-134): its +// ask is FirstBinding, whose outcome goes to applied; the picture keeps no binding, which the node's own files hold. +func bindingsFollower(p Paths, ask Ask, write WriteManaged, applied func(string, error)) *views.Follower { + return &views.Follower{Owner: Manager, Tool: SeatVerb("bindings"), State: "bindings", + Ask: func(context.Context, string) (views.Answer, error) { + done, err := FirstBinding(p, ask, write) + if err != nil { + return views.Answer{}, err + } + applied(done, nil) + return views.Answer{Owner: Manager, State: "bindings", Values: map[string]json.RawMessage{}, At: time.Now()}, nil + }} +} // bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an // empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json). diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index e3a32a129..3c7198058 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -906,10 +906,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time if why := approverReady(bus); why != "" { return refuse(why + "; " + AtTheTerminalInstead) } - if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen { - return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+ - "questions at once; claude_code_proposals lists them", waiting, occ.MostOpen)) - } if problem := ProposalProblem(prop); problem != "" { return refuse(problem) } @@ -956,26 +952,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time }, nil } -// waitingProposals counts the proposals still open: asked, not decided here, not expired. -func waitingProposals(store ProposalStore, approved Approved, now time.Time) int { - keys, err := store.Keys() - if err != nil { - return 0 - } - n := 0 - for _, k := range keys { - prop, ok := readProposal(store, k) - if !ok || prop.Asked != "" { - continue - } - if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) { - continue - } - n++ - } - return n -} - func readProposal(store ProposalStore, key string) (Proposal, bool) { raw, found, err := store.Get(key) if err != nil || !found { diff --git a/modules/claude-code/cmd/claude-code/questions.go b/modules/claude-code/cmd/claude-code/questions.go index 07b5d1231..ea8eb200d 100644 --- a/modules/claude-code/cmd/claude-code/questions.go +++ b/modules/claude-code/cmd/claude-code/questions.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "regexp" - "sort" "strconv" "strings" "sync" @@ -38,26 +37,8 @@ const QuestionsState = "questions" // QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's. const QuestionPrefix = "q-" -// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks -// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274) -// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by -// create or by compare-and-set, so two nodes asking at once cannot both take the last one. -const MostQuestions = occ.MostOpen - 1 - -// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires: -// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can -// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it -// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it -// when it is taken (oc.Ask.Check), so the slot is free and the question has ended. - -// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it. -func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) } - -// slot is what a slot key holds: the question holding it. -type slot struct { - Question string `json:"question"` - At time.Time `json:"at"` -} +// A question is never refused for how many wait: the router offers an asker at most three asks at once and holds the +// oldest back behind newer ones, offered again when one ends (novox/hq ADR 0666 §12), so no question is hidden here. // The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the // router's bound for an approval, with the margin a proposal keeps), an hour when none is given. @@ -372,19 +353,9 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now if err := occ.Check(ask, now); err != nil { return refuse(err.Error()) } - // The question is kept before its slot is taken, so a slot always names a question another node can judge. if err := store.Create(q.ID, q); err != nil { return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err) } - if open, err := takeSlot(store, bus, q, now); err != nil { - _ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error())) - return refuse(err.Error()) - } else if open != nil { - _ = store.Put(q.ID, tombstone(q, "not asked: two questions were open")) - return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+ - "at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+ - "claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions)) - } if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil { _ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error())) return nil, fmt.Errorf("the operator could not be asked: %v", err) @@ -420,75 +391,6 @@ func labelsOf(q Question) string { return strings.Join(out, ", ") } -// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's -// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess. -func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) { - q, ok, err := readQuestionErr(store, id) - if err != nil { - return true, err - } - if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) { - return false, nil - } - entry, err := bus.Record(q.ID) - if err != nil { - return true, err - } - if entry == nil { - return true, nil - } - var rec routerRecord - if json.Unmarshal(entry.Value, &rec) != nil { - return true, nil - } - return rec.State == "" || rec.State == "open", nil -} - -// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer -// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries -// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a -// slot or its question cannot be read. -func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) { - mine := slot{Question: q.ID, At: q.At} - var open []string - for n := 1; n <= MostQuestions; n++ { - key := slotKey(n) - raw, rev, found, err := store.Get(key) - if err != nil { - return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) - } - if !found { - if err := store.Create(key, mine); err == nil { - return nil, nil - } else if !errors.Is(err, stdio.ErrChanged) { - return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) - } - if raw, rev, found, err = store.Get(key); err != nil || !found { - return nil, fmt.Errorf("the open questions could not be counted, so none more is asked") - } - } - var held slot - if json.Unmarshal(raw, &held) == nil && held.Question != "" { - waits, err := questionOpen(store, bus, held.Question, now) - if err != nil { - return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) - } - if waits { - open = append(open, held.Question) - continue - } - } - if err := store.Update(key, mine, rev); err == nil { - return nil, nil - } else if !errors.Is(err, stdio.ErrChanged) { - return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) - } - open = append(open, "one taken just now") - } - sort.Strings(open) - return open, nil -} - // tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and // why it ended — none of its words, which the router judged unfit to leave the mesh or never saw. func tombstone(q Question, ended string) Question { @@ -608,7 +510,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map case entry == nil: out["state"] = AnswerWaiting out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " + - "refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " + + "refusal being heard here. The operator may still be asked until it expires at " + q.Expires.Format(time.RFC3339) case entry == nil, rec.State == "open", rec.Warrant == nil: out["state"] = AnswerWaiting diff --git a/modules/claude-code/cmd/claude-code/questions_test.go b/modules/claude-code/cmd/claude-code/questions_test.go index 8ff3fd74a..a0ffcc8fa 100644 --- a/modules/claude-code/cmd/claude-code/questions_test.go +++ b/modules/claude-code/cmd/claude-code/questions_test.go @@ -6,7 +6,6 @@ package main import ( "encoding/json" - "errors" "strings" "sync" "testing" @@ -182,22 +181,21 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) { } } -func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) { +// ADR 0666 §12: a question is never refused for how many wait; the router holds the oldest back, offered again when one ends. +func TestAThirdQuestionIsAskedNotRefusedAndAProposalIsAskedBesideThem(t *testing.T) { p, _ := node(t, "g14") store, r := newMemQuestions(), newRouter() - first := askMergeTonight(t, p, store, r, t0) - askMergeTonight(t, p, store, r, t0) - if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") { - t.Fatalf("a third question: %v", err) + for i := 0; i < 4; i++ { + if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err != nil { + t.Fatalf("question %d: %v", i+1, err) + } } - r.close(r.chose(first, "o1", t0.Add(time.Minute))) - if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil { - t.Fatalf("a question once one was answered: %v", err) + if len(r.subjects) != 4 { + t.Errorf("the router was asked %d times, want 4", len(r.subjects)) } - // A proposal is still asked beside two waiting questions. pstore := &memProposals{m: map[string]json.RawMessage{}} if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil { - t.Errorf("a proposal beside two questions: %v", err) + t.Errorf("a proposal beside the questions: %v", err) } } @@ -234,9 +232,6 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) { if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") { t.Errorf("read after the refusal: %v %v", out, err) } - if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil { - t.Errorf("a refused question still counts as waiting: %v %v", waits, err) - } var kept Question _ = json.Unmarshal(store.m[refusing.id], &kept) if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" { @@ -316,10 +311,6 @@ type memQuestions struct { m map[string]json.RawMessage rev map[string]uint64 n uint64 - // failGet fails every read of a key with this prefix. - failGet string - // afterGet runs after a read, outside the lock: another node writing between a read and a write. - afterGet func(key string) } func newMemQuestions() *memQuestions { @@ -328,17 +319,9 @@ func newMemQuestions() *memQuestions { func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) { s.mu.Lock() - if s.failGet != "" && strings.HasPrefix(key, s.failGet) { - s.mu.Unlock() - return nil, 0, false, errors.New("the bus did not answer") - } + defer s.mu.Unlock() raw, ok := s.m[key] - rev, after := s.rev[key], s.afterGet - s.mu.Unlock() - if after != nil { - after(key) - } - return raw, rev, ok, nil + return raw, s.rev[key], ok, nil } func (s *memQuestions) put(key string, value any) error { raw, err := json.Marshal(value) @@ -397,121 +380,3 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) { } } } - -// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused. -func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) { - store, r := newMemQuestions(), &lockedRouter{router: newRouter()} - nodes := []string{"g14", "shanks", "ace"} - var wg sync.WaitGroup - errs := make([]error, len(nodes)) - for i, name := range nodes { - p, _ := node(t, name) - wg.Add(1) - go func(i int, p Paths) { - defer wg.Done() - in := mergeTonight() - in.By = "fix-589" - _, errs[i] = AskQuestion(p, in, store, r, t0) - }(i, p) - } - wg.Wait() - refused := 0 - for _, err := range errs { - if err != nil { - if !strings.Contains(err.Error(), "questions already wait") { - t.Errorf("refused for another reason: %v", err) - } - refused++ - } - } - if refused != 1 { - t.Errorf("%d of three questions asked at once were refused; one is", refused) - } -} - -// A slot that cannot be read refuses the question: the cap fails closed. -func TestTheCapFailsClosed(t *testing.T) { - p, _ := node(t, "g14") - store, r := newMemQuestions(), newRouter() - store.failGet = "slot-" - if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") { - t.Fatalf("asked with the slots unreadable: %v", err) - } - if len(r.subjects) != 0 { - t.Error("the operator was asked anyway") - } -} - -// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as -// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends. -func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) { - p, _ := node(t, "g14") - store, r := newMemQuestions(), newRouter() - first := askMergeTonight(t, p, store, r, t0) - askMergeTonight(t, p, store, r, t0) - delete(r.records, first) // the router down or backlogged, or its refusal never heard here - later := t0.Add(QuestionDefault - time.Minute) - if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil { - t.Fatal("the slot of a question the router may still show was freed before it expired") - } - out, _ := ReadAnswer(store, r, first, later) - if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") { - t.Errorf("before expiry read as %v", out) - } - expired := t0.Add(QuestionDefault) - if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded || - out["now"] != "do what you said: I leave the pull request open" { - t.Errorf("at expiry read as %v", out) - } - if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil { - t.Errorf("the slot of an expired question was not freed: %v", err) - } -} - -// A slot is taken only at the revision read: when another node takes both slots between this node's read and its -// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost. -func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) { - p, _ := node(t, "g14") - store, r := newMemQuestions(), newRouter() - // Two questions that ended: their slots are free to take again. - for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} { - r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))}) - } - shanks, _ := node(t, "shanks") - other := newRouter() - store.afterGet = func(key string) { - if !strings.HasPrefix(key, "slot-") { - return - } - // Another node asks between this node's read of the slot and its write: its question waits. - q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other", - From: shanks.Node, At: t0, Expires: t0.Add(time.Hour), - Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}} - _ = store.Put(q.ID, q) - _ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID) - r.records[q.ID] = other.records[q.ID] - _ = store.Put(key, slot{Question: q.ID, At: t0}) - } - _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)) - store.afterGet = nil - if err == nil || !strings.Contains(err.Error(), "questions already wait") { - t.Fatalf("a slot lost to another node was taken: %v", err) - } -} - -// lockedRouter is the router fake, safe for sessions asking at once. -type lockedRouter struct { - mu sync.Mutex - *router -} - -func (l *lockedRouter) Publish(subject string, body any, id string) error { - l.mu.Lock() - defer l.mu.Unlock() - return l.router.Publish(subject, body, id) -} -func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) { - l.mu.Lock() - defer l.mu.Unlock() - return l.router.Record(id) -} diff --git a/modules/claude-code/cmd/claude-code/replace_registered_test.go b/modules/claude-code/cmd/claude-code/replace_registered_test.go index d61fb1060..57f8784c4 100644 --- a/modules/claude-code/cmd/claude-code/replace_registered_test.go +++ b/modules/claude-code/cmd/claude-code/replace_registered_test.go @@ -2,11 +2,12 @@ package main import ( "encoding/json" - occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "strings" "testing" "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) // A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is diff --git a/modules/claude-code/cmd/claude-code/stop.go b/modules/claude-code/cmd/claude-code/stop.go index d1328c778..2a7bab6b8 100644 --- a/modules/claude-code/cmd/claude-code/stop.go +++ b/modules/claude-code/cmd/claude-code/stop.go @@ -60,13 +60,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) { "{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID) } -// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a -// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook -// cannot know, so it is offered conditionally; a watcher that rereads the read address always works. +// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, design 47 E §20): a wait with an event is +// followed by mesh_wait on it, rereading what it reads after every answer, or by a watcher that does; with no event, by a +// watcher that rereads the read address. The advice puts no condition on a grant. func watcherAdvice(w Wait) string { if w.Family != "" { - return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+ - "that rereads %s", w.Family, w.Read.Address) + return fmt.Sprintf("mesh_wait on %s and reread %s after every answer, or start a watcher that does", w.Family, w.Read.Address) } return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address) } diff --git a/modules/claude-code/cmd/claude-code/stop_test.go b/modules/claude-code/cmd/claude-code/stop_test.go index 71501a566..11ead0cf0 100644 --- a/modules/claude-code/cmd/claude-code/stop_test.go +++ b/modules/claude-code/cmd/claude-code/stop_test.go @@ -300,15 +300,15 @@ func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) { } } -// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool -// runner may not be granted it); without one, the advice is only to start a watcher. -func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) { +// Design 47 E §20: a wait with an event is followed by mesh_wait on it and a reread after every answer, or a watcher +// that does, with no condition on a grant; without an event, the advice is only to start a watcher. +func TestWaitBlocksStopAdvisesMeshWaitOnItsEventOtherwiseAWatcher(t *testing.T) { withFamily := openWait("w-f", 10) withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"} withFamily.Family = "mesh-delivery.decided" _, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute)) - if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") || - !strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") { + if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided and reread mesh-delivery.recheck after every answer, or start a watcher that does") || + strings.Contains(reason, "granted") { t.Fatalf("%s", reason) } diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 7b9a5cfc8..fb54aa5ad 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -7,12 +7,11 @@ require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 gopkg.in/yaml.v3 v3.0.1 ) @@ -26,6 +25,10 @@ require ( ) require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect ) + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 80ef182df..882b366ab 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -1,6 +1,6 @@ { "module": "claude-code", - "version": "1", + "version": "0.1", "moved": true, "slug": "agent", "capabilities": [ @@ -11,6 +11,25 @@ { "name": "gitea-read", "optional": true + }, + { + "name": "finding-events", + "storage": "stream", + "template": "events", + "options": { + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + } + }, + { + "name": "findings", + "storage": "key-value", + "template": "state" } ], "binds": { @@ -26,19 +45,23 @@ "seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", - "mesh-delivery-indexer.indexer_read_modules", - "mesh-delivery-indexer.indexer_read_manifests", + "seat:mesh-catalog.revision", + "seat:mesh-catalog.registrations", + "seat:mesh-delivery.source", "mesh-delivery-assigner.assigner_read_assignments", "seat:anthropic-licence-manager.bindings", "seat:anthropic-licence-manager.current", - "seat:mesh-vault.get" + "seat:mesh-vault.get", + "seat:operator-channel.notify" ], "consumes": [ - "mesh-delivery-indexer.views.modules", - "mesh-delivery-indexer.views.manifests", + "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed", + "mesh-delivery.source-moved", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", + "mesh-conditions.findings-asked", "claude-licence-manager.holdings.asked", "claude-licence-manager.binding.changed", "mesh-vault.secret-changed" @@ -50,8 +73,7 @@ "proposals", "questions", "memory", - "waits", - "findings" + "waits" ], "emits": [ "judged", @@ -237,7 +259,16 @@ "path": "${dir:state}/settings.json", "mode": "0600", "owner": "${machine:account}", - "takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"], + "takes-config": [ + "role", + "mcp_servers", + "managed_settings", + "instructions", + "output_styles", + "skills", + "commands", + "agents" + ], "merge": "json", "template": "files/settings.json" } diff --git a/modules/claude-licence-manager/cmd/manager/manager.go b/modules/claude-licence-manager/cmd/manager/manager.go index caf89e33a..3325630e6 100644 --- a/modules/claude-licence-manager/cmd/manager/manager.go +++ b/modules/claude-licence-manager/cmd/manager/manager.go @@ -96,6 +96,10 @@ func (m *Manager) record(ctx context.Context, what, line string, detail map[stri return nil } +// BindingChanged is the event of a consumer's binding written or deleted, said as `binding.changed.`: the consumer is +// its further part, the one form of G's seat events (novox/hq design 47 G A-134), so a reader adopts it. +const BindingChanged = "binding.changed" + // changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a // release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token. func (m *Manager) changed(b Binding, from string) { @@ -103,7 +107,7 @@ func (m *Manager) changed(b Binding, from string) { if b.Licence != "" { key = KeyFor(b.Licence) } - _ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key, + _ = m.Emit(BindingChanged+"."+b.Consumer, map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key, "kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By, "at": m.Now().UTC().Format(time.RFC3339Nano)}) } diff --git a/modules/claude-licence-manager/cmd/manager/manager_test.go b/modules/claude-licence-manager/cmd/manager/manager_test.go index a5c1104f4..56fadfbd2 100644 --- a/modules/claude-licence-manager/cmd/manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/manager/manager_test.go @@ -573,9 +573,14 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) { func (mm *miniMesh) changedSince(mark int) []map[string]any { var out []map[string]any for _, e := range mm.events[mark:] { - if body, ok := strings.CutPrefix(e, "binding.changed "); ok { + if rest, ok := strings.CutPrefix(e, BindingChanged+"."); ok { + // The consumer is the event's further part (A-134); one said under another consumer's name reads as none. + consumer, body, _ := strings.Cut(rest, " ") var v map[string]any _ = json.Unmarshal([]byte(body), &v) + if v["consumer"] != consumer { + v["consumer"] = "said as binding.changed." + consumer + } out = append(out, v) } } diff --git a/modules/claude-licence-manager/cmd/manager/manifest_test.go b/modules/claude-licence-manager/cmd/manager/manifest_test.go index 72e758fe9..1460168c1 100644 --- a/modules/claude-licence-manager/cmd/manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/manager/manifest_test.go @@ -147,6 +147,22 @@ func logsSection(logs []json.RawMessage) map[string]map[string]any { // mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`.@`) // is invoked as `.`. +// emits is whether the manifest names the event key's family: the key itself, or a family it extends by further parts, +// as binding.changed. extends binding.changed (design 47 G A-134). +func (i issued) emits(key string) bool { + for k := key; k != ""; { + if i.Emits[k] { + return true + } + dot := strings.LastIndex(k, ".") + if dot < 0 { + break + } + k = k[:dot] + } + return false +} + func (i issued) mayAsk(key string) bool { k, _, _ := strings.Cut(key, "@") return i.Invokes[k] diff --git a/modules/claude-licence-manager/cmd/manager/switching_test.go b/modules/claude-licence-manager/cmd/manager/switching_test.go index fe9928acf..3823deccc 100644 --- a/modules/claude-licence-manager/cmd/manager/switching_test.go +++ b/modules/claude-licence-manager/cmd/manager/switching_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "strings" "testing" "time" @@ -105,7 +106,7 @@ func TestOneSwitchedPerAct(t *testing.T) { switch name { case "licence.switched": switched = append(switched, f) - case "binding.changed": + case "binding.changed." + fmt.Sprint(f["consumer"]): changed++ } } diff --git a/modules/claude-licence-manager/cmd/manager/tools_test.go b/modules/claude-licence-manager/cmd/manager/tools_test.go index ce528e61a..c1b858509 100644 --- a/modules/claude-licence-manager/cmd/manager/tools_test.go +++ b/modules/claude-licence-manager/cmd/manager/tools_test.go @@ -154,6 +154,9 @@ func TestResyncSaysEachOnce(t *testing.T) { count := map[string]int{} for _, e := range mm.events { name, _, _ := strings.Cut(e, " ") + if strings.HasPrefix(name, BindingChanged+".") { + name = BindingChanged + } count[name]++ } puts, _ := got["licence-put"].([]any) diff --git a/modules/claude-licence-manager/cmd/manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/manager/whole_integration_test.go index fa1afe7df..4ba3603d4 100644 --- a/modules/claude-licence-manager/cmd/manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/manager/whole_integration_test.go @@ -291,7 +291,7 @@ func (m *mesh) until(what string, ok func() bool) { func (m *mesh) count(key string) int { n := 0 for _, s := range m.said { - if s.key == key { + if s.key == key || strings.HasPrefix(s.key, key+".") { n++ } } @@ -302,7 +302,7 @@ func (m *mesh) count(key string) int { func (m *mesh) of(key string) []map[string]any { var out []map[string]any for _, s := range m.said { - if s.key == key { + if s.key == key || strings.HasPrefix(s.key, key+".") { var b map[string]any _ = json.Unmarshal(s.body, &b) out = append(out, b) @@ -543,7 +543,7 @@ func (p *started) refused(method string, params json.RawMessage) error { return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log) case method == "mesh/ask" && !p.own.mayAsk(a.Key): return fmt.Errorf("this account may not call %s", a.Key) - case method == "mesh/publish" && !p.own.Emits[a.Key]: + case method == "mesh/publish" && !p.own.emits(a.Key): return fmt.Errorf("%s does not emit %s", Publisher, a.Key) } return nil diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 2fec797ef..62145c08b 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -1,6 +1,6 @@ { "module": "claude-licence-manager", - "version": "1", + "version": "0.1", "slug": "licmgr", "runs-as": "claude-licence-manager", "requires": [ diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json index 30ec9c0cf..1c59e061b 100644 --- a/modules/clipmenu/module.json +++ b/modules/clipmenu/module.json @@ -1,6 +1,6 @@ { "module": "clipmenu", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/confluence/module.json b/modules/confluence/module.json index caa0d1be2..3580e2d1f 100644 --- a/modules/confluence/module.json +++ b/modules/confluence/module.json @@ -1,6 +1,6 @@ { "module": "confluence", - "version": "1", + "version": "0.1", "slug": "confl", "own-secrets": { "token": "${dir:state}/token" diff --git a/modules/cups/module.json b/modules/cups/module.json index 01a482288..a11903c28 100644 --- a/modules/cups/module.json +++ b/modules/cups/module.json @@ -1,6 +1,6 @@ { "module": "cups", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/dbus/module.json b/modules/dbus/module.json index c0e28b3e4..e71f5100a 100644 --- a/modules/dbus/module.json +++ b/modules/dbus/module.json @@ -1,6 +1,6 @@ { "module": "dbus", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/desk-channel/module.json b/modules/desk-channel/module.json index 115ce6202..562945690 100644 --- a/modules/desk-channel/module.json +++ b/modules/desk-channel/module.json @@ -1,6 +1,6 @@ { "module": "desk-channel", - "version": "1", + "version": "0.1", "slug": "desk", "runs-as": "desk-channel", "claims": [ diff --git a/modules/dev-tools/module.json b/modules/dev-tools/module.json index 094f83363..0505c8666 100644 --- a/modules/dev-tools/module.json +++ b/modules/dev-tools/module.json @@ -1,6 +1,6 @@ { "module": "dev-tools", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/disk-load/module.json b/modules/disk-load/module.json index 7b162bf90..3d33aecb9 100644 --- a/modules/disk-load/module.json +++ b/modules/disk-load/module.json @@ -1,6 +1,6 @@ { "module": "disk-load", - "version": "1", + "version": "0.1", "tools": [ "disk_load", "disk_top", diff --git a/modules/distribution/cmd/distribution-tools/contract.go b/modules/distribution/cmd/distribution-tools/contract.go index c16ada558..4fe26822a 100644 --- a/modules/distribution/cmd/distribution-tools/contract.go +++ b/modules/distribution/cmd/distribution-tools/contract.go @@ -10,7 +10,7 @@ import ( "fmt" "time" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" "distribution/internal/holder" ) diff --git a/modules/distribution/cmd/distribution-tools/contract_test.go b/modules/distribution/cmd/distribution-tools/contract_test.go index 7bd5c1e22..1ed66b9f2 100644 --- a/modules/distribution/cmd/distribution-tools/contract_test.go +++ b/modules/distribution/cmd/distribution-tools/contract_test.go @@ -3,12 +3,11 @@ package main import ( "bytes" "encoding/json" - "errors" "reflect" "strings" "testing" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" "distribution/internal/holder" ) @@ -31,7 +30,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) { reg.VerbVersions: reg.VersionsInput{}, reg.VerbRemove: reg.RemoveInput{}, reg.VerbHold: reg.HoldInput{}, reg.VerbRelease: reg.ReleaseInput{}, reg.VerbMirror: reg.MirrorInput{}, reg.VerbTags: reg.TagsInput{}} seen := 0 - for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.DefaultCallers(), nil }) { + for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}) { seat, verb, ok := strings.Cut(tool.Name, ".") in, isVerb := inputs[verb] if !ok || !isVerb || (seat != holder.ImageSeat && seat != holder.ArtifactSeat) { @@ -57,28 +56,6 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) { t.Errorf("%s's body %s is not the contract's: %v", event, b, err) } } - // The caller list is read held: one never read answers no verb, and registry_callers says it as read. - unread := func() (holder.Callers, error) { return nil, errors.New("config refused") } - for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) { - if tool.Name == holder.ImageSeat+"."+holder.VerbVersions { - if _, err := tool.Run(map[string]any{"name": "m/a"}); err == nil || !strings.Contains(err.Error(), "caller list") { - t.Errorf("versions with an unread caller list = %v, want refused naming the caller list", err) - } - } - } - answered := false - for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.Callers{holder.VerbPublish: {"mesh-build-go"}}, nil }) { - if tool.Name == holder.CallersTool { - answered = true - got, err := tool.Run(map[string]any{}) - if b, _ := json.Marshal(got); err != nil || string(b) != `{"callers":{"publish":["mesh-build-go"]}}` { - t.Errorf("%s = %s, %v", holder.CallersTool, b, err) - } - } - } - if !answered { - t.Errorf("no tool %s: the program could not read the caller list", holder.CallersTool) - } if a := publishAnswer(holder.PublishAnswer{Transfer: "t", Missing: []string{"sha256:bb"}}); !reflect.DeepEqual(a.Lacks, []string{"sha256:bb"}) || a.Held { t.Errorf("publish answers %+v", a) } diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index 4270a2adb..b0278ca9f 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -12,19 +12,19 @@ import ( "encoding/json" "errors" "fmt" - builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "net/url" "os" "strings" "sync" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" - "git.novox.be/novox/mesh-delivery/sdk/parts" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" "distribution/internal/holder" ) @@ -68,21 +68,6 @@ func say(seat, event string, body holder.Event) error { return stdio.SeatPublish("mesh.seat."+seat+".event."+event, eventBody(event, body), body.ID) } -func finding(kind, subject, words string, raised bool) { - key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, "distribution", kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) - if !raised { - _ = stdio.State(meshconditions.StateFindings).Delete(key) - return - } - f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], - Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: "distribution"}, Severity: meshconditionsclient.Warning, - Summary: words, Headline: words, Explanation: words, Resolved: "cleared once it no longer holds", - Source: kind, Observer: "distribution", At: time.Now().UTC()} - if _, err := stdio.State(meshconditions.StateFindings).Put(key, f); err != nil { - logf("the finding %s could not be written: %v", key, err) - } -} - // learned are the proved callers whose build this process recorded as publishing through the seats. var learned sync.Map @@ -248,16 +233,8 @@ func arg[T any](args map[string]any) (T, error) { func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } -// tools are distribution's tools over v; each call that checks a caller reads the caller list held (callers: -// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on. -func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool { - now := func() (*holder.Verbs, error) { - c, err := callers() - if err != nil { - return nil, fmt.Errorf("the caller list cannot be read, so no verb is answered: %w", err) - } - return v.WithCallers(c), nil - } +// tools are distribution's tools over v. +func tools(v *holder.Verbs, windows kept[holder.Window]) []stdio.Tool { obj := func(props map[string]any, req ...string) map[string]any { return map[string]any{"type": "object", "required": req, "properties": props} } @@ -268,10 +245,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbPublish, Description: "Open a transfer of a publish (ADR 0611): its name, version, kind, digest and parts; answers the parts the holder lacks.", Input: obj(map[string]any{"transfer": s("the transfer's id"), "name": s("/"), "version": s("the builder's version"), "kind": s("image, bundle, archive or upstream"), "digest": s("sha256:…"), "parts": map[string]any{"type": "array"}}, "transfer", "name", "kind", "digest", "parts"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.PublishInput](args) if err != nil { return nil, err @@ -285,10 +258,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbPublishPart, Description: "Send one chunk (at most 1 MiB) of one part of an open transfer, from the caller that opened it.", Input: obj(map[string]any{"transfer": s("its id"), "part": s("the part's digest"), "sequence": map[string]any{"type": "integer"}, "last": map[string]any{"type": "boolean"}, "data": s("base64")}, "transfer", "part", "sequence", "last", "data"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.PublishPartInput](args) if err != nil { return nil, err @@ -302,10 +271,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. }}, stdio.Tool{Name: seat + "." + holder.VerbVersions, Description: "The versions the seat holds of a name.", Input: obj(map[string]any{"name": s("/")}, "name"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } a, err := v.Versions(seat, stdio.CallerOf(args), str(args, "name")) if err != nil { return nil, err @@ -315,10 +280,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbRemove, Description: "Remove a tagged version (the builder), or for retention an untagged one by its digest (the artifact sweep; an index with the platform manifests no tagged or held index names, and never a manifest such an index names); says removed.", Input: obj(map[string]any{"name": s("name"), "version": s("a tagged version"), "tag": s("its tag"), "digest": s("for retention"), "reason": s("why")}, "name", "reason"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.RemoveInput](args) if err != nil { return nil, err @@ -333,10 +294,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbList, Description: "What the store holds of one repository, or of every repository the seat holds: each manifest's digest, whether it is an index and the manifests it names, the tags naming it, its size and whether it is held (ADR 0632 §3). Read from the store's own files; changes nothing.", Input: obj(map[string]any{"name": s("/; every repository when not given")}), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } repos, err := v.List(context.Background(), seat, stdio.CallerOf(args), str(args, "name")) if err != nil { return nil, err @@ -346,10 +303,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbHold, Description: "Keep one untagged bundle, archive or copied base from retention (the artifact sweep alone); says held.", Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("which of the sweep's rules keeps it")}, "name", "digest", "reason"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.HoldInput](args) if err != nil { return nil, err @@ -362,10 +315,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: seat + "." + holder.VerbRelease, Description: "Let a hold go (the artifact sweep, or the operator's session through registry_holds); says released.", Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("why it is let go")}, "name", "digest", "reason"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.ReleaseInput](args) if err != nil { return nil, err @@ -381,10 +330,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. out = append(out, stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbTags, Description: "The tags of an image.", Input: obj(map[string]any{"name": s("name")}, "name"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } a, err := v.Tags(holder.ImageSeat, stdio.CallerOf(args), str(args, "name")) if err != nil { return nil, err @@ -394,10 +339,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbMirror, Description: "Copy an outside image, pinned by its digest, into the store as upstream// (every platform, only the parts it lacks): answers at once with the reference the copy will have, and says published (or refused, with why) on the seat's events when the copy ends; wait on that event (mesh-build-image alone).", Input: obj(map[string]any{"source": s("/"), "digest": s("sha256:…")}, "source", "digest"), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } in, err := arg[reg.MirrorInput](args) if err != nil { return nil, err @@ -408,28 +349,15 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. } return reg.MirrorAnswer{Digest: in.Digest, Reference: ref}, nil }}, - stdio.Tool{Name: holder.CallersTool, Description: "Who may call which verb of distribution's seats: the trusted config value callers as mesh-config resolves it on this machine now (design 51's list where unset). The program asks it at each wake of the windows, as it has no config of its own.", - Input: obj(map[string]any{}), - Run: func(map[string]any) (any, error) { - c, err := callers() - if err != nil { - return nil, err - } - return map[string]any{"callers": c}, nil - }}, stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5100); fails naming the program when it does not answer.", Input: obj(map[string]any{}), Run: func(map[string]any) (any, error) { front, _ := url.Parse("http://127.0.0.1:5100") - return holder.Health(&holder.OCI{Base: front}, finding) + return holder.Health(&holder.OCI{Base: front}, findings.Say) }}, stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).", Input: obj(map[string]any{"drop": s("a transfer id to drop"), "why": s("why it is dropped")}), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } idle, _ := v.DropIdle() if id := str(args, "drop"); id != "" { t, err := v.DropByHand(id, stdio.CallerOf(args), str(args, "why")) @@ -472,10 +400,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. stdio.Tool{Name: "registry_holds", Description: "What is held, since when and for whom; with release, lets one go through the seat's release as the operator's session (a hand act).", Input: obj(map[string]any{"seat": s("the seat"), "release": s("the name to release"), "digest": s("its digest"), "why": s("why")}), Run: func(args map[string]any) (any, error) { - v, err := now() - if err != nil { - return nil, err - } if name := str(args, "release"); name != "" { if str(args, "why") == "" { return nil, errors.New("a release by hand is a hand act: say why") @@ -517,14 +441,38 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. return out } +// findings keeps the open findings of this bundle for its writer (design 47 G §689). +var findings = &holder.Findings{Logf: logf} + func main() { + // The tools bundle's findings go through the module's held writer (design 47 G §689, A-16, A-29, A-32); it owns the transfers' + // kinds, the program's writer the driver's. + w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: holder.Self, State: stdio.State("findings"), Kinds: holder.TransferFindingKinds, Logf: logf}) + if err != nil { + logf("%v", err) + os.Exit(1) + } + findings.Writer = w + // the runtime answers the writer's state and subscription only once Serve has its channel + go func() { + <-stdio.Served() + // the keys of the older form end in no kind the writer owns, so it does not clear them (A-31) + if err := holder.DeleteOldFindings(stdio.State("findings"), holder.Self, holder.TransferFindingKinds); err != nil { + logf("the old findings are not deleted: %v", err) + } + if err := w.Start(context.Background()); err != nil { + logf("%v", err) + os.Exit(1) + } + }() base, _ := url.Parse("http://127.0.0.1:5001") store := &holder.OCI{Base: base, Upstream: &holder.Upstream{}} - h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: builds.BuilderVersions{Ask: stdio.Ask}, Store: store, Say: say, Finding: finding, + h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: holder.Reserved{Ask: stdio.Ask}, Store: store, Say: say, Finding: findings.Say, Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Record: recordAct, Idle: parts.BusArm(holder.IdleState)} files := &holder.Files{Container: or(os.Getenv("MESH_REGISTRY_CONTAINER"), "mesh-registry"), Run: holder.ExecRunner, UID: os.Getuid()} v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{}, - Files: files} + Files: files, SweepHolder: func() (string, error) { return seatlist.HolderOf(stdio.Ask, holder.SweepSeat) }} go followMirrors(v) go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) { if err != nil { @@ -534,7 +482,7 @@ func main() { logf("transfer %s had no part for %s: dropped with its parts", id, holder.TransferIdle) }) windows := kept[holder.Window]{state: "windows", key: func(w holder.Window) string { return w.Name }} - if err := stdio.Serve("", tools(v, windows, holder.ConfiguredCallers)); err != nil { + if err := stdio.Serve("", tools(v, windows)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/distribution/cmd/distribution/builds.go b/modules/distribution/cmd/distribution/builds.go new file mode 100644 index 000000000..5c1efed45 --- /dev/null +++ b/modules/distribution/cmd/distribution/builds.go @@ -0,0 +1,66 @@ +package main + +// The build modules the windows' driver counts (design 47 G §610, A-156, A-82): the modules that claim a build seat +// (`node-build-*`) in the catalog half, followed per watched branch by the seat mesh-catalog's client on the program's +// one views reader. No list of names is kept here, and none is configured. + +import ( + "encoding/json" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +// buildSeatPrefix starts the name of every seat a build module claims. +const buildSeatPrefix = "node-build-" + +// buildModulesOf are the modules among entries whose manifest claims a build seat, sorted. +func buildModulesOf(entries map[string]catalog.CatalogEntry) []string { + var out []string + for module, e := range entries { + claims, _ := e.Manifest["claims"].([]any) + for _, c := range claims { + if name, _ := c.(map[string]any)["name"].(string); strings.HasPrefix(name, buildSeatPrefix) { + out = append(out, module) + break + } + } + } + slices.Sort(out) + return out +} + +// claimsBuildSeat says whether a module's entry, as the branch follower tells it, claims a build seat. +func claimsBuildSeat(module string, raw json.RawMessage) bool { + var e catalog.CatalogEntry + if json.Unmarshal(raw, &e) != nil { + return true // an entry not read may be one: the driver judges again + } + return len(buildModulesOf(map[string]catalog.CatalogEntry{module: e})) == 1 +} + +// branchesBuilds answers the driver's build modules from the catalog half of every followed branch; with a branch that has +// no first view yet it is an error naming it, never no build module. +type branchesBuilds struct{ b *meshcatalogclient.Branches } + +func (h branchesBuilds) Modules() ([]string, error) { + if err := h.b.Ready(); err != nil { + return nil, err + } + all := map[string]catalog.CatalogEntry{} + for _, branch := range h.b.Followed() { + c, ok := h.b.Catalog(branch) + if !ok { + continue + } + half, _ := c.View() // a branch at none-yet is ready and empty (§614) + for m, e := range half.Entries { + if _, ok := all[m]; !ok { + all[m] = e + } + } + } + return buildModulesOf(all), nil +} diff --git a/modules/distribution/cmd/distribution/builds_test.go b/modules/distribution/cmd/distribution/builds_test.go new file mode 100644 index 000000000..92467db99 --- /dev/null +++ b/modules/distribution/cmd/distribution/builds_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +func entry(claims ...string) catalog.CatalogEntry { + var cs []any + for _, c := range claims { + cs = append(cs, map[string]any{"name": c, "scope": "node"}) + } + return catalog.CatalogEntry{Manifest: map[string]any{"claims": cs}} +} + +// Design 47 G §610, A-156: the build modules the driver counts are the modules claiming a `node-build-*` seat in the +// catalog half, whatever their names; no list of names is kept. +func TestBuildModulesAreTheClaimantsOfBuildSeats(t *testing.T) { + entries := map[string]catalog.CatalogEntry{ + "a-compiler": entry("node-build-go"), "an-imager": entry("node-build-image", "something"), + "gitea": entry("git"), "mesh-build-go": entry("not-a-build-seat"), "no-claims": {Manifest: map[string]any{}}, + } + if got, want := buildModulesOf(entries), []string{"a-compiler", "an-imager"}; !reflect.DeepEqual(got, want) { + t.Errorf("build modules = %v, want %v", got, want) + } + raw, _ := json.Marshal(entry("node-build-node")) + if !claimsBuildSeat("m", raw) { + t.Error("an entry claiming a build seat is not told as one") + } + raw, _ = json.Marshal(entry("git")) + if claimsBuildSeat("m", raw) { + t.Error("an entry claiming no build seat wakes the driver") + } +} diff --git a/modules/distribution/cmd/distribution/main.go b/modules/distribution/cmd/distribution/main.go index 8db8da6a4..abdc0f9cd 100644 --- a/modules/distribution/cmd/distribution/main.go +++ b/modules/distribution/cmd/distribution/main.go @@ -20,7 +20,10 @@ import ( "syscall" "time" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" "github.com/nats-io/nats.go/jetstream" "distribution/internal/holder" @@ -62,9 +65,47 @@ func run(ctx context.Context, getenv func(string) string) error { return err } defer calls.Close() - findings := process.OptionalBucket(ctx, js, "distribution_findings", "a window held for seven days is said in the log only", logger.Printf) - go drive(ctx, js, cred, &holder.Driver{Delivery: busDelivery{calls}, State: kvWindows{windows}, Callers: busCallers{calls}.Read, - Closed: sayClosed(js, cred), Finding: findingsKV(findings, logger.Printf)}, logger) + // The program's findings go through the module's writer (design 47 G §689); it owns the driver's kinds, the tools + // bundle's writer the transfers'. + findings := &holder.Findings{Logf: logger.Printf} + w, err := views.NewHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: holder.Self, Node: cred.Node, State: calls.State("findings"), Kinds: holder.DriverFindingKinds, + JS: js, Conn: conn, Logf: logger.Printf}) + if err != nil { + return err + } + findings.Writer = w + // the keys of the older form end in no kind the writer owns, so it does not clear them (A-31) + if err := holder.DeleteOldFindings(calls.State("findings"), holder.Self, holder.DriverFindingKinds); err != nil { + logger.Printf("the old findings are not deleted: %v", err) + } + if err := w.Start(ctx); err != nil { + return err + } + defer w.Close() + // Which modules are the build modules is the catalog half's (design 47 G §610, A-156): the branch follower of the seat + // mesh-catalog's client on a views reader that shares the program's one events hub. The driver wakes again when a + // build module's entry comes, changes or goes, so the start's wake, made before the first view, is not the last. + hub := process.NewEventHub(logger.Printf) + reader := &views.ProcessReader{Module: holder.Self, Call: calls.Call, Logf: logger.Printf, Hub: hub} + branches, err := meshcatalogclient.FollowBranchesProcess(reader, calls.Call) + if err != nil { + return err + } + branches.Logf = logger.Printf + catalogMoved := make(chan struct{}, 1) + branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) { + if !deleted && !claimsBuildSeat(module, entry) { + return + } + select { + case catalogMoved <- struct{}{}: + default: + } + } + reader.Start(ctx, js, cred, nil) + go drive(ctx, js, cred, hub, catalogMoved, &holder.Driver{Delivery: busDelivery{calls}, State: kvWindows{windows}, + BuildModules: branchesBuilds{branches}.Modules, Closed: sayClosed(js, cred), Finding: findings.Say}, logger) } front := &holder.Front{Store: store, Window: &open, Logf: logger.Printf, Published: func(name, tag string) (bool, error) { diff --git a/modules/distribution/cmd/distribution/whole_integration_test.go b/modules/distribution/cmd/distribution/whole_integration_test.go index c60bb57bc..c36d44869 100644 --- a/modules/distribution/cmd/distribution/whole_integration_test.go +++ b/modules/distribution/cmd/distribution/whole_integration_test.go @@ -7,8 +7,8 @@ package main // OCI store on the loopback, the status view's node_haves and the node-engine seat's manifest-reads answered on their // call subjects. An away node holds the old name open; its return (manifest-reads-changed) closes it, said once on // distribution.events, after which a direct write is refused naming publish; reads stay open; a waiter on a seat's -// `published` hears none of the windows' events; and the build modules counted are publish's callers as the tools -// bundle answers the config value, not design 51's defaults. +// `published` hears none of the windows' events; and the build modules counted are the modules claiming a `node-build-*` seat +// in the catalog half the seat mesh-catalog answers (design 47 G §610, A-156), not a list of names. import ( "context" @@ -29,6 +29,8 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/testbus" "distribution/internal/holder" @@ -56,7 +58,8 @@ func TestDistributionWhole(t *testing.T) { t.Fatal(err) } if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "novox_distribution", AckPolicy: jetstream.AckExplicitPolicy, - FilterSubjects: []string{"mesh.mod.mesh-delivery-view.event.delivered", "mesh.seat.node-engine.event.manifest-reads-changed.*"}}); err != nil { + FilterSubjects: []string{"mesh.mod.mesh-delivery-view.event.delivered", "mesh.seat.node-engine.event.manifest-reads-changed.*", + "mesh.seat.mesh-catalog.event.catalog-revision.>", "mesh.seat.mesh-catalog.event.registration-changed", "mesh.seat.mesh-delivery.event.source-moved"}}); err != nil { t.Fatal(err) } @@ -77,21 +80,36 @@ func TestDistributionWhole(t *testing.T) { answer(m, map[string]any{"keys": map[string]any{ "ace.mesh-build-go": map[string]any{"fp": "fp-new"}, "g14.mesh-build-go": map[string]any{"fp": fp}, "ace.gitea": map[string]any{"fp": "x"}, "g14.gitea": map[string]any{"fp": "x"}, - // A build module the operator took off publish's list: it holds no window (the caller list as configured). + // A module claiming no build seat in the catalog half: it holds no window. "g14.mesh-build-node": map[string]any{"fp": "fp-node-old"}, }}) }); err != nil { t.Fatal(err) } - // distribution's own tools bundle answers the config value callers as mesh-config resolved it: publish by - // mesh-build-go alone. The program reads it there, never from an environment variable nothing renders. - if _, err := nc.Subscribe("mesh.mod.distribution.>", func(m *nats.Msg) { - if !strings.Contains(m.Subject, holder.CallersTool) { - return + // The catalog half, as the seats mesh-catalog and mesh-delivery answer it: one watched branch, main, whose trunk revision + // has mesh-build-go claiming the build seat node-build-go; mesh-build-node claims no build seat, so it holds no window. + entryOf := func(module string, claims ...string) catalog.CatalogEntry { + var cs []any + for _, c := range claims { + cs = append(cs, map[string]any{"name": c, "scope": "node"}) + } + return catalog.CatalogEntry{Module: module, Manifest: map[string]any{"claims": cs}} + } + catalogHalf := func(m *nats.Msg) { + switch { + case strings.Contains(m.Subject, ".call.registrations."): + answer(m, catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "mesh-build-go", Branches: []string{"main"}}}}) + case strings.Contains(m.Subject, ".call.revision."): + answer(m, catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk, Branch: "main"}, + Entries: []catalog.CatalogEntry{entryOf("mesh-build-go", "node-build-go"), entryOf("mesh-build-node"), entryOf("gitea", "git")}}) + case strings.Contains(m.Subject, ".call.source."): + answer(m, map[string]any{"branches": []string{"main"}}) + } + } + for _, subject := range []string{"mesh.seat.mesh-catalog.>", "mesh.seat.mesh-delivery.call.>"} { + if _, err := nc.Subscribe(subject, catalogHalf); err != nil { + t.Fatal(err) } - answer(m, map[string]any{"callers": map[string]any{"publish": []string{"mesh-build-go"}}}) - }); err != nil { - t.Fatal(err) } engine := func(node string) { for _, subj := range []string{"mesh.seat.node-engine.call.manifest-reads." + node + ".>", "mesh.seat.node-engine.tool.manifest-reads." + node} { @@ -210,9 +228,12 @@ func TestDistributionWhole(t *testing.T) { i, err := c.Info(ctx) return err == nil && i.NumPending == 0 && i.NumAckPending == 0 && i.Delivered.Consumer >= 2 }) - info, _ := s.Info(ctx) - if info.State.Msgs != 2 { - t.Errorf("distribution.events holds %d events, want old-name-closed and direct-writes-closed once each", info.State.Msgs) + // The stream holds the module's own events once each; the finding writer's (design 47 G A-30) are said beside them. + info, _ := s.Info(ctx, jetstream.WithSubjectFilter("mesh.stream.distribution.event.>")) + for _, ev := range []string{holder.OldNameClosedEvent, holder.DirectWritesClosedEvent} { + if n := info.State.Subjects["mesh.stream.distribution.event."+ev]; n != 1 { + t.Errorf("distribution.events holds %s %d times, want once", ev, n) + } } for _, ev := range []string{holder.OldNameClosedEvent, holder.DirectWritesClosedEvent} { if _, err := s.GetLastMsgForSubject(ctx, "mesh.stream.distribution.event."+ev); err != nil { diff --git a/modules/distribution/cmd/distribution/windows.go b/modules/distribution/cmd/distribution/windows.go index 15b166776..9d0db132a 100644 --- a/modules/distribution/cmd/distribution/windows.go +++ b/modules/distribution/cmd/distribution/windows.go @@ -18,8 +18,6 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" - meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/process" "distribution/internal/holder" @@ -29,8 +27,6 @@ import ( const ( havesAsk = "mesh-delivery-view.node_haves" readsAsk = "seat:node-engine.manifest-reads" - // callersAsk is distribution's own tool answering its config value callers as resolved. - callersAsk = holder.Self + "." + holder.CallersTool ) // caller is a process's call of a tool or a seat verb. @@ -39,25 +35,6 @@ type caller interface { CallOn(node, key string, body any) (json.RawMessage, error) } -// busCallers reads the caller list from distribution's own tools bundle (holder.CallersTool, manifest invokes): a -// program has no runtime that resolves its module's config values, the tools bundle has (stdio.Config), so the program -// asks it on each wake rather than read an environment variable nothing renders. -type busCallers struct{ c caller } - -func (b busCallers) Read() (holder.Callers, error) { - raw, err := b.c.Call(callersAsk, map[string]any{}) - if err != nil { - return nil, fmt.Errorf("%s: %w", callersAsk, err) - } - var a struct { - Callers json.RawMessage `json:"callers"` - } - if err := json.Unmarshal(unwrap(raw), &a); err != nil || len(a.Callers) == 0 { - return nil, fmt.Errorf("%s answered what is not a caller list: %s", callersAsk, unwrap(raw)) - } - return holder.ParseCallers(string(a.Callers)) -} - // busDelivery answers holder.Delivery through the bus. type busDelivery struct{ c caller } @@ -173,30 +150,6 @@ func (k kvWindows) Publishing() (holder.Publishing, error) { return p, k.get(holder.PublishingKey, &p) } -// findingsKV writes the driver's findings into distribution's state findings, as the tools bundle writes its own. -func findingsKV(kv jetstream.KeyValue, logf func(string, ...any)) func(kind, subject, words string, raised bool) { - return func(kind, subject, words string, raised bool) { - if kv == nil { - return - } - key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, holder.Self, kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if !raised { - _ = kv.Delete(ctx, key) - return - } - f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], - Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: holder.Self}, Severity: meshconditionsclient.Warning, - Summary: words, Headline: words, Explanation: words + "; registry_old_name or registry_direct_writes drops a retired node by hand", - Resolved: "cleared when the window closes", Source: kind, Observer: holder.Self, At: time.Now().UTC()} - b, _ := json.Marshal(f) - if _, err := kv.Put(ctx, key, b); err != nil { - logf("the finding %s could not be written: %v", key, err) - } - } -} - // sayClosed says a window's closing on distribution's own family distribution.events, once (its id is the window's). func sayClosed(js jetstream.JetStream, cred process.Account) func(holder.Closed) error { return func(c holder.Closed) error { @@ -229,7 +182,7 @@ func wakes(subject string) (string, bool) { // drive wakes the driver at start and on each waking event of the program's durable consumer of the events stream. // A wake that could not judge is said and its event asked again a minute later. -func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d *holder.Driver, logger *log.Logger) { +func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, hub *process.EventHub, catalogMoved <-chan struct{}, d *holder.Driver, logger *log.Logger) { wake := func(why string) error { said, err := d.Wake(why) for _, s := range said { @@ -241,9 +194,9 @@ func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d return err } _ = wake("distribution started") - // The module's one durable on the events stream is pulled by the events hub (mesh-sdk process), each waking event - // registered by its subject pattern: the seat of the view says delivered, the node-engine says a manifest read changed. - hub := process.NewEventHub(logger.Printf) + // The module's one durable on the events stream is pulled by the events hub (mesh-sdk process), which the views reader + // of the catalog half shares: each waking event registered by its subject pattern, the seat of the view saying + // delivered, the node-engine saying a manifest read changed. handle := func(m jetstream.Msg) { why, ok := wakes(m.Subject()) if !ok { @@ -266,6 +219,13 @@ func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d logger.Printf("the events stream cannot be heard (%v): the windows move only at the next start", err) return } - <-ctx.Done() - hub.Stop() + for { + select { + case <-catalogMoved: + _ = wake("a build module moved in the catalog half") + case <-ctx.Done(): + hub.Stop() + return + } + } } diff --git a/modules/distribution/go.mod b/modules/distribution/go.mod index 4a037fc1a..a16a3916a 100644 --- a/modules/distribution/go.mod +++ b/modules/distribution/go.mod @@ -3,13 +3,14 @@ module distribution go 1.26.0 require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) @@ -30,7 +31,7 @@ require ( require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect ) + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/distribution/internal/holder/callers.go b/modules/distribution/internal/holder/callers.go deleted file mode 100644 index b3021139e..000000000 --- a/modules/distribution/internal/holder/callers.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package holder is distribution's holding of the two image seats, `container-image-registry` and -// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The -// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and -// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the -// builder's `builder_versions` and stored in the artifact store, which listens on its node's loopback; the store's port -// is the program's, reads open, writes from its own process alone (front.go). -package holder - -import ( - "fmt" - "slices" - "sort" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// The seats distribution holds, and the old one it holds until its window closes. -const ( - ImageSeat = "container-image-registry" - ArtifactSeat = "oci-artifact-registry" - OldSeat = "mesh-artifact-store" -) - -// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*). -const ( - VerbPublish = "publish" - VerbPublishPart = "publish-part" - VerbVersions = "versions" - VerbRemove = "remove" - VerbMirror = "mirror" - VerbTags = "tags" - VerbHold = "hold" - VerbRelease = "release" -) - -// Seats are the seats distribution answers verbs on. -var Seats = []string{ImageSeat, ArtifactSeat} - -// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold, release and list on both of distribution's. -func VerbsOf(seat string) []string { - v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList} - if seat == ImageSeat { - v = append(v, VerbMirror, VerbTags) - } - sort.Strings(v) - return v -} - -// Callers is the holder's trusted config value `callers`: per verb, the callers that may call it, read as mesh-sdk's -// seat-neutral list per verb reads it (ADR 0627 §2). The holder checks the caller the tool runner proved and stamped on -// the request (design 25 § 12.4), never a name in the request's body. -type Callers = stdio.VerbCallers - -// DefaultCallers is the list design 51 *Who may call which verb* names: publish and publish-part the build modules; -// mirror mesh-build-image; remove the builder (a tagged version) and the artifact sweep's holder (retention); hold the -// artifact sweep's holder alone; release that holder, and the operator's session only through registry_holds (Operator -// below). versions, tags and list are read by anyone on the mesh, as the store's reads are. -func DefaultCallers() Callers { - builds := []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"} - return Callers{ - VerbPublish: builds, - VerbPublishPart: builds, - VerbMirror: {"mesh-build-image"}, - VerbRemove: {"mesh-delivery-builder", SweepHolder}, - VerbHold: {SweepHolder}, - VerbRelease: {SweepHolder, Operator}, - VerbVersions: {Anyone}, - VerbTags: {Anyone}, - VerbList: {Anyone}, - } -} - -// SweepHolder is the module holding the seat `mesh-artifact-sweep`, the artifact sweep. -const SweepHolder = "artifact-store-tools" - -// The entries of a list that stand for a class, and the caller the runtime names for a call it could not prove. -const ( - Anyone = stdio.AnyCaller - Operator = stdio.AnyPerson - Unproven = stdio.Unproven -) - -// Self is distribution's own account: never on release's list, so registry_holds cannot release in its own name. -const Self = "distribution" - -// ParseCallers reads the config value; an empty value is DefaultCallers. A verb no seat has is refused, and Self on -// release's list is refused (design 51). -func ParseCallers(raw string) (Callers, error) { - c, err := stdio.ParseVerbCallers(raw, VerbsOf(ImageSeat), DefaultCallers()) - if err != nil { - return nil, err - } - return selfNotReleasing(c) -} - -// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of -// the value, so a call never asks (issue 1966: an ask that timed out refused every verb). -var configured = stdio.HoldVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers()) - -// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, held from the tools -// runtime's watch of it (never an environment variable: nothing renders a config value into a program's environment); -// unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it. -func ConfiguredCallers() (Callers, error) { - c, err := configured() - if err != nil { - return nil, err - } - return selfNotReleasing(c) -} - -// CallersConfig is the config value's key, and CallersTool the tools bundle's tool answering it as resolved: the -// program, which has no tools runtime, asks that tool over the bus (manifest invokes). -const ( - CallersConfig = "callers" - CallersTool = "registry_callers" -) - -// WithCallers is v with the caller list c: a copy for one call, so a list read on that call never changes another's. -func (v *Verbs) WithCallers(c Callers) *Verbs { - h := *v.Holder - h.Callers = c - out := *v - out.Holder = &h - return &out -} - -func selfNotReleasing(c Callers) (Callers, error) { - if slices.Contains(c[VerbRelease], Self) { - return nil, fmt.Errorf("callers puts %s on release's list: registry_holds would release in its own name", Self) - } - return c, nil -} - -// CallerModule is the module of a proved module caller, which the runtime stamps `.`. -func CallerModule(proved string) string { return stdio.ModuleOf(proved) } diff --git a/modules/distribution/internal/holder/driver.go b/modules/distribution/internal/holder/driver.go index 49cab5bf3..c51e4ed32 100644 --- a/modules/distribution/internal/holder/driver.go +++ b/modules/distribution/internal/holder/driver.go @@ -65,8 +65,9 @@ type WindowState interface { type Driver struct { Delivery Delivery State WindowState - // Callers is the caller list as it holds on this wake: publish's callers are the build modules counted. - Callers func() (Callers, error) + // BuildModules are the modules claiming a build seat (`node-build-*`) in the catalog half as it holds on this wake: + // the build modules counted (design 47 G §610, A-156). + BuildModules func() ([]string, error) // Closed says a window's closing on distribution.events. Closed func(c Closed) error Finding func(kind, subject, words string, raised bool) @@ -87,11 +88,10 @@ func (d *Driver) Read() ([]NodeRead, error) { if err != nil { return nil, fmt.Errorf("the publishing builds cannot be read, so no window moves: %w", err) } - callers, err := d.Callers() + builds, err := d.BuildModules() if err != nil { - return nil, fmt.Errorf("the caller list cannot be read, so no window moves: %w", err) + return nil, fmt.Errorf("the build modules cannot be read from the catalog half, so no window moves: %w", err) } - builds := callers[VerbPublish] byNode := map[string]*NodeRead{} var order []string for _, h := range haves { diff --git a/modules/distribution/internal/holder/findings.go b/modules/distribution/internal/holder/findings.go new file mode 100644 index 000000000..e14a07b84 --- /dev/null +++ b/modules/distribution/internal/holder/findings.go @@ -0,0 +1,80 @@ +package holder + +import ( + "context" + "strings" + "time" + + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + keyparts "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// FindingKinds are the kinds the driver says (the program) and the transfers say (the tools bundle): each bundle's writer +// owns its own, since the two share one state `findings` (design 47 G §580, §689). +var ( + DriverFindingKinds = []string{FindingWindowHeld} + TransferFindingKinds = []string{"publish-refused", "incoming-full"} +) + +// FindingWriter is the module's writer (views.Writer) as the keeper writes and clears through it. +type FindingWriter interface { + Write(ctx context.Context, finding meshconditions.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// Findings keeps a bundle's open findings and says them through the module's writer (design 47 G §689, A-14): its `Say` is +// the Finding callback of the Driver and the Holder; the writer is the held writer (views.HeldWriter), which holds what is open (A-29, A-32). +type Findings struct { + Writer FindingWriter + Logf func(string, ...any) + Now func() time.Time +} + +// Say raises (raised) or clears the finding of a kind about a subject. Its id is the seat's and the subject's, each escaped +// as a key's name is (design 47 G A-14); the kind stays the bundle's own word. +func (k *Findings) Say(kind, subject, words string, raised bool) { + id := keyparts.EscapeName(Self) + "." + keyparts.EscapeName(subject) + key := views.FindingKey("seat", id, kind) + if !raised { + if err := k.Writer.Clear(context.Background(), "seat", id, kind, "it no longer holds"); err != nil { + k.Logf("the finding %s could not be cleared: %v", key, err) + } + return + } + at := time.Now() + if k.Now != nil { + at = k.Now() + } + f := meshconditions.Finding{Scope: "seat", ID: id, Kind: kind, Subject: meshconditions.Subject{Scope: "seat", ID: id}, + Severity: meshconditions.FindingSeverityWarning, Summary: words, Headline: words, Explanation: words, + Source: kind, Observer: Self, At: at.UTC()} + if err := k.Writer.Write(context.Background(), f); err != nil { + k.Logf("the finding %s could not be written: %v", key, err) + } +} + +// DeleteOldFindings deletes, once at a module's start, the findings the keys of an older form hold (design 47 G A-31): the form +// `seat.._`, whose kind is no kind the writer owns in its key, so the writer's own first statement does not +// clear it. Only a key of one of kinds is deleted; the keys of the writer's form (`seat...`) are left. +func DeleteOldFindings(state views.FindingState, seat string, kinds []string) error { + keys, err := state.Keys() + if err != nil { + return err + } + for _, k := range keys { + rest, ok := strings.CutPrefix(k, "seat."+seat+".") + if !ok { + continue + } + for _, kind := range kinds { + if strings.HasPrefix(rest, kind+"_") { + if err := state.Delete(k); err != nil { + return err + } + break + } + } + } + return nil +} diff --git a/modules/distribution/internal/holder/findings_test.go b/modules/distribution/internal/holder/findings_test.go new file mode 100644 index 000000000..677910d1b --- /dev/null +++ b/modules/distribution/internal/holder/findings_test.go @@ -0,0 +1,44 @@ +package holder + +import ( + "sort" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// A-31: the keys of the older form (`seat.._`) are deleted once; a key of the writer's form, another seat's +// and another kind's stay. +func TestTheOldFindingKeysAreDeletedOnce(t *testing.T) { + state := &oldKeysState{keys: map[string]bool{}} + for _, k := range []string{ + "seat.distribution.publish-refused_write", "seat.distribution.publish-refused_a_b_c", + "seat.distribution.sub.publish-refused", "seat.other.publish-refused_write", "seat.distribution.unrelated_write"} { + state.keys[k] = true + } + if err := DeleteOldFindings(state, "distribution", []string{"publish-refused", "incoming-full"}); err != nil { + t.Fatal(err) + } + var left []string + for k := range state.keys { + left = append(left, k) + } + sort.Strings(left) + if got, want := strings.Join(left, " "), "seat.distribution.sub.publish-refused seat.distribution.unrelated_write seat.other.publish-refused_write"; got != want { + t.Errorf("left %s, want %s", got, want) + } +} + +type oldKeysState struct{ keys map[string]bool } + +func (s *oldKeysState) Get(string) (*stdio.StateEntry, error) { return nil, nil } +func (s *oldKeysState) Put(string, any) (uint64, error) { return 0, nil } +func (s *oldKeysState) Delete(k string) error { delete(s.keys, k); return nil } +func (s *oldKeysState) Keys() ([]string, error) { + var out []string + for k := range s.keys { + out = append(out, k) + } + return out, nil +} diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index 6c06fe9a1..1e202aab9 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -17,7 +17,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" - artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference" ) type memObjects map[string][]byte @@ -194,14 +194,15 @@ func (m memHolds) List() ([]Hold, error) { func newBed(builderOK bool) *bed { b := &bed{store: &fakeStore{has: map[string]bool{}}, findings: map[string]bool{}, now: time.Unix(1000, 0), cat: &fakeCatalog{tags: map[string]string{}}} - b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK}, + b.h = &Holder{Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK}, Store: b.store, Now: func() time.Time { return b.now }, Say: func(seat, event string, body Event) error { b.said = append(b.said, said{seat, event, body}) return nil }, Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }} - b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}, Mirrors: memMirrors{}} + b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}, Mirrors: memMirrors{}, + SweepHolder: func() (string, error) { return "artifact-store-tools", nil }} return b } @@ -221,8 +222,9 @@ func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.E const builder = "ace.mesh-build-go" // TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5): -// one table or case per rule, the caller list and the store front's. +// one table or case per rule, the store front's. func TestTheHolderOfTheTwoImageSeats(t *testing.T) { + t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation) blob := []byte(strings.Repeat("a", 3000)) part := Part{Digest: digestOf(blob), Size: int64(len(blob))} req := func(id string) PublishRequest { @@ -242,14 +244,14 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) { t.Errorf("resumed publish = %+v", a) } - // A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again - // (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken. + // A chunk 0 again starts the part afresh (it travels whole again, design 51 Publishing); a chunk that is neither the + // cursor nor 0 is refused, naming the cursor. + if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil { + t.Errorf("a chunk 0 sent again was refused: %v", err) + } if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil { t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held") } - if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil { - t.Errorf("a chunk 0 again was refused: %v", err) - } a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]}) if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" { t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said) @@ -269,8 +271,6 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { do func(b *bed) error want string }{ - {"a caller not on the list", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, "ace.mesh-provisioner", req("t")); return err }, "not on the holder's caller list"}, - {"an unproven caller", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, Unproven, req("t")); return err }, "no caller the tool runner proved"}, {"a part from another caller than the opener", func(b *bed) error { _, _ = b.h.Publish(ArtifactSeat, builder, req("t")) _, err := b.h.PublishPart(ArtifactSeat, "ace.mesh-build-node", PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: blob}) @@ -291,12 +291,12 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { _, err := b.h.Publish(ArtifactSeat, builder, req("t")) return err }, "at their cap"}, - {"a version the builder did not number", func(b *bed) error { + {"a version the builder did not reserve", func(b *bed) error { b.h.Builder = fakeBuilder{ok: false} b.store.has[part.Digest] = true // held already: the publish ends at once _, err := b.h.Publish(ArtifactSeat, builder, req("t")) return err - }, "the builder did not number it"}, + }, "the builder did not reserve it"}, {"an image on the artifact seat", func(b *bed) error { r := req("t") r.Kind = KindImage @@ -319,13 +319,16 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { {"a hold of an image", func(b *bed) error { return b.v.HoldOne(ImageSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/i", Kind: KindImage, Digest: part.Digest}) }, "never held"}, - {"a hold from another caller", func(b *bed) error { - return b.v.HoldOne(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest}) - }, "not on the holder's caller list"}, - {"a release from a caller not on the list", func(b *bed) error { - _, err := b.v.Release(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Digest: part.Digest}) + {"a retention removal by the module that held the sweep seat before", func(b *bed) error { + b.v.SweepHolder = func() (string, error) { return "artifact-sweeper", nil } // the seat's holder as the seat says it now + _, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention}) return err - }, "not on the holder's caller list"}, + }, "artifact sweep's alone"}, + {"a retention removal while the sweep's holder cannot be read", func(b *bed) error { + b.v.SweepHolder = func() (string, error) { return "", errors.New("the seat's holders are not answered") } + _, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention}) + return err + }, "cannot be read"}, {"a release in distribution's own name", func(b *bed) error { _, err := b.v.Release(ArtifactSeat, "novox.distribution", HoldRequest{Name: "m/a", Digest: part.Digest}) return err @@ -340,9 +343,6 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { t.Errorf("%s: err = %v, want one saying %q", c.name, err, c.want) } } - if _, err := ParseCallers(`{"release":["distribution"]}`); err == nil { - t.Error("a caller list putting distribution on release's list is taken") - } }) t.Run("only the missing layers are sent, and an idle transfer is dropped", func(t *testing.T) { b := newBed(true) @@ -776,7 +776,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { var closed []string found := map[string]bool{} now := time.Unix(0, 0) - d := &Driver{Delivery: del, State: st, Callers: func() (Callers, error) { return DefaultCallers(), nil }, Now: func() time.Time { return now }, + d := &Driver{Delivery: del, State: st, BuildModules: func() ([]string, error) { return []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"}, nil }, Now: func() time.Time { return now }, Closed: func(c Closed) error { closed = append(closed, c.Window+" "+c.Seat); return nil }, Finding: func(kind, subject, _ string, raised bool) { found[kind+" "+subject] = raised }} if _, err := d.Wake("start"); err != nil { @@ -848,9 +848,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - // The builder's question is its published package, taken by its series (ADR 0634 §1). - if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" { - t.Errorf("packages %v: want builds of series 0.1", m.Packages) + // The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381). + if len(m.Packages) != 0 { + t.Errorf("packages %v: want none", m.Packages) } // E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while // the running node-engines still find the store by it (issue 1966), and nothing else is provided or served. @@ -869,8 +869,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if _, ok := claims[OldSeat]; !ok { t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open") } - if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) { - t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes) + if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed", "mesh-conditions.findings-asked", + "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"}) { + t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes) and the catalog half's events", m.Consumes) } // E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts. store, program := -1, -1 @@ -915,19 +916,34 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { // Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap // (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key // in idle, a state of keys with their own time to live. + // The image registry is a foundation part (design 47 G §584): its bus state is in its old sections, incoming in + // `objects`, the others in `state`. var req struct { - Requires []struct { - Name string `json:"name"` - Options map[string]any `json:"options"` - } `json:"requires"` + Objects []struct { + Name string `json:"name"` + MaxMiB uint64 `json:"max-mib"` + } `json:"objects"` + State []json.RawMessage `json:"state"` } _ = json.Unmarshal(raw, &req) opts := map[string]map[string]any{} - for _, r := range req.Requires { - opts[r.Name] = r.Options + for _, e := range req.State { + var o map[string]any + if json.Unmarshal(e, &o) != nil { + var name string + _ = json.Unmarshal(e, &name) + o = map[string]any{"name": name} + } + opts[fmt.Sprint(o["name"])] = o } - if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap { - t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap)) + var incoming uint64 + for _, o := range req.Objects { + if o.Name == IncomingObjects { + incoming = o.MaxMiB + } + } + if incoming<<20 != IncomingCap { + t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", incoming, uint64(IncomingCap)) } if _, ok := opts[TransfersState]["ttl-seconds"]; ok { t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState]) diff --git a/modules/distribution/internal/holder/list.go b/modules/distribution/internal/holder/list.go index 9361c35df..022387c4b 100644 --- a/modules/distribution/internal/holder/list.go +++ b/modules/distribution/internal/holder/list.go @@ -13,7 +13,7 @@ import ( "fmt" "sort" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" ) // VerbList is the seats' verb list. @@ -29,9 +29,6 @@ type StoreFiles interface { // and name not held is an empty answer. A manifest whose content cannot be read fails the answer, by name: a listing // missing one would read as the store not holding it. func (v *Verbs) List(ctx context.Context, seat, caller, name string) ([]reg.Repository, error) { - if err := v.Callers.Allowed(VerbList, caller); err != nil { - return nil, err - } if seat != ImageSeat && seat != ArtifactSeat { return nil, fmt.Errorf("list: %s is no seat of distribution's", seat) } diff --git a/modules/distribution/internal/holder/list_test.go b/modules/distribution/internal/holder/list_test.go index a29eaf391..b49cd7dc7 100644 --- a/modules/distribution/internal/holder/list_test.go +++ b/modules/distribution/internal/holder/list_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" ) // dg is a digest made from a short name, so a test reads as names. @@ -128,7 +128,7 @@ func TestListAnswersTheStoreThroughTheSeat(t *testing.T) { f := filesWorld() b := newBed(true) b.v.Files = &Files{Container: "mesh-registry", Run: f.run(t)} - if err := b.v.HoldOne(ArtifactSeat, SweepHolder, HoldRequest{Name: "app/tools", Digest: dg("a-kept"), Why: "kept"}); err != nil { + if err := b.v.HoldOne(ArtifactSeat, "novox.artifact-store-tools", HoldRequest{Name: "app/tools", Digest: dg("a-kept"), Why: "kept"}); err != nil { t.Fatal(err) } ctx := context.Background() diff --git a/modules/distribution/internal/holder/reserved.go b/modules/distribution/internal/holder/reserved.go new file mode 100644 index 000000000..b649431d5 --- /dev/null +++ b/modules/distribution/internal/holder/reserved.go @@ -0,0 +1,42 @@ +package holder + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds" +) + +// Reserved is the holder's check of a publish against the builder's reservation (novox/hq design 47 §381, §478, §504): it asks the +// seat mesh-builds' verb reserved with the publish's name and version, a package's name in its ecosystem, and accepts the publish +// only when that version is the one reserved and an artifact of it has the publish's digest. It reads no catalog and a publish gains +// no field. It is the Builder the holder's Check asks. +type Reserved struct { + // Ask is the runtime's request and reply over the bus (mesh-sdk stdio.Ask). + Ask func(address string, body any) (json.RawMessage, error) +} + +// Address is the seat verb asked, as the manifest's `invokes` names it. +const Address = "seat:" + meshbuilds.Seat + "." + meshbuilds.VerbReserved + +// Recorded answers whether name@version with digest is reserved, and why not when it is not. An answer that cannot be read is an +// error, never a yes; a refusal by the builder (a name no entry carries, a version it did not reserve) is a no that names it. +func (r Reserved) Recorded(name, version, digest string) (bool, string, error) { + raw, err := r.Ask(Address, meshbuilds.ReservedInput{Name: name, Version: version}) + if err != nil { + return false, "", fmt.Errorf("%s did not answer for %s@%s: %w", Address, name, version, err) + } + var a meshbuilds.ReservedAnswer + if err := json.Unmarshal(raw, &a); err != nil || a.Version == "" { + return false, "", fmt.Errorf("%s answered no reservation for %s@%s: %s", Address, name, version, raw) + } + if a.Version != version { + return false, fmt.Sprintf("%s reserved %s for %s, not %s", a.Module, a.Version, name, version), nil + } + for _, art := range a.Artifacts { + if art.Digest == digest { + return true, "", nil + } + } + return false, fmt.Sprintf("%s reserved %s@%s for another digest than %s", a.Module, name, version, digest), nil +} diff --git a/modules/distribution/internal/holder/reserved_test.go b/modules/distribution/internal/holder/reserved_test.go new file mode 100644 index 000000000..faa32b074 --- /dev/null +++ b/modules/distribution/internal/holder/reserved_test.go @@ -0,0 +1,54 @@ +package holder + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// A publish is checked against the builder's reservation by the seat mesh-builds' verb reserved, asked with the package's name +// (design 47 §381, §478). +func reservedChecksAPublishAgainstTheReservation(t *testing.T) { + for _, c := range []struct { + name string + answer string + err error + ok bool + why string // part of the reason, or of the error + }{ + {name: "the reserved version and digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:image","digest":"sha256:aa"}]}`, ok: true}, + {name: "another artifact of it carries the digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:aux"},{"artifact":"mesh-image:image","digest":"sha256:aa"}]}`, ok: true}, + {name: "another digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:image","digest":"sha256:bb"}]}`, why: "another digest than sha256:aa"}, + {name: "another version reserved", answer: `{"module":"mesh-image","version":"0.1.8","artifacts":[]}`, why: "reserved 0.1.8 for registry/mesh-image, not 0.1.7"}, + {name: "the builder refused the name", err: errors.New("no entry carries registry/mesh-image"), why: "no entry carries registry/mesh-image"}, + {name: "an answer that is none", answer: `{"error":"x"}`, why: "answered no reservation"}, + } { + asked := "" + r := Reserved{Ask: func(address string, body any) (json.RawMessage, error) { + b, _ := json.Marshal(body) + asked = address + " " + string(b) + return json.RawMessage(c.answer), c.err + }} + ok, why, err := r.Recorded("registry/mesh-image", "0.1.7", "sha256:aa") + if asked != `seat:mesh-builds.reserved {"name":"registry/mesh-image","version":"0.1.7"}` { + t.Fatalf("%s: asked %s", c.name, asked) + } + if c.ok { + if !ok || err != nil { + t.Errorf("%s: not accepted: %v %v", c.name, why, err) + } + continue + } + if ok || !strings.Contains(why+fmtErr(err), c.why) { + t.Errorf("%s: ok=%v why=%q err=%v, want %q", c.name, ok, why, err, c.why) + } + } +} + +func fmtErr(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/modules/distribution/internal/holder/seats.go b/modules/distribution/internal/holder/seats.go new file mode 100644 index 000000000..3acec39c8 --- /dev/null +++ b/modules/distribution/internal/holder/seats.go @@ -0,0 +1,54 @@ +// Package holder is distribution's holding of the two image seats, `container-image-registry` and +// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The +// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and +// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the +// builder's reservation (`reserved`) and stored in the artifact store, which listens on its node's loopback; the store's port +// is the program's, reads open, writes from its own process alone (front.go). +package holder + +import ( + "sort" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The seats distribution holds, and the old one it holds until its window closes. +const ( + ImageSeat = "container-image-registry" + ArtifactSeat = "oci-artifact-registry" + OldSeat = "mesh-artifact-store" +) + +// SweepSeat is the seat of the artifact sweep, whose holder alone removes an untagged version for retention. +const SweepSeat = "mesh-artifact-sweep" + +// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*). +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" + VerbMirror = "mirror" + VerbTags = "tags" + VerbHold = "hold" + VerbRelease = "release" +) + +// Seats are the seats distribution answers verbs on. +var Seats = []string{ImageSeat, ArtifactSeat} + +// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold, release and list on both of distribution's. +func VerbsOf(seat string) []string { + v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList} + if seat == ImageSeat { + v = append(v, VerbMirror, VerbTags) + } + sort.Strings(v) + return v +} + +// Self is distribution's own account: never the caller of a release, so registry_holds cannot release in its own name. +const Self = "distribution" + +// CallerModule is the module of a proved module caller, which the runtime stamps `.`. +func CallerModule(proved string) string { return stdio.ModuleOf(proved) } diff --git a/modules/distribution/internal/holder/transfer.go b/modules/distribution/internal/holder/transfer.go index 6f2a4e5c3..bbd93b487 100644 --- a/modules/distribution/internal/holder/transfer.go +++ b/modules/distribution/internal/holder/transfer.go @@ -10,7 +10,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" - "git.novox.be/novox/mesh-delivery/sdk/parts" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" ) // The bounds of incoming (design 51 *Publishing*, ADR 0611): a part is at most 1 MiB, a transfer with no part for 24 @@ -129,7 +129,7 @@ type Objects = parts.Objects // TransferState is the state transfers. type TransferState = parts.Transfers[Opening] -// Builder is the builder's tool builder_versions, asked before anything is stored (ADR 0605 §4). +// Builder is the builder's reservation (Reserved), asked before anything is stored (ADR 0605 §4, design 47 §381). type Builder interface { Recorded(name, version, digest string) (ok bool, why string, err error) } @@ -158,7 +158,6 @@ type Finding func(kind, subject, words string, raised bool) // Holder answers the verbs. type Holder struct { - Callers Callers Objects Objects Transfers TransferState Builder Builder @@ -196,17 +195,17 @@ func (h *Holder) receiver() *parts.Receiver[Opening] { ok, err := h.Store.Has(t.Shape.Name, digest) return err == nil && ok }, - // builder_versions is asked before anything is stored (ADR 0605 §4). + // The reservation is asked before anything is stored (ADR 0605 §4, design 47 §381). Check: func(t Transfer) error { if t.Shape.Version == "" { return nil } ok, why, err := h.Builder.Recorded(t.Shape.Name, t.Shape.Version, t.Shape.Digest) if err != nil { - return errors.New("builder_versions did not answer, so nothing is stored: " + err.Error()) + return errors.New("the reservation could not be read, so nothing is stored: " + err.Error()) } if !ok { - return errors.New("the builder did not number it: " + why) + return errors.New("the builder did not reserve it: " + why) } return nil }, @@ -245,9 +244,6 @@ func answerOf(r parts.Received) PublishAnswer { // Publish is `publish` on seat for the proved caller. func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer, error) { - if err := h.Callers.Allowed(VerbPublish, caller); err != nil { - return PublishAnswer{}, err - } if err := checkPublish(seat, r); err != nil { return PublishAnswer{}, err } @@ -272,9 +268,6 @@ func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer, // PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone. func (h *Holder) PublishPart(seat, caller string, r PartRequest) (PublishAnswer, error) { - if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil { - return PublishAnswer{}, err - } got, err := h.receiver().Take(r.Transfer, caller, r.Digest, r.Sequence, r.Last, r.Data, func(t Transfer) error { if t.Shape.Seat != seat { return fmt.Errorf("transfer %s is %s's, not %s's", r.Transfer, t.Shape.Seat, seat) diff --git a/modules/distribution/internal/holder/verbs.go b/modules/distribution/internal/holder/verbs.go index bf4b5f0b6..113697d9d 100644 --- a/modules/distribution/internal/holder/verbs.go +++ b/modules/distribution/internal/holder/verbs.go @@ -10,7 +10,7 @@ import ( "strings" "time" - reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry" ) // Catalog is what the store answers of what it holds. @@ -85,6 +85,9 @@ type Verbs struct { Mirrors Mirrors // Files are the store's own files, which list reads (ADR 0632 §3). Files StoreFiles + // SweepHolder is the module holding the seat `mesh-artifact-sweep`, as the seat's holders say it now (design 47 G §7, §428); + // a removal for retention is that module's alone. + SweepHolder func() (string, error) } // VersionsAnswer is `versions`: the versions the seat holds of name. @@ -96,9 +99,6 @@ type VersionsAnswer struct { // Versions is `versions`. func (v *Verbs) Versions(seat, caller, name string) (VersionsAnswer, error) { - if err := v.Callers.Allowed(VerbVersions, caller); err != nil { - return VersionsAnswer{}, err - } if strings.TrimSpace(name) == "" { return VersionsAnswer{}, errors.New("versions: no name") } @@ -139,16 +139,17 @@ const ReasonSharedDigest = reg.RemoveAnswerReasonSharedDigest // Remove is `remove`: the builder removes a tagged version; the artifact sweep's holder an untagged bundle, archive or // copied base with the reason retention, which a hold keeps. Every removal says `removed` with its reason. func (v *Verbs) Remove(seat, caller string, r RemoveRequest) (Removal, error) { - if err := v.Callers.Allowed(VerbRemove, caller); err != nil { - return Removal{}, err - } if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" { return Removal{}, errors.New("remove: a name and a reason are required") } ev := Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Digest: r.Digest, Holder: Self, Reason: r.Reason, By: caller} switch { case r.Reason == ReasonRetention: - if CallerModule(caller) != SweepHolder { + sweep, err := v.SweepHolder() + if err != nil { + return Removal{}, fmt.Errorf("remove: the holder of %s cannot be read, so nothing is removed for retention: %w", SweepSeat, err) + } + if CallerModule(caller) != sweep { return Removal{}, fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller) } if r.Digest == "" { @@ -231,9 +232,6 @@ type HoldRequest struct { // HoldOne is `hold`: the artifact sweep's holder alone; an image is never held (its own rules keep images). func (v *Verbs) HoldOne(seat, caller string, r HoldRequest) error { - if err := v.Callers.Allowed(VerbHold, caller); err != nil { - return err - } // The seat's contract names no kind: on the image seat a hold is of a copied base (upstream/…), never an image of // its own; on the artifact seat of a bundle or an archive. if r.Kind == "" && seat == ImageSeat && strings.HasPrefix(r.Name, "upstream/") { @@ -261,9 +259,6 @@ func (v *Verbs) Release(seat, caller string, r HoldRequest) (bool, error) { if CallerModule(caller) == Self && !strings.HasPrefix(caller, "person.") { return false, fmt.Errorf("release: refused for %s: distribution cannot release in its own name", caller) } - if err := v.Callers.Allowed(VerbRelease, caller); err != nil { - return false, err - } key := Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key() h, held, err := v.Holds.Get(key) if err != nil { @@ -283,9 +278,6 @@ func (v *Verbs) Tags(seat, caller, name string) (VersionsAnswer, error) { if seat != ImageSeat { return VersionsAnswer{}, fmt.Errorf("tags: %s serves no tags", seat) } - if err := v.Callers.Allowed(VerbTags, caller); err != nil { - return VersionsAnswer{}, err - } got, err := v.Catalog.Versions(name) if err != nil { return VersionsAnswer{}, err @@ -317,9 +309,6 @@ func (v *Verbs) Mirror(seat, caller string, r MirrorRequest) (string, error) { if seat != ImageSeat { return "", fmt.Errorf("mirror: %s serves no mirror", seat) } - if err := v.Callers.Allowed(VerbMirror, caller); err != nil { - return "", err - } if r.Source == "" || !strings.HasPrefix(r.Digest, "sha256:") { return "", errors.New("mirror: refused: an upstream image is copied only by its pinned digest") } diff --git a/modules/distribution/internal/holder/window.go b/modules/distribution/internal/holder/window.go index 4ba0971e8..785b29646 100644 --- a/modules/distribution/internal/holder/window.go +++ b/modules/distribution/internal/holder/window.go @@ -3,7 +3,7 @@ package holder import ( "fmt" - "git.novox.be/novox/mesh-delivery/sdk/window" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/window" "slices" "sort" "time" diff --git a/modules/distribution/module.json b/modules/distribution/module.json index e834f13fc..0aca09da3 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -1,9 +1,6 @@ { "module": "distribution", - "version": "2", - "packages": { - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1" - }, + "version": "0.1", "provides": [ { "name": "artifact-store", @@ -53,7 +50,6 @@ } ], "tools": [ - "registry_callers", "registry_direct_writes", "registry_health", "registry_holds", @@ -62,80 +58,23 @@ "registry_old_name" ], "invokes": [ - "distribution.registry_callers", - "mesh-delivery-builder.builder_versions", + "seat:mesh-builds.reserved", "mesh-delivery-view.node_haves", "seat:node-engine.manifest-reads", - "seat:mesh-hand-acts.hand-act" + "seat:mesh-hand-acts.hand-act", + "seat:mesh-catalog.entry", + "seat:mesh-catalog.registrations", + "seat:mesh-catalog.revision", + "seat:mesh-delivery.source", + "seat:mesh-module-assigner.assignments" ], "consumes": [ "mesh-delivery-view.delivered", - "node-engine.manifest-reads-changed" - ], - "config": { - "callers": { - "type": "string", - "trusted": true, - "runtime": true, - "default": "", - "description": "per verb, the modules that may call it (JSON object of verb to modules; operator for the operator's session); empty is design 51's list" - } - }, - "requires": [ - { - "name": "incoming", - "storage": "object-store", - "template": "objects", - "options": { - "max-mib": 8192 - } - }, - { - "name": "transfers", - "storage": "key-value", - "template": "state" - }, - { - "name": "idle", - "storage": "key-value", - "template": "state", - "options": { - "key-ttl": true - } - }, - { - "name": "holds", - "storage": "key-value", - "template": "state" - }, - { - "name": "windows", - "storage": "key-value", - "template": "state" - }, - { - "name": "mirrors", - "storage": "key-value", - "template": "state" - }, - { - "name": "findings", - "storage": "key-value", - "template": "state" - }, - { - "name": "events", - "storage": "stream", - "template": "events", - "options": { - "events": [ - "old-name-closed", - "direct-writes-closed" - ], - "max-age-days": 7, - "max-mib": 16 - } - } + "node-engine.manifest-reads-changed", + "mesh-conditions.findings-asked", + "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed", + "mesh-delivery.source-moved" ], "capabilities": [ "container-runtime" @@ -281,5 +220,43 @@ } } ] - } + }, + "streams": [ + { + "name": "distribution-findings", + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + }, + { + "name": "distribution-events", + "events": [ + "old-name-closed", + "direct-writes-closed" + ], + "max-age-days": 7, + "max-mib": 16 + } + ], + "objects": [ + { + "name": "incoming", + "max-mib": 8192 + } + ], + "state": [ + "transfers", + { + "name": "idle", + "key-ttl": true + }, + "holds", + "windows", + "mirrors", + "findings" + ] } diff --git a/modules/dmenu/module.json b/modules/dmenu/module.json index aeeb3ce09..61986d1e2 100644 --- a/modules/dmenu/module.json +++ b/modules/dmenu/module.json @@ -1,6 +1,6 @@ { "module": "dmenu", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 75447825a..2318baa09 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -1,6 +1,6 @@ { "module": "dnsmasq", - "version": "1", + "version": "0.1", "upgrade": { "policy": "record", "why": "the mesh's resolver: a build that breaks it can stop a machine resolving the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, issue 260)" diff --git a/modules/docker-compose/module.json b/modules/docker-compose/module.json index 2f576b4ba..7ace4b8ce 100644 --- a/modules/docker-compose/module.json +++ b/modules/docker-compose/module.json @@ -1,6 +1,6 @@ { "module": "docker-compose", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/docker/module.json b/modules/docker/module.json index 500faea68..a51d21c9d 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -1,6 +1,6 @@ { "module": "docker", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager", diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 0c6983b4d..7321e05e7 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -1,6 +1,6 @@ { "module": "dunst", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 446395f2c..7e5008ab3 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -1,6 +1,6 @@ { "module": "fail2ban", - "version": "1", + "version": "0.1", "capabilities": [ "firewall" ], diff --git a/modules/feh/module.json b/modules/feh/module.json index d6e44073b..14de69d04 100644 --- a/modules/feh/module.json +++ b/modules/feh/module.json @@ -1,6 +1,6 @@ { "module": "feh", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/flatpak/module.json b/modules/flatpak/module.json index 76d47213d..d7fc53c52 100644 --- a/modules/flatpak/module.json +++ b/modules/flatpak/module.json @@ -1,6 +1,6 @@ { "module": "flatpak", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/fonts/module.json b/modules/fonts/module.json index b2958da8a..cac0dc2d9 100644 --- a/modules/fonts/module.json +++ b/modules/fonts/module.json @@ -1,6 +1,6 @@ { "module": "fonts", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/forticlient/module.json b/modules/forticlient/module.json index 631d3faf7..24ce2f444 100644 --- a/modules/forticlient/module.json +++ b/modules/forticlient/module.json @@ -1,6 +1,6 @@ { "module": "forticlient", - "version": "1", + "version": "0.1", "upgrade": { "policy": "record", "why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)" diff --git a/modules/gitea/README.md b/modules/gitea/README.md index de98b90ed..c946a02b0 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -67,8 +67,8 @@ refuses the commit that would make a branch a rule guards. A rule covers a branc forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob (`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the -commit. Nothing about merging changes: a merge still needs an independent review at the head and both -required checks green, through `gitea_merge_pull_request`. +commit. Nothing about merging changes: a merge is the seat git's `merge-pull`, at the head the operator's +Approve names. **A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with `patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches` @@ -197,7 +197,7 @@ the module's own object store `incoming`, its transfers in the state `transfers` side), `versions` and `remove` (a tagged version, by the builder). Before anything is uploaded the holder asks the builder whether it numbered the version with that digest; a package goes up as npm's publish sends it, a Go module's zip to the Go upload. Every outcome is said on the seat's events (`published`, `refused`, `removed`), a Go package's -with its module path. Who may call each verb is the trusted config value `callers` (empty: design 51's list). +with its module path. Who may call each verb is the seat definition's `callers`, which the grants part grants by; the holder keeps no list. ## The package registry's retention (novox/hq ADR 0251 §4, to-be 51) diff --git a/modules/gitea/announce.ts b/modules/gitea/announce.ts index 19293af54..42fe46fd1 100644 --- a/modules/gitea/announce.ts +++ b/modules/gitea/announce.ts @@ -5,13 +5,12 @@ // the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has // seen the id of, within its de-duplication window, once. // -// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given. -// It returns to `emit` once the SDK takes an id. +// They are the seat git's events (design 47 *The seat git*), said through the runtime's seat publish with that id. // // The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked // again against a new trunk commit is another announcement), a close by its head. -import { broker } from "@novox/mesh-sdk/messaging"; +import { sayOnSeat } from "./seatevents.js"; import { state as keptState } from "@novox/mesh-sdk/state"; type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string }; @@ -32,23 +31,11 @@ export function announcementId(type: string, body: unknown): string | undefined } } -/** Announce a merge, a head or a close: published with its message id. */ +/** Announce a merge, a head or a close: said on the seat git (seatevents.ts) with its message id. */ export async function announce(type: string, body: unknown): Promise { const id = announcementId(type, body); if (!id) throw new Error(`${type} is not an announcement with a message id`); - const node = process.env.MESH_NODE ?? "unknown"; - await broker().publish({ - key: type, - node, - body, - headers: { - "x-event-id": id, - "x-source": process.env.MESH_MODULE ?? "unknown", - "x-node": node, - "x-time": new Date().toISOString(), - "content-type": "application/json", - }, - }); + if (!(await sayOnSeat(type, (body ?? {}) as Record, id))) throw new Error(`the seat git says no ${type}`); } // --- the watermark: the one value of the old files that is not the bus's to remember -------------------------------- diff --git a/modules/gitea/catalog.ts b/modules/gitea/catalog.ts new file mode 100644 index 000000000..b02664536 --- /dev/null +++ b/modules/gitea/catalog.ts @@ -0,0 +1,151 @@ +// **The forge module's view of the catalog half** (novox/hq design 47 *Every part takes a first view, then follows the +// events*, ADR 0656 §6): which repositories build a registered module is the catalog half's word, asked of the seat +// mesh-catalog's verb `revision` through the seat's generated TypeScript contract and then kept from its event +// `catalog-revision`, never the holder's state nor a module named. The picture is mesh-sdk's views Follower, so the +// module's first-view tools (statusTools) read and steer it as every part's. +// +// The readers' rule: a candidate's revision moves nothing; a trunk revision heard that the view does not hold yet is +// waited for (said in the status) and asked, and past mesh-sdk's WAITS_BOUND_MS the module writes view-waits; until the +// first view is held the module acts on nothing (writes.ts grants no write). + +import { Follower, type Answer, type Finding, type FollowerOptions, type Status } from "@novox/mesh-sdk/views"; +import { + seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, states as CATALOG_STATES, + type CatalogEntry, type CatalogRevisionEvent, type RevisionAnswer, type RevisionInput, +} from "@novox/mesh-delivery-contracts-mesh-catalog"; + +/** The seat verb the view asks, as the manifest's `invokes` names it. */ +export const REVISION_VERB = `seat:${CATALOG}.${CATALOG_VERBS.revision}`; +/** The seat event the view hears, as the manifest's `consumes` names it. */ +export const CATALOG_REVISION_EVENT = `${CATALOG}.${CATALOG_EVENTS.catalogRevision}`; +/** A trunk revision; a candidate's moves no running part (ADR 0656 §6). */ +export const TRUNK = "trunk"; + +/** A revision view is never made again: one fixed creation time lets the follower tell a revision heard from one held. */ +const CREATED = new Date(0).toISOString(); + +/** Asks one seat verb over the bus and answers what the holder said. */ +export type Ask = (verb: string, input: RevisionInput) => Promise; + +/** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */ +export type CatalogHooks = Omit; + +/** A revision's answer as the view takes it: the contract leaves its revision and entries optional, the view needs both. */ +export type HeldAnswer = RevisionAnswer & Required>; + +/** + * The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. An answer without + * its revision or its entries is an error the caller sees (principle 3), never a default. + */ +export function revisionAnswer(raw: unknown): HeldAnswer { + let a: unknown = raw; + if (typeof raw === "string") { + try { + a = JSON.parse(raw); + } catch { + throw new Error(`${REVISION_VERB} answered: ${raw.slice(0, 200)}`); + } + } + const r = a as Partial & { error?: unknown }; + if (!r || typeof r !== "object" || typeof r.revision?.revision !== "number" || r.revision.revision <= 0 || !Array.isArray(r.entries)) { + const said = r && typeof r === "object" && typeof r.error === "string" ? r.error : JSON.stringify(a ?? null).slice(0, 200); + throw new Error(`${REVISION_VERB} answered no revision: ${said}`); + } + return r as HeldAnswer; +} + +export class CatalogView { + readonly follower: Follower; + /** The trunk revision whose pages are being read; 0 between asks. */ + private paging = 0; + /** The trunk revision the view holds whole; 0 before the first view. */ + private heldRevision = 0; + + private readonly ask: Ask; + + constructor(ask: Ask, hooks: CatalogHooks) { + this.ask = ask; + this.follower = new Follower({ ...hooks, owner: CATALOG, tool: REVISION_VERB, state: CATALOG_STATES.catalogRevisions, + ask: (after) => this.page(after) }); + } + + /** The ask the last event began, not waited on by hear: a test awaits it. */ + asked: Promise = Promise.resolve(); + + /** The trunk revision the view holds, 0 before the first view. */ + get held(): number { + return this.heldRevision; + } + + /** One module's entry in the view, as the catalog half says it. */ + entry(module: string): CatalogEntry | undefined { + return this.follower.get(module) as CatalogEntry | undefined; + } + + /** The Follower's ask: the trunk's newest revision, then the next pages of the same one. */ + private async page(after: string): Promise { + const input: RevisionInput = after && this.paging ? { after, revision: this.paging } : {}; + const a = revisionAnswer(await this.ask(REVISION_VERB, input)); + const rev = a.revision.revision; + if (a.revision.kind !== TRUNK) throw new Error(`${REVISION_VERB} answered a ${a.revision.kind} revision for the trunk`); + if (after && this.paging && rev !== this.paging) { + const was = this.paging; + this.paging = 0; + throw new Error(`${REVISION_VERB} answered revision ${rev} for a page of revision ${was}`); + } + this.paging = rev; + const values: Record = {}; + for (const e of a.entries) values[e.module] = e; + if (!a.next) { + this.paging = 0; + this.heldRevision = rev; + const waits = Number(this.follower.status()["waits-for"] ?? 0); + if (waits <= rev) await this.follower.holds(String(rev)); + } + return { v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, revision: rev, created: CREATED, values, + ...(a.next ? { next: a.next } : {}), at: new Date().toISOString() }; + } + + /** + * One `catalog-revision` event. A candidate's moves nothing; a trunk revision the view holds, or an older one, is passed + * over; a newer one is waited for and asked, not waited on here. Data that is no such event is an error. + */ + async hear(ev: CatalogRevisionEvent | undefined): Promise { + if (!ev || typeof ev.revision !== "number" || ev.revision <= 0) { + throw new Error(`not a ${CATALOG_REVISION_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`); + } + if (ev.kind !== TRUNK || ev.revision <= this.heldRevision) return; + const held = this.heldRevision; + // The follower keeps the newest revision heard, so an ask running when it was heard is asked once more. + await this.follower.apply({ v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, key: CATALOG_REVISION_EVENT, + revision: ev.revision, created: CREATED, gap: true }); + await this.follower.waitFor(String(ev.revision)); + this.asked = this.follower.gap(`catalog revision ${ev.revision} said, the view holds ${held}`); + } +} + +/** The kind of a finding of a first view unanswered past its bound (ADR 0615 §6), as mesh-sdk's Go views writes it. */ +export const UNANSWERED_KIND = "first-view-unanswered"; + +/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ +export function findingKey(scope: string, id: string, kind: string): string { + return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); +} + +/** A finding as the condition keeper reads it from the module's state `findings`: its key and its value. */ +export function findingValue(f: Finding): { key: string; value: Record } { + const key = findingKey("module", f.id, f.kind); + return { key, value: { + v: 1, key, kind: f.kind, subject: { scope: "module", id: f.id }, severity: "warning", summary: f.summary, headline: f.headline, + explanation: f.explanation, resolved: "", evidence: [{ at: f.at, said: f.summary }], source: `${f.observer}.${f.kind}`, + observer: f.observer, at: f.at, + } }; +} + +/** A part's finding of a first view unanswered past its bound, from the follower's status. */ +export function unansweredFinding(part: string, s: Status, at: Date): Finding { + const summary = `${part} has no first view of ${s.owner}'s ${s.state}: ${s.tool} has not answered since ${s.asked ?? "its start"} (${(s.error ?? "").trim()})`; + return { observer: part, kind: UNANSWERED_KIND, id: `${part}.${s.owner}.${s.state}`, summary, headline: `${part} waits for ${s.owner}`, + explanation: `${part} reads ${s.owner}'s ${s.state} only through ${s.tool}, and keeps asking it; it never reads the state itself. The answer ` + + `clears this. Look at ${s.owner}'s holder: is it running and connected?`, at: at.toISOString() }; +} diff --git a/modules/gitea/check.ts b/modules/gitea/check.ts deleted file mode 100644 index 3af490492..000000000 --- a/modules/gitea/check.ts +++ /dev/null @@ -1,225 +0,0 @@ -// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 -// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone). -// -// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools) -// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue -// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the -// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module -// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces -// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of -// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is -// written on its head, once per revision; a revision older than one already written on the key is dropped. -// -// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses -// a status is asked again on a bound, until it writes or a newer revision of the key comes. - -import type { CommitStatus } from "./client.js"; -import { bindUntilHeld, bindWait } from "./bind.js"; -import { checkName } from "./guard.js"; -import { CHECK_STATUS } from "./pulls.js"; - -/** The checker's state this module reads, and the act of its keys this module writes. */ -export const CHECKER_OWED = "mesh-delivery-checker.owed"; -export const CHECK_ACT = `status=${CHECK_STATUS}`; - -const states = new Set(["pending", "success", "error", "failure", "warning"]); - -/** What writing the check needs of the forge. */ -export interface CheckForge { - setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; -} - -/** One change of the checker's owed, as the SDK's state watch hands it. */ -export interface OwedChange { - key: string; - op: "put" | "delete"; - value?: unknown; - revision: number; - current?: boolean; -} - -/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */ -function unescapeName(part: string): string { - if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`); - return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8"); -} - -/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey): - * `/...status=mesh/check`, owner and name escaped. */ -export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } { - const parts = String(key ?? "").split("."); - if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, ...`); - const [repository, number, sha, act] = parts; - if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`); - const m = /^([^/]+)\/([^/]+)$/.exec(repository); - if (!m) throw new Error(`${JSON.stringify(key)} names no /`); - if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`); - if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`); - return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha }; -} - -/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */ -export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } { - if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`); - const { owner, repo, sha } = owedKeyOf(change.key); - const revision = change.revision; - if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) { - throw new Error(`${JSON.stringify(revision)} is not a revision`); - } - const v = change.value; - if (!v || typeof v !== "object") throw new Error("the value is no owed act"); - const o = v as Record; - if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`); - if (o.act !== "status" || o.context !== CHECK_STATUS) { - throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`); - } - const state = String(o.state ?? ""); - if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`); - let description = String(o.text ?? "").replace(/\s+/g, " ").trim(); - if (description.length > 140) description = description.slice(0, 139) + "…"; - const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined; - const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description, - ...(target ? { target_url: target } : {}) }; - return { owner, repo, sha, revision, status }; -} - -/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository - * or commit (404), as the client words it ("Gitea API : ..."). */ -export function refusedForGood(err: unknown): boolean { - return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err)); -} - -/** How many mesh/check writes this module has at the forge at once. */ -export const WRITES_AT_ONCE = 4; - -/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */ -export class CheckStatuses { - /** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */ - private written = new Map(); - private seen = new Map(); - /** The turn each key's writes wait on, and the asks again still running. */ - private turns = new Map>(); - private again = new Set>(); - /** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */ - private writing = 0; - private queued: (() => void)[] = []; - private forge: CheckForge; - private say: (line: string) => void; - private sleep: (ms: number) => Promise; - - constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l), - sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms))) { - this.forge = forge; - this.say = say; - this.sleep = sleep; - } - - /** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A - * forge that refuses throws. */ - async apply(change: OwedChange): Promise<"written" | "dropped"> { - let asked; - try { - asked = checkStatusOf(change); - } catch (err) { - // A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain). - if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) { - const seen = this.seen.get(change.key); - if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision); - return "dropped"; - } - this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`); - return "dropped"; - } - const key = change.key; - const seen = this.seen.get(key); - if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision); - // One key's writes take turns, so two at once are weighed one after the other. - const before = this.turns.get(key) ?? Promise.resolve(); - const turn = before.then(async (): Promise<"written" | "dropped"> => { - const newest = this.written.get(key); - if (newest !== undefined && asked.revision < newest) { - this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`); - return "dropped"; - } - await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status)); - this.remember(this.written, key, asked.revision); - return "written"; - }); - const settled = turn.catch(() => {}); - this.turns.set(key, settled); - void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); }); - return turn; - } - - /** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a - * bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses - * the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a - * time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that - * bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take - * turns in revision order (apply). */ - async take(change: OwedChange): Promise { - const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err)); - this.again.add(run); - void run.finally(() => this.again.delete(run)); - } - - /** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */ - private async atTheForge(write: () => Promise): Promise { - if (this.writing >= WRITES_AT_ONCE) await new Promise((r) => this.queued.push(r)); - else this.writing++; - try { - return await write(); - } finally { - const next = this.queued.shift(); - if (next) next(); - else this.writing--; - } - } - - /** Resolves when no write or ask again is running. */ - async settled(): Promise { - while (this.again.size) await Promise.all([...this.again]); - } - - private async askAgain(change: OwedChange, first: unknown): Promise { - let err = first; - for (let attempt = 1; ; attempt++) { - if (refusedForGood(err)) { - // A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue - // 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew. - this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`); - return; - } - const wait = bindWait(attempt); - this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`); - await this.sleep(wait); - const newest = this.seen.get(change.key); - if (newest !== undefined && newest > change.revision) return; - try { - await this.apply(change); - return; - } catch (e) { - err = e; - } - } - } - - private remember(map: Map, key: string, revision: number): void { - map.delete(key); - map.set(key, revision); - if (map.size > 4000) map.delete(map.keys().next().value as string); - } -} - -/** - * Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch - * refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects. - */ -export async function watchOwed(watch: (handler: (change: OwedChange) => Promise) => Promise, checks: CheckStatuses, - sleep?: (ms: number) => Promise, say: (line: string) => void = (l) => console.error(l)): Promise { - let refusals = 0; - await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => { - if (/could not be bound/.test(line) && refusals++ > 0) return; - say(line); - }); -} diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 8a8079a89..7720ff58d 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -413,9 +413,10 @@ export class GiteaClient { const pull = await this.getPullRequest(found.owner, found.name, found.number); if (pull.state !== "open" || !pull.head_sha) continue; // The base's protection as the forge applies it to that branch (appliedProtection): readable without administration. - const base = `${full}:${pull.base || "main"}`; - if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined)); - const rule = rules.get(base); + // A pull request the forge gave no base is not guessed one (design 47 §7): its protection is unknown. + const base = `${full}:${pull.base ?? ""}`; + if (pull.base && !rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base).catch(() => undefined)); + const rule = pull.base ? rules.get(base) : undefined; const checks = await this.headChecks(found.owner, found.name, pull.head_sha); const labels = found.labels ?? []; out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head: pull.head, head_sha: pull.head_sha, @@ -481,6 +482,39 @@ export class GiteaClient { return GiteaClient.mapPull(await this.request(`${repoPath(owner, repo)}/pulls/${index}`)); } + /** The pull request a commit was merged by (the forge's own lookup of a merge commit): what the seat git's `pull` + * answers by merge commit. A commit no pull request merged is the forge's 404. */ + async pullOfCommit(owner: string, repo: string, sha: string): Promise { + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`${sha} is not a commit's full 40-hex sha`); + return GiteaClient.mapPull(await this.request(`${repoPath(owner, repo)}/commits/${sha}/pull`)); + } + + /** One page of a repository's tags, newest first, by name: what the seat git's `tags` answers. */ + async tags(owner: string, repo: string, page: number, limit: number): Promise { + if (!Number.isInteger(page) || page < 1) throw new Error(`${page} is not a page, from 1`); + const list = await this.request(`${repoPath(owner, repo)}/tags?page=${page}&limit=${limit}`); + if (!Array.isArray(list)) throw new Error(`the forge answered no list of the tags of ${owner}/${repo}`); + return list.map((t) => String(t?.name ?? "")).filter((n) => n !== ""); + } + + /** The commit a tag names, or null when the repository has no such tag (the forge's 404 only). */ + async tagCommit(owner: string, repo: string, tag: string): Promise { + try { + const t = await this.request(`${repoPath(owner, repo)}/tags/${pathSegments("tag", tag)}`); + const sha = String(t?.commit?.sha ?? t?.id ?? ""); + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`the forge named no commit for ${owner}/${repo}'s tag ${tag}`); + return sha; + } catch (err) { + if (/: 404 /.test(err instanceof Error ? err.message : "")) return null; + throw err; + } + } + + /** Write a lightweight tag at a commit (design 47 §370): what the seat git's `tag` does. */ + async createTag(owner: string, repo: string, tag: string, commit: string): Promise { + await this.request(`${repoPath(owner, repo)}/tags`, { method: "POST", body: JSON.stringify({ tag_name: tag, target: commit }) }); + } + /** A commit's parents, by sha, in order: what a merge commit says it joined (novox/hq issue 381). An answer * that names no parents is no answer, never an empty list read as a root commit. */ async commitParents(owner: string, repo: string, sha: string): Promise { @@ -760,6 +794,12 @@ export class GiteaClient { { method: "PATCH", body: JSON.stringify(body) }); } + /** Remove the protection rule named rule (gitea_branch_protection_loosen, design 46 approval mark §21). As this + * client's account, which the forge refuses where it is no administrator. */ + async deleteBranchProtection(owner: string, repo: string, rule: string): Promise { + await this.request(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", rule)}`, { method: "DELETE" }); + } + async createPullRequest( owner: string, repo: string, @@ -1063,6 +1103,14 @@ export class GiteaAdmin { return res.body as BranchProtection; } + /** Remove the protection rule named rule, as the forge's administrator (gitea_branch_protection_loosen, design 46 + * approval mark §21): the forge answers 204. */ + async deleteBranchProtection(owner: string, repo: string, rule: string): Promise { + const path = `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", rule)}`; + const res = await this.request(path, { method: "DELETE" }); + if (res.status !== 204 && res.status !== 200) GiteaAdmin.fail(path, res); + } + /** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */ async repoSettings(owner: string, repo: string): Promise> { const path = repoPath(owner, repo); diff --git a/modules/gitea/cmd/forge/commit.go b/modules/gitea/cmd/forge/commit.go index a2c0245e2..bb911820a 100644 --- a/modules/gitea/cmd/forge/commit.go +++ b/modules/gitea/cmd/forge/commit.go @@ -16,8 +16,7 @@ import ( // gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull // request / approval rule without anyone pushing by hand. By the rule gitea_api already enforces for a file // written (novox/hq ADR 0266): never the repository's default branch, never a branch a protection rule names. -// Nothing about merging changes — that still needs a review at the head and both required checks green -// (gitea_merge_pull_request). +// Nothing about merging changes — a merge is the seat git's merge-pull, at the head the operator's Approve names. // FileChange is one file of a commit: its new content, as text or base64, or its deletion. type FileChange struct { diff --git a/modules/gitea/cmd/forge/main.go b/modules/gitea/cmd/forge/main.go index 89900a64e..fadedecdd 100644 --- a/modules/gitea/cmd/forge/main.go +++ b/modules/gitea/cmd/forge/main.go @@ -49,8 +49,8 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { Name: "gitea_commit", Description: "Put one commit on a branch of its own in a forge repository, creating the branch from base " + "when it does not exist. Refused on the repository's default branch and on any branch a protection " + - "rule names (novox/hq ADR 0266): a change reaches those only by a pull request whose head an " + - "independent review approved and whose required checks passed (gitea_merge_pull_request). Carries " + + "rule names (novox/hq ADR 0266): a change reaches those only by a pull request merged at the head the " + + "operator's Approve names (the seat git's merge-pull). Carries " + "either files — each {path, content (text) | content_base64 | delete: true | copy_from {owner, repo, ref, path}: a file or directory of another repository, copied by the module}, created or updated as " + "the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " + "At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " + diff --git a/modules/gitea/cmd/package-registry/findings.go b/modules/gitea/cmd/package-registry/findings.go new file mode 100644 index 000000000..ebbce5955 --- /dev/null +++ b/modules/gitea/cmd/package-registry/findings.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "strings" + "time" + + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + keyparts "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" + + "gitea/internal/packageseats" +) + +// findingKinds are the kinds this bundle says: the token refusal and the transfers' (design 47 G §580, §689); the module's +// TypeScript bundle shares the state `findings` and owns its own. +var findingKinds = []string{PackagesTokenRefused, "publish-refused", "incoming-full"} + +// findingWriter is the module's writer (views.Writer) as the keeper writes and clears through it. +type findingWriter interface { + Write(ctx context.Context, finding meshconditions.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// Findings keeps a bundle's open findings and says them through the module's writer (design 47 G §689, A-14): its `Say` is +// the Finding callback of the Driver and the Holder; the writer is the held writer (views.HeldWriter), which holds what is open (A-29, A-32). +type Findings struct { + Writer findingWriter + Logf func(string, ...any) + Now func() time.Time +} + +// Say raises (raised) or clears the finding of a kind about a subject. Its id is the seat's and the subject's, each escaped +// as a key's name is (design 47 G A-14); the kind stays the bundle's own word. +func (k *Findings) Say(kind, subject, words string, raised bool) { + id := keyparts.EscapeName(packageseats.Self) + "." + keyparts.EscapeName(subject) + key := views.FindingKey("seat", id, kind) + if !raised { + if err := k.Writer.Clear(context.Background(), "seat", id, kind, "it no longer holds"); err != nil { + k.Logf("the finding %s could not be cleared: %v", key, err) + } + return + } + at := time.Now() + if k.Now != nil { + at = k.Now() + } + f := meshconditions.Finding{Scope: "seat", ID: id, Kind: kind, Subject: meshconditions.Subject{Scope: "seat", ID: id}, + Severity: meshconditions.FindingSeverityWarning, Summary: words, Headline: words, Explanation: words, + Source: kind, Observer: packageseats.Self, At: at.UTC()} + if err := k.Writer.Write(context.Background(), f); err != nil { + k.Logf("the finding %s could not be written: %v", key, err) + } +} + +// DeleteOldFindings deletes, once at a module's start, the findings the keys of an older form hold (design 47 G A-31): the form +// `seat.._`, whose kind is no kind the writer owns in its key, so the writer's own first statement does not +// clear it. Only a key of one of kinds is deleted; the keys of the writer's form (`seat...`) are left. +func DeleteOldFindings(state views.FindingState, seat string, kinds []string) error { + keys, err := state.Keys() + if err != nil { + return err + } + for _, k := range keys { + rest, ok := strings.CutPrefix(k, "seat."+seat+".") + if !ok { + continue + } + for _, kind := range kinds { + if strings.HasPrefix(rest, kind+"_") { + if err := state.Delete(k); err != nil { + return err + } + break + } + } + } + return nil +} diff --git a/modules/gitea/cmd/package-registry/findings_test.go b/modules/gitea/cmd/package-registry/findings_test.go new file mode 100644 index 000000000..df05ad213 --- /dev/null +++ b/modules/gitea/cmd/package-registry/findings_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "sort" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// A-31: the keys of the older form (`seat.._`) are deleted once; a key of the writer's form, another seat's +// and another kind's stay. +func TestTheOldFindingKeysAreDeletedOnce(t *testing.T) { + state := &oldKeysState{keys: map[string]bool{}} + for _, k := range []string{ + "seat.gitea.packages-token-refused_write", "seat.gitea.packages-token-refused_a_b_c", + "seat.gitea.sub.packages-token-refused", "seat.other.packages-token-refused_write", "seat.gitea.unrelated_write"} { + state.keys[k] = true + } + if err := DeleteOldFindings(state, "gitea", []string{"packages-token-refused", "publish-refused", "incoming-full"}); err != nil { + t.Fatal(err) + } + var left []string + for k := range state.keys { + left = append(left, k) + } + sort.Strings(left) + if got, want := strings.Join(left, " "), "seat.gitea.sub.packages-token-refused seat.gitea.unrelated_write seat.other.packages-token-refused_write"; got != want { + t.Errorf("left %s, want %s", got, want) + } +} + +type oldKeysState struct{ keys map[string]bool } + +func (s *oldKeysState) Get(string) (*stdio.StateEntry, error) { return nil, nil } +func (s *oldKeysState) Put(string, any) (uint64, error) { return 0, nil } +func (s *oldKeysState) Delete(k string) error { delete(s.keys, k); return nil } +func (s *oldKeysState) Keys() ([]string, error) { + var out []string + for k := range s.keys { + out = append(out, k) + } + return out, nil +} diff --git a/modules/gitea/cmd/package-registry/forge.go b/modules/gitea/cmd/package-registry/forge.go index f752a4116..82fb0e69a 100644 --- a/modules/gitea/cmd/package-registry/forge.go +++ b/modules/gitea/cmd/package-registry/forge.go @@ -37,7 +37,7 @@ type Forge struct { // key in the module's findings state (packages-account.ts PACKAGES_TOKEN_REFUSED watches that key). const ( PackagesTokenRefused = "packages-token-refused" - PackagesTokenRefusedKey = "seat.gitea.packages-token-refused_write" + PackagesTokenRefusedKey = "seat.gitea.write.packages-token-refused" ) // wrote is a write's answer, said on Refused: a 401 raises the finding, a write the forge took clears it. @@ -86,7 +86,7 @@ func ForgeFromEnv() (*Forge, error) { } } f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER"), Refused: func(raised bool) { - finding(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised) + findings.Say(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised) }} if f.Owner == "" { missing = append(missing, "MESH_NPM_OWNER") diff --git a/modules/gitea/cmd/package-registry/incoming.go b/modules/gitea/cmd/package-registry/incoming.go index fb0984bde..ce8add7b7 100644 --- a/modules/gitea/cmd/package-registry/incoming.go +++ b/modules/gitea/cmd/package-registry/incoming.go @@ -10,7 +10,7 @@ import ( "os" "strings" - "git.novox.be/novox/mesh-delivery/sdk/parts" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" stdio "git.novox.be/novox/mesh-sdk/go" "gitea/internal/packageseats" diff --git a/modules/gitea/cmd/package-registry/main.go b/modules/gitea/cmd/package-registry/main.go index 0869cfca1..02de1384c 100644 --- a/modules/gitea/cmd/package-registry/main.go +++ b/modules/gitea/cmd/package-registry/main.go @@ -13,7 +13,9 @@ import ( "os" "strings" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" "gitea/internal/packageseats" ) @@ -23,7 +25,30 @@ import ( // of the module's seats as their verbs. The README names these two, and a test holds the two together. var ToolNames = []string{"go_packages", "go_retention", "go_incoming", "npm_packages", "npm_retention", "npm_incoming"} +// findings keeps this bundle's open findings for its writer (design 47 G §689). +var findings = &Findings{Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }} + func main() { + // The bundle's findings go through the module's held writer (design 47 G A-16, A-29, A-32). + w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: packageseats.Self, State: stdio.State("findings"), Kinds: findingKinds}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + findings.Writer = w + // the runtime answers the writer's state and subscription only once Serve has its channel + go func() { + <-stdio.Served() + // the keys of the older form end in no kind the writer owns, so it does not clear them (A-31) + if err := DeleteOldFindings(stdio.State("findings"), packageseats.Self, findingKinds); err != nil { + fmt.Fprintln(os.Stderr, "the old findings are not deleted:", err) + } + if err := w.Start(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }() forge := func() (*Forge, error) { return ForgeFromEnv() } holder := func() (*packageseats.Holder, error) { return busHolder(forge) } go followIdle(holder) diff --git a/modules/gitea/cmd/package-registry/retention_test.go b/modules/gitea/cmd/package-registry/retention_test.go index 7145bdddf..4eb926efb 100644 --- a/modules/gitea/cmd/package-registry/retention_test.go +++ b/modules/gitea/cmd/package-registry/retention_test.go @@ -437,6 +437,8 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { Serves []string `json:"serves"` } `json:"claims"` Requires []any `json:"requires"` + Objects []any `json:"objects"` + State []any `json:"state"` Packages map[string]string `json:"packages"` Invokes []string `json:"invokes"` Build struct { @@ -450,12 +452,12 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - // The builder's question is its published package, taken by its series (ADR 0634 §1). - if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" { - t.Errorf("packages %v: want builds of series 0.1", m.Packages) + // The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381). + if len(m.Packages) != 0 { + t.Errorf("packages %v: want none", m.Packages) } // Both package seats are claimed serving the four verbs this bundle answers as ., over its own incoming - // and transfers, asking the builder's numbering (design 51 *Publishing*). + // and transfers, asking the builder's reservation (design 51 *Publishing*, design 47 §381). claimed := map[string]string{} for _, c := range m.Claims { claimed[c.Name] = strings.Join(c.Serves, ",") @@ -472,10 +474,15 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { if len(names) != 8 || !names[packageseats.GoSeat+".publish-part"] { t.Errorf("the seat tools are %v", names) } - req, _ := json.Marshal(m.Requires) - for _, want := range []string{`"name":"incoming"`, `"name":"transfers"`, `"name":"idle","options":{"key-ttl":true}`} { + // The forge module is a foundation part (design 47 G §584): incoming is in its old objects section, transfers and idle + // (key-ttl) in its old state section. + req, _ := json.Marshal(struct { + Objects any `json:"objects"` + State any `json:"state"` + }{m.Objects, m.State}) + for _, want := range []string{`"name":"incoming"`, `"transfers"`, `"key-ttl":true,"name":"idle"`} { if !strings.Contains(string(req), want) { - t.Errorf("the manifest does not require %s (%s)", want, req) + t.Errorf("the manifest does not keep %s in its old sections (%s)", want, req) } } // findings is one local name in one form (novox/hq ADR 0595 §11): the old section state, which the TypeScript bundle @@ -487,8 +494,8 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { if strings.Contains(string(req), `"name":"transfers","options"`) { t.Errorf("transfers has options (a lifetime): %s", req) } - // The retention asks the builder's versions; the forge's other bundles ask the indexer's modules and record a re-read. - if strings.Join(m.Invokes, ",") != "mesh-delivery-indexer.indexer_read_modules,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" { + // The retention asks the builder's versions; the forge's other bundles ask the catalog half and record a re-read. + if strings.Join(m.Invokes, ",") != "seat:mesh-catalog.revision,seat:mesh-catalog.registrations,seat:mesh-hand-acts.hand-act,seat:mesh-builds.reserved" { t.Errorf("the manifest invokes %v", m.Invokes) } if len(m.Tools) != 0 { diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go index cb4bce4df..246da7580 100644 --- a/modules/gitea/cmd/package-registry/seats.go +++ b/modules/gitea/cmd/package-registry/seats.go @@ -19,21 +19,16 @@ import ( "encoding/json" "errors" "fmt" - builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "io" "net/http" "net/url" - "os" "path" "strings" - "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" - meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - goreg "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" - npmreg "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" - "git.novox.be/novox/mesh-delivery/sdk/parts" + goreg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/gopackageregistry" + npmreg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" stdio "git.novox.be/novox/mesh-sdk/go" "gitea/internal/packageseats" @@ -250,21 +245,6 @@ func sayOn(seat, event string, o packageseats.Outcome) error { return stdio.SeatPublish("mesh.seat."+seat+".event."+event, body, "") } -func finding(kind, subject, words string, raised bool) { - key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, packageseats.Self, kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) - if !raised { - _ = stdio.State(meshconditions.StateFindings).Delete(key) - return - } - f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], - Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: packageseats.Self}, Severity: meshconditionsclient.Warning, - Summary: words, Headline: words, Explanation: words, Resolved: "cleared once it no longer holds", - Source: kind, Observer: packageseats.Self, At: time.Now().UTC()} - if _, err := stdio.State(meshconditions.StateFindings).Put(key, f); err != nil { - fmt.Fprintf(os.Stderr, "the finding %s could not be written: %v\n", key, err) - } -} - // recordAct records a hand act through the seat mesh-hand-acts alone. func recordAct(q meshhandactsclient.Request) error { _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(q) @@ -274,15 +254,11 @@ func recordAct(q meshhandactsclient.Request) error { // boundCap is incoming's cap from its binding, read once per bundle. var boundCap = parts.BusCap(packageseats.IncomingObjects) -// busHolder is the holder over the runtime's bus state, the builder's numbering and the forge. +// busHolder is the holder over the runtime's bus state, the builder's reservation and the forge. func busHolder(forge func() (*Forge, error)) (*packageseats.Holder, error) { - callers, err := packageseats.ConfiguredCallers() - if err != nil { - return nil, err - } - return &packageseats.Holder{Callers: callers, Objects: parts.BusObjects(packageseats.IncomingObjects), + return &packageseats.Holder{Objects: parts.BusObjects(packageseats.IncomingObjects), Transfers: parts.BusTransfers[packageseats.Opening](packageseats.TransfersState), - Builder: builds.BuilderVersions{Ask: stdio.Ask}, Store: seatStore{forge}, Say: say, Finding: finding, + Builder: packageseats.Reserved{Ask: stdio.Ask}, Store: seatStore{forge}, Say: say, Finding: findings.Say, Cap: boundCap, Record: recordAct, Idle: parts.BusArm(packageseats.IdleState)}, nil } diff --git a/modules/gitea/cmd/package-registry/seats_test.go b/modules/gitea/cmd/package-registry/seats_test.go index 6deb1ab47..13d4fbd0f 100644 --- a/modules/gitea/cmd/package-registry/seats_test.go +++ b/modules/gitea/cmd/package-registry/seats_test.go @@ -19,8 +19,10 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + keyparts "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" "gitea/internal/forgehttp" + "gitea/internal/packageseats" ) @@ -133,7 +135,7 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) { // and the next write the forge takes clears it (no timer looks at the token). var refused []bool forge.Refused = func(raised bool) { refused = append(refused, raised) } - if k := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, packageseats.Self, PackagesTokenRefused+".write"); k != PackagesTokenRefusedKey { + if k := views.FindingKey("seat", keyparts.EscapeName(packageseats.Self)+".write", PackagesTokenRefused); k != PackagesTokenRefusedKey { t.Errorf("the refusal's key is %s, the watcher watches %s", k, PackagesTokenRefusedKey) } _ = os.WriteFile(writer.TokenFile, []byte("stale\n"), 0o600) diff --git a/modules/gitea/cmd/package-registry/whole_integration_test.go b/modules/gitea/cmd/package-registry/whole_integration_test.go index 64ef99a8c..b7b76057f 100644 --- a/modules/gitea/cmd/package-registry/whole_integration_test.go +++ b/modules/gitea/cmd/package-registry/whole_integration_test.go @@ -6,7 +6,7 @@ package main // the built package-registry bundle, played over its real stdin and stdout as the node's tool runtime plays it, its // `incoming` an object store and its `transfers` and `findings` key-value buckets of a nats-server of this test alone // (so the publish in parts runs through mesh-sdk's parts.BusObjects and BusTransfers, ruling 4 of the lead's 14:0x), the -// builder's builder_versions answered on mesh/ask, the config value `callers` watched on mesh/provision.watch, and the forge an +// builder's reserved answered on mesh/ask, and the forge an // httptest server that takes reads only with the tools' token and writes only with the packages account's. // // A Go package the builder numbered goes up in two chunks, is uploaded whole as the packages account and says @@ -19,7 +19,9 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -37,7 +39,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/testbus" "gitea/internal/packageseats" @@ -61,6 +63,8 @@ type player struct { in io.Writer next int answers map[string]chan wire + puts map[string][]byte // the put sessions open, by call + gets map[string][]byte // the get sessions open, by call } func (p *player) send(m map[string]any) { @@ -113,26 +117,30 @@ func (p *player) handle(m wire) { Data []byte `json:"data"` Subject string `json:"subject"` Body json.RawMessage `json:"body"` - Kind string `json:"kind"` TTL int `json:"ttl_seconds"` } _ = json.Unmarshal(m.Params, &a) switch { - case m.Method == "mesh/ask" && a.Key == builds.BuilderVersionsAddress: - var q struct{ Name, Version, Digest string } + case m.Method == "mesh/ask" && a.Key == packageseats.Address: + var q struct{ Name, Version string } _ = json.Unmarshal(a.Body, &q) d, ok := p.numbered[q.Name+"@"+q.Version] - p.answer(m.ID, map[string]any{"recorded": ok && d == q.Digest, "why": "not numbered with that digest"}, nil) - case m.Method == "mesh/provision.watch" && a.Kind == "config": - // The watch of `callers`, as the runtime answers it: the value now as one change (mesh/provision, - // named by the watch's id) handed over before the watch is answered, here unset, so design 51's list holds. - p.send(map[string]any{"id": "change-" + watchID(m.ID), "method": "mesh/provision", "params": map[string]any{ - "watch": watchID(m.ID), "change": map[string]any{"kind": "config", "key": a.Key}}}) - p.answer(m.ID, map[string]any{}, nil) + if !ok { + p.answer(m.ID, nil, errors.New("no version of "+q.Name+" is reserved")) + break + } + p.answer(m.ID, map[string]any{"module": "m", "version": q.Version, "artifacts": []map[string]any{{"artifact": "m:a", "digest": d}}}, nil) case m.Method == "mesh/seat.publish": p.answer(m.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body)) - case m.Method == "mesh/publish": + case m.Method == "mesh/capabilities": + p.answer(m.ID, map[string]any{"words": []string{"put-sessions", "finding-calls"}}, nil) + case m.Method == "mesh/publish", m.Method == "mesh/subscribe": + // the module's finding writer emits its events and subscribes to findings-asked (design 47 G A-16); none is played p.answer(m.ID, map[string]any{}, nil) + case m.Method == buscalls.ObjectsPutChunk: + p.putChunk(m) + case m.Method == buscalls.ObjectsGetChunk: + p.getChunk(m) case strings.HasPrefix(m.Method, "mesh/objects."): p.objects(m, a.Objects, a.Name, a.Data) case strings.HasPrefix(m.Method, "mesh/state."): @@ -142,13 +150,57 @@ func (p *player) handle(m wire) { } } -// watchID is the watch's id as a string, the form the change names it by. -func watchID(raw json.RawMessage) string { - var id string - if json.Unmarshal(raw, &id) != nil { - return string(raw) +// putChunk plays the runtime's put session (design 47 G §518, A-21): the chunks of one call in order, each answered as taken, the +// last put whole and answered as the put's. +func (p *player) putChunk(m wire) { + var c buscalls.ObjectsPutChunkRequest + _ = json.Unmarshal(m.Params, &c) + p.mu.Lock() + if p.puts == nil { + p.puts = map[string][]byte{} } - return id + if c.Seq == 0 || (len(p.puts[c.Call]) == 0 && c.Seq != 1) { + p.mu.Unlock() + p.answer(m.ID, buscalls.ObjectsChunkTaken{Seq: c.Seq, Error: "a put session begins at chunk 1"}, nil) + return + } + p.puts[c.Call] = append(p.puts[c.Call], c.Data...) + whole := p.puts[c.Call] + if c.Last { + delete(p.puts, c.Call) + } + p.mu.Unlock() + if !c.Last { + p.answer(m.ID, buscalls.ObjectsChunkTaken{Seq: c.Seq}, nil) + return + } + os, err := p.js.ObjectStore(p.ctx, "gitea_"+c.Objects) + if err != nil { + p.answer(m.ID, nil, err) + return + } + info, err := os.PutBytes(p.ctx, c.Name, whole) + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"name": info.Name, "size": info.Size, "digest": info.Digest, "seq": c.Seq}, nil) +} + +// getChunk plays the runtime's get session: the chunk of the session's value. +func (p *player) getChunk(m wire) { + var c buscalls.ObjectsGetChunkRequest + _ = json.Unmarshal(m.Params, &c) + p.mu.Lock() + whole := p.gets[c.Call] + p.mu.Unlock() + at := int(c.Seq-1) * buscalls.ChunkBytes + if whole == nil || c.Seq == 0 || at >= len(whole) { + p.answer(m.ID, buscalls.ObjectsChunk{Seq: c.Seq, Error: "no such chunk of the session"}, nil) + return + } + end := min(at+buscalls.ChunkBytes, len(whole)) + p.answer(m.ID, buscalls.ObjectsChunk{Seq: c.Seq, Last: end == len(whole), Data: whole[at:end]}, nil) } func (p *player) objects(m wire, local, name string, data []byte) { @@ -167,6 +219,20 @@ func (p *player) objects(m wire, local, name string, data []byte) { p.answer(m.ID, map[string]any{"name": info.Name, "size": info.Size, "digest": info.Digest}, nil) case "mesh/objects.get": got, err := os.GetBytes(p.ctx, name) + if err == nil && len(got) > buscalls.ChunkBytes { + // a value over one chunk is read by a session (design 47 G §490, §518) + sum := sha256.Sum256(got) + call := fmt.Sprintf("get-%s-%d", name, len(got)) + p.mu.Lock() + if p.gets == nil { + p.gets = map[string][]byte{} + } + p.gets[call] = got + p.mu.Unlock() + p.answer(m.ID, buscalls.ObjectsChunked{Chunked: json.RawMessage("true"), Size: uint64(len(got)), + Digest: "sha256:" + hex.EncodeToString(sum[:]), Call: call}, nil) + break + } p.answer(m.ID, map[string]any{"info": map[string]any{"name": name, "size": len(got)}, "data": got}, err) case "mesh/objects.delete": err := os.Delete(p.ctx, name) @@ -320,7 +386,7 @@ func TestThePackageSeatsWhole(t *testing.T) { return file } cmd := exec.CommandContext(ctx, bin) - cmd.Env = append(os.Environ(), "MESH_GITEA_URL="+srv.URL, "MESH_NPM_OWNER=acme", "MESH_GITEA_TOOLS_USER=mesh-tools", + cmd.Env = append(os.Environ(), "MESH_NODE=novox", "MESH_GITEA_URL="+srv.URL, "MESH_NPM_OWNER=acme", "MESH_GITEA_TOOLS_USER=mesh-tools", "MESH_GITEA_TOKEN_FILE="+kept("token", "reader", "read:package,read:user,write:issue,write:repository for mesh-tools"), "MESH_GITEA_PACKAGES_TOKEN_FILE="+kept("packages-token", "writer", "write:package for mesh-packages")) cmd.Stderr = os.Stderr @@ -411,12 +477,12 @@ func TestThePackageSeatsWhole(t *testing.T) { t.Errorf("an ended transfer is still kept: %v", keys) } - // One the builder did not number: refused before the forge sees it, said, and the seat's finding written. + // One the builder did not reserve: refused before the forge sees it, said, and the seat's finding written. other := bigZip(t, "git.example/acme/sdk@v0.1.16") od := "sha256:" + sha(other) transfer, _ = publish("0.1.16", od, len(other)) _ = send(transfer, od, other) - if o := event("refused"); !strings.Contains(fmt.Sprint(o["reason"]), "did not number") { + if o := event("refused"); !strings.Contains(fmt.Sprint(o["reason"]), "no version of git.example/acme/sdk is reserved") { t.Errorf("refused says %v", o) } findings, _ := js.KeyValue(ctx, "gitea_findings") diff --git a/modules/gitea/delivery.ts b/modules/gitea/delivery.ts index 050f60678..ed65da6f4 100644 --- a/modules/gitea/delivery.ts +++ b/modules/gitea/delivery.ts @@ -1,7 +1,6 @@ // What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under -// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery, -// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes -// to it itself. +// refs/notes/mesh-plan and the delivery's view on its pull request, asked through the seat git's verbs `note` and +// `view` (seat.ts). The forge is this module's; no delivery part writes to it itself. // // **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a // note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's @@ -13,13 +12,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import type { CommitStatus } from "./client.js"; -import { checkName } from "./guard.js"; -import { CHECK_STATUS } from "./pulls.js"; - -export { CHECK_STATUS }; - /** How a command is run: docker on the forge's machine, or a test's. */ export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>; @@ -64,12 +56,13 @@ export function noteArgs(container: string, owner: string, repo: string, commit: /** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */ export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string, - notesRef: string, line: string): Promise<{ added: boolean; lines: number }> { + notesRef: string, line: string, heldId?: string): Promise<{ added: boolean; lines: number }> { const wanted = noteLine(line); const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef)); // No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said. const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : []; - if (lines.includes(wanted)) return { added: false, lines: lines.length }; + // A line that carries the act's id is the act already done, whatever else it says. + if (lines.includes(wanted) || (heldId && lines.some((l) => l.includes(`[${heldId}]`)))) return { added: false, lines: lines.length }; const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted)); if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`); return { added: true, lines: lines.length + 1 }; @@ -89,77 +82,3 @@ export function viewComment(comments: T[ return comments.find((c) => c.body.includes(VIEW_MARKER)); } -const states = new Set(["pending", "success", "error", "failure", "warning"]); - -/** The merge check's contexts (pulls.ts): a branch's protection may require them. */ -const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]); - - -/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a - * bounded description. */ -export function deliveryStatus(context: string, state: string, description: string, target?: string) { - if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`); - if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`); - // mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any - // state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1): - // check.ts writes it from the checker's owed state (ADR 0521). - if (context === CHECK_STATUS) { - throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`); - } - // The merge check's statuses are its verdict's, set when the controller says it, and a branch may require - // them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq - // issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes, - // and what a recheck of the same head says, so the old verdict does not let it merge while the fresh check - // runs. - if (mergeCheckContexts.has(context) && state !== "pending") { - throw new Error(`${context} is the merge check's status, set by its verdict only (pending, for a check asked again, is the one state it takes by hand)`); - } - let d = String(description ?? "").replace(/\s+/g, " ").trim(); - if (d.length > 140) d = d.slice(0, 139) + "…"; - return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d, - ...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) }; -} - -/** What the status tool writes with: the forge, and the gate writer the merge check's statuses go through. */ -export interface StatusWriters { - setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; - gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; -} - -/** - * The `gitea_commit_status` tool. **The merge check's pending goes through the module's one writer** (novox/hq issue - * 456): a recheck's pending on mesh/merge-gate or mesh/repo-check takes its turn on the head's chain (gate.ts), so an - * old run's verdict in flight lands before it and a redelivered one is dropped. Served from the watcher's bundle - * (index.ts), the process that holds that writer: each bundle the runtime serves is its own process. The mesh's other - * statuses (mesh/delivery, mesh/delivery-group) are written as they were. - */ -export function commitStatusTool(writers: StatusWriters): ToolDefinition { - return { - name: "gitea_commit_status", - description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - sha: { type: "string", description: "the commit" }, - context: { type: "string", description: "the status's name, mesh/…" }, - state: { type: "string", description: "pending | success | error | failure | warning" }, - description: { type: "string", description: "one line" }, - target_url: { type: "string", description: "the page it links to (the pull request)" }, - }, - run: async (args) => { - const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? ""); - const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""), - args.target_url ? String(args.target_url) : undefined); - if (mergeCheckContexts.has(status.context)) { - // The writer keeps a head by its whole sha, as the forge announced it: a short one would be another head. - if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { - throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: the merge check's status names the head whole. Nothing was sent`); - } - await writers.gates.rechecked(owner, repo, sha, status); - } else { - await writers.setCommitStatus(owner, repo, sha, status); - } - return { set: status }; - }, - }; -} diff --git a/modules/gitea/failedsteps.ts b/modules/gitea/failedsteps.ts new file mode 100644 index 000000000..84473b12f --- /dev/null +++ b/modules/gitea/failedsteps.ts @@ -0,0 +1,282 @@ +// **The forge module judges a head by the failed steps it heard, never by a call** (novox/hq design 47 §39, §475, §487, §548, +// §135): the seat git's `merge-pull` refuses a head at whose build-input fingerprint any run failed a `required` step, from +// the module's own state `failed-steps`, kept from the runner's `step-ended`, so a head merges while the runner is down. +// +// Two key forms live in the state: +// .. a failed `required` step (the contract's FailedStep): the run that failed it and every +// commit it was seen at; cleared by a later passing `rerun` of the same step. +// seen.. the build-input fingerprints the runs named for that commit and module, written from +// every `step-ended` that carries a pipeline's commit and fingerprint, whatever its outcome +// (§487, §548), so a head whose runs all ended before another commit of the same content +// failed is still refused. +// +// An entry is deleted once none of its commits is a watched branch's head or the head an open pull request names (§135), +// judged on each `branch-moved` and each pull request's close (Heads, below). + +import type { State } from "@novox/mesh-sdk/state"; +import { askRuntime } from "@novox/mesh-sdk/runtime"; +import { + failedStepsByModuleFingerprintStepKey as failedKey, type FailedStep, type PullClosedEvent, type PullMergedEvent, type PullUpdatedEvent, +} from "@novox/mesh-delivery-contracts-git"; +import type { StepEndedEvent } from "@novox/mesh-delivery-contracts-mesh-pipeline-runner"; + +/** The key prefix of the commits' fingerprints (§487). */ +export const SEEN = "seen."; +/** The bucket's fill against its caps as the bundle's state call `mesh/state.info` answers it (design 47 G §623). */ +export interface StateFill { + bytes: number; + keys: number; + max_bytes: number; +} +/** The fill of the state `failed-steps`, asked of the runtime (§623). */ +export const askFill = (): Promise => askRuntime("mesh/state.info", { state: "failed-steps" }) as Promise; + +/** How long a `seen` key is kept after its `step-ended`, and how many go on one event (§581). */ +export const SEEN_DAYS = 30; +export const SEEN_MOST_PER_EVENT = 100; + +/** What a `seen` key holds: a commit's fingerprints at one module. */ +export interface Seen { + v: 1; + commit: string; + module: string; + fingerprints: string[]; + at: string; +} + +/** The step a head is refused for: the run, the module and the step the refusal names. */ +export interface Judged { + run: string; + module: string; + step: string; + fingerprint: string; +} + +const SHA = /^[0-9a-f]{40}$/; +/** A part of a key as the bus keeps keys: letters, digits and `-_=/`. */ +const safe = (s: string): string => s.replace(/[^A-Za-z0-9_=/-]/g, "_"); +export const seenKey = (commit: string, module: string): string => `${SEEN}${commit}.${safe(module)}`; +export const failedStepKey = (module: string, fingerprint: string, step: string): string => failedKey(safe(module), safe(fingerprint), safe(step)); + +export class FailedSteps { + /** Every act runs after the one before it, so a step-ended and a head's loss never interleave their reads and writes. */ + private chain: Promise = Promise.resolve(); + + constructor(private readonly st: State, private readonly log: (line: string) => void = () => {}, + /** Whether a commit is now a watched branch's head or an open pull request's head. */ + private readonly current: (commit: string) => boolean = () => false, + /** The state's fill against its caps: the bundle's state call (§623). */ + private readonly fillOf: () => Promise = askFill) {} + + private turn(act: () => Promise): Promise { + const r = this.chain.then(act, act); + this.chain = r.catch(() => {}); + return r; + } + + private async all(): Promise<{ failed: Map; seen: Map }> { + const failed = new Map(), seen = new Map(); + for (const key of await this.st.keys()) { + const e = await this.st.get(key); + if (!e?.value) continue; + if (key.startsWith(SEEN)) seen.set(key, e.value as Seen); + else failed.set(key, e.value as FailedStep); + } + return { failed, seen }; + } + + /** + * One `step-ended` (§39, §475, §487, §548). A `step-ended` that carries no pipeline's commit and fingerprint (an ask of another + * kind, §602) writes nothing. Its commit's fingerprint is kept; a failed `required` step is kept with every commit seen at its + * module and fingerprint; a later step-ended at a kept failure's module and fingerprint adds its commit; a passing `rerun` of + * a kept failure's step clears it, and nothing else does. + */ + stepEnded(ev: StepEndedEvent): Promise { + // The `seen` keys past their 30 days are judged on each step end, with no timer (§581). + return this.record(ev).then(() => this.expireSeen(new Date(Date.parse(String(ev?.at ?? "")) || Date.now())).then(() => {})); + } + + private record(ev: StepEndedEvent): Promise { + const r = ev?.result; + const fingerprint = r?.fingerprint || ev?.fingerprint || ""; + if (!r || !SHA.test(String(ev.commit ?? "")) || !fingerprint || !ev.module) return Promise.resolve(); + return this.turn(async () => { + const at = ev.at ?? new Date().toISOString(); + const sk = seenKey(ev.commit, ev.module); + const held = (await this.st.get(sk))?.value as Seen | undefined; + if (!held?.fingerprints.includes(fingerprint)) { + await this.st.put(sk, { v: 1, commit: ev.commit, module: ev.module, fingerprints: [...(held?.fingerprints ?? []), fingerprint], at } satisfies Seen); + } else if (held.at !== at) { + await this.st.put(sk, { ...held, at }); + } + const key = failedStepKey(ev.module, fingerprint, r.step); + const all = await this.all(); + // A step-ended at a kept failure's module and fingerprint, of any outcome, adds its commit to every failure there (§475). + for (const [k, f] of all.failed) { + if (f.module === ev.module && f.fingerprint === fingerprint && !f.commits.includes(ev.commit)) { + const next = { ...f, commits: [...f.commits, ev.commit].sort() }; + all.failed.set(k, next); + await this.st.put(k, next); + } + } + const kept = all.failed.get(key); + if (r.outcome === "failed" && r.status === "required") { + if (kept) return; + const commits = new Set([ev.commit]); + for (const s of all.seen.values()) if (s.module === ev.module && s.fingerprints.includes(fingerprint)) commits.add(s.commit); + await this.st.put(key, { v: 1, module: ev.module, fingerprint, step: r.step, run: ev.pipeline, ...(r.artifact ? { artifact: r.artifact } : {}), + commits: [...commits].sort(), ...(r.why ? { said: r.why } : {}), at } satisfies FailedStep); + return; + } + // The runner's rerun is granted to the operator and the healers alone (its definition's callers), so a step-ended that says + // it is a rerun was asked by one of them; a plain pass, or any other outcome, clears nothing (§39). + if (r.rerun === true && r.outcome === "passed" && kept) { + await this.st.delete(key); + this.log(`${ev.module} ${r.step} at ${fingerprint} passed again by ${r.by ?? "a rerun"}: its failure is cleared`); + } + }); + } + + /** The failed `required` step that refuses this head, or null: a head with a `seen` key is judged by it, one with none merges (§487). */ + judge(head: string): Promise { + return this.turn(async () => { + const { failed, seen } = await this.all(); + for (const [, s] of [...seen].sort(([a], [b]) => (a < b ? -1 : 1))) { + if (s.commit !== head) continue; + for (const fingerprint of s.fingerprints) { + const hit = [...failed.values()].filter((f) => f.module === s.module && f.fingerprint === fingerprint).sort((a, b) => (a.step < b.step ? -1 : 1))[0]; + if (hit) return { run: hit.run, module: hit.module, step: hit.step, fingerprint }; + } + } + return null; + }); + } + + /** + * Commits that are no longer a watched branch's head nor an open pull request's head (§135): their `seen` keys go, they leave the + * failures' commits, and a failure none of whose commits is a head any longer is deleted. + */ + lost(commits: readonly string[]): Promise { + const gone = new Set(commits.filter((c) => !this.current(c))); + if (gone.size === 0) return Promise.resolve(); + return this.turn(async () => { + const { failed, seen } = await this.all(); + for (const [k, s] of seen) if (gone.has(s.commit)) await this.st.delete(k); + for (const [k, f] of failed) { + if (!f.commits.some((c) => gone.has(c))) continue; + const rest = f.commits.filter((c) => !gone.has(c)); + if (rest.some((c) => this.current(c))) await this.st.put(k, { ...f, commits: rest }); + else await this.st.delete(k); + } + }); + } + + /** What is held, for `git_failed_steps`: the failures, the `seen` keys (of one commit when asked), and the store's fill. */ + held(commit?: string): Promise<{ failed: FailedStep[]; seen: Seen[]; fill: { keys: number; bytes: number; cap: number } }> { + return this.turn(async () => { + const { failed, seen } = await this.all(); + const info = await this.fillOf(); + return { failed: [...failed.values()].filter((f) => !commit || f.commits.includes(commit)).sort((a, b) => (a.run < b.run ? -1 : 1)), + seen: [...seen.values()].filter((s) => !commit || s.commit === commit), fill: { keys: info.keys, bytes: info.bytes, cap: info.max_bytes } }; + }); + } + + /** + * Deletes the `seen` keys whose `at` is more than 30 days before `now`, the oldest first, at most `most` of them (§581): run on + * each `step-ended`, at the start, and by the tool `git_seen_expire`. A key with no readable `at` is kept. Answers the keys deleted. + */ + expireSeen(now: Date = new Date(), most = SEEN_MOST_PER_EVENT): Promise { + return this.turn(async () => { + const { seen } = await this.all(); + const limit = now.getTime() - SEEN_DAYS * 24 * 3600 * 1000; + const old = [...seen].filter(([, s]) => Date.parse(s.at) < limit).sort(([, a], [, b]) => Date.parse(a.at) - Date.parse(b.at)).slice(0, most); + for (const [k] of old) await this.st.delete(k); + if (old.length) this.log(`${old.length} seen key(s) past ${SEEN_DAYS} days deleted`); + return old.map(([k]) => k); + }); + } + + /** Drops one held key, a failure by its key or a `seen` key; false when it is not there. */ + drop(key: string): Promise { + return this.turn(async () => { + if (!(await this.st.get(key))) return false; + await this.st.delete(key); + return true; + }); + } +} + +/** + * The commits that are now a watched branch's head or the head an open pull request names (§39, §135). A watched branch is a + * branch a registration names for its origin; a head is read from the forge at the start and when a registration changes, and + * kept by `branch-moved` (its head read again from the forge, never from the event) and a pull request's updates and closes. + */ +export class Heads { + private watched = new Set(); + private branches = new Map(); + private pulls = new Map(); + + has(commit: string): boolean { + for (const h of this.branches.values()) if (h === commit) return true; + for (const h of this.pulls.values()) if (h === commit) return true; + return false; + } + + /** The heads the forge holds now of the watched branches (`@`) and the open pull requests (`#`): + * replaces both pictures and answers the commits that were heads and are not any more. */ + replace(watched: Set, branches: Map, pulls: Map): string[] { + const before = new Set([...this.branches.values(), ...this.pulls.values()]); + this.watched = watched; + this.branches = branches; + this.pulls = pulls; + return [...before].filter((c) => !this.has(c)); + } + + /** A watched branch's new head; the commit it replaces when that is no head any more. A branch no registration watches moves nothing. */ + branchMoved(repository: string, branch: string, head: string): string[] { + if (!this.watched.has(`${repository}@${branch}`)) return []; + const old = this.branches.get(`${repository}@${branch}`); + this.branches.set(`${repository}@${branch}`, head); + return old && old !== head && !this.has(old) ? [old] : []; + } + + pullUpdated(ev: PullUpdatedEvent): string[] { + const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k); + this.pulls.set(k, ev.head); + return old && old !== ev.head && !this.has(old) ? [old] : []; + } + + pullEnded(ev: PullClosedEvent | PullMergedEvent): string[] { + const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k); + this.pulls.delete(k); + return old && !this.has(old) ? [old] : []; + } + + watches(repository: string, branch: string): boolean { + return this.watched.has(`${repository}@${branch}`); + } +} + +/** What the forge holds now: the heads of the watched branches (`@`) and of the open pull requests (`#`). */ +export interface HeadsSource { + branchHead(owner: string, repo: string, branch: string): Promise; + openPulls(): Promise<{ owner: string; repo: string; number: number; head_sha: string }[]>; +} + +/** Reads the heads the picture is made of: each watched branch's head (one a forge cannot name is said and left out) and every open pull request's. */ +export async function readHeads(forge: HeadsSource, watched: ReadonlyMap>, log: (line: string) => void = () => {}) { + const names = new Set(), branches = new Map(), pulls = new Map(); + for (const [repository, list] of watched) { + const [owner, repo] = repository.split("/"); + for (const branch of list) { + names.add(`${repository}@${branch}`); + try { + branches.set(`${repository}@${branch}`, await forge.branchHead(owner, repo, branch)); + } catch (err) { + log(`the head of ${repository}'s watched branch ${branch} could not be read: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + for (const p of await forge.openPulls()) pulls.set(`${p.owner}/${p.repo}#${p.number}`, p.head_sha); + return { names, branches, pulls }; +} diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index e1c36bb56..e738bc70e 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -73,11 +73,11 @@ export class GateWriter { } /** - * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every - * write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same - * chain. A write that fails fails the caller. + * A status the seat git's `status` verb sets (seat.ts), a recheck's pending among them (novox/hq issue 456): it takes + * its turn after every write of the head already asked for, so it lands over them, and a later write lands over it. A + * write that fails fails the caller. */ - rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise { + status(owner: string, repo: string, sha: string, status: CommitStatus): Promise { return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status)); } diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 62607444b..c1b3a1ce4 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,14 +3,13 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 @@ -31,9 +30,9 @@ require ( require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index e285ab907..372f112ad 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -14,9 +14,8 @@ // the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5); // - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that // exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422); -// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed -// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only -// that head; +// - the seat git's `merge-pull` (seat.ts) merges only into a branch that refuses its push, and only the head the +// operator's Approve names (ADR 0661); // - `gitea_delete_repo` refuses a repository any protection rule guards. // // Pure, so it is tested without a forge; the tools do the asking (tools/index.ts). @@ -165,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap `writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` + `that is not the default branch and that no protection rule names. The forge's administration, a ` + `branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` + - `are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` + + `are the operator's at the forge, or have their own tool (the seat git's merge-pull and status, ` + `gitea_branch_protection_set, which never lets a person push). Nothing was sent`, }; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index bb15ebc64..d26359575 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -13,9 +13,8 @@ // statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main // requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2. // -// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and -// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands -// over the latest value of every key whenever this module starts. +// It reads no other module's state: mesh/check, and every act of a delivery part on the forge, arrive as verbs of the seat +// git (seat.ts), asked by the forge bridge (novox/hq design 47 *The seat git*). // // This module is the one writer of mesh/merge-gate. It hears no build seat's event: since R4g a check's steps are asks // on the build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525 §9; issue 1887). @@ -25,9 +24,6 @@ // request into it go back to pending with the reason. Nothing is run for it. A verdict that lands judged against a trunk // commit that is no longer the trunk's head is said out of date in place of its pass or failure. // -// Serves gitea_commit_status and gitea_merge_pull_request from this bundle, not the tools', so a recheck's pending and -// the merge tool's ask of a check reach that one writer (novox/hq issue 456, ADR 0323). -// // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // @@ -42,14 +38,21 @@ import { emit } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { statesAnnouncement } from "./kept.js"; -import { commitStatusTool } from "./delivery.js"; -import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js"; import { state } from "@novox/mesh-sdk/state"; import { on } from "@novox/mesh-sdk/events"; import { broker } from "@novox/mesh-sdk/messaging"; -import { ViewFollower, unansweredFinding, viewTools, type ViewAnswer, type ViewEvent } from "./views.js"; +import { STATUS_STATE, keepStatus, statusTools, viewWaitsFinding, type Follower, type Status } from "@novox/mesh-sdk/views"; +import type { CatalogRevisionEvent, RegistrationChangedEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog"; +import { CATALOG_REVISION_EVENT, CatalogView, findingValue, unansweredFinding } from "./catalog.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; -import { mergeTool } from "./merge.js"; +import { SEAT_ONLY, sayOnSeat } from "./seatevents.js"; +import { FORGE_CONTAINER, seatTools } from "./seat.js"; +import { REGISTRATION_CHANGED_EVENT, Registrations } from "./registrations.js"; +import { FailedSteps, Heads, readHeads } from "./failedsteps.js"; +import { failedStepsTools } from "./tools/failedsteps.js"; +import { seat as GIT, events as GIT_EVENTS, type BranchMovedEvent, type PullClosedEvent, type PullMergedEvent, type PullUpdatedEvent } from "@novox/mesh-delivery-contracts-git"; +import { seat as RUNNER, events as RUNNER_EVENTS, type StepEndedEvent } from "@novox/mesh-delivery-contracts-mesh-pipeline-runner"; +import { run } from "./delivery.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js"; @@ -60,7 +63,7 @@ import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./sibli import { bindInBackground, bindUntilHeld } from "./bind.js"; import { announcementOf, weighTrunks } from "./trunk.js"; import { ReadAll, readAllTools } from "./readall.js"; -import { DeliveredWrites, INDEXER_MODULES_EVENTS, INDEXER_MODULES_TOOL, INDEXER_OWNER, writeTools, type ModulesChange } from "./writes.js"; +import { DeliveredWrites, writeTools } from "./writes.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "./packages-account.js"; @@ -276,7 +279,7 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base // still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's; // a rule that cannot be read is said pending, as before. - const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined); + const rule = pull.base ? await client.appliedProtection(repo.owner, repo.name, String(pull.base)).catch(() => undefined) : undefined; if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha); return true; } @@ -363,14 +366,6 @@ const gates = new GateWriter({ headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), }); -// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521). -const checks = new CheckStatuses({ - setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), -}); - -// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own -// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to -// reach, nothing is served, as the tools' bundle serves nothing then. /** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what * gitea_events says of them (webhook.ts). */ const pace = new Pace(); @@ -391,19 +386,32 @@ let readAllOnce: ReadAll | null = null; const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT))); // **The merging account writes every repository that builds a registered module** (writes.ts; the operator, -// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll. +// 2026-10-26; issue 1945): which those are is the catalog half's word, followed below. Made on first use, as readAll. let writesOnce: DeliveredWrites | null = null; const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(), - process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body))); + process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), async (type, body) => { await sayOnSeat(type, body as Record); })); -// The first view of the indexer's modules, once the module follows it (below); gitea_first_views says where it stands. -let modulesView: { follower: ViewFollower; owner: string; tool: string; state: string } | undefined; +// The first view of the catalog half (catalog.ts), made once the module follows it (below). Its status is kept in the +// module's state first-views, which gitea_first_views reads; gitea_first_view_again asks it again by hand (mesh-sdk views). +const firstViews = state(STATUS_STATE); +const followers: Follower[] = []; +// The first registrations switch merge-pull on (registrations.ts; design 47 §95). +const registrations = new Registrations((verb, input) => broker().request(verb, input), (line) => console.log(`[gitea] ${line}`)); +// The failed steps the runner's step-ended said, and the heads that keep them (failedsteps.ts; design 47 §39, §475, §487, §135). +const heldHeads = new Heads(); +let reloadHeads: () => Promise = async () => {}; +const failedSteps = new FailedSteps(state("failed-steps"), (line) => console.log(`[gitea] ${line}`), (commit) => heldHeads.has(commit)); -// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). -registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView, (act) => broker().request("seat:mesh-hand-acts.hand-act", act)), ...(gitea ? [commitStatusTool({ - setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), - gates, -}), mergeTool(gitea, { gates })] : [])]); +// The `seen` keys past their 30 days are deleted at the start, as on each step-ended (design 47 §581). +void failedSteps.expireSeen().catch((err) => console.log(`[gitea] the seen keys past 30 days could not be deleted at the start: ${err instanceof Error ? err.message : String(err)}`)); + +// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are +// served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the +// announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456). +registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...statusTools("gitea", "gitea_first_views", "gitea_first_view_again", followers, firstViews, + async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); }), + ...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates, registered: () => registrations.written, judge: (head) => failedSteps.judge(head) }) : []), + ...failedStepsTools(failedSteps, async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); })]); /** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */ const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise => actOn(event, { @@ -434,7 +442,11 @@ async function listenForForge(client: GiteaClient): Promise { const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0"; const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery), refused: (reason, from) => pace.refused(reason, from), - publish: (event) => emit(event.name, event.body), + // The seat git's forge events are said on the seat (seatevents.ts); the forge's others stay the module's own. + publish: async (event) => { + if (!SEAT_ONLY.has(event.name)) await emit(event.name, event.body); + await sayOnSeat(event.name, event.body as Record); + }, act: async (event) => { // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => @@ -493,7 +505,7 @@ if (gitea) { // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. // **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle // whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle - // of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below + // of the node starts left gitea_events served nowhere. The pass below // runs again once it is listening, so nothing said before is missed; listenForForge says its own failure. const listenOk = listenForForge(client); // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet @@ -513,29 +525,61 @@ if (gitea) { } catch (err) { console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`); } - // The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a - // repository that builds a registered module is made writable at the start and when its first module is registered - // (writes.ts). A read not granted yet is said and asked again until it holds. + // The catalog half, followed: its first view hands every registered module's entry at the start and each newer trunk + // revision what changed, so a repository that builds a registered module is made writable at the start and when its + // first module is registered (writes.ts); a candidate's revision moves nothing, and nothing is granted before the first view. try { const w = writes(); - // ADR 0615: asked once of the indexer's read tool, then kept by its events; the indexer's state itself is not read. - const modules = new ViewFollower({ owner: INDEXER_OWNER, state: "modules", tool: INDEXER_MODULES_TOOL, - ask: (after) => broker().request(INDEXER_MODULES_TOOL, after ? { values: true, after } : { values: true }) as Promise, - take: (change) => w.take(change as ModulesChange), - // Unanswered past its bound is a finding of the module's own, which the condition keeper raises (ADR 0615 §6). - raise: async (asked, error) => { - const f = unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, asked, error, new Date()); - await state("findings").put(f.key, f.value); - }, - clear: async () => { - await state("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key); - } }); - void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body))); - modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" }; - void modules.start(); + const findings = state("findings"); + const put = async (f: { key: string; value: unknown }) => { await findings.put(f.key, f.value); }; + const view = new CatalogView((verb, input: RevisionInput) => broker().request(verb, input), { + said: keepStatus(firstViews), + changed: (key, value, deleted) => w.take({ key, op: deleted ? "delete" : "put", value }), + // Unanswered past its bound, or a revision waited for past mesh-sdk's WAITS_BOUND_MS, is a finding of the module's + // own, which the condition keeper raises (ADR 0615 §6; design 47 *A part with no view says it waits*). + raise: (s) => put(findingValue(unansweredFinding("gitea", s, new Date()))), + clear: async (s) => { await findings.delete(findingValue(unansweredFinding("gitea", s, new Date())).key); }, + raiseWaits: (s) => put(findingValue(viewWaitsFinding("gitea", s, new Date()))), + clearWaits: async (s) => { await findings.delete(findingValue(viewWaitsFinding("gitea", s, new Date())).key); }, + log: (line) => console.log(`[gitea] ${line}`), + }); + followers.push(view.follower); + void bindUntilHeld(CATALOG_REVISION_EVENT, () => on(CATALOG_REVISION_EVENT, (event) => + // What is no catalog-revision event is said and taken: asked again, it would be no more readable. + view.hear(event.body).catch((err) => console.error(`[gitea] ${err instanceof Error ? err.message : err}`)))); + void view.follower.start().then(() => w.viewed()); + void bindUntilHeld(REGISTRATION_CHANGED_EVENT, () => on(REGISTRATION_CHANGED_EVENT, async (event) => { + try { registrations.hear(event.body); } catch (err) { console.error(`[gitea] ${err instanceof Error ? err.message : err}`); } + // The registrations' watched branches may have changed: asked again, and the heads read again. + await registrations.start(); + await reloadHeads().catch((err) => console.error(`[gitea] the watched heads could not be read — ${err instanceof Error ? err.message : err}`)); + })); } catch (err) { console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`); } + // The runner's step-ended and the heads' moves, heard through durable consumers of the module's own (design 47 §39, §475, §135): a failed + // `required` step is kept, and what no watched branch's head and no open pull request's head names any more is let go. + if (gitea) { + const forge = gitea; + const say = (err: unknown) => console.error(`[gitea] ${err instanceof Error ? err.message : String(err)}`); + reloadHeads = async () => { + const read = await readHeads(forge, registrations.watched, (line) => console.log(`[gitea] ${line}`)); + await failedSteps.lost(heldHeads.replace(read.names, read.branches, read.pulls)); + }; + void bindUntilHeld(`${RUNNER}.${RUNNER_EVENTS.stepEnded}`, () => on(`${RUNNER}.${RUNNER_EVENTS.stepEnded}`, (event) => failedSteps.stepEnded(event.body))); + // A branch's head is read from the forge, never taken from the event (the seat's definition of branch-moved). + void bindUntilHeld(`${GIT}.${GIT_EVENTS.branchMoved}`, () => on(`${GIT}.${GIT_EVENTS.branchMoved}`, async (event) => { + const { repository, branch } = event.body; + if (!heldHeads.watches(repository, branch)) return; + const [owner, repo] = repository.split("/"); + await failedSteps.lost(heldHeads.branchMoved(repository, branch, await forge.branchHead(owner, repo, branch))); + })); + void bindUntilHeld(`${GIT}.${GIT_EVENTS.pullUpdated}`, () => on(`${GIT}.${GIT_EVENTS.pullUpdated}`, (event) => failedSteps.lost(heldHeads.pullUpdated(event.body)))); + for (const name of [GIT_EVENTS.pullClosed, GIT_EVENTS.pullMerged]) { + void bindUntilHeld(`${GIT}.${name}`, () => on(`${GIT}.${name}`, (event) => failedSteps.lost(heldHeads.pullEnded(event.body)))); + } + void registrations.start().then(() => reloadHeads()).catch(say); + } // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. @@ -570,7 +614,4 @@ if (gitea) { // No consumer is bound since ADR 0571 clause 13 took the controller's checked out of this module; a consumer added later // is bound again until it holds, and not awaited (bind.ts, issue 1793). bindInBackground([], () => console.log("[gitea] watching for new repositories and merged pull requests")); - // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, - // apart from the binds above, so neither waits for the other. - void watchOwed((handler) => state(CHECKER_OWED).watch((change) => handler(change)), checks); } diff --git a/modules/gitea/internal/packageseats/holder.go b/modules/gitea/internal/packageseats/holder.go index ea56b67f0..f16b8a8f3 100644 --- a/modules/gitea/internal/packageseats/holder.go +++ b/modules/gitea/internal/packageseats/holder.go @@ -2,8 +2,8 @@ // `go-package-registry` (novox/hq design 51 *The four registry seats*, ADR 0605, ADR 0611). A build module publishes // a package through `publish` and `publish-part`: the package's one file lands in the forge module's own object store // `incoming`, is hashed, checked against the builder's numbering and then uploaded into the forge's package registry -// with the module's own write side. The receiving side is mesh-sdk's seat-neutral parts.Receiver and the caller list -// mesh-sdk's VerbCallers (ADR 0627 §2): this package binds both to the two seats and to the forge. +// with the module's own write side. The receiving side is mesh-sdk's seat-neutral parts.Receiver: this package binds it to the two seats and to the +// forge. Who may call a verb is the seat definition's `callers`, which the grants part grants by (design 47 §68, §321). package packageseats import ( @@ -13,8 +13,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" - "git.novox.be/novox/mesh-delivery/sdk/parts" - stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" ) // The seats the forge module holds. @@ -65,25 +64,6 @@ const ( ReasonRetention = "retention" ) -// Callers is the trusted config value `callers`, read as mesh-sdk's list per verb. -type Callers = stdio.VerbCallers - -// DefaultCallers is design 51's list for the package seats: publish and publish-part the two build modules that -// publish packages; remove the builder (a tagged version whose branch ended); versions anyone on the mesh, as the -// registry's reads are anonymous. -func DefaultCallers() Callers { - builds := []string{"mesh-build-go", "mesh-build-node"} - return Callers{VerbPublish: builds, VerbPublishPart: builds, VerbRemove: {"mesh-delivery-builder"}, - VerbVersions: {stdio.AnyCaller}} -} - -// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of -// the value, so a call never asks (issue 1966). -var configured = stdio.HoldVerbCallers("callers", Verbs, DefaultCallers()) - -// ConfiguredCallers is the config value `callers` as mesh-config resolved it, held from its watch; unset is DefaultCallers. -func ConfiguredCallers() (Callers, error) { return configured() } - // Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share // one shape): what the tools bundle reads from either seat's PublishInput. type Publish struct { @@ -128,7 +108,7 @@ type Version struct { Published time.Time } -// Builder answers whether the builder numbered a version with a digest (the builder package's builds.BuilderVersions). +// Builder answers whether the builder reserved a version with a digest (Reserved). type Builder interface { Recorded(name, version, digest string) (ok bool, why string, err error) } @@ -147,7 +127,6 @@ type Store interface { // Holder answers the verbs of both seats. type Holder struct { - Callers Callers Objects parts.Objects Transfers parts.Transfers[Opening] Builder Builder @@ -186,14 +165,14 @@ func key(seat, transfer string) string { return seat + "_" + transfer } func (h *Holder) receiver() *parts.Receiver[Opening] { return &parts.Receiver[Opening]{Objects: h.Objects, Transfers: h.Transfers, Cap: IncomingCap, Bound: h.Cap, MaxChunk: MaxChunk, Idle: TransferIdle, Arm: h.Idle, Full: IncomingFullPct, Now: h.now, - // The builder's numbering is asked before anything is uploaded (ADR 0605 §4). + // The builder's reservation is asked before anything is uploaded (ADR 0605 §4, design 47 §381). Check: func(t Transfer) error { ok, why, err := h.Builder.Recorded(t.Shape.Name, t.Shape.Version, t.Shape.Digest) if err != nil { - return errors.New("builder_versions did not answer, so nothing is stored: " + err.Error()) + return errors.New("the reservation could not be read, so nothing is stored: " + err.Error()) } if !ok { - return errors.New("the builder did not number it: " + why) + return errors.New("the builder did not reserve it: " + why) } return nil }, @@ -236,9 +215,6 @@ func (h *Holder) answer(transfer string, r parts.Received) Answer { // Publish is `publish` on seat for the proved caller: a package is one file, its digest the file's sha256. func (h *Holder) Publish(seat, caller string, p Publish) (Answer, error) { - if err := h.Callers.Allowed(VerbPublish, caller); err != nil { - return Answer{}, err - } if err := checkPublish(seat, p); err != nil { return Answer{}, err } @@ -269,9 +245,6 @@ func (h *Holder) Publish(seat, caller string, p Publish) (Answer, error) { // PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone. func (h *Holder) PublishPart(seat, caller, transfer, part string, sequence int, last bool, data []byte) (Answer, error) { - if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil { - return Answer{}, err - } got, err := h.receiver().Take(key(seat, transfer), caller, part, sequence, last, data, func(t Transfer) error { if t.Shape.Seat != seat { return fmt.Errorf("transfer %s is %s's, not %s's", transfer, t.Shape.Seat, seat) @@ -286,9 +259,6 @@ func (h *Holder) PublishPart(seat, caller, transfer, part string, sequence int, // Versions is `versions`: every version the seat holds of name, newest first. func (h *Holder) Versions(seat, caller, name string) ([]Version, error) { - if err := h.Callers.Allowed(VerbVersions, caller); err != nil { - return nil, err - } if _, ok := Seats[seat]; !ok { return nil, fmt.Errorf("versions: the forge holds no seat %s", seat) } @@ -305,9 +275,6 @@ func (h *Holder) Versions(seat, caller, name string) ([]Version, error) { // Remove is `remove`: the builder removes a tagged version (its branch ended); an untagged version goes only by the // forge's own retention, never by a call. A version already gone is said removed all the same, so a retry ends. func (h *Holder) Remove(seat, caller, name, version, reason string) (Outcome, error) { - if err := h.Callers.Allowed(VerbRemove, caller); err != nil { - return Outcome{}, err - } if _, ok := Seats[seat]; !ok { return Outcome{}, fmt.Errorf("remove: the forge holds no seat %s", seat) } diff --git a/modules/gitea/internal/packageseats/holder_test.go b/modules/gitea/internal/packageseats/holder_test.go index e8e9bdc14..9fd27c9b2 100644 --- a/modules/gitea/internal/packageseats/holder_test.go +++ b/modules/gitea/internal/packageseats/holder_test.go @@ -9,8 +9,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" - "git.novox.be/novox/mesh-delivery/sdk/parts" - stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts" ) type memObjects map[string][]byte @@ -102,7 +101,7 @@ type bed struct { func newBed(numbered bool) *bed { b := &bed{forge: &forge{files: map[string]map[string][]byte{}}, findings: map[string]bool{}} - b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: builder{ok: numbered}, + b.h = &Holder{Objects: memObjects{}, Transfers: memTransfers{}, Builder: builder{ok: numbered}, Store: b.forge, Now: func() time.Time { return time.Unix(1e9, 0) }, Say: func(seat, event string, o Outcome) error { b.said = append(b.said, said{seat, event, o}); return nil }, Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }} @@ -120,6 +119,7 @@ const goBuild, nodeBuild = "ace.mesh-build-go", "ace.mesh-build-node" // file comes in parts, is checked against the builder's numbering and its digest, and is uploaded with the forge's own // write side; the outcome is said on the seat's events. func TestTheForgeHoldsBothPackageSeats(t *testing.T) { + t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation) file := []byte("a package's tarball") t.Run("a publish in parts is uploaded and said published", func(t *testing.T) { for _, seat := range []string{NpmSeat, GoSeat} { @@ -212,14 +212,6 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { do func(b *bed) error want string }{ - {"a caller not on publish's list", func(b *bed) error { - _, err := b.h.Publish(NpmSeat, "ace.claude-code", pub("x", "0.1.0", file)) - return err - }, "not on the holder's caller list"}, - {"an unproven caller", func(b *bed) error { - _, err := b.h.Publish(NpmSeat, stdio.Unproven, pub("x", "0.1.0", file)) - return err - }, "no caller the tool runner proved"}, {"a seat the forge does not hold", func(b *bed) error { _, err := b.h.Publish("cargo-package-registry", goBuild, pub("x", "0.1.0", file)) return err @@ -244,7 +236,6 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { _, err := b.h.PublishPart(GoSeat, nodeBuild, "t-x", digest(file), 0, true, file) return err }, "not open"}, - {"remove by a build module", func(b *bed) error { _, err := b.h.Remove(GoSeat, goBuild, "x", "0.1.1-feat.1", "tag-gone"); return err }, "not on the holder's caller list"}, {"remove of a version with no tag", func(b *bed) error { _, err := b.h.Remove(GoSeat, "novox.mesh-delivery-builder", "x", "0.1.1", "tag-gone") return err @@ -262,7 +253,7 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { numbered bool data []byte want string - }{{"the builder did not number it", false, file, "did not number"}} { + }{{"the builder did not reserve it", false, file, "did not reserve"}} { b := newBed(c.numbered) if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil { t.Fatal(err) @@ -303,4 +294,5 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { t.Errorf("removed %v, said %+v", b.forge.removed, b.said) } }) + } diff --git a/modules/gitea/internal/packageseats/reserved.go b/modules/gitea/internal/packageseats/reserved.go new file mode 100644 index 000000000..15da4fad9 --- /dev/null +++ b/modules/gitea/internal/packageseats/reserved.go @@ -0,0 +1,42 @@ +package packageseats + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds" +) + +// Reserved is the holder's check of a publish against the builder's reservation (novox/hq design 47 §381, §478, §504): it asks the +// seat mesh-builds' verb reserved with the publish's name and version, a package's name in its ecosystem, and accepts the publish +// only when that version is the one reserved and an artifact of it has the publish's digest. It reads no catalog and a publish gains +// no field. It is the Builder the holder's Check asks. +type Reserved struct { + // Ask is the runtime's request and reply over the bus (mesh-sdk stdio.Ask). + Ask func(address string, body any) (json.RawMessage, error) +} + +// Address is the seat verb asked, as the manifest's `invokes` names it. +const Address = "seat:" + meshbuilds.Seat + "." + meshbuilds.VerbReserved + +// Recorded answers whether name@version with digest is reserved, and why not when it is not. An answer that cannot be read is an +// error, never a yes; a refusal by the builder (a name no entry carries, a version it did not reserve) is a no that names it. +func (r Reserved) Recorded(name, version, digest string) (bool, string, error) { + raw, err := r.Ask(Address, meshbuilds.ReservedInput{Name: name, Version: version}) + if err != nil { + return false, "", fmt.Errorf("%s did not answer for %s@%s: %w", Address, name, version, err) + } + var a meshbuilds.ReservedAnswer + if err := json.Unmarshal(raw, &a); err != nil || a.Version == "" { + return false, "", fmt.Errorf("%s answered no reservation for %s@%s: %s", Address, name, version, raw) + } + if a.Version != version { + return false, fmt.Sprintf("%s reserved %s for %s, not %s", a.Module, a.Version, name, version), nil + } + for _, art := range a.Artifacts { + if art.Digest == digest { + return true, "", nil + } + } + return false, fmt.Sprintf("%s reserved %s@%s for another digest than %s", a.Module, name, version, digest), nil +} diff --git a/modules/gitea/internal/packageseats/reserved_test.go b/modules/gitea/internal/packageseats/reserved_test.go new file mode 100644 index 000000000..dff3f891f --- /dev/null +++ b/modules/gitea/internal/packageseats/reserved_test.go @@ -0,0 +1,54 @@ +package packageseats + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// A publish is checked against the builder's reservation by the seat mesh-builds' verb reserved, asked with the package's name +// (design 47 §381, §478). +func reservedChecksAPublishAgainstTheReservation(t *testing.T) { + for _, c := range []struct { + name string + answer string + err error + ok bool + why string // part of the reason, or of the error + }{ + {name: "the reserved version and digest", answer: `{"module":"mesh-sdk","version":"0.1.19","artifacts":[{"artifact":"mesh-sdk:ts","digest":"sha256:aa"}]}`, ok: true}, + {name: "another artifact of it carries the digest", answer: `{"module":"mesh-sdk","version":"0.1.19","artifacts":[{"artifact":"mesh-sdk:go"},{"artifact":"mesh-sdk:ts","digest":"sha256:aa"}]}`, ok: true}, + {name: "another digest", answer: `{"module":"mesh-sdk","version":"0.1.19","artifacts":[{"artifact":"mesh-sdk:ts","digest":"sha256:bb"}]}`, why: "another digest than sha256:aa"}, + {name: "another version reserved", answer: `{"module":"mesh-sdk","version":"0.1.20","artifacts":[]}`, why: "reserved 0.1.20 for @novox/mesh-sdk, not 0.1.19"}, + {name: "the builder refused the name", err: errors.New("no entry carries @novox/mesh-sdk"), why: "no entry carries @novox/mesh-sdk"}, + {name: "an answer that is none", answer: `{"error":"x"}`, why: "answered no reservation"}, + } { + asked := "" + r := Reserved{Ask: func(address string, body any) (json.RawMessage, error) { + b, _ := json.Marshal(body) + asked = address + " " + string(b) + return json.RawMessage(c.answer), c.err + }} + ok, why, err := r.Recorded("@novox/mesh-sdk", "0.1.19", "sha256:aa") + if asked != `seat:mesh-builds.reserved {"name":"@novox/mesh-sdk","version":"0.1.19"}` { + t.Fatalf("%s: asked %s", c.name, asked) + } + if c.ok { + if !ok || err != nil { + t.Errorf("%s: not accepted: %v %v", c.name, why, err) + } + continue + } + if ok || !strings.Contains(why+fmtErr(err), c.why) { + t.Errorf("%s: ok=%v why=%q err=%v, want %q", c.name, ok, why, err, c.why) + } + } +} + +func fmtErr(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/modules/gitea/kept.ts b/modules/gitea/kept.ts index a591b88f0..9288b8aa5 100644 --- a/modules/gitea/kept.ts +++ b/modules/gitea/kept.ts @@ -1,12 +1,17 @@ // The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue // 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is // granted what it said (clause 3). A state is readable when another module's manifest reads it: mesh-provisioner reads -// `bound` (the forge accounts it binds), the condition keeper (mesh-conditions) reads `findings`. test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's +// `bound` (the forge accounts it binds), the condition keeper (mesh-conditions) reads `findings`. Every state the manifest +// declares is named, so each can be read by its tools (design 47 G A-287). test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's // checks/readable every state another manifest reads to a readable announcement. export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [ { name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, + { name: "failed-steps" }, + { name: "first-views" }, + { name: "idle" }, + { name: "transfers" }, ]; /** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1, diff --git a/modules/gitea/mainrules.ts b/modules/gitea/mainrules.ts index 6137559e4..e8ac249f7 100644 --- a/modules/gitea/mainrules.ts +++ b/modules/gitea/mainrules.ts @@ -15,19 +15,6 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean { } catch { return false; } } -/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses - * us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false - * is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an - * older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */ -export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean { - const file = env.MESH_GITEA_CONFIG_FILE; - if (!file) return true; - try { - const v = (JSON.parse(readFileSync(file, "utf8")) as Record)["check-gates-merge"]; - return !(v === false || v === "false"); - } catch { return true; } -} - /** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */ export function mainRulesStep(keep: boolean, put: () => Promise, say: (line: string) => void): () => Promise { let said = false; diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts deleted file mode 100644 index 96f6d69a2..000000000 --- a/modules/gitea/merge.ts +++ /dev/null @@ -1,279 +0,0 @@ -// The forge's merge tool, `gitea_merge_pull_request` (novox/hq ADR 0266, issue 381, ADR 0323). -// -// **Served from the watcher's bundle, beside the merge check's one writer** (index.ts, gate.ts; novox/hq issue 456): -// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and -// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. -// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. -// -// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's -// verdict, whose line names the trunk it judged ("pass on "), must be a pass against the trunk's head. -// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses, -// until the forge's holder takes it off, that status judges, as before. A mesh/check of another trunk is refused and -// nothing is asked: the checker sets it out of date on the trunk's move and checks it again. Where the base requires no -// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9). - -import { announce } from "./announce.js"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import type { CommitStatus, GiteaClient } from "./client.js"; -import { mergeableUnder, newestByContext } from "./checks.js"; -import { MERGE_METHODS, Refused, checkName } from "./guard.js"; -import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js"; -import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; -import { checkGatesMerge } from "./mainrules.js"; -import { announcementOf, askedWords, outOfDateWords, sameCommit, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js"; - -/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ -const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; - -/** What the merge tool reaches beside the forge: the merge check's one writer, how an event is said, the module's - * state directory, and the clock. */ -export interface MergeDeps { - gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; - emit?: (type: string, body: unknown) => Promise; - stateDir?: string; - now?: () => Date; - /** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */ - checkGates?: () => boolean; -} - -export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition { - const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, - now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) }; - return { - name: "gitea_merge_pull_request", - description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " + - "its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " + - "where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " + - "optionally deleting the source branch afterwards. Refused " + - "otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " + - "reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " + - "the trunk's head is asked once: ask again at the same head " + - "once it passes — the tool never merges by itself.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the PR number" }, - head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " + - "refused unless it is the pull request's head" }, - method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " + - "Only merge and fast-forward-only can be read back to prove the head you named is what landed" }, - delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, - approved_heads: { type: "array", items: { type: "string" }, description: "the heads the operator's Approve covers, " + - "each owner/repo@, one per member of the group (novox/hq ADR 0421 §9): refused unless this pull " + - "request's repository is named at the head it has now. Required when the pull request's verdict read siblings, " + - "where each sibling's head must be named too; there the Approve takes the place of an approving review" }, - }, - run: async (args) => { - const owner = checkName("owner", args.owner); - const repo = checkName("name", args.repo); - const number = Number(args.number); - const method = args.method ? String(args.method) : "merge"; - if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); - const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); - // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the - // forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge - // applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that - // cannot be read is no rule passed. - // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, - // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the - // head was and nothing told it which head the review had approved. The caller names it; a head that moved - // since is refused, and the forge is handed the same head, so a push between this read and the merge is - // refused by the forge too. - const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase(); - if (!named) { - throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` + - `that commit can land (novox/hq issue 381). Nothing was merged`); - } - // Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the - // reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way. - if (!REVIEWED_HEAD.test(named)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` + - `sha, or at least its first 12 characters. Nothing was merged`); - } - const pull = await gitea.getPullRequest(owner, repo, number); - if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`); - if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`); - if (!pull.head_sha.toLowerCase().startsWith(named)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + - `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); - } - // **Only a head the operator's Approve covers** (novox/hq ADR 0421 §9): mesh-delivery hands the heads its Approve - // named; a pull request whose head differs is refused, so a push between the Approve and this call merges nothing. - const approved = approvedHeadsOf(args.approved_heads); - if (approved) { - const why = notApproved(approved, `${owner}/${repo}`, number, String(pull.head_sha)); - if (why) throw new Refused(`${owner}/${repo}#${number} is not merged: ${why}. A merge lands the heads the operator ` + - `approved (novox/hq ADR 0421). Nothing was merged`); - } - const rule = await gitea.appliedProtection(owner, repo, pull.base); - // Only into a branch whose protection refuses this account's push: a pull request whose base was moved to a - // branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4). - if (!rule || rule.enable_push !== false) { - throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` + - `a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`); - } - // **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the - // head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared - // here at the moment the merge is asked, not left to the statuses the trunk's move resets a pass later. When they - // differ nothing is merged, and the head's check against the trunk's head is asked once — the announcement a new - // head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes. - const trunkHead = await gitea.branchHead(owner, repo, pull.base); - const checks = await gitea.headChecks(owner, repo, pull.head_sha); - const required = requiredOf(rule) ?? []; - // **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires - // no status, only a head the Approve names merges, and mesh/check judges it as where it was required. - if (required.length === 0 && !approved) { - throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` + - `lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@ ` + - `(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`); - } - const kept = maintained(required); - const byCheck = required.includes(CHECK_STATUS) || kept.length === 0; - // **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's - // Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without - // approved_heads the check still judges. - if (byCheck && (deps.checkGates() || !approved)) { - // **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it - // is not current; nothing is written or asked — the checker checks a head again when its trunk moves. - const check = checks[CHECK_STATUS]; - const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS); - if (!check || check.state === "pending") { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` + - `${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` + - `${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`); - } - if (check.state !== "success") { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` + - `(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). Nothing was merged`); - } - if (!at.current) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` + - `${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` + - `${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` + - `ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`); - } - } - // Where the base still requires the controller's status (mesh/merge-gate, only on a - // revert), the first it requires judges, as before (ADR 0323 §5). - const judge = kept[0] ?? CHECK_CONTEXT; - const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false }; - if (!at.current) { - // A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a - // trunk's move is, and a failed ask puts those words back, not the trunk's. - const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined; - const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})` - : at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against"; - const head = twelve(pull.head_sha), now = twelve(trunkHead); - // **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own - // check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was - // refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited. - if (waitsForComposed(checks[judge])) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` + - `verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` + - `(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`); - } - if (at.onItsWay) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + - `${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` + - `asked again. Ask again at ${head} once both statuses pass: this tool never merges by itself ` + - `(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`); - } - // Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An - // announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather - // than wait for ever on a check nobody asked. - // The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own - // check beside it where a revert has the base require both. - const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT])); - // Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new - // check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older. - const say = async (description: string) => { - for (const context of contexts) { - await deps.gates.rechecked(owner, repo, String(pull.head_sha), { state: "pending", context, description, - ...(pull.html_url ? { target_url: pull.html_url } : {}) }); - } - }; - await say(askedWords(deps.now(), trunkHead)); - try { - const cloneURL = (await gitea.getRepo(owner, repo)).clone_url; - await deps.emit("pull.updated", await announcementOf(gitea, owner, repo, cloneURL, pull)); - } catch (err) { - await say(siblingWords ?? outOfDateWords(at.judged, trunkHead)).catch(() => {}); - throw new Error(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + - `${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`); - } - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + - `${now}. The check of head ${head} merged into trunk ${now} was asked. Ask again at ${head} once both ` + - `statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` + - `move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` + - `again with mesh-delivery.recheck. Nothing was merged`); - } - const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses); - const verdict = mergeableUnder(pull.base, rule, statuses); - if (verdict.mergeable !== true) { - throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` + - `its checks succeed; no tool merges past them (novox/hq ADR 0266)`); - } - // **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the - // siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record - // of them is no verdict to merge on, never read as none. - // Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the - // Approve's heads above; a record the controller's verdicts left before the switch judges nothing there. - const heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)]; - const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : []; - if (!siblings.length && !byCheck && - namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` + - `here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`); - } - if (siblings.length) { - const missing = await groupRefusal({ headChecks: (o, r, sha) => gitea.headChecks(o, r, sha) }, - { owner, repo, number, head: String(pull.head_sha) }, siblings, approved); - if (missing) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict was judged beside ${siblings.length} ` + - `sibling(s), and ${missing}. A member judged beside its siblings merges once its group passed at the heads that ` + - `verdict read and the operator's Approve covers each of them (novox/hq ADR 0382, 0421). Nothing was merged`); - } - } - await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha); - // The merge commit only exists now; answered so the caller can follow what is built from it. - // pull.merged is the events entrypoint's to announce (index.ts), once, within its poll. - // From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot - // be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened. - const answer: Record = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch }; - let landed: string | undefined; - try { - const merged = await gitea.getPullRequest(owner, repo, number); - answer.merge_commit_sha = merged.merge_commit_sha; - // What landed, read back from the commit the merge made rather than from the forge's record of the pull - // request, which is the very thing a race would leave behind. A merge commit's second parent is the head it - // joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head. - if (!merged.merge_commit_sha) { - answer.read_back = "the forge named no merge commit, so what landed cannot be read back"; - } else if (method === "merge") { - const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); - if (parents.length === 2) landed = parents[1]; - else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`; - } else if (method === "fast-forward-only") { - // That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge: - // a different sha is said, not raised as another head having landed. - if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha; - else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` + - `not the head: that the head landed could not be confirmed`; - } else { - answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`; - } - } catch (err) { - answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`; - } - // Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken - // back by this tool; what must not happen is that it reads as the merge that was asked for. - if (landed !== undefined && landed !== pull.head_sha) { - throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` + - `${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` + - `reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`); - } - if (landed !== undefined) answer.landed = landed; - return answer; - }, - }; -} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 7d803c79c..24da1123d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -1,35 +1,11 @@ { "module": "gitea", - "version": "1", - "packages": { - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1" - }, + "version": "0.1", "moved": true, "requires": [ "postgres-database", "route", - "secret", - { - "name": "incoming", - "storage": "object-store", - "template": "objects", - "options": { - "max-mib": 2048 - } - }, - { - "name": "transfers", - "storage": "key-value", - "template": "state" - }, - { - "name": "idle", - "storage": "key-value", - "template": "state", - "options": { - "key-ttl": true - } - } + "secret" ], "contributes": { "postgres-database": { @@ -64,16 +40,10 @@ ], "emits": [ "repo.created", - "repo.writable", "issue.opened", - "pull.merged", - "pull.updated", - "pull.closed", "forge.push", "forge.create", - "forge.delete", "forge.fork", - "forge.status", "forge.pull-request.opened", "forge.pull-request.reopened", "forge.pull-request.synchronized", @@ -123,7 +93,7 @@ "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take" + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" }, { "name": "forge-events", @@ -173,9 +143,31 @@ }, { "name": "git", - "scope": "mesh" + "scope": "mesh", + "serves": [ + "pull", + "branch", + "open-pulls", + "tags", + "trees", + "file", + "contains", + "statuses", + "status", + "comment", + "view", + "note", + "merge-pull", + "tag" + ] } ], + "uses": [ + "operator-channel" + ], + "needs-approval": [ + "gitea_branch_protection_loosen" + ], "data": { "own": [ { @@ -221,7 +213,7 @@ }, "keep-main-rules": { "type": "boolean", - "description": "Whether the module keeps main's branch protection as the mesh wants it. True: every ten minutes, and at the start, main's rule in every repository of the organisation novox is put to no push and the tools' account alone merging, and each apply of the `repositories` value narrows the main rule of the repositories it lists, requiring no status. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge, but takes away a required status it finds on a rule covering main, once (ADR 0534 \u00a71); a repository listed in `repositories` that does not exist is still made, with its first protection.", + "description": "Whether the module keeps main's branch protection as the mesh wants it. True: every ten minutes, and at the start, main's rule in every repository of the organisation novox is put to no push and the tools' account alone merging, and each apply of the `repositories` value narrows the main rule of the repositories it lists, requiring no status. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge, but takes away a required status it finds on a rule covering main, once (ADR 0534 §1); a repository listed in `repositories` that does not exist is still made, with its first protection.", "trusted": true, "default": false }, @@ -231,26 +223,13 @@ "trusted": true, "default": {} }, - "check-gates-merge": { - "type": "boolean", - "description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 \u00a74). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.", - "trusted": true, - "default": true - }, "main-mergers": { "type": "list", - "description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 \u00a72): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.", + "description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.", "trusted": true, "default": [ "jschoubben" ] - }, - "callers": { - "type": "string", - "trusted": true, - "runtime": true, - "default": "", - "description": "per verb of the two package seats, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); empty is design 51's list: publish and publish-part mesh-build-go and mesh-build-node, remove mesh-delivery-builder, versions anyone" } }, "facts": { @@ -379,8 +358,7 @@ "takes-config": [ "url", "keep-main-rules", - "main-mergers", - "check-gates-merge" + "main-mergers" ] }, { @@ -497,6 +475,9 @@ "name": "code", "kind": "bundle", "language": "typescript", + "needs": [ + "mesh-delivery-assigner:program" + ], "entrypoints": [ "index.js", "tools/index.js" @@ -629,7 +610,20 @@ "state": [ "bound", "announced", - "findings" + "findings", + { + "name": "first-views", + "history": 1 + }, + "transfers", + { + "name": "idle", + "key-ttl": true + }, + { + "name": "failed-steps", + "history": 1 + } ], "reads": [ "mesh-provisioner.plans", @@ -637,16 +631,40 @@ "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", - "mesh-build-image.keys", - "mesh-delivery-checker.owed", - "mesh-delivery-gate.source" + "mesh-build-image.keys" ], "invokes": [ - "mesh-delivery-indexer.indexer_read_modules", + "seat:mesh-catalog.revision", + "seat:mesh-catalog.registrations", "seat:mesh-hand-acts.hand-act", - "mesh-delivery-builder.builder_versions" + "seat:mesh-builds.reserved" ], "consumes": [ - "mesh-delivery-indexer.views.modules" + "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed", + "mesh-pipeline-runner.step-ended", + "git.branch-moved", + "git.pull-updated", + "git.pull-closed", + "git.pull-merged", + "mesh-conditions.findings-asked" + ], + "streams": [ + { + "name": "gitea-findings", + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + } + ], + "objects": [ + { + "name": "incoming", + "max-mib": 2048 + } ] } diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 8e33075b9..8ca41df32 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -1,7 +1,7 @@ { "name": "@novox/module-gitea", "version": "0.1.0", - "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", + "description": "gitea \u2014 git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", "type": "module", "private": true, "scripts": { @@ -9,7 +9,10 @@ "test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-delivery-contracts-git": "^0.1.0", + "@novox/mesh-delivery-contracts-mesh-catalog": "^0.1.0", + "@novox/mesh-delivery-contracts-mesh-pipeline-runner": "^0.1.0", + "@novox/mesh-sdk": "^0.1.19" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/gitea/packages-account.ts b/modules/gitea/packages-account.ts index ffbfab7b9..ac0ec296b 100644 --- a/modules/gitea/packages-account.ts +++ b/modules/gitea/packages-account.ts @@ -89,7 +89,7 @@ export async function keepPackagesToken(source: MintedToken, url: string, owner } /** The finding the Go bundle raises in the module's findings state when the forge refuses the write token (cmd/package-registry PackagesTokenRefusedKey). */ -export const PACKAGES_TOKEN_REFUSED = "seat.gitea.packages-token-refused_write"; +export const PACKAGES_TOKEN_REFUSED = "seat.gitea.write.packages-token-refused"; /** One change of the findings state, as its watch hands it. */ export interface FindingChange { readonly key: string; readonly op: "put" | "delete" } diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index e3c836b7a..b288f0ca7 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -206,52 +206,35 @@ function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: bo return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` }; } -/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */ -export type SourceRead = () => Promise>; +/** Where a repository's trunk is said to come from: the mesh's one trunk per repository (novox/hq design 47, *The trunk + * is what runs*), the branch main. No other module's state is read for it (ADR 0596 §2): the catalog's trunk heads name + * a repository and its commit, not a branch, so a second trunk would first be a branch in the seat mesh-catalog's + * revision. */ +export const TRUNK_FROM = "the mesh's one trunk of every repository (design 47)"; -/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each - * repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */ -export const TRUNK_SOURCE = "mesh-delivery-gate.source"; - -/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's - * source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and - * `from` says which. The one place trunks are read: several trunks later change only this. */ -export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> { - const full = `${owner}/${repo}`; - let src: Record; - try { - src = await read(); - } catch (err) { - return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` }; - } - const names = Object.keys(src).filter((k) => { - const heads = (src[k] as { heads?: Record } | null)?.heads; - return !!heads && typeof heads === "object" && full in heads; - }).sort(); - if (names.length) return { names, from: TRUNK_SOURCE }; - return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` }; +/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): main, and `from` says + * why. The one place trunks are named: a later trunk changes only this. */ +export function trunksOf(): { names: string[]; from: string } { + return { names: [MAIN_BRANCH], from: TRUNK_FROM }; } -/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */ -export function sourceFromBus(get: (name: string) => { keys(): Promise; get(k: string): Promise<{ value: unknown } | null> }): SourceRead { - return async () => { - const st = get(TRUNK_SOURCE); - const out: Record = {}; - for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null; - return out; - }; -} +/** The tool that alone removes a trunk's protection or allows a force-push onto a trunk, marked in the manifest's + * `needs-approval`, so it runs only on the operator's Approve of that one call (design 46 approval mark §21). */ +export const LOOSEN_TOOL = "gitea_branch_protection_loosen"; + +/** The two acts LOOSEN_TOOL performs (design 46 approval mark §21). */ +export const LOOSEN_ACTS = ["remove-protection", "allow-force-push"] as const; /** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28 - * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No - * tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */ + * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). Those + * two acts are LOOSEN_TOOL's alone (design 46 approval mark §21), so such a call is refused with nothing sent, naming it. */ export function forcePushToTrunk(rule: string, body: Record, trunks: string[]): string | null { const covered = trunks.filter((t) => ruleCovers(rule, t)); if (!covered.length) return null; const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true || (["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0); if (!opens) return null; - return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` + - `the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` + - `at the forge. Nothing was sent`; + return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: removing a trunk's protection ` + + `or allowing a force-push onto a trunk is ${LOOSEN_TOOL}'s alone, which runs on the operator's Approve of that one call. ` + + `Nothing was sent`; } diff --git a/modules/gitea/registrations.ts b/modules/gitea/registrations.ts new file mode 100644 index 000000000..3d8f5828d --- /dev/null +++ b/modules/gitea/registrations.ts @@ -0,0 +1,62 @@ +// **The first registrations switch the merge on** (novox/hq design 47 §95, §353): until the catalog half has said its first +// `registration-changed`, the seat git's `merge-pull` refuses every merge naming the missing registrations, because a merge +// made with no Approve could reach the old assigner, which writes wants from the trunk with no pipeline. The module learns it +// two ways, both through the seat `mesh-catalog`: its verb `registrations` asked at the start (a restart after the first +// registrations were written), and its event `registration-changed` (the first being written, or heard again from the +// seat's kept stream). Neither is the holder's state or a module named. + +import { + seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, + type RegistrationChangedEvent, type RegistrationsAnswer, type RegistrationsInput, +} from "@novox/mesh-delivery-contracts-mesh-catalog"; + +/** The seat verb asked, as the manifest's `invokes` names it. */ +export const REGISTRATIONS_VERB = `seat:${CATALOG}.${CATALOG_VERBS.registrations}`; +/** The seat event heard, as the manifest's `consumes` names it. */ +export const REGISTRATION_CHANGED_EVENT = `${CATALOG}.${CATALOG_EVENTS.registrationChanged}`; + +export class Registrations { + private first = false; + private byRepository = new Map>(); + constructor(private readonly ask: (verb: string, input: RegistrationsInput) => Promise, + private readonly log: (line: string) => void = () => {}) {} + + /** Whether the first registrations are written: the one fact the merge switches on. */ + get written(): boolean { + return this.first; + } + + /** The watched branches the registrations name, by their origin repository (`/`) as of the last read. */ + get watched(): ReadonlyMap> { + return this.byRepository; + } + + /** One `registration-changed`: the first registration written, however many were before this module started. */ + hear(ev: RegistrationChangedEvent | undefined): void { + if (!ev || typeof ev.module !== "string" || ev.module === "") { + throw new Error(`not a ${REGISTRATION_CHANGED_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`); + } + if (!this.first) this.log(`the first registration is written (${ev.module}); merge-pull merges from here`); + this.first = true; + } + + /** The start's first view, asked again after each `registration-changed`: any registration held is the first written, and its + * watched branches are read. A failed ask is said and left to the event. */ + async start(): Promise { + try { + let a: unknown = await this.ask(REGISTRATIONS_VERB, {}); + if (typeof a === "string") a = JSON.parse(a); + const r = (a as RegistrationsAnswer | undefined)?.registrations; + if (!Array.isArray(r)) throw new Error(`${REGISTRATIONS_VERB} answered no registrations: ${JSON.stringify(a ?? null).slice(0, 200)}`); + const by = new Map>(); + for (const x of r) for (const b of x.branches ?? []) (by.get(x.origin) ?? by.set(x.origin, new Set()).get(x.origin)!).add(b); + this.byRepository = by; + if (r.length > 0) { + this.first = true; + this.log(`${r.length} registrations are held; merge-pull merges`); + } + } catch (err) { + this.log(`the registrations could not be read: ${err instanceof Error ? err.message : String(err)}; merge-pull waits for registration-changed`); + } + } +} diff --git a/modules/gitea/seat.ts b/modules/gitea/seat.ts new file mode 100644 index 000000000..8cd08bd95 --- /dev/null +++ b/modules/gitea/seat.ts @@ -0,0 +1,339 @@ +// The forge module holds the seat `git` (novox/hq design 47 *The seat git*, ADR 0596 §1, §2): every read and act a +// delivery part makes of the forge is a verb of the seat, served here as a tool `git.` against the seat's generated +// contract, and no part names this module's own tools. +// +// **Who may call which verb** is the seat definition's `callers`, which the grants part grants by: the bus refuses a caller a +// definition does not name (design 47 §68, §313, §321), so this holder judges no caller and keeps no list of its own. +// +// **A failed read answers an error, never an empty answer**: the forge's 404 is the kind `not-found`, its refusal (another +// 4xx, or a refusal of this holder) the kind `refused`, a forge that failed or did not answer the kind `failed`; an empty list or an absent file is answered +// only when the forge said so. +// +// **The acts carry the caller's id** (status, comment, view, note, merge-pull): an id the pull request or the note already +// holds is answered `held` and nothing is done again; `merge-pull` merges the head the caller names, only into a base +// whose protection refuses a push, and refuses a head at whose fingerprint a run failed a `required` step (design 47 §39, §314). + +import type { ToolDefinition } from "@novox/mesh-sdk/tools"; +import { + seat as GIT, verbs as GIT_VERBS, + type BranchAnswer, type ContainsAnswer, type CommentAnswer, type ErrorKind, type FileAnswer, type MergePullAnswer, type Merging, type NoteAnswer, + type OpenPull, type OpenPullsAnswer, type Pull, type PullAnswer, type Status, type StatusAnswer, type StatusesAnswer, + type TagAnswer, type TagsAnswer, type TreesAnswer, type ViewAnswer, +} from "@novox/mesh-delivery-contracts-git"; +import type { CommitStatus, GiteaClient, GiteaComment, GiteaPull } from "./client.js"; +import { MAX_FILE_BYTES } from "./client.js"; +import { containedFrom, newestByContext } from "./checks.js"; +import { Refused, checkName } from "./guard.js"; +import { appendNote, viewBody, viewComment, type Runner } from "./delivery.js"; +import type { Judged } from "./failedsteps.js"; + +/** The verbs of the seat, in its definition's order. */ +export const VERBS: readonly string[] = Object.values(GIT_VERBS); + +/** How many tags a page of `tags` holds. */ +export const TAGS_PAGE = 50; + +/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name + * is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */ +export const FORGE_CONTAINER = "gitea"; + +/** The writer every status of a head takes its turn on (gate.ts): the seat's status lands after every write of the head + * already asked for, so a recheck's pending never lands over a later verdict (novox/hq issue 456). */ +export interface HeadStatuses { + status(owner: string, repo: string, sha: string, status: CommitStatus): Promise; +} + +/** What the holder reaches beside the forge's API: git in the forge's container (the note), the head's one writer of its + * statuses, and whether the first registrations are written. */ +export interface SeatDeps { + run: Runner; + container: string; + statuses: HeadStatuses; + /** Whether the first registrations were written (registrations.ts): until then merge-pull refuses every merge (design 47 §95). */ + registered: () => boolean; + /** The failed `required` step that refuses a head, judged from the module's own state `failed-steps` (failedsteps.ts), or null. */ + judge: (head: string) => Promise; +} + +/** A failure as the contract says it: the forge's or this holder's refusal, or a forge that failed. */ +export function failure(err: unknown): { error: string; "error-kind": ErrorKind } { + const text = err instanceof Error ? err.message : String(err); + // The forge's 404: what was asked about (a repository, a commit, a pull request, a branch, a tag) does not exist. + if (/^Gitea API [^:]*: 404 /.test(text)) return { error: text, "error-kind": "not-found" }; + const refused = err instanceof Refused || /^Gitea API [^:]*: 4\d\d /.test(text) || /^Gitea API: /.test(text); + return { error: text, "error-kind": refused ? "refused" : "failed" }; +} + +/** owner/name of a `repository` argument, each checked as a name (guard.ts). */ +export function repositoryOf(raw: unknown): { owner: string; repo: string; full: string } { + const parts = String(raw ?? "").split("/"); + if (parts.length !== 2) throw new Refused(`repository ${JSON.stringify(raw)} is not /`); + const owner = checkName("owner", parts[0]), repo = checkName("name", parts[1]); + return { owner, repo, full: `${owner}/${repo}` }; +} + +const SHA = /^[0-9a-f]{40}$/; +/** The git tag of a released Go version (design 51; design 47 §370). */ +const GO_TAG = /^go\/v\d+\.\d+\.\d+$/; +const STATES = new Set(["pending", "success", "failure", "error", "warning"]); +function commitOf(name: string, raw: unknown): string { + const sha = String(raw ?? "").trim(); + if (!SHA.test(sha)) throw new Refused(`${name} is a commit's full 40-hex sha`); + return sha; +} +function numberOf(raw: unknown): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) throw new Refused(`${JSON.stringify(raw)} is not a pull request's number`); + return n; +} +function idOf(raw: unknown): string { + const id = String(raw ?? "").trim(); + if (!id || /[\s<>]/.test(id)) throw new Refused("id is the act's id: one word"); + return id; +} + +/** The marker an act's id leaves in a comment, so an id said again is found and skipped. */ +export function actMarker(id: string): string { + return ``; +} + +/** A pull request as the contract says it; never its description (ADR 0659 §1). */ +export function pullOf(full: string, p: GiteaPull): Pull { + const state = p.merged ? "merged" : p.state === "open" ? "open" : "closed"; + return { + repository: full, number: p.number, base: String(p.base ?? ""), branch: String(p.head ?? ""), head: String(p.head_sha ?? ""), + state, ...(p.user ? { author: p.user } : {}), ...(p.merge_commit_sha ? { "merge-commit": p.merge_commit_sha } : {}), + ...(p.merged_at ? { "merged-at": p.merged_at } : {}), ...(p.title ? { title: p.title } : {}), + ...(p.html_url ? { url: p.html_url } : {}), + }; +} + +function statusOf(s: { context: string; state: string; description?: string; target_url?: string; updated_at?: string; creator?: string }): Status { + return { context: s.context, state: s.state, ...(s.description ? { description: s.description } : {}), + ...(s.target_url ? { target: s.target_url } : {}), ...(s.updated_at ? { at: s.updated_at } : {}), + ...(s.creator ? { by: s.creator } : {}) }; +} + +/** What the forge would merge now, for an open pull request: whole, or the verb's error. */ +async function mergingOf(gitea: GiteaClient, owner: string, repo: string, p: GiteaPull): Promise { + if (p.mergeable === undefined) throw new Error(`the forge did not say whether ${owner}/${repo}#${p.number} merges now`); + const rule = await gitea.appliedProtection(owner, repo, String(p.base ?? "")); + const writes = (await gitea.getRepo(owner, repo)).writes; + if (writes === undefined) throw new Error(`the forge did not say whether its account writes ${owner}/${repo}`); + const required = rule?.enable_status_check ? [...(rule.status_check_contexts ?? [])] : []; + return { mergeable: p.mergeable, protected: !!rule, required, writes }; +} + +/** The seat's verbs as tools `git.`, each answering in the seat's contract. */ +export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] { + const verb = (name: string, description: string, act: (args: Record) => Promise): ToolDefinition => ({ + name: `${GIT}.${name}`, + description, + input: {}, + run: async (args: Record) => { + try { + return await act(args); + } catch (err) { + return failure(err); + } + }, + }); + + return [ + verb(GIT_VERBS.pull, "The seat git's pull: one pull request by its repository and number or by its merge commit, and for an open one what the forge would merge now.", + async (args): Promise => { + const { owner, repo, full } = repositoryOf(args.repository); + const p = args["merge-commit"] !== undefined + ? await gitea.pullOfCommit(owner, repo, commitOf("merge-commit", args["merge-commit"])) + : await gitea.getPullRequest(owner, repo, numberOf(args.number)); + const pull = pullOf(full, p); + return pull.state === "open" ? { pull, merging: await mergingOf(gitea, owner, repo, p) } : { pull }; + }), + verb(GIT_VERBS.branch, "The seat git's branch: a branch's head, and whether a protection rule refuses a direct push and admits only merges.", + async (args): Promise => { + const { owner, repo, full } = repositoryOf(args.repository); + const branch = String(args.branch ?? ""); + // The head first: a branch the forge does not hold is its 404, the kind not-found, never an unprotected branch. + const head = await gitea.branchHead(owner, repo, branch); + return { repository: full, branch, head, protected: pushRefused(await gitea.appliedProtection(owner, repo, branch)) }; + }), + verb(GIT_VERBS.openPulls, "The seat git's open-pulls: every open pull request of every repository, with the statuses its base requires and those its head has.", + async (): Promise => { + const pulls: OpenPull[] = (await gitea.openPulls()).map((o) => ({ + pull: { repository: `${o.owner}/${o.repo}`, number: o.number, base: o.base, branch: String(o.head ?? ""), head: o.head_sha, + state: "open", ...(o.html_url ? { url: o.html_url } : {}) }, + required: [...o.required], + checks: Object.fromEntries(Object.entries(o.checks ?? {}).map(([context, c]) => + [context, statusOf({ context, state: c.state, description: c.description, updated_at: c.updated_at })])), + })); + return { pulls }; + }), + verb(GIT_VERBS.tags, "The seat git's tags: a repository's tags, a page at a time, newest first; more when another page exists.", + async (args): Promise => { + const { owner, repo, full } = repositoryOf(args.repository); + const page = args.page === undefined ? 1 : Number(args.page); + const prefix = String(args.prefix ?? ""); + // One more than the page is asked, so whether another page exists is the forge's answer, not a guess. + const got = await gitea.tags(owner, repo, page, TAGS_PAGE + 1); + const more = got.length > TAGS_PAGE; + const tags = got.slice(0, TAGS_PAGE).filter((t) => t.startsWith(prefix)); + return { repository: full, page, bound: TAGS_PAGE, tags, ...(more ? { more } : {}) }; + }), + verb(GIT_VERBS.trees, "The seat git's trees: the trees of one commit, with files and paths when asked; a listing the forge cut is refused.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const got = await gitea.commitTrees(owner, repo, commit, args.paths as Record | undefined, + args.files as string[] | undefined); + return { commit, ...got }; + }), + verb(GIT_VERBS.file, "The seat git's file: one file at one commit, branch or tag, decoded; over 512 KiB refused; absent only when the forge said so.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const path = String(args.path ?? ""); + const ref = args.ref === undefined ? undefined : String(args.ref); + try { + const f = await gitea.getFile(owner, repo, path, ref); + if (f.size > MAX_FILE_BYTES) throw new Refused(`${path} is ${f.size} bytes, over ${MAX_FILE_BYTES / 1024} KiB`); + return { path: f.path, ...(ref ? { ref } : {}), blob: f.sha, size: f.size, content: f.content }; + } catch (err) { + if (err instanceof Error && /^Gitea API [^:]*\/contents\/[^:]*: 404 /.test(err.message)) return { path, ...(ref ? { ref } : {}), absent: true }; + throw err; + } + }), + verb(GIT_VERBS.contains, "The seat git's contains: whether one commit contains another, an ancestor of it or the same.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit), within = commitOf("in", args.in); + const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit)); + return { commit, in: within, contains, ahead }; + }), + verb(GIT_VERBS.statuses, "The seat git's statuses: the statuses a commit carries, the newest of each context.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const combined = await gitea.combinedStatus(owner, repo, commit); + return { commit, combined: combined.state, statuses: newestByContext(combined.statuses).map(statusOf) }; + }), + verb(GIT_VERBS.status, "The seat git's status: an act of the forge bridge, a commit status set in its turn after every write of the head already asked for; the same id said again sets it again to the same.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const state = String(args.state ?? ""); + if (!STATES.has(state)) throw new Refused(`state ${JSON.stringify(state)} is none of ${[...STATES].join(", ")}`); + await deps.statuses.status(owner, repo, commit, { state: state as CommitStatus["state"], context: String(args.context ?? ""), + description: String(args.description ?? ""), ...(args.target ? { target_url: String(args.target) } : {}) }); + return { id, performed: true }; + }), + verb(GIT_VERBS.comment, "The seat git's comment: an act of the forge bridge, a comment on a pull request carrying the act's id; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const number = numberOf(args.number); + if (holds(await gitea.listComments(owner, repo, number), id)) return { id, held: true, performed: true }; + await gitea.addComment(owner, repo, number, `${String(args.text ?? "")}\n\n${actMarker(id)}`); + return { id, performed: true }; + }), + verb(GIT_VERBS.view, "The seat git's view: an act of the forge bridge, the delivery's one view comment kept on a pull request; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const number = numberOf(args.number); + const existing = viewComment(await gitea.listComments(owner, repo, number)); + if (existing && existing.body.includes(actMarker(id))) return { id, held: true, performed: true }; + const body = `${viewBody(String(args.text ?? ""))}\n${actMarker(id)}`; + if (existing) await gitea.editComment(owner, repo, existing.id, body); + else await gitea.addComment(owner, repo, number, body); + return { id, performed: true }; + }), + verb(GIT_VERBS.note, "The seat git's note: an act of the forge bridge, one line on a commit's note under refs/notes/mesh-plan carrying the act's id; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const said = await appendNote(deps.run, deps.container, owner, repo, commit, "mesh-plan", `${String(args.line ?? "")} [${id}]`, id); + return said.added ? { id, performed: true } : { id, held: true, performed: true }; + }), + verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it refuses a head at whose fingerprint a required step failed.", + async (args): Promise => mergePull(gitea, args, deps.registered, deps.judge)), + verb(GIT_VERBS.tag, "The seat git's tag: the git tag go/vMAJOR.MINOR.PATCH of a released version, written at the commit its build was made from; a tag that is at that commit is performed.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const tag = String(args.tag ?? ""); + if (!GO_TAG.test(tag)) throw new Refused(`${JSON.stringify(tag)} is not go/vMAJOR.MINOR.PATCH`); + const commit = commitOf("commit", args.commit); + const at = await gitea.tagCommit(owner, repo, tag); + if (at === commit) return { tag, performed: true }; + if (at !== null) throw new Refused(`${tag} is at ${at.slice(0, 12)} already, not at ${commit.slice(0, 12)}; a tag is never moved`); + await gitea.createTag(owner, repo, tag, commit); + return { tag, performed: true }; + }), + ]; +} + +/** Whether a protection rule refuses a direct push and admits only merges: the rule exists and takes no push. */ +export function pushRefused(rule: { enable_push?: boolean } | null): boolean { + return !!rule && rule.enable_push === false; +} + +function holds(comments: GiteaComment[], id: string): boolean { + return comments.some((c) => String(c.body ?? "").includes(actMarker(id))); +} + +/** merge-pull (design 47 §314): only the head the caller names, only into a base that takes no push, no check judged. */ +export async function mergePull(gitea: GiteaClient, args: Record, registered: () => boolean, + judge: (head: string) => Promise): Promise { + const id = idOf(args.id); + const { owner, repo, full } = repositoryOf(args.repository); + const number = numberOf(args.number); + const head = commitOf("head", args.head); + const no = (why: string, kind: NonNullable): MergePullAnswer => + ({ id, refused: `${full}#${number} is not merged: ${why}`, "refused-kind": kind }); + // Until the first registrations are written no merge is made: the old assigner would write a want from the trunk with no + // pipeline (design 47 §95). It names what is missing. + if (!registered()) return no("no module is registered yet: the catalog has said no registration-changed, so no merge is made until the first registrations are written", "no-registrations"); + const pull = await gitea.getPullRequest(owner, repo, number); + if (pull.merged) { + // Already merged at that head: performed (the act said again after its answer was lost); at another, refused. + return pull.head_sha === head ? { id, performed: true, held: true, ...(pull.merge_commit_sha ? { "merge-commit": pull.merge_commit_sha } : {}) } + : no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved"); + } + if (pull.state !== "open") return no("it is closed", "git-refused"); + if (pull.head_sha !== head) { + return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved"); + } + const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? "")); + if (!pushRefused(rule)) { + return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`, "unprotected"); + } + // The head's runs, judged from the module's own state (design 47 §39): a step that failed at its fingerprint in any run refuses it, + // naming the run, the module and the step; a head no run named merges, and the runner being down changes nothing. + const failed = await judge(head); + if (failed) { + return { id, refused: `${full}#${number} is not merged: the ${failed.step} step of ${failed.module} failed in run ${failed.run} at its build-input fingerprint ${failed.fingerprint}`, + "refused-kind": "policy-failed", run: failed.run, module: failed.module, step: failed.step }; + } + try { + await gitea.mergePullRequest(owner, repo, number, "merge", true, head); + } catch (err) { + const f = failure(err); + if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`, "git-refused"); + throw err; + } + const merged = await gitea.getPullRequest(owner, repo, number); + if (!merged.merge_commit_sha) throw new Error(`${full}#${number} WAS merged, but the forge named no merge commit`); + // The merge is done once the forge named its commit: a read-back that fails is said, never answered as a failed act. + let parents: string[]; + try { + parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); + } catch (err) { + console.error(`[gitea] ${full}#${number} was merged (${merged.merge_commit_sha}); which head it joined could not be read back — ${err instanceof Error ? err.message : err}`); + return { id, performed: true, "merge-commit": merged.merge_commit_sha }; + } + if (parents.length === 2 && parents[1] !== head) { + throw new Error(`${full}#${number} WAS merged (${merged.merge_commit_sha}), but the forge merged ${parents[1].slice(0, 12)}, ` + + `not the ${head.slice(0, 12)} the caller names (novox/hq issue 381)`); + } + return { id, performed: true, "merge-commit": merged.merge_commit_sha }; +} diff --git a/modules/gitea/seatevents.ts b/modules/gitea/seatevents.ts new file mode 100644 index 000000000..4d49da269 --- /dev/null +++ b/modules/gitea/seatevents.ts @@ -0,0 +1,107 @@ +// The seat git's events, said by its holder (novox/hq design 47 *The seat git*): a pull request's new head, its merge and +// its close (pull.updated, pull.merged, pull.closed), the forge's own webhook of a pull request, a status and a deleted +// branch (forge.pull, forge.status, forge.delete), and a repository the forge can write again (repo.writable). Each is +// said on the seat's subject through the runtime (mesh-sdk `runtime.seatPublish`, `mesh/seat.publish`), in the body the +// seat's contract types, never as this module's own event: its hearers consume the seat, not the forge module. +// +// What the module's paths make (the poll, the webhook, the account's writes) is read here once and turned into the +// contract's body; a pull request's description is never carried (ADR 0659 §1). + +import { seatPublish } from "@novox/mesh-sdk/runtime"; +import { + forgeDeleteSubject, forgePullSubject, forgeStatusSubject, pullClosedSubject, pullMergedSubject, pullUpdatedSubject, + repoWritableSubject, branchMovedSubject, + type Change, type ForgeDeleteEvent, type ForgePullEvent, type ForgeStatusEvent, type PullClosedEvent, type PullMergedEvent, + type PullUpdatedEvent, type RepoWritableEvent, type BranchMovedEvent, +} from "@novox/mesh-delivery-contracts-git"; + +/** The forge's pull request actions the seat's forge.pull says (the contract's word list). */ +export const PULL_ACTIONS = ["opened", "reopened", "synchronized", "edited", "closed", "merged"]; + +type Body = Record; +const s = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined); +const opt = (k: K, v: unknown) => (v === undefined || v === null || v === "" ? {} : { [k]: v } as Record); + +function repositoryOf(b: Body): string { + const full = s(b.full_name) ?? (s(b.owner) && s(b.repo) ? `${b.owner}/${b.repo}` : undefined); + if (!full) throw new Error("the event names no repository"); + return full; +} + +function changeOf(b: Body): Change | undefined { + if (!Array.isArray(b.paths)) return undefined; + return { paths: b.paths.map(String), ...opt("paths-truncated", b.paths_truncated === true ? true : undefined), + ...opt("removed", Array.isArray(b.removed) && b.removed.length ? b.removed.map(String) : undefined), + ...opt("module-dirs", Array.isArray(b.module_dirs) ? b.module_dirs.map(String) : undefined) }; +} + +/** The seat's subject and body for one of the module's announcements, or undefined when the seat says no such event. */ +export function seatEventOf(type: string, b: Body, now: () => Date = () => new Date()): { subject: string; body: unknown; id?: string } | undefined { + switch (type) { + case "forge.push": { + // A branch moved, by a merge or an admitted push alike (design 47 §11): the forge's push webhook of a branch with a + // head after it. A tag's push, and a branch's deletion (the head is the zero hash), move no branch. + const ref = s(b.ref) ?? ""; + const head = s(b.after) ?? ""; + if (!ref.startsWith("refs/heads/") || !/^[0-9a-f]{40,64}$/.test(head) || /^0+$/.test(head)) return undefined; + const branch = ref.slice("refs/heads/".length); + const body: BranchMovedEvent = { repository: repositoryOf(b), branch, head, at: now().toISOString() }; + return { subject: branchMovedSubject, body, id: `moved:${body.repository}:${branch}@${head}` }; + } + case "pull.updated": { + const body: PullUpdatedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("base-head", s(b.base_sha)), + ...opt("author", s(b.author)), ...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) }; + return { subject: pullUpdatedSubject, body }; + } + case "pull.merged": { + const body: PullMergedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), "merge-commit": String(b.merge_commit_sha ?? ""), + ...opt("merged-at", s(b.merged_at)), ...opt("head-checks", b.head_checks), ...opt("author", s(b.author)), + ...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) }; + return { subject: pullMergedSubject, body }; + } + case "pull.closed": { + const body: PullClosedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("author", s(b.author)), ...opt("title", s(b.title)), + ...opt("url", s(b.html_url)) }; + return { subject: pullClosedSubject, body }; + } + case "forge.status": { + const body: ForgeStatusEvent = { repository: repositoryOf(b), commit: String(b.sha ?? ""), context: String(b.context ?? ""), + state: String(b.state ?? ""), ...opt("description", s(b.description)), ...opt("sender", s(b.sender)) }; + return { subject: forgeStatusSubject, body }; + } + case "forge.delete": { + const body: ForgeDeleteEvent = { repository: repositoryOf(b), ref: String(b.ref ?? ""), ...opt("ref-type", s(b.ref_type)), + ...opt("sender", s(b.sender)) }; + return { subject: forgeDeleteSubject, body }; + } + case "repo.writable": { + const body: RepoWritableEvent = { repository: repositoryOf(b), account: String(b.account ?? "") }; + return { subject: repoWritableSubject, body }; + } + } + const action = type.startsWith("forge.pull-request.") ? type.slice("forge.pull-request.".length) : ""; + if (PULL_ACTIONS.includes(action)) { + const body: ForgePullEvent = { repository: repositoryOf(b), number: Number(b.number), action, + ...opt("base", s(b.base)), ...opt("branch", s(b.head)), ...opt("head", s(b.head_sha)), ...opt("author", s(b.author)), + ...opt("merge-commit", s(b.merge_commit_sha)), ...(b.merged === true ? { merged: true } : {}), ...opt("sender", s(b.sender)) }; + return { subject: forgePullSubject, body }; + } + return undefined; +} + +/** The module's own events the seat says instead: never said as the module's (its `emits` names none of them). `forge.push` + * stays the module's own too: the seat's branch-moved is said beside it. */ +export const SEAT_ONLY = new Set(["pull.updated", "pull.merged", "pull.closed", "forge.status", "forge.delete", "repo.writable"]); + +export type Say = (subject: string, body: unknown, id?: string) => Promise; + +/** Say an announcement on the seat, de-duplicated by `id` (the same announcement by two paths is one message). */ +export async function sayOnSeat(type: string, body: Body, id = "", say: Say = seatPublish): Promise { + const e = seatEventOf(type, body); + if (!e) return false; + await say(e.subject, e.body, id || e.id); + return true; +} diff --git a/modules/gitea/test/announce.test.ts b/modules/gitea/test/announce.test.ts index 222fee212..a40ff4a3d 100644 --- a/modules/gitea/test/announce.test.ts +++ b/modules/gitea/test/announce.test.ts @@ -22,25 +22,43 @@ test("the same merge, head or close announced twice has the same id, and another assert.equal(announcementId("pull.closed", closed), "closed:novox/app#7@aaa111"); }); -test("an announcement is published through the broker with its id as the event id, and the provenance emit stamps", async () => { - const { useBroker } = await import("@novox/mesh-sdk/messaging"); - const published: any[] = []; - useBroker(() => ({ publish: async (env: any) => { published.push(env); } }) as any); - process.env.MESH_MODULE = "gitea"; - process.env.MESH_NODE = "anchor"; +test("an announcement is said on the seat git with its id, in the contract's body, never the description", async () => { + const { useRuntime } = await import("@novox/mesh-sdk/runtime"); + const said: any[] = []; + useRuntime(async (method, params) => { said.push({ method, ...(params as object) }); return null; }); const { announce } = await import("../announce.ts"); - await announce("pull.merged", merged); + const { seatEventOf, SEAT_ONLY } = await import("../seatevents.ts"); + await announce("pull.merged", { ...merged, body: "a description", head: "feat/x", paths: ["a/b.go"], removed: [] }); await announce("pull.merged", { ...merged }); - assert.equal(published.length, 2); - assert.equal(published[0].key, "pull.merged"); - assert.equal(published[0].headers["x-event-id"], "merged:novox/app#7@mmm222"); - assert.equal(published[1].headers["x-event-id"], published[0].headers["x-event-id"], "one id for one announcement"); - assert.equal(published[0].headers["x-source"], "gitea"); - assert.equal(published[0].headers["x-node"], "anchor"); - assert.equal(published[0].headers["content-type"], "application/json"); - assert.ok(published[0].headers["x-time"]); - assert.equal(published[0].node, "anchor"); - assert.deepEqual(published[0].body, merged); + useRuntime(undefined); + assert.equal(said.length, 2); + assert.deepEqual([said[0].method, said[0].subject, said[0].id], ["mesh/seat.publish", "mesh.seat.git.event.pull-merged", "merged:novox/app#7@mmm222"]); + assert.equal(said[1].id, said[0].id, "one id for one announcement"); + assert.deepEqual(said[0].body, { repository: "novox/app", number: 7, base: "main", branch: "feat/x", head: "aaa111", + "merge-commit": "mmm222", change: { paths: ["a/b.go"] } }); + // Every event the seat says, from what the module's paths make; the others are none of the seat's. + const rows: [string, Record, string | undefined][] = [ + ["pull.updated", updated, "mesh.seat.git.event.pull-updated"], + ["pull.closed", closed, "mesh.seat.git.event.pull-closed"], + ["forge.status", { full_name: "novox/app", sha: "aaa111", context: "mesh/check", state: "success" }, "mesh.seat.git.event.forge-status"], + ["forge.delete", { owner: "novox", repo: "app", ref: "feat/x", ref_type: "branch" }, "mesh.seat.git.event.forge-delete"], + ["repo.writable", { owner: "novox", repo: "app", account: "mesh-tools" }, "mesh.seat.git.event.repo-writable"], + ["forge.pull-request.synchronized", { full_name: "novox/app", number: 7, head_sha: "aaa111" }, "mesh.seat.git.event.forge-pull"], + ["forge.pull-request.labeled", { full_name: "novox/app", number: 7 }, undefined], + ["forge.push", { full_name: "novox/app", ref: "refs/tags/v1", after: "a".repeat(40) }, undefined], + ["forge.push", { full_name: "novox/app", ref: "refs/heads/gone", after: "0".repeat(40) }, undefined], + ["forge.push", { full_name: "novox/app", ref: "refs/heads/feat/x", after: "a".repeat(40) }, "mesh.seat.git.event.branch-moved"], + ]; + for (const [type, body, subject] of rows) assert.equal(seatEventOf(type, body)?.subject, subject, type); + assert.deepEqual(seatEventOf("pull.updated", updated)?.body, { repository: "novox/app", number: 7, base: "main", branch: "", + head: "aaa111", "base-head": "ttt333" }); + assert.equal(seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })?.body && + "body" in (seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })!.body as object), false); + // A push of a branch (a merge and an admitted push alike) says branch-moved with the new head, one id per move. + const moved = seatEventOf("forge.push", { full_name: "novox/app", ref: "refs/heads/main", after: "b".repeat(40) }, () => new Date("2026-11-11T10:00:00Z")); + assert.deepEqual(moved?.body, { repository: "novox/app", branch: "main", head: "b".repeat(40), at: "2026-11-11T10:00:00.000Z" }); + assert.equal(moved?.id, `moved:novox/app:main@${"b".repeat(40)}`); + assert.deepEqual([...SEAT_ONLY].sort(), ["forge.delete", "forge.status", "pull.closed", "pull.merged", "pull.updated", "repo.writable"]); }); test("an announcement of a kind it has no id for is refused, never published with a made-up one", async () => { diff --git a/modules/gitea/test/bundle-import.test.ts b/modules/gitea/test/bundle-import.test.ts index 14f7786cb..c14f47726 100644 --- a/modules/gitea/test/bundle-import.test.ts +++ b/modules/gitea/test/bundle-import.test.ts @@ -37,85 +37,12 @@ test("importing the bundle resolves while the broker keeps refusing the binds", assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`); }); -// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2 -// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed -// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus -// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest -// written, and nothing after it. -test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => { - const index = pathToFileURL(join(here, "..", "dist", "index.js")).href; - const sha = "0123456789abcdef0123456789abcdef01234567"; - const script = ` - const http = await import("node:http"); - const statuses = []; - const forge = http.createServer((req, res) => { - let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => { - if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b); - res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}"); - }); - }); - await new Promise((r) => forge.listen(0, "127.0.0.1", r)); - process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port; - const bound = []; - const { useBroker } = await import("@novox/mesh-sdk/messaging"); - useBroker(() => ({ - subscribe: async (pattern) => { bound.push(pattern); return () => {}; }, - publish: async () => {}, request: async () => { throw new Error("no responders"); }, - handle: async () => () => {}, close: async () => {}, - })); - const key = "novox/mesh-sdk.12.${sha}.status=mesh/check"; - const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" }); - const watched = []; - let granted = 0; - const { useStateChannel } = await import("@novox/mesh-sdk/state"); - useStateChannel({ - ask: async () => null, - watch: async (params, deliver) => { - watched.push(params.state); - if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"'); - granted++; - // Two revisions were written while the read was refused; the bucket hands the latest, then a late older one. - await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true }); - await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false }); - }, - }); - await import(${JSON.stringify(index)}); - const { collectTools } = await import("@novox/mesh-sdk/tools"); - const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name)); - for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20)); - await new Promise((r) => setTimeout(r, 200)); - console.log(JSON.stringify({ tools, bound, watched, statuses })); - process.exit(0);`; - const env = { ...process.env, GITEA_TOKEN: "x" }; - for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k]; - const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); - let out = ""; - let err = ""; - child.stdout.on("data", (d) => (out += d)); - child.stderr.on("data", (d) => (err += d)); - const ended = new Promise((r) => child.on("exit", () => r())); - const timer = setTimeout(() => child.kill("SIGKILL"), 15000); - await ended; - clearTimeout(timer); - const line = out.split("\n").find((l) => l.startsWith("{")); - assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`); - const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] }; - assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`); - assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check"); - assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`); - assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again"); - assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`); - assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`)); - assert.match(got.statuses[0], /"state":"success".*pass at 4/); - assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said"); -}); - // **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up // asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while // every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never -// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge -// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every -// request and never answers: the import resolves at once all the same, with the bundle's tools registered. +// started again, and gitea_events was served nowhere and no new pull request was announced. Here the forge stand-in takes +// every request and never answers: the import resolves at once all the same, with the bundle's tools registered. The +// bundle serves no merge and no status of its own: those are the seat git's acts (design 47 *The seat git*). test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => { const index = pathToFileURL(join(here, "..", "dist", "index.js")).href; const script = ` @@ -155,7 +82,8 @@ test("importing the bundle resolves, its tools registered, while the forge never const line = out.split("\n").find((l) => l.startsWith("{")); assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`); const got = JSON.parse(line!) as { tools: string[] }; - for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) { - assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`); + assert.ok(got.tools.includes("gitea_events"), `gitea_events is registered: ${got.tools}`); + for (const t of ["gitea_commit_status", "gitea_merge_pull_request"]) { + assert.ok(!got.tools.includes(t), `${t} is retired: ${got.tools}`); } }); diff --git a/modules/gitea/test/check-owed.test.ts b/modules/gitea/test/check-owed.test.ts deleted file mode 100644 index d3184a145..000000000 --- a/modules/gitea/test/check-owed.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 -// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event: -// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps -// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over -// the latest value of every key when it starts, whenever that is. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; - -const sha = "0123456789abcdef0123456789abcdef01234567"; -const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`; -const owed = (over: Record = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success", - text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over }); -const put = (k: string, value: unknown, revision: number, current = false) => - ({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current }); - -function forge() { - const sent: string[] = []; - let failing = 0; - return { - sent, - fail: (n: number) => { failing = n; }, - setCommitStatus: async (o: string, r: string, s: string, st: any) => { - if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); } - sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`); - }, - }; -} -const noSleep = async () => {}; - -test("the checker's owed is what this module reads, and it consumes no check.status", async () => { - const { CHECKER_OWED } = await import("../check.ts"); - assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed"); - const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); - assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed"); - assert.ok(!(manifest.consumes ?? []).some((c: string) => c.includes("check.status")), "no check.status is consumed"); -}); - -test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written"); - const other = sha.replace(/0/g, "f"); - assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written"); - assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1"); - assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]); - assert.ok(f.sent[1].length < 200, "the line is cut as every status's is"); - assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written"); -}); - -test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - const cases = [ - put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1), - put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1), - put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1), - put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1), - { state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false }, - ]; - for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c)); - assert.deepEqual(f.sent, []); -}); - -test("a revision older than one written on the key is dropped; the same one is written again", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - await checks.apply(put(key(), owed({ state: "success" }), 10)); - assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict"); - assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written"); - assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own"); - assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written"); - assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success", - "novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]); -}); - -test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const said: string[] = []; - const waits: number[] = []; - const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); }); - f.fail(2); - await checks.take(put(key(), owed({ state: "success" }), 5)); - await checks.settled(); - assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask"); - assert.deepEqual(waits, [2_000, 5_000]); - assert.match(said[0], /502.*asked again/); - // A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it. - const g = forge(); - let release: () => void = () => {}; - const gate = new Promise((r) => { release = r; }); - const later = new CheckStatuses(g, () => {}, () => gate); - g.fail(1); - await later.take(put(key(), owed({ state: "pending" }), 6)); - await later.take(put(key(), owed({ state: "success" }), 7)); - release(); - await later.settled(); - assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]); -}); - -test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => { - const { CheckStatuses, watchOwed } = await import("../check.ts"); - const f = forge(); - const said: string[] = []; - const checks = new CheckStatuses(f, () => {}, noSleep); - // The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused. - const kept = new Map(); - kept.set(key(), { value: owed({ state: "pending" }), revision: 3 }); - kept.set(key(), { value: owed({ state: "success" }), revision: 4 }); - let asked = 0; - const watch = async (handler: (c: any) => Promise) => { - asked++; - if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\""); - for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true)); - }; - await watchOwed(watch, checks, noSleep, (l) => said.push(l)); - await checks.settled(); - assert.equal(asked, 3); - assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written"); - assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`); - assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | ")); -}); - -test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => { - const delivery = await import("../delivery.ts"); - assert.equal((delivery as Record).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status"); - assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/); - const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); - assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status"); -}); - -test("the watch's current values are taken without waiting on the forge, so a slow forge never times the watch out (issue 1726)", async () => { - // The tool runtime hands a watch's current values one at a time and waits for each to be taken; it gives the whole set - // 10 s (mesh-tools StateTimeout) and otherwise ends the watch. On 2026-10-23 the owed held ~200 keys, each written to the - // forge before the next was handed over: the set took longer than 10 s, the watch ended at every attempt, and no - // verdict reached the forge's mesh/check again. - const { CheckStatuses, watchOwed } = await import("../check.ts"); - const sent: string[] = []; - let release: () => void = () => {}; - const slow = new Promise((r) => { release = r; }); - const f = { setCommitStatus: async (_o: string, _r: string, s: string, st: any) => { await slow; sent.push(`${s.slice(0, 4)} ${st.state}`); } }; - const checks = new CheckStatuses(f, () => {}, noSleep); - const keys = Array.from({ length: 200 }, (_, i) => key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1)); - let handedOver = 0; - let attempts = 0; - const watch = async (handler: (c: any) => Promise) => { - if (++attempts > 1) return; // a second attempt is not what this test waits for: the first must hold - // The runtime's bound on the current values: the forge has answered none of them yet. - const timedOut = new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200)); - const delivered = (async () => { for (const [i, k] of keys.entries()) { await handler(put(k, owed(), i + 1, true)); handedOver++; } return "delivered" as const; })(); - if (await Promise.race([delivered, timedOut]) === "timeout") throw new Error("the current values of mesh-delivery-checker.owed did not arrive in 10s"); - }; - const said: string[] = []; - await watchOwed(watch, checks, noSleep, (l) => said.push(l)); - assert.equal(handedOver, 200, "every current value was taken before the forge answered"); - assert.ok(!said.some((l) => /could not be bound/.test(l)), said.join(" | ")); - assert.equal(sent.length, 0, "nothing is written before the forge answers"); - release(); - await checks.settled(); - assert.equal(sent.length, 200, "every owed status is written once the forge answers"); -}); - -test("the writes a watch's start hands over go to the forge a few at a time, never all at once (issue 1726)", async () => { - const { CheckStatuses, WRITES_AT_ONCE } = await import("../check.ts"); - let running = 0, most = 0, done = 0; - const f = { setCommitStatus: async () => { running++; most = Math.max(most, running); await new Promise((r) => setTimeout(r, 1)); running--; done++; } }; - const checks = new CheckStatuses(f, () => {}, noSleep); - for (let i = 0; i < 50; i++) await checks.take(put(key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1), owed(), i + 1, true)); - await checks.settled(); - assert.equal(done, 50); - assert.equal(most, WRITES_AT_ONCE); -}); - -// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal -// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and -// never asked again, a 5xx is still asked again, and a delete ends a loop that is running. -test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => { - const { CheckStatuses, refusedForGood } = await import("../check.ts"); - for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true], - ["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false], - ["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) { - assert.equal(refusedForGood(new Error(err)), forGood, err); - } - const said: string[] = []; - const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } }; - const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); }); - await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3)); - await checks.settled(); - assert.equal(said.length, 1); - assert.match(said[0], /refused for good.*403.*not asked again/); - // A 502 asks again; the key's delete meanwhile ends the loop before its next ask. - const g = forge(); - let release: () => void = () => {}; - const gate = new Promise((r) => { release = r; }); - const later = new CheckStatuses(g, () => {}, () => gate); - g.fail(5); - await later.take(put(key(), owed(), 8)); - await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any); - release(); - await later.settled(); - assert.deepEqual(g.sent, [], "a deleted key is not written"); -}); diff --git a/modules/gitea/test/delivery.test.ts b/modules/gitea/test/delivery.test.ts index 724660283..b134f8696 100644 --- a/modules/gitea/test/delivery.test.ts +++ b/modules/gitea/test/delivery.test.ts @@ -41,41 +41,12 @@ test("a note that cannot be written is said, never read as written", async () => await assert.rejects(appendNote(runner, "gitea", "novox", "x", "abcdef1234567", "mesh-plan", "l"), /could not be appended/); }); -test("one view per pull request, found by its marker; only the mesh's statuses", async () => { - const { viewBody, viewComment, deliveryStatus, VIEW_MARKER } = await import("../delivery.ts"); +test("one view per pull request, found by its marker", async () => { + const { viewBody, viewComment, VIEW_MARKER } = await import("../delivery.ts"); assert.ok(viewBody("**Delivery**").startsWith(VIEW_MARKER)); assert.equal(viewBody(`${VIEW_MARKER}\nx`), `${VIEW_MARKER}\nx`, "a marked body is kept as it is"); const comments = [{ id: 1, body: "a review" }, { id: 2, body: `${VIEW_MARKER}\nold` }, { id: 3, body: `${VIEW_MARKER}\nlater` }]; assert.equal(viewComment(comments)?.id, 2); assert.equal(viewComment([{ id: 1, body: "x" }]), undefined); - const s = deliveryStatus("mesh/delivery", "pending", "y".repeat(300), "https://forge.invalid/novox/x/pulls/1"); - assert.ok(s.description.length <= 140 && s.target_url); - assert.throws(() => deliveryStatus("ci/other", "success", "x"), /mesh's own/); - assert.throws(() => deliveryStatus("mesh/delivery", "green", "x"), /not a status/); - // The merge check's statuses are its verdict's alone: required by a branch, they are never set by hand. - assert.throws(() => deliveryStatus("mesh/merge-gate", "success", "x"), /merge check's status/); - assert.throws(() => deliveryStatus("mesh/repo-check", "warning", "x"), /merge check's status/); - assert.throws(() => deliveryStatus("mesh/merge-gate", "failure", "x"), /merge check's status/); - // Pending is no verdict: a recheck says it so the old verdict does not let the head merge meanwhile. - assert.equal(deliveryStatus("mesh/merge-gate", "pending", "checking again: main moved on").state, "pending"); - assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check"); - assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined); }); -// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool -// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state -// (check.ts, test/check-owed.test.ts; ADR 0521). -test("mesh/check is refused by the status tool in every state", async () => { - const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts"); - assert.equal(CHECK_STATUS, "mesh/check"); - const sha = "0123456789abcdef0123456789abcdef01234567"; - const sent: string[] = []; - const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`); - const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } }; - for (const state of ["pending", "success", "failure", "error", "warning"]) { - assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/); - await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha, - context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/); - } - assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check"); -}); diff --git a/modules/gitea/test/failedsteps.test.ts b/modules/gitea/test/failedsteps.test.ts new file mode 100644 index 000000000..07e8f32dc --- /dev/null +++ b/modules/gitea/test/failedsteps.test.ts @@ -0,0 +1,123 @@ +// The forge module judges a head by the failed steps it heard (design 47 §39, §475, §487, §548, §135): what the runner's +// step-ended writes, what a passing rerun clears, which head is refused, and what goes once no head names a commit. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { FailedSteps, Heads, failedStepKey, seenKey } from "../failedsteps.js"; + +const C1 = "1".repeat(40), C2 = "2".repeat(40), C3 = "3".repeat(40); + +function memory() { + const kv = new Map(); + return { kv, st: { + get: async (k: string) => (kv.has(k) ? { key: k, value: kv.get(k), revision: 1 } : null), + put: async (k: string, v: unknown) => { kv.set(k, structuredClone(v)); return 1; }, + delete: async (k: string) => { kv.delete(k); }, + keys: async () => [...kv.keys()].sort(), + watch: async () => {}, + } }; +} +const ended = (commit: string, outcome: string, extra: Record = {}, over: Record = {}) => ({ + v: 1, pipeline: `main.7.nats-${commit.slice(0, 2)}`, module: "nats", branch: "main", commit, at: "2026-11-11T12:00:00Z", + result: { step: "unit", status: "required", outcome, fingerprint: "fp-1", ...extra }, ...over }) as any; + +test("a head is refused by a required step that failed at its fingerprint in any run, until a rerun passes it", async () => { + const { kv, st } = memory(); + const heads = new Heads(); + const f = new FailedSteps(st as any, () => {}, (c) => heads.has(c)); + const notes: [string, string | null][] = []; + const judged = async (name: string, commit: string, want: string | null) => { + const got = await f.judge(commit); + assert.equal(got ? `${got.run} ${got.module} ${got.step}` : null, want, name); + }; + await f.stepEnded(ended(C1, "passed")); + await judged("a head that passed alone merges", C1, null); + await judged("a head no run named merges", C3, null); + await f.stepEnded(ended(C2, "failed", {}, { pipeline: "run-on-request" })); + await judged("the head that failed", C2, "run-on-request nats unit"); + await judged("an earlier commit of the same content (passed before, failed after)", C1, "run-on-request nats unit"); + assert.deepEqual((kv.get(failedStepKey("nats", "fp-1", "unit")) as any).commits, [C1, C2], "the commits it was seen at"); + // A step-ended of an ask that is no pipeline's step (no commit, no fingerprint) writes nothing (§548). + const before = kv.size; + await f.stepEnded({ v: 1, asker: "mesh-build-go", ask: "a-1", node: "ace", result: { step: "unit", status: "required", outcome: "failed" } } as any); + assert.equal(kv.size, before, "a builder's failed build is no run of a commit"); + // A later commit at the fingerprint is refused too, and a failed optional step refuses nothing. + await f.stepEnded(ended(C3, "passed")); + await judged("a later commit of the same content", C3, "run-on-request nats unit"); + await f.stepEnded(ended(C3, "failed", { step: "lint", status: "optional", fingerprint: "fp-2" })); + await judged("a failed optional step", C3, "run-on-request nats unit"); + assert.equal(kv.has(failedStepKey("nats", "fp-2", "lint")), false); + // A result that names no fingerprint is kept at the one the seat's step event carries (`fingerprint`). + await f.stepEnded(ended(C3, "failed", { step: "lint", fingerprint: undefined }, { fingerprint: "fp-3" })); + assert.ok(kv.has(failedStepKey("nats", "fp-3", "lint")), "the event's fingerprint is kept"); + // A plain pass clears nothing; a rerun that passes clears it. + await f.stepEnded(ended(C2, "passed")); + await judged("a plain pass", C2, "run-on-request nats unit"); + await f.stepEnded(ended(C2, "passed", { rerun: true, by: "operator" })); + await judged("a passing rerun", C2, null); + await judged("a passing rerun, for the earlier commit too", C1, null); + void notes; +}); + +test("an entry goes once none of its commits is a watched branch's head or an open pull request's head", async () => { + const { kv, st } = memory(); + const heads = new Heads(); + const f = new FailedSteps(st as any, () => {}, (c) => heads.has(c)); + heads.replace(new Set(["novox/app@main"]), new Map([["novox/app@main", C1]]), new Map([["novox/app#4", C2]])); + await f.stepEnded(ended(C2, "failed")); + await f.stepEnded(ended(C1, "passed")); + assert.deepEqual([...kv.keys()].sort(), [failedStepKey("nats", "fp-1", "unit"), seenKey(C1, "nats"), seenKey(C2, "nats")].sort()); + // The pull request is closed: its commit is no head, its seen key goes; the entry stays for the trunk's head that shares it. + await f.lost(heads.pullEnded({ repository: "novox/app", number: 4 } as any)); + assert.equal(kv.has(seenKey(C2, "nats")), false); + assert.deepEqual((kv.get(failedStepKey("nats", "fp-1", "unit")) as any).commits, [C1]); + // The branch moves on: nothing names the entry's commits, so it goes. A branch no registration watches moves nothing. + assert.deepEqual(heads.branchMoved("novox/app", "feature", C3), []); + await f.lost(heads.branchMoved("novox/app", "main", C3)); + assert.deepEqual([...kv.keys()], [], "the failure and the seen keys are gone"); +}); + +test("git_failed_steps shows what is held and its fill, and git_failed_steps_drop writes the hand act before it drops", async () => { + const { failedStepsTools } = await import("../tools/failedsteps.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const { st } = memory(); + const f = new FailedSteps(st as any, () => {}, () => false, async () => ({ bytes: 4321, keys: 2, max_bytes: 64 * 1024 * 1024 })); + await f.stepEnded(ended(C2, "failed")); + const acts: any[] = []; + let recorded = true; + const [read, drop] = failedStepsTools(f, async (a) => { if (!recorded) throw new Error("no hand-act log"); acts.push(a); }); + const shown: any = await read.run({}); + assert.deepEqual([shown.failed.map((x: any) => [x.key, x.run]), shown.seen.length, shown.fill.keys, shown.fill.bytes, shown.fill.cap], + [[["nats.fp-1.unit", "main.7.nats-22"]], 1, 2, 4321, 64 * 1024 * 1024], "the fill is the state call's answer, not counted from the keys"); + assert.equal(((await read.run({ commit: C1 })) as any).failed.length, 0, "a commit asked for filters"); + recorded = false; + await assert.rejects(runAs({ "mesh/caller": "operator" }, {}, () => drop.run({ key: "nats.fp-1.unit", why: "stale" })), /hand act was not recorded, so nothing was dropped/); + assert.equal((await f.judge(C2))?.step, "unit", "nothing dropped without the hand act"); + recorded = true; + const done: any = await runAs({ "mesh/caller": "operator" }, {}, () => drop.run({ key: "nats.fp-1.unit", why: "stale" })); + assert.deepEqual([done.dropped, acts.length, acts[0].verb], [true, 1, "git_failed_steps_drop"]); + assert.equal(await f.judge(C2), null); +}); + +test("a seen key past 30 days is deleted at the next step-ended, a younger one kept, at most 100 oldest first, and git_seen_expire runs the same now (§581)", async () => { + const { failedStepsTools } = await import("../tools/failedsteps.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const { st, kv } = memory() as any; + const f = new FailedSteps(st as any); + // The step-ended is at 2026-11-11T12:00:00Z, so 30 days before it is 2026-10-12. + const now = new Date("2026-11-11T12:00:00Z"); + const old = (n: number) => new Date(Date.parse("2026-10-01T00:00:00Z") - n * 60000).toISOString(); + await st.put(seenKey(C1, "old"), { v: 1, commit: C1, module: "old", fingerprints: ["x"], at: old(0) }); + await st.put(seenKey(C2, "young"), { v: 1, commit: C2, module: "young", fingerprints: ["x"], at: "2026-10-20T00:00:00Z" }); + await f.stepEnded(ended(C3, "passed")); + assert.equal(kv.has(seenKey(C1, "old")), false, "past 30 days: deleted at the next step-ended"); + assert.equal(kv.has(seenKey(C2, "young")), true, "younger: kept"); + assert.equal(kv.has(seenKey(C3, "nats")), true, "the step's own key is new"); + for (let i = 0; i < 101; i++) await st.put(seenKey(C1, `m${String(i).padStart(3, "0")}`), { v: 1, commit: C1, module: `m${i}`, fingerprints: ["x"], at: old(i) }); + assert.equal((await f.expireSeen(now)).length, 100, "at most 100 for each event"); + const acts: any[] = []; + const tool = failedStepsTools(f, async (a) => { acts.push(a); }).find((t) => t.name === "git_seen_expire")!; + const done: any = await runAs({ "mesh/caller": "operator" }, {}, () => tool.run({ why: "stale" })); + assert.deepEqual([done.deleted.length, acts[0].verb], [1, "git_seen_expire"]); +}); diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index eee5342b6..404452c84 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -42,8 +42,7 @@ function aForge() { }; } -// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back -// to pending through the gitea_commit_status tool. +// **A recheck's pending goes through the one writer** (novox/hq issue 456). const recheckAt = new Date("2026-10-11T01:30:00Z"); const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" }; @@ -63,30 +62,8 @@ test("a recheck the forge does not take fails its caller, and the next write sti const gates = new GateWriter(forge, () => recheckAt); await gates.pending("novox", "hq", SHA); forge.failWrites(); - await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain)); - await gates.rechecked("novox", "hq", SHA, checkingAgain); + await assert.rejects(gates.status("novox", "hq", SHA, checkingAgain)); + await gates.status("novox", "hq", SHA, checkingAgain); assert.equal(gateOf(forge), "pending checking again: main moved on"); }); -test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => { - const { GateWriter } = await import("../gate.ts"); - const { commitStatusTool } = await import("../delivery.ts"); - const forge = aForge(); - const direct: string[] = []; - const gates = new GateWriter(forge, () => recheckAt); - const tool = commitStatusTool({ - setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`), - gates, - }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" }); - assert.equal(gateOf(forge), "pending checking again: x"); - assert.equal(forge.kept["mesh/repo-check"].state, "pending"); - assert.deepEqual(direct, ["mesh/delivery success"]); - // A head named short is another head to the writer: refused, nothing sent. - await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }), - /whole sha/); - await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }), - /set by its verdict only/); -}); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 3b93f56e7..de835a654 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -1,22 +1,17 @@ // What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools // with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from -// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the -// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing -// sent; the operator-approved merge of a pull request whose checks passed still goes through. +// dropping a branch's protection, writing to main, setting the merge check's status, or merging. Each route a review +// found is reproduced here against a fake forge, and each is refused with nothing sent; a merge is the seat git's +// merge-pull alone, at the head the caller names (the last test's rows). import { test, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { readFileSync } from "node:fs"; import { GiteaClient } from "../dist/client.js"; import { ConfiguredToken } from "../dist/token.js"; import { getGiteaTools } from "../dist/tools/index.js"; -import { mergeTool } from "../dist/merge.js"; -import { commitStatusTool } from "../dist/delivery.js"; -import { GateWriter } from "../dist/gate.js"; import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js"; interface Sent { method: string; path: string; body: any } @@ -54,6 +49,8 @@ const state = { groupStatus: "", siblingGroup: "", reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[], + // The forge's own mergeable of pull request 7; unset, the forge does not say. + mergeable: undefined as boolean | undefined, }; const REVIEWED = "abc1234def5678901234567890abcdef12345678"; const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40); @@ -107,7 +104,7 @@ const server = createServer(async (req, res) => { state.asked.push(`${method} ${path}${url.search}`); if (method !== "GET") state.sent.push({ method, path, body }); const repo = "/repos/novox/mesh-catalog"; - if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); + if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch, permissions: { push: true } }); if (method === "PATCH" && path === repo) { if (body?.default_branch) state.defaultBranch = body.default_branch; return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); @@ -146,7 +143,7 @@ const server = createServer(async (req, res) => { if (method === "GET" && path === `${repo}/pulls/7`) { return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged, head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7", - merge_commit_sha: state.merged ? state.mergeCommit : null }); + merge_commit_sha: state.merged ? state.mergeCommit : null, ...(state.mergeable === undefined ? {} : { mergeable: state.mergeable }) }); } if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) { if (state.commitsFail) return json(res, state.commitsFail, { message: "the forge is not well" }); @@ -165,6 +162,7 @@ const server = createServer(async (req, res) => { return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []); } if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []); + if (method === "GET" && path === `${repo}/pulls/404`) return json(res, 404, { message: "pull request does not exist" }); if (method === "POST" && path === `${repo}/pulls/7/merge`) { state.merged = true; return json(res, 200, null); @@ -175,20 +173,7 @@ await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")); -// gitea_commit_status is served from the watcher's bundle, beside the merge check's one writer (novox/hq issue 456). -const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-")); -// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through. -const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), - headChecks: async () => ({}) }); -const throughTheWriter: string[] = []; -const gates = { rechecked: (o: string, r: string, sha: string, s: any) => { - throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); - return writer.rechecked(o, r, sha, s); -} }; -const tools = [...getGiteaTools(client), commitStatusTool({ - setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer, -}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); }, - now: () => new Date(2026, 9, 12, 15, 1) })]; +const tools = getGiteaTools(client); const tool = (name: string) => tools.find((t) => t.name === name)!; const writes = () => state.sent.map((s) => `${s.method} ${s.path}`); @@ -276,203 +261,6 @@ test("gitea_branch_protection_set writes no required status, and refuses a push assert.equal(sent.enable_push, false); }); -test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED }); - state.statuses = [judgedAt(TRUNK)[0], judgedAt(TRUNK, "failure")[1]]; - await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/); - state.statuses = [judgedAt(TRUNK)[0]]; - await assert.rejects(merge(), /mesh\/repo-check \(missing\)/); - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "manually-merged" }), /not a merge method/); - assert.deepEqual(writes(), []); - // The operator said yes and the checks passed: the flow that stays. - state.statuses = judgedAt(TRUNK); - await merge(); - const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!; - assert.equal(sent.body.force_merge, false); - assert.equal(sent.body.head_commit_id, REVIEWED); -}); - - -// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq -// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65 -// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second -// while it is on its way; and once the verdict judges -// the trunk's head and passes, the merge goes through — asked again, never by itself. -test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - - state.trunk = MOVED; // another merge moved the trunk after the verdict - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/); - assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/); - assert.match(err.message, /never merges by itself/); - return true; - }); - assert.equal(state.merged, false); - assert.equal(state.emitted.length, 1, "one check asked"); - assert.equal(state.emitted[0].type, "pull.updated"); - assert.equal(state.emitted[0].body.head_sha, REVIEWED); - assert.equal(state.emitted[0].body.base, "main"); - assert.deepEqual(state.emitted[0].body.paths, ["modules/gitea/x.ts"]); - // The announcement names the trunk's head, for a verdict no build judges (ADR 0323 §3). - assert.equal(state.emitted[0].body.base_sha, MOVED); - // Its pending went through the merge check's one writer (novox/hq issue 456), not around it. - assert.deepEqual(throughTheWriter.splice(0), ["abc1234def56 mesh/merge-gate pending", "abc1234def56 mesh/repo-check pending"]); - const gate = state.statuses.find((s) => s.context === "mesh/merge-gate")!; - assert.equal(gate.state, "pending"); - assert.match(gate.description!, /^waiting in the build queue since 15:01: asked by the merge tool against trunk 3c4d5e6f7a8b/); - assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending"); - - // Asked again while that check is on its way: refused, and nothing is asked a second time. - await assert.rejects(merge(), /already asked and not yet answered .*not asked again.*mesh-delivery\.recheck/); - assert.equal(state.emitted.length, 1, "no second check while one is on its way"); - state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)]; - await assert.rejects(merge(), /not asked again/); - assert.equal(state.emitted.length, 1); - - // A verdict put out of date by the trunk's move is asked too, once. - state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending", - description: "out of date: judged against trunk 7a8b9c0d1e2f, the trunk is now 3c4d5e6f7a8b; judged again on a recheck or when it is merged" })); - await assert.rejects(merge(), /was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b\. The check .* was asked/); - assert.equal(state.emitted.length, 2); - - // A verdict that names no trunk commit — a build seat from before the rule — is not taken as the trunk's head. - state.statuses = [{ context: "mesh/merge-gate", state: "success", description: "pass: x" }, - { context: "mesh/repo-check", state: "success", description: "pass: y" }]; - await assert.rejects(merge(), /names no trunk commit it was judged against, and main is now 3c4d5e6f7a8b/); - assert.equal(state.emitted.length, 3); - assert.equal(state.merged, false); - - // An ask that cannot be announced puts the statuses back to out of date, so the next ask asks again. - state.statuses = judgedAt(TRUNK); - const failing = mergeTool(client, { gates, stateDir, emit: async () => { throw new Error("the bus is away"); } }); - await assert.rejects(failing.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }), - /its check could not be asked: the bus is away\. Nothing was merged/); - assert.match(state.statuses.find((s) => s.context === "mesh/merge-gate")!.description!, /^out of date: /); - await assert.rejects(merge(), /was asked/); - assert.equal(state.emitted.length, 4, "asked again after the announcement failed"); - - // The check judged the merge into the trunk's head and passed: asked again at the reviewed head, it merges. - state.statuses = judgedAt(MOVED); - const done = (await merge()) as Record; - assert.equal(done.merged, true); - assert.equal(state.emitted.length, 4, "a merge asks no check"); -}); - -// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue -// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and -// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing. -test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.trunk = TRUNK; - const before = state.emitted.length; - state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" }, - { context: "mesh/repo-check", state: "success", description: "pass: y" }]; - for (let i = 0; i < 3; i++) { - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /waits for its group's composed check/); - assert.match(err.message, /mesh-delivery asks it, this tool asks no check/); - assert.match(err.message, /Nothing was merged/); - return true; - }); - } - assert.equal(state.emitted.length, before, "no check is announced again and again"); - assert.equal(state.merged, false); - // Once the composed verdict names the member's unmoved trunk, it merges. - state.statuses = judgedAt(TRUNK); - assert.equal(((await merge()) as Record).merged, true); -}); - -// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head, -// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was. -test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => { - const merge = (head?: string) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - ...(head === undefined ? {} : { head }) }); - - // No head named: there is no review to hold the merge to. - await assert.rejects(merge(), /name the head you reviewed/); - await assert.rejects(merge(" "), /name the head you reviewed/); - await assert.rejects(merge("main"), /does not name one commit/); - // Seven characters can be matched by a commit made to match them: twelve are asked for. - await assert.rejects(merge(REVIEWED.slice(0, 7)), /does not name one commit — give its sha, or at least its first 12/); - await assert.rejects(merge(REVIEWED.slice(0, 11)), /does not name one commit/); - - // The head moved after the review, and its checks passed where it is now: the case of mesh-controller#197. - state.head = "3ced96fc6f8ad6cc7b93698912cb08a71635158a"; - await assert.rejects(merge(REVIEWED), /its head is 3ced96fc6f8a, not the abc1234def5678901234567890abcdef12345678 you named/); - await assert.rejects(merge(REVIEWED.slice(0, 12)), /not the abc1234def56 you named\. If you reviewed an earlier head/); - assert.deepEqual(writes(), [], "a refused merge sends nothing"); - - // Named as reviewed, by its first 12 characters, in either case: the forge is handed the whole head, and the merge - // commit's second parent says that head landed. - state.head = REVIEWED; - const done = (await merge(REVIEWED.slice(0, 12).toUpperCase())) as Record; - const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!; - assert.equal(sent.body.head_commit_id, REVIEWED); - assert.equal(done.head, REVIEWED); - assert.equal(done.landed, REVIEWED); - assert.equal(done.read_back, undefined); -}); - -// The forge is handed the head, and what landed is read from the merge commit, not from the forge's record of the -// pull request (which here still says the head it was handed). Another head landing is an error that says the merge -// happened, never a field beside merged: true. -test("gitea_merge_pull_request is loud when the forge merged a head other than the one it was handed", async () => { - state.landed = "3ced96fc6f8ad6cc7b93698912cb08a71635158a"; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED }), /WAS merged \(feedface0000.*\), but the forge merged 3ced96fc6f8a, not the abc1234def56 it was handed/); - assert.equal(state.merged, true, "the merge happened: the error is what says so"); -}); - -// The merge has happened once the forge says so: a read-back that fails after it must not read as nothing merged. -test("gitea_merge_pull_request says the merge happened when reading back what landed fails", async () => { - state.commitsFail = 500; - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED })) as Record; - assert.equal(state.merged, true); - assert.equal(done.merged, true); - assert.equal(done.landed, undefined); - assert.match(String(done.read_back), /the merge happened, but reading back what landed failed/); - - // A parent the forge names without its sha is a failed read-back, never an empty head said to have landed. - state.merged = false; - state.commitsFail = 0; - state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", ""]; - const blank = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED })) as Record; - assert.equal(blank.merged, true); - assert.match(String(blank.read_back), /without its sha/); -}); - -test("gitea_merge_pull_request reads back a fast-forward, and says it cannot read back a squash or a rebase", async () => { - state.mergeCommit = REVIEWED; // a fast-forward lands the head itself - // Its parents name another commit second, so reading a fast-forward as a merge's second parent would fail here. - state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b"]; - const ff = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "fast-forward-only" })) as Record; - assert.equal(ff.landed, REVIEWED); - assert.equal(ff.read_back, undefined); - - // A fast-forward whose commit the forge names otherwise is said, not raised: that it names the head is assumed. - state.merged = false; - state.mergeCommit = "feedface00000000000000000000000000000000"; - const odd = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "fast-forward-only" })) as Record; - assert.equal(odd.merged, true); - assert.equal(odd.landed, undefined); - assert.match(String(odd.read_back), /as this fast-forward's commit, not the head/); - - for (const method of ["squash", "rebase", "rebase-merge"]) { - state.merged = false; - state.mergeCommit = "feedface00000000000000000000000000000000"; - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method })) as Record; - assert.equal(done.landed, undefined, method); - assert.match(String(done.read_back), new RegExp(`a ${method} makes new commits that name no head`)); - } -}); test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => { state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) @@ -496,65 +284,6 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba assert.equal(writes().length, 2); }); -test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => { - const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; - state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }]; - state.rules = []; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads }), /no protection rule refusing/); - state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }]; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads }), /no protection rule refusing/); - assert.deepEqual(writes(), []); -}); - -// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve** -// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with -// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge. -test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - keepSiblings(siblingsFile(stateDir), {}); - const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; - const merge = (args: Record = { approved_heads }) => - tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args }); - state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [], - block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }]; - const line = (trunk: string, s = "success", words = "pass") => - [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; - const before = state.emitted.length; - - state.statuses = line(TRUNK); - await assert.rejects(merge({}), /Approve/); - state.statuses = []; - await assert.rejects(merge(), /mesh\/check is not set yet/); - state.statuses = line(TRUNK, "pending", "checking"); - await assert.rejects(merge(), /mesh\/check is pending/); - state.statuses = line(TRUNK, "failure", "fail"); - await assert.rejects(merge(), /mesh\/check is failure/); - state.statuses = line(MOVED); - await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); - state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check - await assert.rejects(merge(), /mesh\/check/); - assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); - assert.equal(state.emitted.length, before, "no check is asked"); - assert.equal(state.merged, false); - - state.statuses = line(TRUNK); - assert.equal(((await merge()) as Record).merged, true); - - // check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says; - // without approved_heads it is refused as before. - const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false }); - const mergeUngated = (args: Record = { approved_heads }) => - ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args }); - for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) { - state.merged = false; - state.statuses = statuses; - await assert.rejects(mergeUngated({}), /Approve/); - assert.equal(((await mergeUngated()) as Record).merged, true, JSON.stringify(statuses)); - } -}); - test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, @@ -635,9 +364,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud ["gitea_delete_branch", { ...repo, branch: "%2e%2e" }], ["gitea_get_file", { ...repo, path: "../../../../version" }], ["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }], - ["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }], - ["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }], - ["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }], ["gitea_commit_statuses", { ...repo, sha: ".." }], ["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }], ["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }], @@ -661,172 +387,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud }); -// **A member judged beside its siblings merges only with its group passed and the Approve covering each head** (novox/hq -// ADR 0382 §6 as ADR 0421 §9 changes it, issues 867 and 1022): the verdict names the sibling; mesh/delivery-group must -// be a success at the member's head and the sibling's head that verdict read, and mesh-delivery hands the heads the -// operator's Approve covers: every head read must be among them. No forge review is asked. A verdict that read none -// merges as before. -test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each head approved", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; - const merge = (approved_heads?: string[]) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, ...(approved_heads ? { approved_heads } : {}) }); - const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${SIBLING_HEAD.slice(0, 8)}`; - const named = (siblings: unknown[]) => keepSiblings(siblingsFile(stateDir), - { "novox/mesh-catalog#7": { head: REVIEWED, siblings: siblings as any } }); - state.statuses = [{ context: "mesh/merge-gate", state: "success", description: `${beside} — pass: x` }, - { context: "mesh/repo-check", state: "success", description: `${beside} — pass: y` }]; - named([{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: "c".repeat(40) }]); - - // Nothing passed and no approved heads handed: every missing thing is named, nothing merged. - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh-catalog#7@abc1234def56 is covered by no Approve: the merge was handed no approved heads; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is covered by no Approve/); - return true; - }); - assert.equal(state.merged, false); - - // The group passed at the member's head only: the sibling's head still lacks it. - state.groupStatus = "success"; - await assert.rejects(merge(both), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb\. A member judged beside/); - // Passed at both heads, but the Approve covers the sibling at another head, or does not name it: not an approved head. - state.siblingGroup = "success"; - await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + "d".repeat(40)]), - /mesh-host#114@bbbbbbbbbbbb is not the head the Approve covers \(dddddddddddd\)\. A member judged beside/); - await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED]), /mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers/); - assert.equal(state.merged, false); - - // Passed and approved at the very heads read, with no forge review of the sibling: it merges. - assert.deepEqual(state.reviews, [], "no review is asked of the forge"); - assert.equal(((await merge(both)) as Record).merged, true); - - // A status that says it read siblings, with no record of them, is no verdict to merge on. - state.merged = false; - keepSiblings(siblingsFile(stateDir), {}); - await assert.rejects(merge(both), /its verdict read siblings, and which ones is not held here/); - assert.equal(state.merged, false); - - // A verdict that read none (an older controller, or a group of one) merges exactly as before. - state.statuses = judgedAt(TRUNK); - state.groupStatus = ""; - assert.equal(((await merge()) as Record).merged, true); -}); - -// **The merge lands only the head the Approve named** (novox/hq ADR 0421 §9, issue 1022): a pull request whose head -// differs from the approved one, or whose repository the Approve does not name, is refused with nothing merged. -test("gitea_merge_pull_request refuses a head the Approve did not name", async () => { - state.merged = false; - state.statuses = judgedAt(TRUNK); - const merge = (approved_heads: unknown) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, approved_heads }); - await assert.rejects(merge(["novox/mesh-catalog@" + "e".repeat(40)]), /is not the head the Approve covers \(eeeeeeeeeeee\)/); - await assert.rejects(merge(["novox/mesh-host@" + REVIEWED]), /is not among the heads the Approve covers/); - await assert.rejects(merge(["novox/mesh-catalog@abc123"]), /which is not owner\/repo@/); - await assert.rejects(merge("novox/mesh-catalog@" + REVIEWED), /must be a list/); - assert.equal(state.merged, false); - assert.equal(((await merge(["novox/mesh-catalog@" + REVIEWED.toUpperCase()])) as Record).merged, true); -}); - -// **A group approved together merges in one pass, on the real merge tool** (novox/hq ADR 0418, issue 1049): the member's -// own verdict named its sibling at an old head; the sibling got a new head, the group's composed verdict passed for both, -// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its -// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does. -test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => { - const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js"); - const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; - const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40); - const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`; - state.statuses = [{ context: "mesh/merge-gate", state: "success", - description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} \u2014 pass: group g composed: ok` }, - { context: "mesh/repo-check", state: "success", description: `${beside} \u2014 pass: y` }]; - state.groupStatus = "success"; - state.siblingGroup = "success"; - // The member's own verdict named the sibling at its old head. - const record: Record = { "novox/mesh-catalog#7": { head: REVIEWED, - siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } }; - // The composed verdict then judged both new heads. - Object.assign(record, { - "novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }, - "novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } }); - // The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what - // that weighing decides, through the writer. - const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE }; - const forge = { - async getPullRequest(_o: string, repo: string, n: number) { - return repo === "mesh-host" ? pulled : { number: n, title: "", html_url: "", merged: false, state: "open", head_sha: REVIEWED }; - }, - async branchHead() { return HOST_MERGE; }, - async commitParents() { return [HOST_BASE, SIBLING_HEAD]; }, - }; - const wrote: string[] = []; - const gatesOut = { async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[]) { - const put = decide({ "mesh/merge-gate": { state: "success", description: state.statuses[0].description }, - "mesh/repo-check": { state: "success", description: "x" } }); - for (const s of put) { - wrote.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); - state.statuses = [{ ...s }, ...state.statuses.filter((x) => x.context !== s.context)]; - } - return put; - } }; - await weighSiblings(forge as any, gatesOut as any, record, "novox/mesh-host", 114); - assert.deepEqual(wrote, [], "the member's statuses were left alone"); - keepSiblings(siblingsFile(stateDir), record); - // The member merges on its first ask, with the one Approve's heads. - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads: both })) as Record; - assert.equal(done.merged, true); - assert.equal(state.emitted.length, 0, "no check was asked"); -}); - -// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that -// requires mesh/check is judged by the checker's line ("pass on …"): it merges on a pass that names the -// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on -// a trunk's move is the checker's. No mesh/merge-gate is read or written there. -test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }]; - const line = (trunk: string, s = "success", words = "pass") => - [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; - const before = state.emitted.length; - - state.statuses = line(MOVED); - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /mesh\/check/); - assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); - assert.match(err.message, /checker checks it again/i); - assert.match(err.message, /Nothing was merged/); - return true; - }); - state.statuses = line(TRUNK, "pending", "checking"); - await assert.rejects(merge(), /mesh\/check is pending/); - state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires - await assert.rejects(merge(), /mesh\/check/); - assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); - assert.equal(state.emitted.length, before, "no check is asked"); - assert.equal(state.merged, false); - - state.statuses = line(TRUNK); - assert.equal(((await merge()) as Record).merged, true); -}); - -// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on -// mesh/repo-check alone; no mesh/merge-gate is written on it. -test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }]; - state.statuses = [judgedAt(MOVED)[1]]; - const before = state.emitted.length; - await assert.rejects(merge(), /judged against trunk/); - assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`), - ["mesh/repo-check pending"]); - assert.equal(state.emitted.length, before + 1, "its check is asked once"); - await assert.rejects(merge(), /already asked/); - state.sent = []; - state.statuses = [judgedAt(TRUNK)[1]]; - assert.equal(((await merge()) as Record).merged, true); -}); - // Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as // gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards. test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => { @@ -870,3 +430,197 @@ test("the two new tools are named for what mesh_search is asked, and gitea_api s assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" })); assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" })); }); + +// **merge-pull judges the head it is asked at and no check** (design 47 *The seat git*, §314): merge-pull merges the +// head it is named into a base that takes no push, whatever its statuses say, and an id said again after the merge is +// answered performed. +test("merge-pull merges the head the caller names, and refuses one at whose fingerprint a required step failed", async () => { + const { seatTools } = await import("../dist/seat.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const bridge = "ace.mesh-delivery-forge-bridge"; + let registered = true; + let failedStep: { run: string; module: string; step: string; fingerprint: string } | null = null; + // The head's one writer of its statuses (gate.ts), over a forge whose first write is still in flight when the next is asked. + const { GateWriter } = await import("../dist/gate.js"); + const landed: string[] = []; + let inFlight: Promise | null = null, land = () => {}; + const slow = { + headChecks: async () => ({}), + setCommitStatus: async (_o: string, _r: string, _sha: string, s: { state: string; description: string }) => { + if (inFlight) { const w = inFlight; inFlight = null; await w; } + landed.push(`${s.state} ${s.description}`); + }, + }; + const gates = new GateWriter(slow); + const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }), + { run: async () => ({ stdout: "", code: 0 }), container: "gitea", statuses: gates, registered: () => registered, judge: async () => failedStep }); + const call = (verb: string, args: Record, caller = bridge) => + runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise; + const merge = (extra: Record = {}, caller?: string) => + call("merge-pull", { id: "act-1", repository: "novox/mesh-catalog", number: 7, head: REVIEWED, ...extra }, caller); + const rows: { name: string; set: () => void; caller?: string; extra?: Record; want: RegExp | "merged" | "held"; loud?: boolean; kind?: string }[] = [ + { name: "no registration written yet", set: () => { registered = false; }, want: /no module is registered yet/, kind: "no-registrations" }, + { name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/, kind: "head-moved" }, + { name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a base whose protection was removed after the last branch-moved", set: () => { state.rules = [protectedMain()]; state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a required step failed at the head's fingerprint", set: () => { failedStep = { run: "main.7.nats", module: "nats", step: "unit", fingerprint: "fp-1" }; }, + want: /the unit step of nats failed in run main\.7\.nats/, kind: "policy-failed" }, + { name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" }, + { name: "no status at all", set: () => { state.statuses = []; }, want: "merged" }, + { name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" }, + { name: "the id said again after the merge", set: () => { state.merged = true; }, want: "held" }, + { name: "a pull request merged at another head", set: () => { state.merged = true; state.head = MOVED; }, want: /merged at 3c4d5e6f7a8b, not the abc1234def56/ }, + { name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/, kind: "git-refused" }, + { name: "a fast-forward", set: () => { state.parents = [REVIEWED]; }, want: "merged" }, + { name: "the merge read back fails", set: () => { state.commitsFail = 502; }, want: "merged" }, + { name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true }, + ]; + for (const r of rows) { + registered = true; failedStep = null; state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = []; + state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = ""; + r.set(); + const got = await merge(r.extra, r.caller); + if (r.want === "merged") { + assert.equal(got.performed, true, `${r.name}: ${JSON.stringify(got)}`); + assert.equal(got["merge-commit"], state.mergeCommit, r.name); + assert.equal(state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))?.body.head_commit_id, REVIEWED, r.name); + } else if (r.want === "held") { + assert.deepEqual([got.performed, got.held], [true, true], r.name); + assert.deepEqual(writes(), [], `${r.name}: nothing merged again`); + } else { + assert.match(String(got.refused ?? got.error), r.want, r.name); + if (r.kind) assert.equal(got["refused-kind"], r.kind, r.name); + if (r.kind === "policy-failed") assert.deepEqual([got.run, got.module, got.step], ["main.7.nats", "nats", "unit"], r.name); + assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`); + } + } + // A read: an open pull request answers what the forge would merge now; a forge that fails is the kind failed. + state.merged = false; state.head = REVIEWED; state.rules = [protectedMain()]; + const pull = await call("pull", { repository: "novox/mesh-catalog", number: 7 }); + assert.equal(pull.pull, undefined, "answered whole or not at all"); + assert.equal(pull.error?.startsWith("the forge did not say whether novox/mesh-catalog#7 merges now"), true, JSON.stringify(pull)); + assert.equal(pull["error-kind"], "failed", "a read the forge could not make answers the verb's error, never a guess"); + state.mergeable = true; + const open = await call("pull", { repository: "novox/mesh-catalog", number: 7 }); + state.mergeable = undefined; + assert.deepEqual([open.pull?.head, open.pull?.state, open.merging?.protected, open.merging?.required], + [REVIEWED, "open", true, ["mesh/merge-gate", "mesh/repo-check"]], JSON.stringify(open)); + assert.equal("body" in (open.pull ?? {}), false, "never its description"); + // Two writes of one head out of order (issue 456): a recheck's pending still in flight when the verdict is set through the + // seat; the verdict takes its turn after it and is what the head carries. + inFlight = new Promise((r) => (land = r)); + const recheck = gates.pending("novox", "mesh-catalog", REVIEWED); + const verdict = call("status", { id: "act-s", repository: "novox/mesh-catalog", commit: REVIEWED, state: "success", + context: "mesh/merge-gate", description: "passed" }); + await new Promise((r) => setImmediate(r)); + land(); + await recheck; + assert.equal((await verdict).performed, true); + assert.equal(landed.at(-1), "success passed", `the verdict lands last: ${JSON.stringify(landed)}`); + // A pull request the forge does not have is the kind not-found, so a caller never reads it out of the words. + const gone = await call("pull", { repository: "novox/mesh-catalog", number: 404 }); + assert.deepEqual([gone.pull, gone["error-kind"]], [undefined, "not-found"], JSON.stringify(gone)); +}); + +// Each verb of the seat git answers the contract's shape from what the forge gives, and an error naming why when the forge +// does not (design 47 §310, §312, §317): an empty list or an absent file only when the forge said so, a forge that failed +// the kind failed, a 404 the kind not-found, and an act whose id the pull request or the note already holds is held. +test("every read and act of the seat git answers its contract, and an error is an error, never an empty answer", async () => { + const { seatTools } = await import("../dist/seat.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const SHA = "a".repeat(40), OTHER = "b".repeat(40); + const gone = () => { throw new Error("Gitea API GET /repos/novox/app/pulls/9: 404 Not Found"); }; + const down = () => { throw new Error("Gitea API GET /repos/novox/app/x: 502 Bad Gateway"); }; + const pr = { number: 9, title: "t", state: "open", merged: false, head: "feat/x", head_sha: SHA, base: "main", user: "jochen", html_url: "http://f/9", mergeable: true }; + const tagged: string[] = []; + let comments: { id: number; body: string }[] = [], noteShown = "", added = 0, noteWritten = 0; + const forge: Record = { + getPullRequest: async (_o: string, _r: string, n: number) => (n === 9 ? pr : gone()), + pullOfCommit: async () => ({ ...pr, state: "closed", merged: true, merge_commit_sha: OTHER, merged_at: "2026-11-11T10:00:00Z" }), + appliedProtection: async () => ({ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/check"] }), + getRepo: async () => ({ writes: true }), + openPulls: async () => [{ owner: "novox", repo: "app", number: 9, base: "main", head: "feat/x", head_sha: SHA, required: ["mesh/check"], checks: {} }], + tags: async () => ["go/v0.1.1", "go/v0.1.0", "v1"], + commitTrees: async () => ({ root: SHA, trees: { "": SHA } }), + getFile: async (_o: string, _r: string, path: string) => (path === "gone" ? (() => { throw new Error("Gitea API GET /repos/novox/app/contents/gone: 404 Not Found"); })() : path === "down" ? down() : { path, sha: OTHER, size: 3, content: "abc" }), + compare: async () => ({ total_commits: 2, commits: [{ sha: OTHER }, { sha: SHA }] }), + combinedStatus: async () => ({ sha: SHA, state: "success", statuses: [{ context: "mesh/check", status: "success", description: "ok" }] }), + listComments: async () => comments, + addComment: async (_o: string, _r: string, _n: number, body: string) => { added++; comments.push({ id: added, body }); }, + editComment: async () => {}, + }; + const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append") + ? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 }); + const seat = seatTools(forge as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true, judge: async () => null }); + const call = (verb: string, args: Record, over: Record = {}) => { + const t = seat.find((x: any) => x.name === `git.${verb}`)!; + const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", statuses: { status: async () => {} }, registered: () => true, judge: async () => null }); + return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise; + }; + const rows: [string, string, Record, Record, (a: any) => unknown, unknown][] = [ + ["pull", "by number", { repository: "novox/app", number: 9 }, {}, (a) => [a.pull.head, a.pull.state, a.merging.mergeable, a.merging.required, a.merging.writes], [SHA, "open", true, ["mesh/check"], true]], + ["pull", "by merge commit", { repository: "novox/app", "merge-commit": OTHER }, {}, (a) => [a.pull.state, a.pull["merge-commit"], a.merging], ["merged", OTHER, undefined]], + ["pull", "unknown", { repository: "novox/app", number: 404 }, {}, (a) => [a.pull, a["error-kind"]], [undefined, "not-found"]], + ["pull", "forge down", { repository: "novox/app", number: 9 }, { getPullRequest: down }, (a) => [a.pull, a["error-kind"]], [undefined, "failed"]], + ["branch", "a branch a rule guards", { repository: "novox/app", branch: "main" }, { branchHead: async () => SHA, appliedProtection: async () => ({ rule_name: "main", enable_push: false }) }, (a) => [a.head, a.protected], [SHA, true]], + ["branch", "a branch no rule guards", { repository: "novox/app", branch: "scratch" }, { branchHead: async () => SHA, appliedProtection: async () => null }, (a) => [a.head, a.protected], [SHA, false]], + ["branch", "a rule that takes a push", { repository: "novox/app", branch: "main" }, { branchHead: async () => SHA, appliedProtection: async () => ({ rule_name: "main", enable_push: true }) }, (a) => [a.protected], [false]], + ["branch", "no such branch", { repository: "novox/app", branch: "nope" }, { branchHead: gone }, (a) => [a.head, a["error-kind"]], [undefined, "not-found"]], + ["open-pulls", "one", {}, {}, (a) => [a.pulls.length, a.pulls[0].required], [1, ["mesh/check"]]], + ["open-pulls", "forge down", {}, { openPulls: down }, (a) => [a.pulls, a["error-kind"]], [undefined, "failed"]], + ["tags", "a page", { repository: "novox/app", prefix: "go/" }, {}, (a) => [a.tags, a.page, a.bound, a.more], [["go/v0.1.1", "go/v0.1.0"], 1, 50, undefined]], + ["tags", "forge down", { repository: "novox/app" }, { tags: down }, (a) => [a.tags, a["error-kind"]], [undefined, "failed"]], + ["trees", "root", { repository: "novox/app", commit: SHA }, {}, (a) => [a.commit, a.root], [SHA, SHA]], + ["trees", "forge down", { repository: "novox/app", commit: SHA }, { commitTrees: down }, (a) => [a.root, a["error-kind"]], [undefined, "failed"]], + ["file", "present", { repository: "novox/app", path: "a.txt" }, {}, (a) => [a.content, a.absent], ["abc", undefined]], + ["file", "the forge said absent", { repository: "novox/app", path: "gone" }, {}, (a) => [a.absent, a.error], [true, undefined]], + ["file", "forge down is no absent file", { repository: "novox/app", path: "down" }, {}, (a) => [a.absent, a["error-kind"]], [undefined, "failed"]], + ["contains", "ahead", { repository: "novox/app", commit: SHA, in: OTHER }, {}, (a) => [a.contains, a.ahead], [false, 2]], + ["contains", "forge down", { repository: "novox/app", commit: SHA, in: OTHER }, { compare: down }, (a) => [a.contains, a["error-kind"]], [undefined, "failed"]], + ["statuses", "newest of each", { repository: "novox/app", commit: SHA }, {}, (a) => [a.combined, a.statuses.map((s: any) => s.context)], ["success", ["mesh/check"]]], + ["statuses", "forge down", { repository: "novox/app", commit: SHA }, { combinedStatus: down }, (a) => [a.statuses, a["error-kind"]], [undefined, "failed"]], + ["tag", "written", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => null, createTag: async (_o: string, _r: string, tag: string, c: string) => { tagged.push(`${tag}@${c}`); } }, (a) => [a.performed, tagged.splice(0)], [true, [`go/v1.2.3@${SHA}`]]], + ["tag", "already at that commit", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => SHA, createTag: async () => { throw new Error("written again"); } }, (a) => [a.performed, a.error], [true, undefined]], + ["tag", "at another commit is never moved", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => OTHER }, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], + ["tag", "no Go version", { repository: "novox/app", tag: "v1", commit: SHA }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], + ["status", "refused state", { id: "s1", repository: "novox/app", commit: SHA, context: "c", state: "bogus" }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], + ["comment", "first time", { id: "c1", repository: "novox/app", number: 9, text: "hi" }, {}, (a) => [a.performed, a.held, added], [true, undefined, 1]], + ["comment", "forge down", { id: "c2", repository: "novox/app", number: 9, text: "hi" }, { listComments: down }, (a) => [a.performed, a["error-kind"]], [undefined, "failed"]], + ["view", "first time", { id: "v1", repository: "novox/app", number: 9, text: "state" }, {}, (a) => [a.performed, a.held], [true, undefined]], + ["note", "a line", { id: "n1", repository: "novox/app", commit: SHA, line: "planned" }, {}, (a) => [a.performed, a.held, noteWritten], [true, undefined, 1]], + ]; + for (const [verb, name, args, over, pick, want] of rows) { + assert.deepEqual(pick(await call(verb, args, over)), want, `${verb}: ${name}`); + } + // The same comment id said again is held, and nothing is written again; so is a note line whose id the note holds. + const before = added; + const again = await call("comment", { id: "c1", repository: "novox/app", number: 9, text: "hi" }); + assert.deepEqual([again.performed, again.held, added], [true, true, before]); + const view = await call("view", { id: "v1", repository: "novox/app", number: 9, text: "state" }); + assert.deepEqual([view.performed, view.held], [true, true]); + noteShown = "planned [n1]"; noteWritten = 0; + const note = await call("note", { id: "n1", repository: "novox/app", commit: SHA, line: "worded differently" }); + assert.deepEqual([note.performed, note.held, noteWritten], [true, true, 0]); +}); + +// The first registrations switch merge-pull on (design 47 §95, §353): none held and none heard, it waits; a registration held +// at the start, or the first registration-changed, switches it, and an event that is none is an error. +test("the first registrations are learned from the seat mesh-catalog's verb at the start and from its event", async () => { + const { Registrations } = await import("../dist/registrations.js"); + const said: string[] = []; + const reg = (answer: unknown) => new Registrations(async () => { if (answer instanceof Error) throw answer; return answer; }, (l) => said.push(l)); + const none = reg({ registrations: [] }); + await none.start(); + assert.equal(none.written, false, "an empty answer is no first registration"); + none.hear({ module: "mesh-sdk", moved: ["watch"], version: 1, warrant: "q-1" }); + assert.equal(none.written, true, "the event switches it"); + const held = reg(JSON.stringify({ registrations: [{ module: "mesh-sdk" }] })); + await held.start(); + assert.equal(held.written, true, "a registration held at the start"); + const down = reg(new Error("no responders")); + await down.start(); + assert.equal(down.written, false); + assert.match(said.join("\n"), /could not be read: no responders/, "a failed first view is said, never taken as none silently"); + assert.throws(() => down.hear(undefined as any), /not a mesh-catalog.registration-changed event/); +}); diff --git a/modules/gitea/test/kept.test.ts b/modules/gitea/test/kept.test.ts index a526eb104..b39114667 100644 --- a/modules/gitea/test/kept.test.ts +++ b/modules/gitea/test/kept.test.ts @@ -16,8 +16,8 @@ test("the states the module announces are the states its manifest declares", asy test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => { const { statesAnnouncement } = await import("../kept.ts"); - assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); - assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); + assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "failed-steps" }, { name: "findings", readable: true }, { name: "first-views" }, { name: "idle" }, { name: "transfers" }] }); + assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "failed-steps" }, { name: "findings", readable: true }, { name: "first-views" }, { name: "idle" }, { name: "transfers" }] }); }); test("index.ts says the announcement as the event `states` before the tools are served", () => { diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index f07e22e76..8cb29b0f5 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -23,7 +23,6 @@ beforeEach(() => { enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0, enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }]; - source = {}; repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true }; sent = []; }); @@ -80,6 +79,12 @@ const server = createServer((req, res) => { giteaEdit(r, body); return send(200, r); } + if (req.method === "DELETE" && m[1]) { + const i = rules.findIndex((x) => x.rule_name === decodeURIComponent(m[1])); + if (i < 0) return send(404, { message: "no rule" }); + rules.splice(i, 1); + res.writeHead(204); return res.end(); + } if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); } send(405, { message: "no" }); }); @@ -88,11 +93,7 @@ await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const url = `http://127.0.0.1:${port}`; -// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories -// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel. -let source: Record }> = {}; -const readSource = async () => source; -const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource); +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); const setRepo = (args: Record) => tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args }); const setFull = (args: Record) => @@ -137,6 +138,39 @@ test("an unknown field, a field of the wrong kind, no field, no reason or a logi }); // judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words. +// Design 46 approval mark §21: a trunk's protection is loosened only by gitea_branch_protection_loosen, marked in the +// manifest's needs-approval; gitea_branch_protection_set_full refuses a force-push onto a trunk, naming it. +const loosen = (args: Record) => + tools.find((t) => t.name === "gitea_branch_protection_loosen")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args }); + +test("set_full refuses a force-push onto a trunk, nothing sent, naming gitea_branch_protection_loosen", async () => { + for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true, force_push_allowlist_usernames: ["jschoubben"] }]) { + await assert.rejects(setFull(args), /gitea_branch_protection_loosen's alone/); + } + assert.deepEqual(sent, []); +}); + +test("gitea_branch_protection_loosen removes a trunk's protection, or lets a force-push onto it, and says the trunk rule no longer holds", async () => { + const forced: any = await loosen({ act: "allow-force-push" }); + assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main", body: { enable_force_push: true } }]); + assert.equal(forced.before.fields.enable_force_push, false); + assert.equal(forced.after.fields.enable_force_push, true); + sent = []; + const removed: any = await loosen({ act: "remove-protection" }); + assert.deepEqual(sent, [{ method: "DELETE", path: "/repos/novox/de-spiegel/branch_protections/main", body: undefined }]); + assert.equal(removed.after, null); + assert.deepEqual(rules, []); + assert.equal(removed.trunk_rule.holds, false); + assert.match(removed.trunk_rule.why, /no protection rule covers main/); +}); + +test("gitea_branch_protection_loosen refuses an unknown act, no reason or a rule the repository lacks, nothing sent", async () => { + await assert.rejects(loosen({ act: "unprotect" }), /one of remove-protection, allow-force-push/); + await assert.rejects(loosen({ act: "remove-protection", why: "" }), /say why/); + await assert.rejects(loosen({ act: "remove-protection", branch: "release/*" }), /no protection rule named release\/\*/); + assert.deepEqual(sent, []); +}); + test("the trunk rule is judged as the controller judges it", () => { const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }; const cases: [any[], string, boolean, RegExp][] = [ @@ -212,14 +246,13 @@ test("C: allow_merge_update, default_update_style and has_code are taken; projec }); // The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a -// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the -// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else -// runs at once. +// force-push to main. Such a call of this tool is refused, with nothing sent, and names the one tool that may do it and +// that it runs on the operator's Approve of that call (design 47 G A-288); everything else runs at once. test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => { for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true }, { force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] }, { branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) { - await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args)); + await assert.rejects(setFull(args), /gitea_branch_protection_loosen's alone, which runs on the operator's Approve of that one call/, JSON.stringify(args)); } assert.deepEqual(sent, []); await setFull({ branch: "release/*", enable_force_push: true }); @@ -227,24 +260,12 @@ test("a force-push onto a rule covering main is refused, nothing sent; on anothe assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer"); }); -// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as -// the gate's source names them; "main" only when it names none, and said. -test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => { - source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } }; - await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/); - await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/); - assert.deepEqual(sent, []); - const main: any = await setFull({ enable_force_push: true }); - assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" }); - assert.equal(main.trunk_rule.is_trunk, false); - const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }); - assert.equal(beta.trunk_rule.is_trunk, true); - assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why); -}); - -test("a source that names no trunk of the repository falls back to main, and says so", async () => { +// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branch the mesh installs from: main, +// the mesh's one trunk of every repository (design 47); no other module's state is read for it (ADR 0596 §2). +test("main is the repository's trunk, read from no other module, and another branch is none", async () => { const out: any = await setFull({ block_admin_merge_override: true }); - assert.equal(out.trunks.names[0], "main"); - assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/); + assert.deepEqual(out.trunks, { names: ["main"], from: "the mesh's one trunk of every repository (design 47)" }); assert.equal(out.trunk_rule.is_trunk, true); + const beta: any = await setFull({ branch: "beta", enable_force_push: true }); + assert.equal(beta.trunk_rule.is_trunk, false); }); diff --git a/modules/gitea/test/protection-read.test.ts b/modules/gitea/test/protection-read.test.ts index b774da455..68199b22a 100644 --- a/modules/gitea/test/protection-read.test.ts +++ b/modules/gitea/test/protection-read.test.ts @@ -17,12 +17,19 @@ const rules = [ { branch_name: "release", enable_push: false }, ]; const seen: string[] = []; +const opened: { base?: string }[] = []; const server = createServer((req, res) => { const path = (req.url ?? "").replace(/^\/api\/v1/, ""); const as = req.headers.authorization === adminBasic ? "admin" : "tools"; seen.push(`${req.method} ${path} as ${as}`); const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (path === "/repos/novox/app") return send(200, { full_name: "novox/app", default_branch: "trunk" }); + if (req.method === "POST" && path === "/repos/novox/app/pulls") { + let raw = ""; + req.on("data", (c) => (raw += c)); + return req.on("end", () => { opened.push(JSON.parse(raw)); send(201, { number: 7, title: "t", state: "open", html_url: "u" }); }); + } if (path.includes("/branch_protections")) { if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" }); if (req.method === "GET") return send(200, rules); @@ -57,3 +64,10 @@ test("gitea_branch_protection_set still gets the forge's refusal, as the tools' ); assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n")); }); + +// Design 47 G §428: no part's code holds a branch's name; a pull request opened with no base goes to the repository's default branch. +test("gitea_create_pull_request with no base opens against the repository's default branch, and a named base stays", async () => { + await tool("gitea_create_pull_request").run({ owner: "novox", repo: "app", title: "t", head: "feat/x" }); + await tool("gitea_create_pull_request").run({ owner: "novox", repo: "app", title: "t", head: "feat/x", base: "release" }); + assert.deepEqual(opened.map((o) => o.base), ["trunk", "release"]); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 54fc995fc..a33477b82 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -527,8 +527,7 @@ test("the tools register once there is a way to a token, and the first call mint withAdmin.tools.map((t) => t.name), [ "gitea_list_repos", "gitea_create_repo", "gitea_delete_repo", - "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment", - // gitea_merge_pull_request is served from the watcher's bundle, beside the merge check's one writer (ADR 0323). + "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_close_pull_request", "gitea_reopen_pull_request", @@ -540,9 +539,9 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_get_file", "gitea_list_branches", "gitea_delete_branch", - "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full", + "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_branch_protection_loosen", "gitea_repo_settings_set_full", "gitea_default_branch_set", "gitea_pull_base_set", - "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", + "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_list_labels", "gitea_create_label", "gitea_api", ], diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index d05ec24b6..f77b199ae 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -638,7 +638,7 @@ test("on the forge's events: a repository made is granted read at once, a known // The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator, // 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there. -test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => { +test("the merging account is given write on each repository the catalog half's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => { const env = await environment(); const client = fresh(env); await client.listRepos(); // the account and its team exist, as after any mint @@ -652,13 +652,17 @@ test("the merging account is given write on each repository the indexer's module const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l), async (type: string, body: unknown) => { said.push({ type, body }); }); const [unwritableTool, grantTool] = writeTools(() => writes); - await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read"); + await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the catalog half's first view is held"); assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write"); - // 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once. - await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true }); - await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true }); - await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true }); + // 1945/AC1: the first view hands the catalog half's modules; a user's repository a module builds from is made writable, + // once, and only once the first view is held (design 47: a part acts on nothing before its first view). + const viewing = forge.calls.length; + await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" } }); + await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" } }); + await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" } }); + assert.equal(forge.calls.length, viewing, "the first view's entries ask the forge nothing until it is held"); + await writes.viewed(); const from = forge.calls.length; await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } }); assert.equal(forge.calls.length, from, "a repository already written asks nothing more"); @@ -668,7 +672,7 @@ test("the merging account is given write on each repository the indexer's module assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator"); assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true); assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]); - assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/); + assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds a registered module/); assert.match(lines.join("\n"), /broken names no repository/); // 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's @@ -723,7 +727,7 @@ test("the merging account is given write on each repository the indexer's module await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT), /mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/); userRules.delete("jschoubben/dotfiles"); - // A module gone from the indexer leaves its repository out of the delivered list. + // A module gone from the catalog half leaves its repository out of the delivered list. await writes.take({ key: "snake", op: "delete" }); assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]); }); diff --git a/modules/gitea/test/views.test.ts b/modules/gitea/test/views.test.ts index 3f53160c9..48ee8bcaf 100644 --- a/modules/gitea/test/views.test.ts +++ b/modules/gitea/test/views.test.ts @@ -1,62 +1,95 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { ViewFollower, findingKey, unansweredFinding, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts"; +import { viewWaitsFinding } from "@novox/mesh-sdk/views"; +import { CatalogView, REVISION_VERB, findingValue, revisionAnswer, unansweredFinding } from "../catalog.ts"; -// ADR 0615: the module asks the indexer's read tool once, then keeps its picture of `modules` from the indexer's views -// events; a gap asks once more; an owner that is down is asked again and never read instead. +// Design 47 *Every part takes a first view, then follows the events*: the forge module asks the seat mesh-catalog's verb +// `revision` for the catalog half (every page of one trunk revision), then hears its event `catalog-revision`. A candidate's +// revision moves nothing; a newer trunk revision is waited for, said, and asked; waited for past the bound, view-waits is +// written, and cleared once held; an owner that does not answer is asked again and raised past its bound. -const made = "2026-11-04T12:00:00Z"; -const answer = (revision: number, values: Record): ViewAnswer => - ({ v: 1, owner: "o", state: "modules", revision, created: made, values }); -const ev = (revision: number, key: string, value?: unknown, extra: Partial = {}): ViewEvent => - ({ v: 1, owner: "o", state: "modules", key, revision, created: made, value, deleted: value === undefined, ...extra }); +const entry = (module: string, repository: string) => ({ module, repository, dir: ".", manifest: "m", fingerprint: "f", verdict: "accepted" }); +const revisionOf = (revision: number, kind = "trunk") => ({ revision, kind, source: revision, entries: [] }); -test("a follower takes the first view, then the events, and asks again only on a gap", async () => { - const changes: ViewChange[] = []; +/** A holder of the seat whose trunk revisions are pages of entries, and who answers what is asked of it. */ +function aHolder() { + const asked: Record[] = []; + const trunk = new Map([ + [7, [[entry("a", "u/a"), entry("b", "u/b")], [entry("c", "u/c")]]], + [8, [[entry("a", "u/a"), entry("c", "u/c2")]]], + ]); + let newest = 7; + let down = 0; + return { + asked, + set newest(n: number) { newest = n; }, + set down(n: number) { down = n; }, + ask: async (verb: string, input: { after?: string; revision?: number }) => { + assert.equal(verb, REVISION_VERB); + asked.push({ ...input }); + if (down > 0) { down--; return { error: "no responders" }; } + const rev = input.revision ?? newest; + const pages = trunk.get(rev)!; + const at = input.after ? pages.findIndex((p) => p[0].module > input.after!) : 0; + return { revision: revisionOf(rev), entries: pages[at], ...(at < pages.length - 1 ? { next: pages[at].at(-1)!.module } : {}) }; + }, + }; +} + +test("the catalog view takes the trunk's first view page by page, then a newer trunk revision, and a candidate's moves nothing", async () => { + const holder = aHolder(); + const changes: string[] = []; + const view = new CatalogView(holder.ask, { pause: async () => {}, changed: (k, v, deleted) => { changes.push(deleted ? `-${k}` : `${k}=${(v as any).repository}`); } }); + await view.follower.start(); + assert.deepEqual(holder.asked, [{}, { after: "b", revision: 7 }], "the second page names the first page's revision"); + assert.deepEqual([view.held, view.follower.status().view, changes.sort()], [7, "7", ["a=u/a", "b=u/b", "c=u/c"]]); + const rows: { name: string; ev: Record; asks: number; held: number }[] = [ + { name: "a candidate's revision", ev: { v: 1, kind: "candidate", revision: 9, source: 8, group: "g", heads: "h" }, asks: 0, held: 7 }, + { name: "the revision it holds", ev: { v: 1, kind: "trunk", revision: 7, source: 7 }, asks: 0, held: 7 }, + { name: "a newer trunk revision", ev: { v: 1, kind: "trunk", revision: 8, source: 8 }, asks: 1, held: 8 }, + ]; + for (const r of rows) { + holder.newest = 8; + holder.asked.splice(0); + changes.splice(0); + await view.hear(r.ev as any); + await view.asked; + assert.deepEqual([holder.asked.length, view.held], [r.asks, r.held], r.name); + } + assert.deepEqual(changes.sort(), ["-b", "c=u/c2"], "the newer revision hands what changed"); + const s = view.follower.status(); + assert.deepEqual([s.view, s["waits-for"]], ["8", undefined], "it holds what it waited for"); + await assert.rejects(view.hear({ v: 1, kind: "trunk" } as any), /not a mesh-catalog.catalog-revision event/); + assert.throws(() => revisionAnswer({ error: "refused for gitea" }), /answered no revision: refused for gitea/); +}); + +test("a revision waited for past the bound writes view-waits, cleared once held; an unanswered first view is raised, then cleared", async () => { + const holder = aHolder(); const said: string[] = []; - let asks = 0; - let down = 2; - const raised: string[][] = []; - const f = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", - ask: async () => { if (down-- > 0) throw new Error("no responders"); asks++; return answer(9, { a: { repository: "novox/a" }, b: { repository: "novox/b" } }); }, - take: (c) => { changes.push(c); }, raise: (_a, e) => { raised.push(["raised", e]); }, clear: () => { raised.push(["cleared", ""]); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() }); - await f.start(); - assert.equal(f.ready, true); - assert.equal(f.values.size, 2); - assert.deepEqual(raised.map((r) => r.slice(0, 2)), [["raised", "no responders"], ["cleared", ""]]); - assert.ok(said.some((l) => l.includes("no first view of o's modules") && l.includes("o.read")), said.join("|")); - await f.hear(ev(8, "a", { repository: "old" })); // already in the picture - await f.hear(ev(10, "c", { repository: "novox/c" })); - await f.hear(ev(11, "a")); // deleted - assert.deepEqual([f.revision, f.values.has("a"), f.values.has("c"), asks], [11, false, true, 1]); - assert.deepEqual(changes.slice(-2).map((c) => [c.key, c.op]), [["c", "put"], ["a", "delete"]]); - await f.hear(ev(13, "d", { repository: "x" })); // revision 12 missed: a gap - assert.equal(asks, 2); - await f.hear(ev(1, "e", { repository: "x" }, { created: "2026-11-04T13:00:00Z" })); // the state made again: a gap - assert.equal(asks, 3); - await f.hear(ev(10, "f", undefined, { gap: true })); // the next revision, too large to carry: a gap - assert.equal(asks, 4); - await f.hear({ ...ev(10, "g", { repository: "x" }), state: "other" }); // another state - assert.equal(f.values.has("g"), false); -}); - -test("the reader's two tools say where the first view stands and ask it again by hand", async () => { - let asks = 0; - const acts: Record[] = []; - const follower = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { asks++; return answer(5, { a: 1 }); }, - take: () => {}, sleep: async () => {} }); - const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async (act) => { acts.push(act); }); - assert.deepEqual(await first.run({}), { views: [{ owner: "o", tool: "o.read", state: "modules", revision: 0, created: "", keys: 0, answered: false, asks: 0, "by-hand": 0 }] }); - const after = (await again.run({})) as { views: { revision: number; "by-hand": number; asks: number }[] }; - assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks, acts.length], [5, 1, 1, 1]); - await assert.rejects(viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async () => { throw new Error("no holder"); })[1].run({}), /not recorded/); - assert.deepEqual(await viewTools(() => undefined, async () => {})[0].run({}), { views: [] }); -}); - -test("the finding of a first view unanswered is the one mesh-sdk writes: key, kind, subject and the words", () => { - const f = unansweredFinding("gitea", "mesh-delivery-indexer", "modules", "mesh-delivery-indexer.indexer_read_modules", new Date("2026-11-04T12:00:00Z"), "no responders", new Date("2026-11-04T12:03:00Z")); - assert.equal(f.key, "module.gitea.mesh-delivery-indexer.modules.first-view-unanswered"); - assert.equal(findingKey("module", "a.b", "k"), "module.a.b.k"); - assert.equal(f.value.severity, "warning"); - assert.match(String(f.value.summary), /gitea has no first view of mesh-delivery-indexer's modules: .* \(no responders\)/); + let bound = () => {}; + let clock = 0; + const view = new CatalogView(holder.ask, { + pause: () => new Promise((r) => setImmediate(r)), bound: 1000, now: () => new Date(Date.parse("2026-11-09T22:00:00Z") + (clock += 600)), + waitsBound: () => new Promise((r) => (bound = r)), + raise: (s) => { said.push(`raised ${findingValue(unansweredFinding("gitea", s, new Date())).key}`); }, + clear: () => { said.push("cleared"); }, + raiseWaits: (s) => { said.push(`waits ${findingValue(viewWaitsFinding("gitea", s, new Date())).key}`); }, + clearWaits: () => { said.push("waits cleared"); }, + }); + holder.down = 3; + await view.follower.start(); + assert.deepEqual(said, ["raised module.gitea.mesh-catalog.catalog-revisions.first-view-unanswered", "cleared"]); + said.splice(0); + holder.newest = 8; + holder.down = 100; // the holder is away when the newer revision is said + const heard = view.hear({ v: 1, kind: "trunk", revision: 8, source: 8 } as any); + await heard; + assert.equal(view.follower.status()["waits-for"], "8", "it says what it waits for"); + bound(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(said.filter((l) => l.startsWith("waits")), ["waits module.gitea.mesh-catalog.catalog-revisions.view-waits"]); + holder.down = 0; + await view.follower.gap("the holder is back"); + await view.asked; + assert.deepEqual([view.held, view.follower.status()["waits-for"], said.includes("waits cleared")], [8, undefined, true]); }); diff --git a/modules/gitea/tools/failedsteps.ts b/modules/gitea/tools/failedsteps.ts new file mode 100644 index 000000000..c13ba2067 --- /dev/null +++ b/modules/gitea/tools/failedsteps.ts @@ -0,0 +1,62 @@ +// The tools of the forge module's state `failed-steps` (novox/hq design 47 §83, §84): the read of everything it holds, with the +// store's fill against its cap, and the one recovery, dropping a held key, written in the hand-act log before it is done. + +import type { ToolDefinition } from "@novox/mesh-sdk/tools"; +import { callerOf } from "@novox/mesh-sdk/callers"; +import type { HandAct } from "@novox/mesh-sdk/views"; +import type { FailedSteps } from "../failedsteps.js"; + +export function failedStepsTools(kept: FailedSteps, acts: (act: HandAct) => Promise): ToolDefinition[] { + return [ + { + name: "git_failed_steps", + description: "The failed required steps the forge module holds, by module and fingerprint, each with the run that failed it, the commits it was seen at and why; " + + "the `seen` keys (each commit's fingerprints, a commit's with `commit`); and the store's fill against its cap (design 47 §83, §487).", + input: { type: "object", properties: { commit: { type: "string", description: "only what names this commit" } } }, + run: async (args: Record) => { + const commit = typeof args.commit === "string" && args.commit !== "" ? args.commit : undefined; + const held = await kept.held(commit); + return { failed: held.failed.map((f) => ({ ...f, key: `${f.module}.${f.fingerprint}.${f.step}` })), seen: held.seen, fill: held.fill }; + }, + }, + { + name: "git_failed_steps_drop", + description: "Drops one held key of the state failed-steps, a failed step by its key (..) or a seen key (seen..), with why; " + + "the head it refused merges. Written in the hand-act log first (design 47 §84).", + input: { + type: "object", required: ["key", "why"], + properties: { key: { type: "string", description: "the key, as git_failed_steps shows it" }, why: { type: "string", description: "why it is dropped, in plain words" } }, + }, + run: async (args: Record) => { + const key = typeof args.key === "string" ? args.key.trim() : ""; + const why = typeof args.why === "string" ? args.why.trim() : ""; + if (!key || !why) throw new Error("git_failed_steps_drop names the key and why"); + const by = callerOf(args); + try { + await acts({ what: `dropped the held ${key} of the forge module's failed-steps`, why: `${why} (${by})`, cause: "failed-step-dropped", verb: "git_failed_steps_drop" }); + } catch (err) { + throw new Error(`the hand act was not recorded, so nothing was dropped: ${err instanceof Error ? err.message : String(err)}`); + } + return { dropped: await kept.drop(key), key, why, by }; + }, + }, + { + name: "git_seen_expire", + description: "Deletes the seen keys of the state failed-steps that are more than 30 days old, the oldest first, at most 100: the removal the forge module does on each step-ended, now, with why. " + + "Written in the hand-act log first (design 47 §581).", + input: { type: "object", required: ["why"], properties: { why: { type: "string", description: "why it is run now, in plain words" } } }, + run: async (args: Record) => { + const why = typeof args.why === "string" ? args.why.trim() : ""; + if (!why) throw new Error("git_seen_expire says why"); + const by = callerOf(args); + try { + await acts({ what: "deleted the seen keys of the forge module's failed-steps past 30 days", why: `${why} (${by})`, cause: "seen-expired", verb: "git_seen_expire" }); + } catch (err) { + throw new Error(`the hand act was not recorded, so nothing was deleted: ${err instanceof Error ? err.message : String(err)}`); + } + const deleted = await kept.expireSeen(); + return { deleted, why, by }; + }, + }, + ]; +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 94736c81c..2f0c105f5 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,9 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { readTeamRights } from "../teamrights.js"; -import { state } from "@novox/mesh-sdk/state"; -import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js"; -import { appendNote, run, viewBody, viewComment } from "../delivery.js"; +import { LOOSEN_ACTS, LOOSEN_TOOL, PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, trunksOf, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery, @@ -24,10 +22,6 @@ import { /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; -/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name - * is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */ -const forgeContainer = "gitea"; - /** A protection rule as a person reads it: what it guards, not every field the forge keeps. */ export function summarised(p: BranchProtection) { return { @@ -91,13 +85,20 @@ function filesDirs(raw: unknown): string[] | undefined { return raw as string[]; } -export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea, admin, source)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { + // The seat git's verbs are served by the events entrypoint (index.ts), beside the head's one writer of its statuses. + return namesChecked(allGiteaTools(gitea, admin)); } -// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one -// writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] { +// A merge, a status, a comment, a delivery's view and its note are the seat git's acts (seat.ts), asked by the forge +// bridge alone: no tool of the module's own does them (novox/hq design 47 *The seat git*). +function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined): ToolDefinition[] { + // A branch no caller named is the repository's default branch, as the forge says it; no part's code holds one (§428). + const defaultBranchOf = async (owner: string, repo: string): Promise => { + const found = String((await gitea.getRepo(owner, repo)).default_branch ?? ""); + if (!found) throw new Error(`the forge names no default branch of ${owner}/${repo}: name the branch`); + return found; + }; return [ // ---- Repositories ---- { @@ -233,19 +234,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"), }), }, - { - name: "gitea_add_comment", - description: "Add a comment to an issue or pull request.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the issue or PR number" }, - body: { type: "string", description: "the comment body (markdown)" }, - }, - run: async (args) => ({ - comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)), - }), - }, // ---- Pull requests ---- { @@ -287,14 +275,14 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined title: { type: "string", description: "the PR title" }, body: { type: "string", description: "the PR body (markdown)" }, head: { type: "string", description: "the source branch" }, - base: { type: "string", description: "the target branch (default main)" }, + base: { type: "string", description: "the target branch (default: the repository's default branch)" }, }, run: async (args) => ({ pull: await gitea.createPullRequest(String(args.owner), String(args.repo), { title: String(args.title), body: args.body ? String(args.body) : undefined, head: String(args.head), - base: args.base ? String(args.base) : "main", + base: args.base ? String(args.base) : await defaultBranchOf(String(args.owner), String(args.repo)), }), }), }, @@ -502,7 +490,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " + - "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, naming gitea_branch_protection_loosen, which alone removes a trunk's protection or lets a force-push onto it, on the operator's Approve. A repository's trunk, the branch the mesh installs from, is main, the mesh's one trunk of every repository (said in trunks.from). As the forge's " + "administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " + "this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " + "agent-, no administrator override) and why not.", @@ -524,7 +512,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined const given = givenFields(args); if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`); const body = fullProtectionBody(args, given); - const trunks = await trunksOf(owner, repo, source); + const trunks = trunksOf(); const operators = forcePushToTrunk(branch, body, trunks.names); if (operators) throw new Refused(operators); const forge = admin ? admin() : gitea; @@ -548,6 +536,54 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, }, + { + name: LOOSEN_TOOL, + description: "Loosen a branch's protection rule in one of the two ways gitea_branch_protection_set_full refuses: remove the rule " + + "(act remove-protection), or let a force-push onto it (act allow-force-push, enable_force_push true). Marked in the " + + "module's needs-approval, so a call runs only on the operator's Approve of that one call (design 46 approval mark " + + "section 21); tightening stays gitea_branch_protection_set_full's, unasked. A reason (why) is required and logged. As " + + "the forge's administrator. Answers the rule before and after (null once removed), the repository's trunks, and " + + "trunk_rule: whether the controller still registers a module from the branch, and why not.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is loosened" }, + act: { type: "string", enum: [...LOOSEN_ACTS], description: "remove-protection removes the rule; allow-force-push lets a force-push onto it" }, + why: { type: "string", description: "why, in a sentence: logged and answered" }, + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); + const act = String(args.act ?? ""), why = String(args.why ?? "").trim(); + if (!branch) throw new Error("name the branch whose rule is loosened"); + if (!(LOOSEN_ACTS as readonly string[]).includes(act)) throw new Error(`act is ${JSON.stringify(act)}: it is one of ${LOOSEN_ACTS.join(", ")}`); + if (!why) throw new Error("say why (why): a branch's protection is loosened for a reason, which is logged"); + pathSegments("branch", branch); + const forge = admin ? admin() : gitea; + const rules = await forge.branchProtections(owner, repo); + const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null; + if (!before) throw new Refused(`${owner}/${repo} has no protection rule named ${branch}: there is nothing to loosen. Nothing was sent`); + console.log(`[gitea] ${LOOSEN_TOOL} ${owner}/${repo} ${branch}: ${act} — why: ${why}`); + if (act === "remove-protection") await forge.deleteBranchProtection(owner, repo, branch); + else await forge.writeBranchProtection(owner, repo, branch, { enable_force_push: true }, false); + const after = await forge.branchProtections(owner, repo); + const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null; + if (act === "remove-protection" && read) { + throw new Error(`the forge answered the removal of ${owner}/${repo} ${branch}'s rule but still holds it: ${JSON.stringify(summarised(read))}`); + } + if (act === "allow-force-push") { + const missed = read ? unapplied({ enable_force_push: true }, read) : ["the rule itself (the forge no longer holds it)"]; + if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}`); + } + const trunks = trunksOf(); + return { + branch, act, why, + before: { ...summarised(before), fields: before }, + after: read ? { ...summarised(read), fields: read } : null, + trunks, trunk_rule: trunkRule(after, branch, trunks.names), + }; + }, + }, + { name: "gitea_repo_settings_set_full", description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " + @@ -640,40 +676,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, }, - // ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ---- - { - name: "gitea_note_append", - description: "Append one line to a commit's git note under refs/notes/ (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - sha: { type: "string", description: "the commit" }, - ref: { type: "string", description: "the notes ref's name (default mesh-plan)" }, - line: { type: "string", description: "the line, one line" }, - }, - run: async (args) => { - const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha), - String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? "")); - return { commit: String(args.sha), ...said }; - }, - }, - { - name: "gitea_delivery_view", - description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the pull request's number" }, - body: { type: "string", description: "the view, markdown" }, - }, - run: async (args) => { - const owner = String(args.owner), repo = String(args.repo), number = Number(args.number); - const body = viewBody(String(args.body ?? "")); - const existing = viewComment(await gitea.listComments(owner, repo, number)); - if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) }; - return { created: await gitea.addComment(owner, repo, number, body) }; - }, - }, + // ---- A commit's statuses (a delivery's note, view and statuses are the seat git's acts, seat.ts) ---- { name: "gitea_commit_statuses", description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.", @@ -682,7 +685,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "a pull request's number: its head is read, and its base's protection" }, sha: { type: "string", description: "a commit, or the start of one, instead of a pull request" }, - base: { type: "string", description: "with sha: the branch whose protection is read (default main)" }, + base: { type: "string", description: "with sha: the branch whose protection is read (default: the repository's default branch)" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); @@ -705,7 +708,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined if (!ref) throw new Error("name a pull request's number or a commit"); const combined = await gitea.combinedStatus(owner, repo, ref); const statuses = newestByContext(combined.statuses); - base = base || "main"; + base = base || (await defaultBranchOf(owner, repo)); let rule: BranchProtection | null = null, unreadable: string | undefined; try { rule = await gitea.appliedProtection(owner, repo, base); @@ -768,8 +771,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined return { commit, in: within, contains, ahead }; }, }, - // gitea_commit_status is served from the watcher's bundle (index.ts), beside the merge check's one writer - // (novox/hq issue 456). // ---- Labels ---- { @@ -805,7 +806,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined name: "gitea_api", description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " + "Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " + - "pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " + + "pull requests (not their merge: the seat git's merge-pull), branches, and files on a branch that is not the " + "default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " + "hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).", input: { diff --git a/modules/gitea/views.ts b/modules/gitea/views.ts deleted file mode 100644 index dbcc7d6a4..000000000 --- a/modules/gitea/views.ts +++ /dev/null @@ -1,241 +0,0 @@ -// A reader's picture of one state of the indexer or the assigner (novox/hq ADR 0615 §1, §4 to §6; design 25 § 12.2): asked once -// of the owner's read tool, then kept by the owner's views events alone. It is the TypeScript form of the Go reader -// (mesh-delivery sdk/views Follower) and follows the same rules: -// -// - at its start it asks the tool until it is answered, waiting longer between asks (up to a minute), and past two minutes -// says so naming the owner and the tool; it never reads the owner's state instead; -// - an event of another `created` is a gap (the owner's state was made again); one at or below the revision held is already -// in the picture; one past the next revision is a gap; one with `gap: true` (a value too large to carry) is a gap; -// - a gap asks the tool once more, said in the log; -// - events heard while an ask runs are taken again after its answer, so none is lost. - -/** The answer of the owner's read tool with `values`: one page of the state, at the owner's revision. */ -export interface ViewAnswer { - v: number; - owner: string; - state: string; - revision: number; - created: string; - values: Record; - next?: string; -} - -/** One change of one key, said on the owner's views family. */ -export interface ViewEvent { - v: number; - owner: string; - state: string; - key: string; - revision: number; - created: string; - deleted?: boolean; - value?: unknown; - gap?: boolean; -} - -/** One change of the picture, in the form the module's watch handed before. */ -export interface ViewChange { - key: string; - op: "put" | "delete"; - value?: unknown; - revision?: number; - current?: boolean; -} - -export interface ViewFollowerOptions { - owner: string; - state: string; - /** The owner's read tool as the manifest invokes it, for the log. */ - tool: string; - /** Asks the tool for the page after `after` (none: the first). */ - ask: (after?: string) => Promise; - take: (change: ViewChange) => void | Promise; - say?: (line: string) => void; - sleep?: (ms: number) => Promise; - now?: () => number; - /** How long it asks before it says so; two minutes. */ - boundMs?: number; - /** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */ - raise?: (asked: Date, error: string) => void | Promise; - clear?: () => void | Promise; -} - -export class ViewFollower { - readonly values = new Map(); - revision = 0; - created = ""; - ready = false; - asks = 0; - byHand = 0; - private asking = false; - private during: ViewEvent[] = []; - - private readonly o: ViewFollowerOptions; - - constructor(o: ViewFollowerOptions) { - this.o = o; - } - - private say(line: string): void { - (this.o.say ?? ((l) => console.error(`[gitea] ${l}`)))(line); - } - - /** Asks until answered: each failed ask waits, doubling to a minute, and past the bound says what waits. */ - async start(): Promise { - await this.askUntil("start"); - } - - /** Asks once more by hand, until answered (a hand act, counted). */ - async again(why: string): Promise { - this.byHand++; - await this.askUntil(`by hand: ${why}`); - } - - private async askUntil(why: string): Promise { - if (this.asking) return; // one ask at a time; the one running brings the picture to its newest - this.asking = true; - this.during = []; - const sleep = this.o.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); - const now = this.o.now ?? Date.now; - const first = now(); - let said = false; - try { - for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) { - try { - await this.askOnce(); - if (said) { - this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`); - await this.o.clear?.(); - } - return; - } catch (err) { - if (!said && now() - first >= (this.o.boundMs ?? 120_000)) { - said = true; - await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err)); - this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` + - `(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`); - } - await sleep(wait); - } - } - } finally { - this.asking = false; - } - } - - private async askOnce(): Promise { - this.asks++; - const values = new Map(); - let firstPage: ViewAnswer | undefined; - for (let after: string | undefined; ;) { - const a = await this.o.ask(after); - firstPage ??= a; - for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v); - if (!a.next || a.next === after) break; - after = a.next; - } - const old = new Map(this.values); - this.values.clear(); - for (const [k, v] of values) this.values.set(k, v); - this.revision = firstPage.revision; - this.created = firstPage.created; - this.ready = true; - const during = this.during.sort((a, b) => a.revision - b.revision); - this.during = []; - for (const ev of during) { - if (ev.created === this.created && ev.revision === this.revision + 1 && !ev.gap) { - if (ev.deleted) this.values.delete(ev.key); - else this.values.set(ev.key, ev.value); - this.revision = ev.revision; - } - } - for (const [k, v] of this.values) { - if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.take({ key: k, op: "put", value: v, current: true }); - } - for (const k of old.keys()) if (!this.values.has(k)) await this.o.take({ key: k, op: "delete", current: true }); - } - - /** One event of the owner's views family: taken into the picture, or a gap that asks the tool once more. */ - async hear(ev: ViewEvent): Promise { - if (ev.state !== this.o.state) return; - if (this.asking) this.during.push(ev); - if (!this.ready) return; // the first view holds it - let why = ""; - if (ev.created !== this.created) why = `${this.o.owner}'s ${this.o.state} was made again`; - else if (ev.revision <= this.revision) return; - else if (ev.revision > this.revision + 1) why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.revision}`; - else if (ev.gap) why = `${this.o.owner}'s ${this.o.state}.${ev.key} was too large to carry`; - if (why) { - this.say(`a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`); - await this.askUntil(`a gap: ${why}`); - return; - } - if (ev.deleted) this.values.delete(ev.key); - else this.values.set(ev.key, ev.value); - this.revision = ev.revision; - await this.o.take({ key: ev.key, op: ev.deleted ? "delete" : "put", value: ev.value, revision: ev.revision, current: false }); - } -} - -type Tool = { name: string; description: string; input: Record; run: (args: Readonly>) => Promise }; - -/** Where a first view stands (design 25 § 12.2): the owner and its tool, the revision the picture holds, how many asks. (r) */ -export function status(f: ViewFollower, owner: string, tool: string, state: string): Record { - return { owner, tool, state, revision: f.revision, created: f.created, keys: f.values.size, answered: f.ready, asks: f.asks, "by-hand": f.byHand }; -} - -/** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */ -export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined, - record: (act: Record) => Promise): Tool[] { - return [ - { - name: "gitea_first_views", - description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, how many asks (by hand among them). (r)", - input: {}, - run: async () => { - const v = view(); - return { views: v ? [status(v.follower, v.owner, v.tool, v.state)] : [] }; - }, - }, - { - name: "gitea_first_view_again", - description: "Ask the indexer's modules again by hand: a hand act, for a picture the operator doubts. Answers where it stands after. (a)", - input: {}, - run: async () => { - const v = view(); - if (!v) throw new Error("the module follows no first view yet"); - // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing. - try { - await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" }); - } catch (err) { - throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`); - } - await v.follower.again("the operator asked"); - return { views: [status(v.follower, v.owner, v.tool, v.state)] }; - }, - }, - ]; -} - -/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ -export function findingKey(scope: string, id: string, kind: string): string { - return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); -} - -export const UNANSWERED_KIND = "first-view-unanswered"; - -/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the - * condition keeper raises the condition from the module's state `findings`. */ -export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date): - { key: string; value: Record } { - const id = `${reader}.${owner}.${state}`; - const key = findingKey("module", id, UNANSWERED_KIND); - const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`; - return { key, value: { - v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary, - headline: `${reader} waits for ${owner}`, - explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` + - `Look at ${owner}'s runtime of its own: is it running and connected?`, - resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(), - } }; -} diff --git a/modules/gitea/writes.ts b/modules/gitea/writes.ts index 029e4d8ba..2bd8aa980 100644 --- a/modules/gitea/writes.ts +++ b/modules/gitea/writes.ts @@ -3,15 +3,16 @@ // 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first // act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts). // -// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge -// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines' +// **Which account.** The tools' account, `mesh-tools`: the seat git's acts (seat.ts) set every commit status and merge +// with its token. The build modules' `nox-builder` and the machines' // `nox-agent` only fetch, and keep read (readall.ts). // -// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its -// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads -// that state through the indexer's read tool (its manifest's `invokes`) and then its views events (`consumes`; ADR 0615): the -// first view hands the latest value of every key when the module starts, and then every change, so a repository is granted -// at the module's start and when a module of it is first registered. Nothing polls and the state is not read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other +// **Which repositories: the catalog half's word, read over the bus.** The seat mesh-catalog's trunk revision holds every +// registered module's entry, each naming the repository it builds from. This module asks it of the seat's verb `revision` +// (its manifest's `invokes`) and then hears the seat's event `catalog-revision` (`consumes`; catalog.ts, design 47): the +// first view hands every entry when the module starts, and each newer trunk revision what changed, so a repository is +// granted at the module's start and when a module of it is first registered. A candidate's revision moves nothing, and +// before the first view nothing is granted. Nothing polls and no holder's state is read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other // repository of a user (the operator's dotfiles, say) is left as it is: read only. // // **Every grant is read back** from the forge (its permission for the account), and said on the bus as @@ -24,19 +25,15 @@ // set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but // blocked, with why (merge_blocked). // -// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it; +// Not taken away: a repository whose last module leaves the catalog keeps its collaborator until a hand removes it; // gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now. import type { ForgeRepository } from "./client.js"; import type { BranchProtection } from "./protection.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js"; -/** The indexer, whose state `modules` this module follows: one key per registered module, its value naming the repository. - * It asks the indexer's read tool once and then hears its views events (ADR 0615), never reading the state. */ -export const INDEXER_OWNER = "mesh-delivery-indexer"; -export const INDEXER_MODULES_TOOL = `${INDEXER_OWNER}.indexer_read_modules`; -export const INDEXER_MODULES_EVENTS = `${INDEXER_OWNER}.views.modules`; -const INDEXER_MODULES = `${INDEXER_OWNER}.modules`; +/** What the view is called in what this module says: the catalog half, through the seat mesh-catalog. */ +const CATALOG_HALF = "the catalog half (the seat mesh-catalog's revision)"; /** The event said once an account writes a repository it could not write before. */ export const WRITABLE_EVENT = "repo.writable"; @@ -75,18 +72,16 @@ export function mergeBlocks(rule: BranchProtection | null, account: string): str const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`; -/** One change of the indexer's modules state, as the SDK's state watch hands it. */ +/** One change of the catalog half's entries: a module, put with its entry or deleted. */ export interface ModulesChange { key: string; op: "put" | "delete"; value?: unknown; - revision?: number; - current?: boolean; } export interface Written { account: string; - /** The repositories that build a registered module, as the indexer says now. */ + /** The repositories that build a registered module, as the catalog half says now. */ delivered: string[]; /** Made writable now, each read back from the forge. */ granted: string[]; @@ -113,9 +108,9 @@ function split(full: string): [string, string] { } export class DeliveredWrites { - /** Module to the repository it builds from, as the indexer's state says. */ + /** Module to the repository it builds from, as the catalog half says. */ readonly modules = new Map(); - /** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */ + /** Whether the first view of the catalog half is held: until then, which repositories are delivered is not known. */ heard = false; /** Repositories seen written by the account in this process: a module registered again asks nothing more. */ readonly writable = new Set(); @@ -129,9 +124,23 @@ export class DeliveredWrites { return [...new Set(this.modules.values())].sort(); } - /** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */ - async take(change: ModulesChange): Promise { + /** The first view is held, its entries taken: from now on which repositories are delivered is known, even when the + * catalog lists none, and every one the account cannot write yet is granted now, in one pass. Never throws. */ + async viewed(): Promise { + if (this.heard) return; this.heard = true; + try { + const r = await this.grant(); + for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds a registered module`); + for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository} — ${x.why}`); + } catch (err) { + this.log(`which repositories ${this.account} writes could not be settled at the first view — ${said(err)}`); + } + } + + /** One change of the catalog half: a repository newly delivered is made writable now. Never throws. Before the first + * view is held (viewed) nothing is granted: the change is kept, and the first view's changes follow it. */ + async take(change: ModulesChange): Promise { if (change.op === "delete") { this.modules.delete(change.key); return; @@ -140,11 +149,11 @@ export class DeliveredWrites { try { split(repository); } catch { - this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`); + this.log(`${CATALOG_HALF}: ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`); return; } this.modules.set(change.key, repository); - if (this.writable.has(repository)) return; + if (!this.heard || this.writable.has(repository)) return; try { const r = await this.grant([repository]); for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`); @@ -158,8 +167,8 @@ export class DeliveredWrites { * delivered); with none named, also say which repositories it cannot write are left so, and why. */ async grant(only?: string[]): Promise { if (!this.heard) { - throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` + - "(the watch is not granted or not answered); nothing is granted"); + throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view ` + + "held (not answered, or waited for); nothing is granted"); } const delivered = this.delivered(); const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] }; @@ -169,7 +178,7 @@ export class DeliveredWrites { split(full); if (delivered.includes(full)) targets.push(full); else result.left_read_only.push({ repository: full, permission: "unchanged", - why: "builds no registered module (the indexer names none from it), so the account is given no write there" }); + why: "builds no registered module (the catalog half names none from it), so the account is given no write there" }); } for (const full of targets) { const [owner, name] = split(full); @@ -248,7 +257,7 @@ export class DeliveredWrites { } if (WRITES.has(permission)) continue; result.left_read_only.push({ repository: repo.full_name, permission, - why: "builds no registered module (the indexer names none from it), so the account is given no write there" }); + why: "builds no registered module (the catalog half names none from it), so the account is given no write there" }); } } return result; @@ -257,7 +266,7 @@ export class DeliveredWrites { /** Every delivered repository the account cannot write, as the forge says now, and why. */ async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[]; merge_blocked: { repository: string; why: string }[] }> { - if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`); + if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view held`); const delivered = this.delivered(); const out: { repository: string; permission: string; why: string }[] = []; const blocked: { repository: string; why: string }[] = []; @@ -295,7 +304,7 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] { return [ { name: "gitea_unwritable_repos", - description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " + + description: "Every repository that builds a registered module (the catalog half's trunk revision, read over the bus) on which " + "the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " + "permission the forge gives it there and why: such a pull request can be checked but never merged, and the " + "delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " + diff --git a/modules/gitlab/module.json b/modules/gitlab/module.json index 3cb33fb27..960832669 100644 --- a/modules/gitlab/module.json +++ b/modules/gitlab/module.json @@ -1,6 +1,6 @@ { "module": "gitlab", - "version": "1", + "version": "0.1", "own-secrets": { "token": "${dir:state}/token" }, diff --git a/modules/gnome-keyring/module.json b/modules/gnome-keyring/module.json index 9f75b9ff4..76ca633c5 100644 --- a/modules/gnome-keyring/module.json +++ b/modules/gnome-keyring/module.json @@ -1,6 +1,6 @@ { "module": "gnome-keyring", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/go/module.json b/modules/go/module.json index 9783eda42..ca0264bd8 100644 --- a/modules/go/module.json +++ b/modules/go/module.json @@ -1,6 +1,6 @@ { "module": "go", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager" ], diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 1cbf995fd..ce84cc904 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -1,6 +1,6 @@ { "module": "grafana", - "version": "1", + "version": "0.1", "emits": [ "alert.firing" ], diff --git a/modules/hello-web/module.json b/modules/hello-web/module.json index 005578cd5..bbe7fd9af 100644 --- a/modules/hello-web/module.json +++ b/modules/hello-web/module.json @@ -1,7 +1,7 @@ { "module": "hello-web", "slug": "hello", - "version": "1", + "version": "0.1", "capabilities": [ "container-runtime" ], diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 9c4f99db9..f43735649 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -1,6 +1,6 @@ { "module": "home-assistant", - "version": "1", + "version": "0.1", "slug": "hass", "capabilities": [ "container-runtime" diff --git a/modules/hostname/module.json b/modules/hostname/module.json index e84e7be25..94ec44788 100644 --- a/modules/hostname/module.json +++ b/modules/hostname/module.json @@ -1,6 +1,6 @@ { "module": "hostname", - "version": "1", + "version": "0.1", "claims": [ { "name": "node-hostname", diff --git a/modules/i3/module.json b/modules/i3/module.json index 1ec924268..04a5810b4 100644 --- a/modules/i3/module.json +++ b/modules/i3/module.json @@ -1,6 +1,6 @@ { "module": "i3", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index 1e07556b1..8e052bf67 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -1,6 +1,6 @@ { "module": "i3status-rust", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 6f9ab549e..052147018 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -1,6 +1,6 @@ { "module": "icecast", - "version": "1", + "version": "0.1", "requires": [ "route", "secret" diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index 13a7905f7..23e0e4b25 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -1,6 +1,6 @@ { "module": "influxdb", - "version": "1", + "version": "0.1", "provides": [ { "name": "influxdb-api", diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json index 29670a3e6..89ace98a3 100644 --- a/modules/invoicing/module.json +++ b/modules/invoicing/module.json @@ -1,6 +1,6 @@ { "module": "invoicing", - "version": "1", + "version": "0.1", "slug": "invoice", "capabilities": [ "container-runtime" diff --git a/modules/jira/module.json b/modules/jira/module.json index 9f98dd835..81b7e845d 100644 --- a/modules/jira/module.json +++ b/modules/jira/module.json @@ -1,6 +1,6 @@ { "module": "jira", - "version": "1", + "version": "0.1", "own-secrets": { "token": "${dir:state}/token" }, diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 7a111ea0b..d5fd22bc9 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -1,6 +1,6 @@ { "module": "kernel", - "version": "1", + "version": "0.1", "capabilities": [ "service-manager" ], diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 84c08f10b..38f9b0610 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -1,6 +1,6 @@ { "module": "keycloak", - "version": "1", + "version": "0.1", "moved": true, "upgrade": { "policy": "record", diff --git a/modules/lab/module.json b/modules/lab/module.json index 580bc77e2..4bddf1725 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -1,6 +1,6 @@ { "module": "lab", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "container-runtime", diff --git a/modules/lemurs/module.json b/modules/lemurs/module.json index 702a9596b..668e94a1f 100644 --- a/modules/lemurs/module.json +++ b/modules/lemurs/module.json @@ -1,6 +1,6 @@ { "module": "lemurs", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager", diff --git a/modules/letta/module.json b/modules/letta/module.json index de7baf643..d97ba80cb 100644 --- a/modules/letta/module.json +++ b/modules/letta/module.json @@ -1,6 +1,6 @@ { "module": "letta", - "version": "1", + "version": "0.1", "capabilities": [ "container-runtime" ], diff --git a/modules/local-model-consumer/module.json b/modules/local-model-consumer/module.json index 730020920..47403651f 100644 --- a/modules/local-model-consumer/module.json +++ b/modules/local-model-consumer/module.json @@ -1,6 +1,6 @@ { "module": "local-model-consumer", - "version": "1", + "version": "0.1", "slug": "local", "requires": [ "model-access" diff --git a/modules/localization/cmd/localization-tools/manifest_test.go b/modules/localization/cmd/localization-tools/manifest_test.go index 2c6ab5b61..6fca0e4e8 100644 --- a/modules/localization/cmd/localization-tools/manifest_test.go +++ b/modules/localization/cmd/localization-tools/manifest_test.go @@ -11,7 +11,7 @@ import ( func TestTheFilesSayWhatTheToolsCompareAgainst(t *testing.T) { m := manifest(t) - if m.Module != "localization" || m.Version != "1" { + if m.Module != "localization" || m.Version != "0.1" { t.Fatalf("%s %s", m.Module, m.Version) } locale := m.resource(t, "locale") diff --git a/modules/localization/module.json b/modules/localization/module.json index 0974ed382..14d5845e8 100644 --- a/modules/localization/module.json +++ b/modules/localization/module.json @@ -1,6 +1,6 @@ { "module": "localization", - "version": "1", + "version": "0.1", "capabilities": [ "service-manager" ], diff --git a/modules/logrotate/module.json b/modules/logrotate/module.json index 8318451b7..1c41ac39b 100644 --- a/modules/logrotate/module.json +++ b/modules/logrotate/module.json @@ -1,6 +1,6 @@ { "module": "logrotate", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 0faccc58e..c1dd44617 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -1,6 +1,6 @@ { "module": "mailu", - "version": "1", + "version": "0.1", "upgrade": { "policy": "record", "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" diff --git a/modules/marrytts/module.json b/modules/marrytts/module.json index ed242aa32..ddadf075c 100644 --- a/modules/marrytts/module.json +++ b/modules/marrytts/module.json @@ -1,6 +1,6 @@ { "module": "marrytts", - "version": "1", + "version": "0.1", "capabilities": [ "container-runtime" ], diff --git a/modules/matrix/module.json b/modules/matrix/module.json index 170719ccd..3c6282545 100644 --- a/modules/matrix/module.json +++ b/modules/matrix/module.json @@ -1,6 +1,6 @@ { "module": "matrix", - "version": "1", + "version": "0.1", "capabilities": [ "container-runtime" ], diff --git a/modules/memory-pressure/module.json b/modules/memory-pressure/module.json index 1af96e30a..6fe32ff11 100644 --- a/modules/memory-pressure/module.json +++ b/modules/memory-pressure/module.json @@ -1,6 +1,6 @@ { "module": "memory-pressure", - "version": "1", + "version": "0.1", "capabilities": [ "package-manager", "service-manager" diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index d62555dd2..ceee31df6 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -149,11 +149,11 @@ seat nothing holds, fails the step `not stored: : A build the builder numbers later is published late: the module consumes the builder's `version.numbered` and, for a bundle or an archive with a digest, publishes that held digest under its version, naming its one part by digest and sending nothing (`latepublish.go`); a holder that no longer holds the digest fails the event, which is offered again. Every -ask of a seat a build module makes (a publish, a late publish, an image's copy) is narrated by `buildstep/narrate.go`: +ask of a seat a build module makes (a publish, a late publish, an image's copy) is narrated by the delivery SDK's `steprun/narrate.go`: asked, waiting on the seat every five minutes while it has not answered, stored or refused, each the ask's phase on its running step key (logged outside an ask). A contracts module (a package artifact naming `generated-from`) has its generated Go files regenerated with the seat -generator this module is built with and compared (`buildstep/generated.go`): the unit level of `build_go_test` fails +generator this module is built with and compared (the delivery SDK's `steprun/generated.go`): the unit level of `build_go_test` fails naming each file that differs, is missing or was not written by the generator (its `out-of-step`), and a build ask answers the same files in its answer's `out-of-step`, a failed build's too, so a trunk build's end can raise `generated-types-out-of-step` where no test step runs. Its TypeScript files are mesh-build-node's to compare. @@ -174,19 +174,17 @@ and xz, read in Go as the build agent reads it, issue 1590); it runs no command ## The runner's package, and what a build says at its end (ADR 0593) -- The build runner (the pull of asks, the step lifecycle, the toolchain guard, the fetch, the parsers of `go test -json` and - `node --test`, `Pack`, the conformance kit and the `mesh-pack` command) is this module's package - `git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep`, a Go module of its own in `buildstep/`. mesh-build-node - and mesh-build-image require it and replace it to the sibling directory, since it is the same repository. It is declared - as this module's Go package artifact `buildstep`, series `0.1` (ADR 0606): a user in another repository accepts its - series, never a commit. What the delivery - parts read of a step (the failure contract, the fetch request, the stale-step sweep, the step id) stays in mesh-sdk's - `buildstep`. -- The seat generator (design 26 *Contracts modules*) is the package `buildstep/seatgen` and the command `buildstep/cmd/seatgen`, +- The build runner (the pull of asks, the step lifecycle, the toolchain guard, the fetch and release, the regenerate-and-compare + step, the parsers of `go test -json` and `node --test`, `Pack`) is the delivery SDK's package `steprun` + (`git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun`), with the per-node CPU share `steprun/buildcpu` and + the conformance kit `steprun/conformance` (design 47 G §464, §680). mesh-build-go, mesh-build-node and mesh-build-image + require the delivery SDK at the zero version and keep no copy of it. What every delivery part reads of a step (the + failure contract, the fetch request, the stale-step sweep, the step id) is the delivery SDK's `buildstep`. +- The seat generator (design 26 *Contracts modules*) is the delivery SDK's package `seatgen` and its command `cmd/seatgen`, one program writing a contracts module's Go and TypeScript (`seatgen `; `seatgen -check ` names every path out of - step and runs the client and artifact checks). Its version is buildstep's, so the build input of a compare is the - mesh-build-go (or mesh-build-node) build itself: a new generator is a new build module and a new fingerprint. A contracts - repository runs it as `go run git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/cmd/seatgen@ -check .`. + step and runs the client and artifact checks); `mesh-pack` is the delivery SDK's `cmd/mesh-pack`. The build input of a + compare is the build module's build, which takes the delivery SDK it was built against. A contracts repository runs it + as `go run git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/cmd/seatgen@ -check .`. - `build_go_imports` (a tool, and the last step of a build ask that built an artifact) answers the mesh packages the module's Go programs import: `go list -deps` of each Go bundle's program, tests left out, the packages of the forge outside the module's own directory (a sibling package of its own repository counts), sorted and each once. A build ask carries them as diff --git a/modules/mesh-build-go/buildstep/askrun.go b/modules/mesh-build-go/buildstep/askrun.go deleted file mode 100644 index 027b3e1ba..000000000 --- a/modules/mesh-build-go/buildstep/askrun.go +++ /dev/null @@ -1,550 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "fmt" - "maps" - "path" - "slices" - "sort" - "strings" - - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - trunk "git.novox.be/novox/mesh-delivery/sdk/trunk" -) - -// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies, -// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then -// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest, -// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as -// it judged the tools' answers. - -// StepFunc is one step tool's body: its arguments in, its answer out. -type StepFunc func(ctx context.Context, args map[string]any) (any, error) - -// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's. -type AskSteps struct { - Fetch, Build, Copy, Digest, Test StepFunc - // Imports reads the mesh packages the built programs import (the Go module's alone, ADR 0593 §2): it answers - // {"imports": [...]}, sorted, from the workspace the build ran in. A build that built an artifact asks it once, at - // its end; nil reports none. - Imports StepFunc - // Compare regenerates a contracts module's generated files (a package artifact naming generated-from) and answers - // {"out-of-step": [...]} (CompareStep): a build ask runs it once on the fetched workspace and answers the paths in - // its out-of-step, a failed build's too, so a trunk build's end names them where no test step runs. Nil compares none. - Compare StepFunc - // Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing - // (an image or an archive names none). - Toolchain func(ctx context.Context) (sdkstep.ToolchainUsed, error) - // Kept looks up a kept verdict by level and digest (the checker's kept bucket, the checker seat's kept key): whether one is - // kept and how many tests it counted. Nil takes none. - Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error) - // Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to - // the module's sweep. - Release func(workspace string) error - Logf func(format string, args ...any) - // Has says whether this node has a need a package's tests declare (sdkstep.AskNeeds, issue 890); a package needing - // what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing. - Has func(need string) bool -} - -// RunAsk runs ask's job with steps and answers a sdkstep.BuildAnswer or a sdkstep.CheckAnswer. -func RunAsk(ctx context.Context, steps AskSteps, ask sdkstep.StepAsk) (any, error) { - if ask.Build != nil { - job := *ask.Build - job.Fetch = withPackages(job.Fetch, ask.Packages) - return steps.build(ctx, job) - } - if ask.Check != nil { - job := *ask.Check - job.Fetch = *withPackages(&job.Fetch, ask.Packages) - return steps.check(ctx, job) - } - return nil, Refuse("the ask %s holds no job", ask.ID) -} - -// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module -// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that -// fetches nothing, is left as it is. -func withPackages(fetch *sdkstep.Fetch, packages sdkstep.PackageVersions) *sdkstep.Fetch { - if len(packages) == 0 || fetch == nil { - return fetch - } - out := *fetch - out.Packages = maps.Clone(packages) - return &out -} - -// FetchArgs is a fetch as the fetch tool takes it (sdkstep.FetchRequest's Args and the build module's own, beside): the -// ask's fetch through the one JSON form, without the layout version, which no tool takes. -func FetchArgs(f sdkstep.Fetch) (map[string]any, error) { - b, err := json.Marshal(f) - if err != nil { - return nil, err - } - args := map[string]any{} - if err := json.Unmarshal(b, &args); err != nil { - return nil, err - } - delete(args, "v") - return args, nil -} - -func raw(v any) (json.RawMessage, error) { - if r, ok := v.(json.RawMessage); ok { - return r, nil - } - return json.Marshal(v) -} - -// fetched is what an ask reads of a fetch's answer. -type fetched struct { - Workspace string `json:"workspace"` - Merge string `json:"merge"` - Conflicts []string `json:"conflicts"` - Changed []string `json:"changed"` - Modules []string `json:"modules"` -} - -func (s AskSteps) fetch(ctx context.Context, fetch *sdkstep.Fetch) (json.RawMessage, fetched, error) { - if s.Fetch == nil { - return nil, fetched{}, Refuse("this build module fetches nothing") - } - if fetch == nil { - return nil, fetched{}, Refuse("the ask names no fetch") - } - args, err := FetchArgs(*fetch) - if err != nil { - return nil, fetched{}, err - } - if fetch.Base != "" { - Phase(ctx, "fetch and merge") - } else { - Phase(ctx, "fetch") - } - out, err := s.Fetch(ctx, args) - if err != nil { - return nil, fetched{}, err - } - r, err := raw(out) - if err != nil { - return nil, fetched{}, err - } - var f fetched - if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" { - return r, f, fmt.Errorf("the fetch answered no workspace: %s", r) - } - return r, f, nil -} - -// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when -// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and -// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2). -func (s AskSteps) onToolchain(ctx context.Context, want *sdkstep.ToolchainUsed) (*sdkstep.ToolchainUsed, error) { - if want == nil || s.Toolchain == nil { - return nil, nil - } - tc, err := s.Toolchain(ctx) - if err != nil { - return nil, err - } - if tc.Version != want.Version || tc.Recipe != want.Recipe { - return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)}, - Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain", - want.Version, want.Recipe, tc.Version, tc.Recipe)} - } - return &tc, nil -} - -func (s AskSteps) build(ctx context.Context, job sdkstep.BuildJob) (any, error) { - answer := sdkstep.BuildAnswer{} - tc, err := s.onToolchain(ctx, job.Toolchain) - if err != nil { - return nil, err - } - answer.Toolchain = tc - var f fetched - defer func() { s.release(f.Workspace) }() - for _, a := range job.Artifacts { - if a.From == "" && f.Workspace == "" { - r, got, err := s.fetch(ctx, job.Fetch) - if err != nil { - return nil, err - } - if job.Fetch.Base != "" { - // A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the - // trunk, and building that would store the trunk's build under the candidate's fingerprint. - if len(got.Conflicts) > 0 { - return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", ")) - } - if got.Merge == "" { - return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r) - } - } - f = got - if s.Compare != nil { - Phase(ctx, "compare the generated files") - out, err := s.Compare(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path}) - if err != nil { - return nil, fmt.Errorf("the generated files of %s: %w", job.Module, err) - } - r, err := raw(out) - if err != nil { - return nil, err - } - var read struct { - OutOfStep []string `json:"out-of-step"` - } - if err := json.Unmarshal(r, &read); err != nil { - return nil, fmt.Errorf("the compare of %s answered what is not a list: %s", job.Module, r) - } - answer.OutOfStep = read.OutOfStep - } - } - var ( - out any - err error - ) - if a.From != "" { - if s.Copy == nil { - return nil, Refuse("this build module copies no artifact (%s)", a.Artifact) - } - Phase(ctx, "copy "+a.Artifact) - out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From}) - } else { - if s.Build == nil { - return nil, Refuse("this build module builds no artifact (%s)", a.Artifact) - } - args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact} - if job.Publish { - args["publish"] = true - } - // The seat it is published through and the version the builder gave it (design 51 *Publishing*, CAT-BG2). - if a.Seat != "" { - args["seat"] = a.Seat - } - if a.Version != "" { - args["version"] = a.Version - } - Phase(ctx, "build "+a.Artifact) - out, err = s.Build(ctx, args) - } - if err != nil { - // The compare's out-of-step is answered with the failure, so the build's end still names it. - if len(answer.OutOfStep) > 0 { - return answer, fmt.Errorf("%s: %w", a.Artifact, err) - } - return nil, fmt.Errorf("%s: %w", a.Artifact, err) - } - r, err := raw(out) - if err != nil { - return nil, err - } - var built struct { - Digest string `json:"digest"` - Reference string `json:"reference"` - } - if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" { - return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r) - } - answer.Artifacts = append(answer.Artifacts, sdkstep.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) - } - if s.Imports != nil && f.Workspace != "" { - out, err := s.Imports(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path}) - if err != nil { - return nil, fmt.Errorf("the imports of %s: %w", job.Module, err) - } - r, err := raw(out) - if err != nil { - return nil, err - } - var read struct { - Imports []string `json:"imports"` - } - if err := json.Unmarshal(r, &read); err != nil { - return nil, fmt.Errorf("the imports of %s answered what is not a list: %s", job.Module, r) - } - if err := trunk.CheckImports(read.Imports); err != nil { - return nil, err - } - answer.Imports = read.Imports - } - return answer, nil -} - -func (s AskSteps) check(ctx context.Context, job sdkstep.CheckJob) (any, error) { - if _, err := s.onToolchain(ctx, job.Toolchain); err != nil { - return nil, err - } - r, f, err := s.fetch(ctx, &job.Fetch) - if err != nil { - return nil, err - } - defer s.release(f.Workspace) - answer := sdkstep.CheckAnswer{} - if err := json.Unmarshal(r, &answer.Fetch); err != nil { - return nil, fmt.Errorf("the fetch answered what is not a fetch answer: %s: %w", r, err) - } - if len(f.Conflicts) > 0 { - return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error - } - if s.Test == nil { - return nil, Refuse("this build module tests nothing") - } - runner := "" - if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) { - runner = job.Runner - } - // Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator, - // 2026-11-02: "A failed check shows only its first fault"). - for _, level := range job.Levels { - la := sdkstep.LevelAnswer{Level: level} - _, err := s.level(ctx, job, level, f, runner, &la) - answer.Levels = append(answer.Levels, la) - if err != nil { - // A level that errors after the fetch merged (it does not compile or generate) ends the step failed with its - // answer all the same: its fetch and the levels so far beside the error, which the step's end keeps, so the - // checker previews the merge from it (design 47 G, A-286). - return answer, err - } - } - return answer, nil -} - -// level runs one level of a check ask and says whether it passed. -func (s AskSteps) level(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, la *sdkstep.LevelAnswer) (bool, error) { - withRunner := func(args map[string]any) map[string]any { - if runner != "" { - args["runner"] = runner - } - if len(job.NodeNames) > 0 { - args[sdkstep.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12) - } - return args - } - test := func(args map[string]any) (bool, error) { - what := "the whole level" - if dir, _ := args["module"].(string); dir != "" { - what = dir - } else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 { - what = fmt.Sprintf("%d packages", len(pkgs)) - } - Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what)) - out, err := s.Test(ctx, args) - if err != nil { - return false, err - } - r, err := raw(out) - if err != nil { - return false, err - } - la.Tests = append(la.Tests, r) - var t struct { - Passed bool `json:"passed"` - } - _ = json.Unmarshal(r, &t) - return t.Passed, nil - } - // The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every - // other test step of the level, also after one that did not pass, so one check names every failing test of the level - // and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did. - all := true - if s.Digest == nil { - // A module with no digest (Node) tests each module directory of the tree, those whose tests changed first. - for _, priority := range []bool{true, false} { - for _, dir := range f.Modules { - if changesTestIn(f.Changed, dir) != priority { - continue - } - passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level})) - if err != nil { - return false, fmt.Errorf("the %s level of %s: %w", level, dir, err) - } - all = all && passed - } - } - return all, nil - } - todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la) - if !keepRunner { - runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2) - } - if whole && len(job.Packages) > 0 { - todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4) - } - for _, priority := range []bool{true, false} { - args := withRunner(map[string]any{"workspace": f.Workspace, "level": level}) - if job.Run != "" { - args["run"] = job.Run - } - if whole { - if priority { - continue // nothing to put first: the level runs whole after - } - } else { - var run []string - for _, p := range todo { - if changesTestOf(f.Changed, p) == priority { - run = append(run, p) - } - } - if len(run) == 0 { - continue - } - args["packages"] = run - } - passed, err := test(args) - if err != nil { - return false, fmt.Errorf("the %s level: %w", level, err) - } - all = all && passed // the others still run after a failure (issue 1921) - } - return all, nil -} - -// release removes an ask's workspace, saying a failure: the module's sweep removes it later. -func (s AskSteps) release(workspace string) { - if workspace == "" || s.Release == nil { - return - } - if err := s.Release(workspace); err != nil && s.Logf != nil { - s.Logf("the workspace %s is left to the sweep: %v", workspace, err) - } -} - -// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and -// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the -// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing -// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is -// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package -// or does not cover the siblings the fetch placed (ADR 0444, L5). -func (s AskSteps) plan(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *sdkstep.LevelAnswer) ([]string, bool, bool) { - if job.Run != "" { - return nil, true, true - } - Phase(ctx, level+": digest") - out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level})) - if err != nil { - if s.Logf != nil { - s.Logf("no digest of the %s level, so it runs whole: %v", level, err) - } - return nil, true, true - } - r, err := raw(out) - if err != nil { - return nil, true, true - } - var d sdkstep.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930) - if err := json.Unmarshal(r, &d); err != nil { - return nil, true, true - } - if runner != "" { - if strings.HasPrefix(d.JudgedBy, "candidate ") { - la.Judged = d.JudgedBy - return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept - } - runner = "" - } - if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) { - return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole - } - la.Digest = &d - only := map[string]bool{} - for _, p := range job.Packages { - only[p] = true - } - var todo []string - for _, pd := range d.Packages { - p := pd.Package - if len(only) > 0 && !only[p] { - continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4) - } - if job.Kept && s.Kept != nil { - tests, found, err := s.Kept(ctx, level, pd.Digest) - if err != nil && s.Logf != nil { - s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err) - } - if err == nil && found { - la.Kept = append(la.Kept, sdkstep.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests}) - continue - } - } - if needs := d.NeedsOf(p); len(sdkstep.Lacks(needs, s.Has)) > 0 { - // Its tests would only skip here (issue 890): it is left for a node that has what they need. - la.Unmet = append(la.Unmet, sdkstep.PackageNeeds{Package: p, Needs: needs}) - if s.Logf != nil { - s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p, - strings.Join(needs, ", "), strings.Join(sdkstep.Lacks(needs, s.Has), ", ")) - } - continue - } - todo = append(todo, p) - } - return todo, false, false -} - -// besideOf are the siblings a fetch's arguments place, as a digest echoes them: ` `, sorted. -func besideOf(fetch sdkstep.Fetch) []string { - var out []string - for _, r := range fetch.Beside { - out = append(out, r.Repository+" "+r.Commit) - } - sort.Strings(out) - return out -} - -// changesUnder says whether a path the merge changes is under prefix. -func changesUnder(changed []string, prefix string) bool { - for _, p := range changed { - if strings.HasPrefix(p, prefix) { - return true - } - } - return false -} - -// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test. -// and .spec. files and test directories. -func isTestFile(p string) bool { - switch { - case strings.HasSuffix(p, "_test.go"): - return true - case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."): - return true - } - for _, seg := range strings.Split(path.Dir(p), "/") { - if seg == "__tests__" || seg == "test" || seg == "tests" { - return true - } - } - return false -} - -// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's -// directory and the import path agree on their last segments. -func changesTestOf(changed []string, pkg string) bool { - for _, f := range changed { - if !strings.HasSuffix(f, "_test.go") { - continue - } - dir := path.Dir(f) - if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) { - return true - } - } - return false -} - -// changesTestIn says whether the merge changes a test file inside the directory of a Node module. -func changesTestIn(changed []string, dir string) bool { - for _, f := range changed { - if !isTestFile(f) || strings.HasSuffix(f, "_test.go") { - continue - } - if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") { - return true - } - } - return false -} diff --git a/modules/mesh-build-go/buildstep/askrun_test.go b/modules/mesh-build-go/buildstep/askrun_test.go deleted file mode 100644 index 869bc859d..000000000 --- a/modules/mesh-build-go/buildstep/askrun_test.go +++ /dev/null @@ -1,398 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "testing" - "time" - - nodebuildgo "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" - nodebuildnode "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" -) - -const runCommit = "0123456789abcdef0123456789abcdef01234567" - -// recorder is a build module's step tools that record each call. -type recorder struct { - calls []string - fetch map[string]any - tests map[string]bool // package or module directory -> passed - digests map[string]string - needs []sdkstep.PackageNeeds -} - -func (r *recorder) steps() AskSteps { - call := func(name string, answer func(args map[string]any) any) StepFunc { - return func(ctx context.Context, args map[string]any) (any, error) { - b, _ := json.Marshal(args) - r.calls = append(r.calls, name+" "+string(b)) - return answer(args), nil - } - } - return AskSteps{ - Fetch: call("fetch", func(map[string]any) any { return r.fetch }), - Build: call("build", func(args map[string]any) any { - return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)} - }), - Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }), - Digest: call("digest", func(map[string]any) any { - return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs} - }), - Test: call("test", func(args map[string]any) any { - passed := true - if pkgs, ok := args["packages"].([]string); ok { - for _, p := range pkgs { - passed = passed && r.tests[p] - } - } - if dir, ok := args["module"].(string); ok { - passed = r.tests[dir] - } - return map[string]any{"passed": passed, "tests": 1} - }), - Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil }, - } -} - -func ask(job any) sdkstep.StepAsk { - a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "t", At: time.Now()} - switch j := job.(type) { - case sdkstep.BuildJob: - a.Build = &j - case sdkstep.CheckJob: - a.Check = &j - } - return a -} - -// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a -// fetch, and answers each artifact's digest and reference; an artifact's seat and the version the builder gave it reach -// its build (CAT-BG2). -func TestRunAskBuilds(t *testing.T) { - r := &recorder{fetch: map[string]any{"workspace": "/w/1"}} - steps := r.steps() - var released []string - steps.Release = func(ws string) error { released = append(released, ws); return nil } - // ADR 0593 §2: a Go module's build ends by saying which mesh packages its programs import. - steps.Imports = func(ctx context.Context, args map[string]any) (any, error) { - b, _ := json.Marshal(args) - r.calls = append(r.calls, "imports "+string(b)) - return map[string]any{"imports": []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/health"}}, nil - } - // Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were. - a := ask(sdkstep.BuildJob{Module: "m", Path: "modules/m", Publish: true, - Fetch: &sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, - Artifacts: []sdkstep.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib", Seat: "go-package-registry", Version: "v0.1.15"}}}) - a.Packages = sdkstep.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit} - out, err := RunAsk(context.Background(), steps, a) - if err != nil { - t.Fatal(err) - } - if len(a.Build.Fetch.Packages) != 0 { - t.Error("the ask's fetch arguments were changed") - } - got := strings.Join(r.calls, "\n") - want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} -fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"} -build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"} -build {"artifact":"lib","module":"modules/m","publish":true,"seat":"go-package-registry","version":"v0.1.15","workspace":"/w/1"} -imports {"module":"modules/m","workspace":"/w/1"}` - if got != want { - t.Fatalf("calls:\n%s\nwant:\n%s", got, want) - } - if len(released) != 1 || released[0] != "/w/1" { - t.Errorf("released %v, want the one workspace", released) - } - answer := out.(sdkstep.BuildAnswer) - if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { - t.Fatalf("answer %+v", answer) - } - if !slices.Equal(answer.Imports, []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/health"}) { - t.Errorf("the answer's imports are %v", answer.Imports) - } -} - -// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again. -func TestRunAskRefusesAnotherToolchain(t *testing.T) { - r := &recorder{fetch: map[string]any{"workspace": "/w"}} - s := r.steps() - s.Toolchain = func(context.Context) (sdkstep.ToolchainUsed, error) { - return sdkstep.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil - } - _, err := RunAsk(context.Background(), s, ask(sdkstep.BuildJob{Module: "m", Path: ".", Fetch: &sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, - Artifacts: []sdkstep.AskArtifact{{Artifact: "bundle"}}, Toolchain: &sdkstep.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) - var dec *Declining - if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 { - t.Fatalf("err %v, calls %v", err, r.calls) - } - // A check ask names the toolchain too (ADR 0584 clause 2), and a node on another declines it before it fetches. - check := ask(sdkstep.CheckJob{Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, Levels: []string{sdkstep.LevelUnit}, - Toolchain: &sdkstep.ToolchainUsed{Version: "go1.26", Recipe: "3"}}) - _, err = RunAsk(context.Background(), s, check) - if !errors.As(err, &dec) || len(r.calls) != 0 { - t.Fatalf("a check ask: err %v, calls %v", err, r.calls) - } -} - -// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept -// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs -// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02). -func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { - r := &recorder{ - fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}}, - digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"}, - tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false}, - } - out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit, Base: runCommit}})) - if err != nil { - t.Fatal(err) - } - got := strings.Join(r.calls, "\n") - want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"} -digest {"level":"unit","workspace":"/w"} -test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"} -test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"} -digest {"level":"integration","workspace":"/w"} -test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"} -test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` - if got != want { - t.Fatalf("calls:\n%s\nwant:\n%s", got, want) - } - a := out.(sdkstep.CheckAnswer) - if a.Fetch.Workspace != "/w" || !slices.Equal(a.Fetch.Changed, []string{"pkg/b/b_test.go"}) { - t.Fatalf("the answer's fetch is the tool's whole answer, typed: %+v", a.Fetch) - } - if len(a.Levels) != 2 { - t.Fatalf("levels %+v, want both levels", a.Levels) - } - u := a.Levels[0] - if u.Level != sdkstep.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil { - t.Fatalf("the unit level %+v", u) - } - - // ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when - // they change). - r.calls = nil - if _, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, NodeNames: []string{"anchor"}, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) || - !strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) { - t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err) - } - - // Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is - // tested on a node that has it. - for _, has := range []bool{false, true} { - r.calls, r.tests["x/pkg/b"] = nil, true - r.needs = []sdkstep.PackageNeeds{{Package: "x/pkg/a", Needs: []string{sdkstep.NeedNodeBuildNode}}} - steps := r.steps() - if has { - steps.Has = func(n string) bool { return n == sdkstep.NeedNodeBuildNode } - } - out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - u := out.(sdkstep.CheckAnswer).Levels[0] - tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`) - if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || sdkstep.UnmetNeeds([]sdkstep.LevelAnswer{u})[0] != sdkstep.NeedNodeBuildNode)) { - t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls) - } - } - // A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would - // run whole (ADR 0552 §4). - for _, digests := range []map[string]string{r.digests, nil} { - r.calls, r.digests = nil, digests - steps := r.steps() - steps.Has = func(string) bool { return true } - out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"}, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - u := out.(sdkstep.CheckAnswer).Levels[0] - if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 { - t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept) - } - } -} - -// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says -// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as -// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too. -func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) { - for _, tc := range []struct{ judged, want string }{ - {"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"} -test {"level":"unit","runner":"workspace","workspace":"/w"}`}, - {"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`}, - } { - r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}}, - digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}} - steps := r.steps() - digest := steps.Digest - steps.Digest = func(ctx context.Context, args map[string]any) (any, error) { - _, _ = digest(ctx, args) - return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil - } - out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - if got := strings.Join(r.calls[1:], "\n"); got != tc.want { - t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want) - } - l := out.(sdkstep.CheckAnswer).Levels[0] - if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") { - t.Errorf("an installed module's level %+v, want the kept verdict taken", l) - } - if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) { - t.Errorf("the candidate's level %+v, want nothing kept and the program named", l) - } - } - r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}} - out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit, Beside: []sdkstep.FetchBeside{{Repository: "novox/mesh-sdk", Commit: runCommit}}}})) - if err != nil { - t.Fatal(err) - } - if l := out.(sdkstep.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil { - t.Fatalf("a digest without the siblings kept %+v", l) - } -} - -// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch -// alone, a verdict for the asker. -func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) { - r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}}, - tests: map[string]bool{"a": true, "b": true}} - s := r.steps() - s.Digest = nil - if _, err := RunAsk(context.Background(), s, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})); err != nil { - t.Fatal(err) - } - if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` { - t.Fatalf("calls %v", r.calls) - } - r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}} - out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil || len(r.calls) != 1 || len(out.(sdkstep.CheckAnswer).Levels) != 0 { - t.Fatalf("a conflict: %v %v", err, r.calls) - } - // A-286: a level that errors after the fetch merged ends the step failed with its answer all the same, its fetch and - // the levels so far beside the error, and the step's end keeps it, so the checker previews the merge from it. - r = &recorder{fetch: map[string]any{"workspace": "/w", "merge": "m1", "modules": []string{"a"}}, tests: map[string]bool{"a": true}} - s = r.steps() - s.Digest = nil - passing := s.Test - s.Test = func(ctx context.Context, args map[string]any) (any, error) { - if args["level"] == sdkstep.LevelIntegration { - return nil, errors.New("does not compile") - } - return passing(ctx, args) - } - errs := ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}}) - out, err = RunAsk(context.Background(), s, errs) - if a, _ := out.(sdkstep.CheckAnswer); err == nil || a.Fetch.Merge != "m1" || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 1 { - t.Fatalf("an erring level: %v, answer %+v: want the error beside the fetch and both levels", err, out) - } - k := newKeys() - id, err := Start(context.Background(), k.put, "ace", "test", func(ctx context.Context) (any, error) { return RunAsk(ctx, s, errs) }) - if err != nil { - t.Fatal(err) - } - k.waitEnd(t) - var kept sdkstep.CheckAnswer - if end := k.last("ace." + id); end.State != sdkstep.StepFailed || json.Unmarshal(end.Result, &kept) != nil || kept.Fetch.Merge != "m1" { - t.Fatalf("the failed end %+v keeps no answer with its fetch", end) - } -} - -// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its -// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check -// names every failing test (issue 1921). A Node check does the same with its module directories. -func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { - for _, row := range []struct { - name string - changed []string - tests map[string]bool - want []string // the packages tested at each level, in order - }{ - {"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}}, - {"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}}, - {"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}}, - } { - r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests, - digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}} - if _, ok := row.tests["x/pkg/c"]; ok { - r.digests["x/pkg/c"] = "d-c" - } - out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - var got []string - for _, c := range r.calls[1:] { - if strings.HasPrefix(c, "test ") { - var a struct{ Packages []string } - _ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a) - got = append(got, strings.Join(a.Packages, " ")) - } - } - if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) { - t.Errorf("%s: tested %v, want %v", row.name, got, want) - } - if a := out.(sdkstep.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) { - t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels) - } - } - - r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}}, - tests: map[string]bool{"a": true, "b": false}} - s := r.steps() - s.Digest = nil - out, err := RunAsk(context.Background(), s, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - if a := out.(sdkstep.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 { - t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels) - } -} - -// A seat's own ask type (the generated one of any of the three build seats) is the neutral ask through the form's one -// conversion (sdkstep.AskOf), reads for its step description through the same conversion (askRead), and reaches the -// fetch tool as the fetch's arguments alone: no layout version, nothing of the ask's. -func TestASeatsAskTypeRunsAsTheNeutralAsk(t *testing.T) { - at := time.Now().UTC() - fetch := &nodebuildgo.Fetch{Commit: runCommit, Repository: "novox/x", Dir: "modules/m", Paths: []string{"src"}} - goAsk := nodebuildgo.StepAsk{V: sdkstep.StepAskVersion, ID: sdkstep.NewID(), Kind: sdkstep.AskDelivery, Language: sdkstep.AskGo, Asker: "mesh-delivery-builder", At: &at, - Build: &nodebuildgo.StepAskBuild{Module: "m", Path: "modules/m", Fetch: fetch, Artifacts: []nodebuildgo.AskArtifact{{Artifact: "bundle"}}}, - Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": runCommit}} - a, err := sdkstep.AskOf(goAsk) - if err != nil { - t.Fatal(err) - } - if read := askRead(a); read.Kind != sdkstep.AskDelivery || read.Build == nil || read.Build.Module != "m" { - t.Errorf("the step description reads %+v", read) - } - r := &recorder{fetch: map[string]any{"workspace": "/w"}} - if _, err := RunAsk(context.Background(), r.steps(), a); err != nil { - t.Fatal(err) - } - want := `fetch {"commit":"` + runCommit + `","dir":"modules/m","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"paths":["src"],"repository":"novox/x"}` - if len(r.calls) == 0 || r.calls[0] != want { - t.Errorf("the fetch tool was called with %v, want %s", r.calls, want) - } - if back, err := sdkstep.AskAs[nodebuildnode.StepAsk](a); err != nil || back.Build == nil || back.Build.Module != "m" { - t.Errorf("the ask as another seat's type: %+v, %v", back, err) - } -} diff --git a/modules/mesh-build-go/buildstep/buildcpu/buildcpu.go b/modules/mesh-build-go/buildstep/buildcpu/buildcpu.go deleted file mode 100644 index dcd2f364b..000000000 --- a/modules/mesh-build-go/buildstep/buildcpu/buildcpu.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else -// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu -// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power"). -// -// Three parts, each with one owner: -// -// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer -// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in -// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts. -// Interactive and service work always comes first; builds take the cycles nothing else wants. -// - **One cap per node, shared.** The node's share of its processors is one value, the node-engine's own config -// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the -// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module. -// - **Threads.** The quota bounds the total; threads only size each step. A step gets the share divided by its own -// module's capacity (Threads), from the share the composer hands as a word (ShareWord): GOMAXPROCS sets every go -// command's -p, the compiler's concurrency, a test binary's processors and -parallel. A module given no word sets -// no thread limit at all: without the slice there is nothing to size a step to, and a limit then would only slow -// builds. -package buildcpu - -import ( - "os" - "runtime" - "strconv" - "strings" -) - -// ShareSetting is the node-engine's config value of the node's processor share for its builds, in percent of the -// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3). -const ShareSetting = "build-cpu-share" - -// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for -// itself whatever builds. -const DefaultPercent = 50 - -// ShareWord is the word the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, in percent. -const ShareWord = "MESH_BUILD_CPU_SHARE" - -// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one -// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step. -func Quota(percent, cores int) int { - q := percent * cores - if q < 100 { - q = 100 - } - return q -} - -// Threads is how many threads one step may run at once: its part of the share when its own module runs slots steps at -// once, at least one. The slice's quota bounds the steps of all the node's build modules together; this only sizes one. -func Threads(percent, cores, slots int) int { - if slots < 1 { - slots = 1 - } - if percent < 1 || percent > 100 { - percent = DefaultPercent - } - t := cores * percent / 100 / slots - if t < 1 { - t = 1 - } - return t -} - -// Limits is what one step is held to on this node, as a status tool says it. -type Limits struct { - // Percent is the node's share in percent of its processors, and From where it was read: ShareWord, or "none" when - // the composer gave no share and a step runs without a thread limit. - Percent int `json:"share-percent,omitempty"` - From string `json:"share-from"` - // Cores is the machine's processor count. - Cores int `json:"cores"` - // Slots is this module's own capacity on the node: steps at once. - Slots int `json:"slots"` - // Threads is what each step is given (Threads); 0 is no limit. - Threads int `json:"threads-per-step"` -} - -// FromWords is the limits of a step from the share the composer gave this process (ShareWord) and the module's own -// capacity. Without a usable share there is no limit: Threads is 0. -func FromWords(getenv func(string) string, own int) Limits { - if own < 1 { - own = 1 - } - l := Limits{From: "none", Cores: runtime.NumCPU(), Slots: own} - if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 { - l.Percent, l.From = n, ShareWord - l.Threads = Threads(n, l.Cores, own) - } - return l -} - -// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every -// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as -// KEY=value words to append to a command's environment; nothing for 0 threads, no limit. -func Env(threads int) []string { - if threads < 1 { - return nil - } - t := strconv.Itoa(threads) - return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t} -} - -// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice -// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight, -// idle mark and quota. A field the machine does not answer is left empty and Unread says which. -// -// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under -// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds. -type InForce struct { - Nice int `json:"nice"` - Cgroup string `json:"cgroup,omitempty"` - Unit Group `json:"unit"` - Slice Group `json:"slice"` - // Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none. - Quota int `json:"quota-percent,omitempty"` - Unread []string `json:"unread,omitempty"` -} - -// Group is one cgroup's controls. -type Group struct { - Path string `json:"path,omitempty"` - CPUIdle string `json:"cpu-idle,omitempty"` - CPUWeight string `json:"cpu-weight,omitempty"` - // CPUMax is the cgroup's cpu.max: " ", or "max" with no quota. - CPUMax string `json:"cpu-max,omitempty"` - IOWeight string `json:"io-weight,omitempty"` -} - -// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere. -var cgroupRoot = "/sys/fs/cgroup" - -// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere. -var procSelfCgroup = "/proc/self/cgroup" - -// procSelfStat is this process's stat file. A variable so a test can point it elsewhere. -var procSelfStat = "/proc/self/stat" - -// ReadInForce reads what the kernel holds this process to. -func ReadInForce() InForce { - var f InForce - f.Nice = nice() - raw, err := os.ReadFile(procSelfCgroup) - if err != nil { - f.Unread = append(f.Unread, "cgroup: "+err.Error()) - return f - } - for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { - if strings.HasPrefix(line, "0::") { - f.Cgroup = strings.TrimPrefix(line, "0::") - } - } - if f.Cgroup == "" { - f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy") - return f - } - read := func(path string) Group { - g := Group{Path: path} - value := func(name string) string { - b, err := os.ReadFile(cgroupRoot + path + "/" + name) - if err != nil { - f.Unread = append(f.Unread, path+"/"+name+": "+err.Error()) - return "" - } - return strings.TrimSpace(string(b)) - } - g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight") - return g - } - f.Unit = read(f.Cgroup) - if i := strings.LastIndex(f.Cgroup, "/"); i > 0 { - f.Slice = read(f.Cgroup[:i]) - } - for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} { - if q > 0 && (f.Quota == 0 || q < f.Quota) { - f.Quota = q - } - } - return f -} - -// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0. -func quotaOf(cpuMax string) int { - fields := strings.Fields(cpuMax) - if len(fields) != 2 || fields[0] == "max" { - return 0 - } - q, err1 := strconv.Atoi(fields[0]) - p, err2 := strconv.Atoi(fields[1]) - if err1 != nil || err2 != nil || p <= 0 { - return 0 - } - return q * 100 / p -} - -// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing -// parenthesis, which may itself hold spaces); 0 when it cannot be read. -func nice() int { - raw, err := os.ReadFile(procSelfStat) - if err != nil { - return 0 - } - s := string(raw) - i := strings.LastIndex(s, ")") - if i < 0 { - return 0 - } - fields := strings.Fields(s[i+1:]) - // After the command: state is field 3, so nice (field 19) is index 16. - if len(fields) < 17 { - return 0 - } - n, _ := strconv.Atoi(fields[16]) - return n -} diff --git a/modules/mesh-build-go/buildstep/buildcpu/buildcpu_agree_test.go b/modules/mesh-build-go/buildstep/buildcpu/buildcpu_agree_test.go deleted file mode 100644 index bf61988df..000000000 --- a/modules/mesh-build-go/buildstep/buildcpu/buildcpu_agree_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package buildcpu - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The share's names the composer gives are the build modules' (catalogue writes them too, so it does not import this). -func TestTheShareNamesAgreeWithTheCatalogues(t *testing.T) { - if ShareSetting != catalogue.BuildCPUShareSetting || ShareWord != catalogue.BuildCPUShareWord || DefaultPercent != catalogue.BuildCPUDefaultPercent { - t.Fatal("buildcpu's share names differ from catalogue's") - } -} diff --git a/modules/mesh-build-go/buildstep/buildcpu/buildcpu_test.go b/modules/mesh-build-go/buildstep/buildcpu/buildcpu_test.go deleted file mode 100644 index 674068706..000000000 --- a/modules/mesh-build-go/buildstep/buildcpu/buildcpu_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package buildcpu - -import ( - "os" - "path/filepath" - "reflect" - "runtime" - "testing" -) - -// The node's share as a quota, the threads one step is given for its own module's capacity, and the word the composer -// hands, in one table (ADR 0553, issue 1806). -func TestShareQuotaAndThreads(t *testing.T) { - for _, c := range []struct { - name string - percent, cores, slots int - quota, threads int - }{ - {"shanks: half of 24, three slots", 50, 24, 3, 1200, 4}, - {"ace: three quarters of 24, two slots", 75, 24, 2, 1800, 9}, - {"g14: half of 16, one slot", 50, 16, 1, 800, 8}, - {"one processor at least", 10, 2, 4, 100, 1}, - {"no slots counts as one", 100, 4, 0, 400, 4}, - {"a share out of range is the default", 0, 8, 1, 0, 4}, - } { - if c.quota != 0 { - if q := Quota(c.percent, c.cores); q != c.quota { - t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota) - } - } - if th := Threads(c.percent, c.cores, c.slots); th != c.threads { - t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads) - } - } - l := FromWords(func(k string) string { return map[string]string{ShareWord: "75"}[k] }, 2) - if l.Percent != 75 || l.From != ShareWord || l.Slots != 2 || l.Threads != Threads(75, runtime.NumCPU(), 2) { - t.Errorf("from the word: %+v", l) - } - // Told nothing usable, a step has no thread limit: nothing in its environment. - for _, word := range []string{"", "250", "lots"} { - l := FromWords(func(string) string { return word }, 3) - if l.Threads != 0 || l.From != "none" || Env(l.Threads) != nil { - t.Errorf("share %q: %+v, env %v", word, l, Env(l.Threads)) - } - } - if got := Env(3); !reflect.DeepEqual(got, []string{"GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) { - t.Errorf("Env = %q", got) - } -} - -// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed. -func TestReadInForce(t *testing.T) { - dir := t.TempDir() - cg := filepath.Join(dir, "cg") - slice := filepath.Join(cg, "meshyield.slice") - unit := filepath.Join(slice, "mesh-build-go-runtime.service") - if err := os.MkdirAll(unit, 0o755); err != nil { - t.Fatal(err) - } - for at, files := range map[string]map[string]string{ - slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"}, - unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"}, - } { - for name, v := range files { - if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil { - t.Fatal(err) - } - } - } - self := filepath.Join(dir, "cgroup") - stat := filepath.Join(dir, "stat") - _ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644) - // A command with a space and a parenthesis in it, then nice 19 in field 19. - _ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644) - cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat - t.Cleanup(func() { - cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat" - }) - - got := ReadInForce() - want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service", - Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100", - CPUMax: "max 100000", IOWeight: "default 100"}, - Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000", - IOWeight: "default 1"}, - Quota: 1200} - if !reflect.DeepEqual(got, want) { - t.Fatalf("ReadInForce = %+v\nwant %+v", got, want) - } -} diff --git a/modules/mesh-build-go/buildstep/buildhours.go b/modules/mesh-build-go/buildstep/buildhours.go deleted file mode 100644 index 6e3dee470..000000000 --- a/modules/mesh-build-go/buildstep/buildhours.go +++ /dev/null @@ -1,129 +0,0 @@ -package buildstep - -import ( - "encoding/json" - "fmt" - "os" - "strings" - "time" -) - -// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside -// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window -// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node); -// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour. -// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of -// another part). - -// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an -// end before its start crosses midnight (18:00-06:00). -const BuildHoursSetting = "build-hours" - -// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive. -type BuildWindow struct { - Start, End int -} - -// String is the window as the setting writes it. -func (w BuildWindow) String() string { - return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60) -} - -// Open says whether t, in its own location, falls in the window. -func (w BuildWindow) Open(t time.Time) bool { - m := t.Hour()*60 + t.Minute() - if w.Start < w.End { - return m >= w.Start && m < w.End - } - return m >= w.Start || m < w.End -} - -// NextEdge is the first time after t, in t's location, at which the window opens or closes. -func (w BuildWindow) NextEdge(t time.Time) time.Time { - var next time.Time - for day := 0; day <= 1; day++ { - for _, m := range []int{w.Start, w.End} { - e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location()) - if e.After(t) && (next.IsZero() || e.Before(next)) { - next = e - } - } - } - return next -} - -// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty. -func ParseBuildWindow(setting string) (*BuildWindow, error) { - setting = strings.TrimSpace(setting) - if setting == "" { - return nil, nil - } - from, to, ok := strings.Cut(setting, "-") - if !ok { - return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting) - } - start, err := minuteOf(from) - if err != nil { - return nil, fmt.Errorf("the build hours %q: %v", setting, err) - } - end, err := minuteOf(to) - if err != nil { - return nil, fmt.Errorf("the build hours %q: %v", setting, err) - } - if start == end { - return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting) - } - return &BuildWindow{Start: start, End: end}, nil -} - -// minuteOf reads HH:MM as minutes after midnight. -func minuteOf(s string) (int, error) { - var h, m int - s = strings.TrimSpace(s) - if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 { - return 0, fmt.Errorf("%q is not a time HH:MM", s) - } - return h*60 + m, nil -} - -// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is -// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that -// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety -// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through -// logf, and the puller writes it in the capacity record that build_usage shows. -func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) { - said := "" - say := func(why string) { - if logf != nil && why != said { - said = why - logf("%s, so %s builds at any hour", why, node) - } - } - fault := func(why string) (*BuildWindow, string) { - say(why) - return nil, why - } - return func() (*BuildWindow, string) { - if path == "" { - return nil, "" - } - raw, err := os.ReadFile(path) - if err != nil { - return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err)) - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) - } - setting := "" - if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil { - return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v)) - } - w, err := ParseBuildWindow(setting) - if err != nil { - return fault(err.Error()) - } - said = "" - return w, "" - } -} diff --git a/modules/mesh-build-go/buildstep/capacity.go b/modules/mesh-build-go/buildstep/capacity.go deleted file mode 100644 index 14fa874d0..000000000 --- a/modules/mesh-build-go/buildstep/capacity.go +++ /dev/null @@ -1,57 +0,0 @@ -package buildstep - -import ( - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - - "encoding/json" - "fmt" - "os" -) - -// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change -// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and -// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not. -func NodeCapacity(path, node string, logf func(string, ...any)) func() int { - say := func(format string, args ...any) { - if logf != nil { - logf(format, args...) - } - } - return func() int { - n, err := ReadCapacity(path) - if err != nil { - say("%v, so %s pulls nothing", err, node) - return 0 - } - return n - } -} - -// ReadCapacity is the capacity the settings file at path holds, and why it cannot be read when it cannot (then 0). -func ReadCapacity(path string) (int, error) { - if path == "" { - return 0, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return 0, fmt.Errorf("the build capacity cannot be read: %w", err) - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - return 0, fmt.Errorf("the settings file %s is not JSON: %w", path, err) - } - return capacityOf(s[sdkstep.CapacitySetting]) -} - -// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is -// refused, naming it. -func capacityOf(value json.RawMessage) (int, error) { - if len(value) == 0 || string(value) == "null" { - return 0, nil - } - var n int - if err := json.Unmarshal(value, &n); err != nil || n < 0 { - return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value) - } - return n, nil -} diff --git a/modules/mesh-build-go/buildstep/capacity_cpu.go b/modules/mesh-build-go/buildstep/capacity_cpu.go deleted file mode 100644 index ff9e3c829..000000000 --- a/modules/mesh-build-go/buildstep/capacity_cpu.go +++ /dev/null @@ -1,141 +0,0 @@ -package buildstep - -import ( - "encoding/json" - "fmt" - "math" - "os" - "runtime" - "strconv" - "strings" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" -) - -// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26 -// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and -// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's -// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at -// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take -// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.` in the pauses -// state that the delivery view's build_usage reads). - -// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the -// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default -// (DefaultCoresPerStep). -const CoresPerStepSetting = "cores-per-step" - -// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog -// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each, -// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a -// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2. -var DefaultCoresPerStep = map[string]float64{sdkstep.AskGo: 6, sdkstep.AskNode: 2, sdkstep.AskImage: 2} - -// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12 -// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's), -// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known. -func Quota() (percent int, from string) { - f := buildcpu.ReadInForce() - if f.Quota > 0 { - path := f.Slice.Path - if path == "" { - path = f.Cgroup - } - return f.Quota, "the cpu.max of " + path - } - if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 { - return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU()) - } - return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer" -} - -// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of -// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one; -// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above -// zero. A coresPerStep of zero or less is taken as 1. -func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) sdkstep.Capacity { - if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) { - coresPerStep = 1 - } - c := sdkstep.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep} - switch { - case configured <= 0: - c.Effective = 0 - c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured) - return c - case quotaPercent <= 0: - c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom) - return c - } - c.QuotaCores = float64(quotaPercent) / 100 - c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9)) - if c.ByCPU < 1 { - c.ByCPU = 1 - } - byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU) - if c.ByCPU < configured { - c.Effective = c.ByCPU - c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured) - } else { - c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU) - } - return c -} - -// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is -// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said -// once per distinct fault through logf. -func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 { - said := "" - say := func(why string) { - if logf != nil && why != said { - said = why - logf("%s, so a step is taken to need %g cores", why, fallback) - } - } - return func() float64 { - if path == "" { - return fallback - } - raw, err := os.ReadFile(path) - if err != nil { - say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err)) - return fallback - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) - return fallback - } - v, ok := s[CoresPerStepSetting] - if !ok { - return fallback // not declared yet (a module before its manifest names it): the default, nothing to say - } - n, err := coresOf(v) - if err != nil { - say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err)) - return fallback - } - said = "" - return n - } -} - -// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero. -func coresOf(v json.RawMessage) (float64, error) { - var n float64 - if err := json.Unmarshal(v, &n); err != nil { - var s string - if err := json.Unmarshal(v, &s); err != nil { - return 0, fmt.Errorf("is neither a number nor a string") - } - if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil { - return 0, fmt.Errorf("is not a number") - } - } - if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) { - return 0, fmt.Errorf("is not above zero") - } - return n, nil -} diff --git a/modules/mesh-build-go/buildstep/capacity_test.go b/modules/mesh-build-go/buildstep/capacity_test.go deleted file mode 100644 index aef58c6e0..000000000 --- a/modules/mesh-build-go/buildstep/capacity_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package buildstep - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls -// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing. -// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And -// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores -// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file. -func TestNodeCapacityAndLimits(t *testing.T) { - // ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node. - if got := NodeCapacity("", "laptop", nil)(); got != 0 { - t.Errorf("no settings file = %d, want 0", got) - } - file := filepath.Join(t.TempDir(), "settings.json") - capacity := NodeCapacity(file, "anchor", nil) - for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0, - `{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} { - os.WriteFile(file, []byte(value), 0o600) - if got := capacity(); got != want { - t.Errorf("%s = %d, want %d", value, got, want) - } - } - if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 { - t.Errorf("a missing file = %d, want 0", got) - } - t.Setenv("MESH_BUILD_CPU_SHARE", "") - if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 { - t.Errorf("without the word: %+v", l) - } - t.Setenv("MESH_BUILD_CPU_SHARE", "75") - if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 { - t.Errorf("from the word: %+v", l) - } - for _, c := range []struct { - name string - configured, quota int - cores float64 - effective, byCPU int - }{ - {"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2}, - {"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3}, - {"g14: 8 cores, configured 2", 2, 800, 6, 1, 1}, - {"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6}, - {"no quota: the configured one", 3, 0, 6, 3, 0}, - {"configured 0 pulls nothing", 0, 1200, 6, 0, 0}, - {"a quota under one step still builds one", 2, 100, 6, 1, 1}, - {"no cores a step is one", 2, 150, 0, 1, 1}, - } { - got := EffectiveCapacity(c.configured, c.quota, "test", c.cores) - if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" { - t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU) - } - } - file = filepath.Join(t.TempDir(), "settings.json") - var said []string - cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) }) - for _, c := range []struct { - file string - want float64 - }{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} { - os.WriteFile(file, []byte(c.file), 0o600) - if got := cores(); got != c.want { - t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want) - } - } - if len(said) != 1 { - t.Errorf("a value not above zero is said once, said %d times", len(said)) - } - - // The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the - // capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour. - // And a node its module proves cannot build (ace without /dev/net/tun, 2026-10-28) judges by 0 at every hour, saying - // why, so its asks go to a node that can. - cet := time.FixedZone("CET", 3600) - day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) } - was := now - defer func() { now = was }() - for _, c := range []struct { - name, setting, node string - at time.Time - effective int - edge string // the window's next edge, HH:MM; "" for no window - bad bool - ineligible string // what the module's Eligible says; "" for eligible - }{ - {"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""}, - {"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""}, - {"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""}, - {"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""}, - {"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""}, - {"no window", "", "anchor", day(10, 0), 2, "", false, ""}, - {"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""}, - {"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""}, - {"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""}, - {"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"}, - {"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"}, - } { - w, err := ParseBuildWindow(c.setting) - if (err != nil) != c.bad { - t.Errorf("%s: parse %v", c.name, err) - } - if edge := ""; w != nil { - edge = w.NextEdge(c.at).Format("15:04") - if edge != c.edge { - t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge) - } - } else if c.edge != "" { - t.Errorf("%s: no window, want one", c.name) - } - file := filepath.Join(t.TempDir(), "settings.json") - os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600) - now = func() time.Time { return c.at } - p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil), - Eligible: func() string { return c.ineligible }} - got := p.CapacityDetail() - outside := c.effective == 0 && c.ineligible == "" - if got.Effective != c.effective || got.OutsideHours != outside || (outside && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) { - t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective) - } - if got.Ineligible != c.ineligible || (c.ineligible != "" && !strings.HasPrefix(got.Why, "0, not eligible: "+c.ineligible+" (otherwise ")) { - t.Errorf("%s: not eligible is not said: %+v", c.name, got) - } - // A value that cannot be read builds at any hour, loudly: the record names the value and says why. - if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) { - t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got) - } - } -} diff --git a/modules/mesh-build-go/buildstep/cmd/mesh-pack/main.go b/modules/mesh-build-go/buildstep/cmd/mesh-pack/main.go deleted file mode 100644 index 5b03c5dcb..000000000 --- a/modules/mesh-build-go/buildstep/cmd/mesh-pack/main.go +++ /dev/null @@ -1,111 +0,0 @@ -// mesh-pack builds a Go bundle by hand with the recipe of the mesh-build-go module and packs it with mesh-sdk's own -// buildstep.Pack, printing the digest, so a hand build has the form of a built artifact and, with the same Go version, -// its digest (novox/hq ADR 0526; fallback record F1; issue 1499 for the Go version). -// -// mesh-pack -dir -from -binary [-system arch] [-out ] -// mesh-pack -pack [-out ] (a `files` archive: pack a tree as it is) -// -// The recipe is mesh-catalog modules/mesh-build-go cmd/mesh-build-go/build.go (recipeFlags, recipeLinkerFlags, -// recipeSystemStamp, buildEnv): CGO_ENABLED=0, GOFLAGS "-trimpath -buildvcs=false -mod=mod" (-mod=vendor when the -// directory vendors), GOTOOLCHAIN=local, GOPROXY=off, and -ldflags "-s -w -X main.builtFor=". -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" -) - -const ( - recipeFlags = "-trimpath -buildvcs=false" - recipeLinkerFlags = "-s -w" - recipeSystemStamp = "main.builtFor" -) - -// Env is the build module's environment for the compile of dir, added to the caller's own. -func Env(dir string) []string { - mod := "-mod=mod" - if _, err := os.Stat(filepath.Join(dir, "vendor", "modules.txt")); err == nil { - mod = "-mod=vendor" - } - return append(os.Environ(), - "CGO_ENABLED=0", - "GOFLAGS="+recipeFlags+" "+mod, - "GOTOOLCHAIN=local", - "GOPROXY=off", - ) -} - -// Args is the arguments of the go command of the recipe. -func Args(dir, from, out, binary, system string) []string { - linker := recipeLinkerFlags - if s := strings.TrimSpace(system); s != "" { - linker += " -X " + recipeSystemStamp + "=" + s - } - return []string{"-C", dir, "build", "-ldflags", linker, "-o", filepath.Join(out, binary), "./" + strings.Trim(from, "./")} -} - -// Build compiles the package into a fresh directory and answers it. -func Build(dir, from, binary, system string) (string, error) { - out, err := os.MkdirTemp("", "mesh-pack-") - if err != nil { - return "", err - } - cmd := exec.Command("go", Args(dir, from, out, binary, system)...) - cmd.Env = Env(dir) - cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr - if err := cmd.Run(); err != nil { - os.RemoveAll(out) - return "", fmt.Errorf("go build of %s: %w", from, err) - } - return out, nil -} - -func run(args []string) (string, error) { - fs := flag.NewFlagSet("mesh-pack", flag.ContinueOnError) - dir := fs.String("dir", ".", "the Go module's directory") - from := fs.String("from", "", "the package to build, relative to -dir (the artifact's from)") - binary := fs.String("binary", "", "the binary's name in the archive") - system := fs.String("system", "", "the system stamped into main.builtFor (an artifact's system, e.g. arch)") - pack := fs.String("pack", "", "pack this directory as it is (a files archive) instead of building") - outFile := fs.String("out", "", "write the archive here") - if err := fs.Parse(args); err != nil { - return "", err - } - root := *pack - if root == "" { - if *from == "" || *binary == "" { - return "", fmt.Errorf("-from and -binary are needed (or -pack )") - } - out, err := Build(*dir, *from, *binary, *system) - if err != nil { - return "", err - } - defer os.RemoveAll(out) - root = out - } - archive, digest, err := buildstep.Pack(root) - if err != nil { - return "", err - } - if *outFile != "" { - if err := os.WriteFile(*outFile, archive, 0o644); err != nil { - return "", err - } - } - return digest, nil -} - -func main() { - digest, err := run(os.Args[1:]) - if err != nil { - fmt.Fprintln(os.Stderr, "mesh-pack:", err) - os.Exit(1) - } - fmt.Println(digest) -} diff --git a/modules/mesh-build-go/buildstep/cmd/mesh-pack/main_test.go b/modules/mesh-build-go/buildstep/cmd/mesh-pack/main_test.go deleted file mode 100644 index 0c866886a..000000000 --- a/modules/mesh-build-go/buildstep/cmd/mesh-pack/main_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" -) - -func fixture(t *testing.T) string { - t.Helper() - d := t.TempDir() - os.MkdirAll(filepath.Join(d, "sub"), 0o755) - os.WriteFile(filepath.Join(d, "a.txt"), []byte("alpha"), 0o644) - os.WriteFile(filepath.Join(d, "sub", "run"), []byte("#!/bin/sh\n"), 0o755) - return d -} - -func TestPackFixtureTwiceGivesBuildstepsDigest(t *testing.T) { - d := fixture(t) - _, want, err := buildstep.Pack(d) - if err != nil { - t.Fatal(err) - } - for i := 0; i < 2; i++ { - got, err := run([]string{"-pack", d, "-out", filepath.Join(t.TempDir(), "x.tgz")}) - if err != nil || got != want { - t.Fatalf("run %d: %q, %v; want %q", i, got, err, want) - } - } -} - -func TestBuildTwiceGivesOneDigestAndRecipeArgs(t *testing.T) { - m := t.TempDir() - os.WriteFile(filepath.Join(m, "go.mod"), []byte("module x\n\ngo 1.21\n"), 0o644) - os.MkdirAll(filepath.Join(m, "cmd", "p"), 0o755) - os.WriteFile(filepath.Join(m, "cmd", "p", "main.go"), []byte("package main\n\nvar builtFor string\n\nfunc main() { println(builtFor) }\n"), 0o644) - args := []string{"-dir", m, "-from", "cmd/p", "-binary", "p", "-system", "arch"} - a, err := run(args) - if err != nil { - t.Skipf("no go toolchain that builds offline: %v", err) - } - b, err := run(args) - if err != nil || a != b { - t.Fatalf("two builds: %q, %q, %v", a, b, err) - } - got := Args("/d", "./cmd/p/", "/o", "p", "arch") - if got[len(got)-1] != "./cmd/p" || got[4] != "-s -w -X main.builtFor=arch" { - t.Fatalf("args %v", got) - } -} diff --git a/modules/mesh-build-go/buildstep/cmd/seatgen/main.go b/modules/mesh-build-go/buildstep/cmd/seatgen/main.go deleted file mode 100644 index f550b911e..000000000 --- a/modules/mesh-build-go/buildstep/cmd/seatgen/main.go +++ /dev/null @@ -1,80 +0,0 @@ -// Command seatgen writes, or with -check compares, a contracts module's generated Go and TypeScript. -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - "sort" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" -) - -func main() { - check := flag.Bool("check", false, "compare instead of writing: name every path out of step") - goModule := flag.String("go-module", "", "the generated Go tree's module path, such as git.novox.be/novox/mesh-contracts//go (there is no root module)") - tsPackage := flag.String("ts-package", "", "the generated TypeScript package's name, such as @novox/mesh-contracts-") - domainSDK := flag.String("domain-sdk", "", "the Go module of the contracts module's domain SDK, which a client may import (ADR 0634 §2); empty when it has none") - flag.Parse() - dir := flag.Arg(0) - m, err := seatgen.Load(dir, *goModule, *tsPackage) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - m.DomainSDK = *domainSDK - files, err := seatgen.Generate(m) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if *check { - out, err := seatgen.Compare(dir, files) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - // The clients beside the contracts (ADR 0627 §3) and module.json's Go and npm package artifacts (ADR 0625 §1, §3). - for _, check := range []func(string, *seatgen.Module) ([]string, error){seatgen.CheckClients, seatgen.CheckArtifacts} { - more, err := check(dir, m) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - out = append(out, more...) - } - for _, p := range out { - fmt.Println(p) - } - if len(out) > 0 { - os.Exit(1) - } - return - } - for _, root := range seatgen.GeneratedRoots { - _ = os.RemoveAll(filepath.Join(dir, root, "src")) - } - // Each seat's npm package's sources too (ADR 0625 §1), so a file no definition writes any more is gone. - stale, _ := filepath.Glob(filepath.Join(dir, "ts", "seats", "*", "src")) - for _, p := range stale { - _ = os.RemoveAll(p) - } - paths := make([]string, 0, len(files)) - for p := range files { - paths = append(paths, p) - } - sort.Strings(paths) - for _, p := range paths { - full := filepath.Join(dir, p) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if err := os.WriteFile(full, files[p], 0o644); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - } - fmt.Println(len(paths), "files") -} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/README.md b/modules/mesh-build-go/buildstep/conformance/captured/README.md deleted file mode 100644 index 3e1456c54..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# The conformance kit's captured runner output - -Every file here is the real output of a test runner, never written by hand (issue 1901: a fixture that agrees with the -mistake proves the mistake). The toolchains are the build nodes' own, as `mesh-build-go.build_go_toolchains` and -`mesh-build-node.build_node_toolchains` reported them on 2026-10-26: go1.27.3-X:nodwarf5 on every build node, node -v26.12.1 (npm 12.2.1) on shanks, novox and g14, and typescript 5.9.3 from mesh-build-node's toolchain/package.json. -To capture a case again, make its tiny project in a fresh directory as below and run the command shown; the kit's case -table (conformance.go) says what each failure must say. - -## go/ — `go test -json` - -- `passfirst.jsonl` and `subtests.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, for issue 1901's thorough fix): in - `/tmp/1901k/go/`, `GOTOOLCHAIN=local GOFLAGS=-mod=mod go test -json -count=1 ./... > ../.jsonl 2>&1`. - - passfirst: module `example.com/passfirst`, `go 1.26`. Package `a` has `TestTheFirstPackagePasses` (empty). Package `b` - has `TestTheSecondPackagePassesFirst` (empty) and `TestTheSecondPackageFails`, which calls - `t.Logf("judged against the seat as it stands")` then `t.Fatalf("the seat has 2 holders, want 1")`: a passing package - and a passing test before the failing one. - - subtests: module `example.com/subtests`; `TestTheTable` runs `t.Run("passes", func(t *testing.T) {})` and - `t.Run("fails", func(t *testing.T) { t.Errorf("got 3 machines, want 4") })`: the parent fails only by its subtest. -- `subpanic.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, review round 1 of issue 1901's fix), the same way: module - `example.com/subpanic`; `TestTable` runs `t.Run("passes", func(t *testing.T) {})` and `t.Run("panics", ...)`, which - writes to a nil map. go test -json files the subtest's panic under the parent `TestTable`. -- `logthenfatal.jsonl`, `panicking`, `timeout`, `deeptimeout`, `buildfail`, `exiting`, `killed`, `passthenfail`, - `mainexit`, `race`, `racemain` were captured for issue 1901's first and third meetings (2026-10-23 and 2026-10-25, - go1.27.3) in mesh-catalog's mesh-build-go (`testdata/captured/go-test-json-*.jsonl`, named there `go-test-json-pkg-` - and `go-test-json-log-then-fatal`) and moved here unchanged. From that README: each is the standard output and error of - `go test -json -count=1 ./...` (the timeouts with `-timeout 2s`; race and racemain with `-race`, CGO_ENABLED=1) in a - throwaway one-file module that fails without a test's own assertion: panicking (a nil map write in TestPanics), timeout - (TestWaitsForever sleeps past the timeout), deeptimeout (two parallel tests and sixty blocked goroutines past the - timeout, so the goroutine dump pushes the panic line out of the last 4 KB), buildfail (a type error in a test file), - exiting (os.Exit(3) in TestExits), killed (TestKilled sends itself SIGKILL), passthenfail (TestMain panics in a - goroutine after m.Run passed), mainexit (TestMain exits 2 after m.Run passed: go test prints "PASS" and then - "FAIL "), race (TestRacesOnTheSeat writes a package variable from itself and a goroutine), racemain (TestMain does - so after m.Run passed). logthenfatal is a test `TestThree` in package `x` that logs a line with t.Logf and then fails - with a multi-line t.Fatalf naming three subscriptions. - -## node/ — `npm test` running `node --test` under the TAP reporter - -Captured 2026-10-26 with node v26.12.1 and npm 12.2.1 (`/usr/bin/node`, `/usr/bin/npm`), in `/tmp/1901k/node/`, the -way mesh-build-node runs a test script: - - PATH=/usr/bin:$PATH NODE_OPTIONS="--test-reporter=tap --test-reporter-destination=/tmp/1901k/node/.tap" \ - npm test > ../.stdout 2> ../.stderr - -`.tap` is the report file, `.console` the stdout followed by the stderr. Each project's package.json is -`{"name":"","private":true,"type":"module","scripts":{"test":"node --test --experimental-strip-types 'test/*.test.ts'"}}` -(the script every TypeScript package of the catalogue and the SDK runs), with these test files: - -- two-files: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` holds - `test('passes in the second file first', () => {})` and `test('fails in the second file', ...)`, which logs - `judged against the seat as it stands` and asserts `assert.strictEqual(1 + 1, 3, 'one plus one is not three')`. -- suite: `test/a.test.ts` has `describe('the seat', ...)` with `it('passes inside', () => {})` and `it('fails inside', ...)` - asserting `assert.deepStrictEqual({ holders: 2 }, { holders: 1 })`. -- crash: `test/a.test.ts` holds `test('passes before the crash', () => {})`; `test/b.test.ts` holds - `test('throws after it returned', ...)`, which throws `new Error('the bus closed under the test')` in a `setTimeout` of - 1 ms, and `test('waits after the throw', ...)`, which waits 50 ms: node fails the file with an uncaught exception. -- logcrash (review round 1): `test/a.test.ts` holds `test('passes and logs', ...)`, which logs - `retrying after Error: the store was not ready yet`; `test/b.test.ts` is crash's. A passing test's console line - holding "Error" comes before the crash's own. -- timeout: `test/a.test.ts` holds `test('passes before the wait', () => {})` and - `test('waits for an answer that never comes', { timeout: 200 }, async () => { await new Promise(() => {}); })`. -- syntax: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` is - `import test from 'node:test';` and `test('never loads', () => {` (cut off): the file does not load. - -## tsc/ — the type-check - -`typeerror.console` (2026-10-26, typescript 5.9.3 under node v26.12.1): in `/tmp/1901k/tsc/typeerror`, `ok.ts` is -`export const holders: number = 1;` and `seat.ts` is `import { holders } from './ok.js'; export const name: string = holders;`; -the command is mesh-build-node's type-check line without a tsconfig.json: - - node /bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts - -and the file is its stdout followed by its stderr. - -## passing/ — a runner's words when the network did not answer (novox/hq issue 1748, ADR 0664 §1) - -Not captured by running: a network cannot be cut on demand in a test. Each file holds the words a runner wrote when a -build step met the fault on the mesh (the journal of ace on 2026-11-09 for ssh and go, the forge's sshd penalty; npm's -error codes and buildah's pull failure as those programs print them), and for each a negative case, a failure of the -source whose words are never a network's (an unknown revision, a checksum, a missing package, a failed RUN). Name: -`-.txt`; the kit's table (conformance.go, PassingCases) says which are transport faults. diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/buildfail.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/buildfail.jsonl deleted file mode 100644 index 2b7967da9..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/buildfail.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"# example.com/buildfail [example.com/buildfail.test]\n"} -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"./p_test.go:5:40: cannot use \"seat\" (untyped string constant) as int value in variable declaration\n"} -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-fail"} -{"Time":"2026-10-25T15:34:33.261137737+01:00","Action":"start","Package":"example.com/buildfail"} -{"Time":"2026-10-25T15:34:33.261255019+01:00","Action":"output","Package":"example.com/buildfail","Output":"FAIL\texample.com/buildfail [build failed]\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.261268099+01:00","Action":"fail","Package":"example.com/buildfail","Elapsed":0,"FailedBuild":"example.com/buildfail [example.com/buildfail.test]"} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/deeptimeout.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/deeptimeout.jsonl deleted file mode 100644 index 401825082..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/deeptimeout.jsonl +++ /dev/null @@ -1,417 +0,0 @@ -{"Time":"2026-10-25T15:35:18.991630993+01:00","Action":"start","Package":"example.com/deeptimeout"} -{"Time":"2026-10-25T15:35:18.993895188+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.993963909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== RUN TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99399484+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== PAUSE TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.9939982+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.99400173+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.99400456+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== RUN TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99400838+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== PAUSE TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99401133+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.99401441+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.99401704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== CONT TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.994074141+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.994102612+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== CONT TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:21.00935195+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"panic: test timed out after 2s\n"} -{"Time":"2026-10-25T15:35:21.00938383+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\trunning tests:\n"} -{"Time":"2026-10-25T15:35:21.009394391+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestHoldsTheSeat (2s)\n"} -{"Time":"2026-10-25T15:35:21.009398021+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestWaitsForTheBus (2s)\n"} -{"Time":"2026-10-25T15:35:21.009401801+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009406481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 81 [running]:\n"} -{"Time":"2026-10-25T15:35:21.009409911+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).startAlarm.func1()\n"} -{"Time":"2026-10-25T15:35:21.009413361+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"} -{"Time":"2026-10-25T15:35:21.009418341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by time.goFunc\n"} -{"Time":"2026-10-25T15:35:21.009421781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"} -{"Time":"2026-10-25T15:35:21.009424991+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009428341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 1 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009431721+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-25T15:35:21.009441811+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2144 +0x425\n"} -{"Time":"2026-10-25T15:35:21.009445511+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702008, 0x282e9646bc8)\n"} -{"Time":"2026-10-25T15:35:21.009449641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2201 +0x123\n"} -{"Time":"2026-10-25T15:35:21.009453471+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.runTests({0x55a45d, 0x17}, {0x55a45d, 0x17}, 0x282e95940f0, {0x6f19f0, 0x2, 0x2}, {0xc2afa2ca3b3c0c8d, 0x7737f168, ...})\n"} -{"Time":"2026-10-25T15:35:21.009462092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"} -{"Time":"2026-10-25T15:35:21.009467222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).Run(0x282e96581e0)\n"} -{"Time":"2026-10-25T15:35:21.009470662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"} -{"Time":"2026-10-25T15:35:21.009473692+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"main.main()\n"} -{"Time":"2026-10-25T15:35:21.009476942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t_testmain.go:48 +0x9b\n"} -{"Time":"2026-10-25T15:35:21.009480092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009483242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 7 [sleep]:\n"} -{"Time":"2026-10-25T15:35:21.009486502+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:35:21.009490682+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:35:21.009493812+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat(0x282e9702248?)\n"} -{"Time":"2026-10-25T15:35:21.009498842+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:14 +0x9e\n"} -{"Time":"2026-10-25T15:35:21.009502242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702248, 0x6d5e78)\n"} -{"Time":"2026-10-25T15:35:21.009505742+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:35:21.009509292+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:35:21.009512752+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:35:21.009515792+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009518623+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 8 [sleep]:\n"} -{"Time":"2026-10-25T15:35:21.009521303+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:35:21.009524423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:35:21.009531403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestWaitsForTheBus(0x282e9702488?)\n"} -{"Time":"2026-10-25T15:35:21.009534943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:19 +0x25\n"} -{"Time":"2026-10-25T15:35:21.009538403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702488, 0x6d5e80)\n"} -{"Time":"2026-10-25T15:35:21.009541953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:35:21.009545233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:35:21.009548323+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:35:21.009551343+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009554263+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 9 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009557523+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009561063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009564513+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009571113+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009574233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009577433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 10 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009580823+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009584174+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009587504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009590974+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009594004+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009597184+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 11 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009600394+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009603874+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009610954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009614714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009618104+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009621364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 12 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009624704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009628324+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009631954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009635404+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009638734+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009641904+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 13 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009645325+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009648645+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009652065+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009655625+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009658885+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009663385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 14 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009666735+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009670235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009676385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009680205+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009683315+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009686475+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 15 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009689785+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009697235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009700795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009704985+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009708085+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009711346+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 16 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009714586+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009717946+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009721676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009725626+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009728866+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009732106+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 17 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009735376+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009738956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009742326+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009746436+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009750666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009754126+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 18 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009758286+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009761846+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009765776+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009769816+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009773167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009780507+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 19 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009784207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009788007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009791627+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009796347+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009799427+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009802937+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 20 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009806667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009809987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009813827+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009818087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009821397+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009824227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 21 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009827607+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009831307+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009835167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009839528+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009842728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009845888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 22 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009849168+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009852548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009855958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009863648+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009866938+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009870088+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 23 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009879828+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009883728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009887608+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009891548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009895238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009898748+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 24 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009902349+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009907109+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009911519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009915609+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009918939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009922329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 25 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009928329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009931909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009935489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009939429+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009942699+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009946019+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 26 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009949559+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009953489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.00996244+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.00996647+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.00996987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.00997314+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 27 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.00997664+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.00998035+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.00998418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.00998793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.00999131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.00999534+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 28 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.00999894+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01000268+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.0100087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01001258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01001605+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01002007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 29 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01002393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010027531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010031351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010035521+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010038781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010042281+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 30 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010046091+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010054261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010057881+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010061781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010064931+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010068261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 31 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010072151+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010075981+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010079841+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010098802+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010103202+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010106382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 32 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010109852+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010113242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010116952+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010120672+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010124112+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010127162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 33 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010130662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010134442+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010138142+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010141972+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010145362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010152262+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 34 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010156353+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010159913+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010163533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010167403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010170533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010173953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 35 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010177453+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010181033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010184693+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010188583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010191753+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010195033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 36 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010198603+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010202393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010206123+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010210583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010213503+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010216914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 37 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010220134+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010223644+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010227154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010235154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010238354+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010241754+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 38 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010270284+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010275144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010278714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010282825+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010285795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010288935+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 39 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010292405+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010295915+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010299545+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010311835+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010315565+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010318975+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 40 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010322655+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010326225+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010329815+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010333745+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010337095+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010340515+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 41 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010344066+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010347706+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010355596+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010359046+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010362036+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010365076+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 42 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010367956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010370906+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010375366+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010378386+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010381006+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010383666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 43 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010386676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010389786+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010392636+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010395746+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010398466+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010401166+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 44 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010404226+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010407186+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010410117+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010413237+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010416157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010418897+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 45 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010423157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010430107+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010433197+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010451207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010454217+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010456967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 46 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010459877+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010463967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010467067+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010470597+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010473418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010476108+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 47 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010478998+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010481958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010484978+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010488258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010490888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010493628+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 48 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010496588+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010499498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010502498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010505768+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010508558+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010514148+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 49 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010517178+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010520238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010523478+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010526818+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010529718+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010532508+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 50 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010535639+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010538749+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010541939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010546689+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010549869+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010552989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 51 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010556299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010560079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010563479+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010567189+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010570299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010573079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 52 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010576319+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010579599+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010583099+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010590519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010594029+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010596989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 53 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01059986+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01060433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01060789+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01061136+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01061393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01061667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 54 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01061984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.0106227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01062566+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01062876+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01063131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01063428+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 55 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01063719+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01063997+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01064278+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01064582+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01064836+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01065114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 56 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01065437+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01065724+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010671481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010675671+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010678431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010691171+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 57 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010694531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010697641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010700781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010704001+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010706611+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010709421+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 58 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010712331+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010715351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010718431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010721691+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010724371+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010727362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 59 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010730592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010733662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010736712+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010740162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010742922+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010745772+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 60 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010752222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010755402+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010758542+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010761822+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010764592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010767992+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 61 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010771062+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010774282+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010779382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010782942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010785722+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010788573+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 62 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010791893+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010794943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010798083+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010801683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010804293+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010807283+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 63 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010810443+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010813553+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010816793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010820233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010823063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010829493+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 64 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010832683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010835953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010839223+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010842463+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010845333+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010848423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 65 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010851663+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010854964+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010858364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010861714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010864914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010867984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 66 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010870944+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010874114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010877464+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010880504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010883144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010886194+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 67 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010889154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010892234+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010895524+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010902154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010904984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010907984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 68 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010911064+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010914074+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010917365+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010920505+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.013209581+01:00","Action":"output","Package":"example.com/deeptimeout","Output":"FAIL\texample.com/deeptimeout\t2.021s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:21.013258851+01:00","Action":"fail","Package":"example.com/deeptimeout","Elapsed":2.022} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/exiting.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/exiting.jsonl deleted file mode 100644 index ec4ce02fc..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/exiting.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"Time":"2026-10-25T15:34:33.768742837+01:00","Action":"start","Package":"example.com/exiting"} -{"Time":"2026-10-25T15:34:33.773822287+01:00","Action":"run","Package":"example.com/exiting","Test":"TestFine"} -{"Time":"2026-10-25T15:34:33.773896608+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773940319+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773945359+01:00","Action":"pass","Package":"example.com/exiting","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:33.773952529+01:00","Action":"run","Package":"example.com/exiting","Test":"TestExits"} -{"Time":"2026-10-25T15:34:33.773956439+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":"=== RUN TestExits\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773959829+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":" p_test.go:11: closing the store\n"} -{"Time":"2026-10-25T15:34:33.775158538+01:00","Action":"output","Package":"example.com/exiting","Output":"FAIL\texample.com/exiting\t0.006s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.775186509+01:00","Action":"fail","Package":"example.com/exiting","Elapsed":0.006} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/killed.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/killed.jsonl deleted file mode 100644 index f4ba5258a..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/killed.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"Time":"2026-10-25T15:34:34.328258934+01:00","Action":"start","Package":"example.com/killed"} -{"Time":"2026-10-25T15:34:34.331465435+01:00","Action":"run","Package":"example.com/killed","Test":"TestKilled"} -{"Time":"2026-10-25T15:34:34.331699248+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"=== RUN TestKilled\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.331853111+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":" p_test.go:10: the kernel is about to kill this\n"} -{"Time":"2026-10-25T15:34:34.334563213+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"signal: killed\n"} -{"Time":"2026-10-25T15:34:34.334611114+01:00","Action":"output","Package":"example.com/killed","Output":"FAIL\texample.com/killed\t0.006s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.334627134+01:00","Action":"fail","Package":"example.com/killed","Elapsed":0.006} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/logthenfatal.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/logthenfatal.jsonl deleted file mode 100644 index c59fba2f1..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/logthenfatal.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"Action":"start","Package":"x"} -{"Action":"run","Package":"x","Test":"TestThree"} -{"Action":"output","Package":"x","Test":"TestThree","Output":"=== RUN TestThree\n","OutputType":"frame"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:4: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:5: 3 subscription(s) derive to a subject no emitter owns:\n","OutputType":"error"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a.b.c\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" d.e.f\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" g.h.i\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":"--- FAIL: TestThree (0.00s)\n","OutputType":"frame"} -{"Action":"fail","Package":"x","Test":"TestThree","Elapsed":0} -{"Action":"output","Package":"x","Output":"FAIL\n","OutputType":"frame"} -{"Action":"output","Package":"x","Output":"FAIL\tx\t0.002s\n","OutputType":"frame"} -{"Action":"fail","Package":"x","Elapsed":0.002} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/mainexit.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/mainexit.jsonl deleted file mode 100644 index 5f106bada..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/mainexit.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"Time":"2026-10-25T15:34:35.274953775+01:00","Action":"start","Package":"example.com/mainexit"} -{"Time":"2026-10-25T15:34:35.278129565+01:00","Action":"run","Package":"example.com/mainexit","Test":"TestFine"} -{"Time":"2026-10-25T15:34:35.278191516+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.278341979+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.278351739+01:00","Action":"pass","Package":"example.com/mainexit","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:35.278359039+01:00","Action":"output","Package":"example.com/mainexit","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.279516487+01:00","Action":"output","Package":"example.com/mainexit","Output":"FAIL\texample.com/mainexit\t0.004s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.279539298+01:00","Action":"fail","Package":"example.com/mainexit","Elapsed":0.005} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/panicking.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/panicking.jsonl deleted file mode 100644 index 7609d5d2f..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/panicking.jsonl +++ /dev/null @@ -1,27 +0,0 @@ -{"Time":"2026-10-25T15:34:33.031091436+01:00","Action":"start","Package":"example.com/panicking"} -{"Time":"2026-10-25T15:34:33.036353279+01:00","Action":"run","Package":"example.com/panicking","Test":"TestBefore"} -{"Time":"2026-10-25T15:34:33.036441781+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036568163+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036609863+01:00","Action":"pass","Package":"example.com/panicking","Test":"TestBefore","Elapsed":0} -{"Time":"2026-10-25T15:34:33.036641304+01:00","Action":"run","Package":"example.com/panicking","Test":"TestPanics"} -{"Time":"2026-10-25T15:34:33.036645914+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"=== RUN TestPanics\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036698995+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":" p_test.go:8: about to look up the seat\n"} -{"Time":"2026-10-25T15:34:33.036773996+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"--- FAIL: TestPanics (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.03897418+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"} -{"Time":"2026-10-25T15:34:33.040332172+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\n"} -{"Time":"2026-10-25T15:34:33.040414493+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"goroutine 8 [running]:\n"} -{"Time":"2026-10-25T15:34:33.040521475+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1.2({0x6b8120, 0x6eff80})\n"} -{"Time":"2026-10-25T15:34:33.040601826+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"} -{"Time":"2026-10-25T15:34:33.040641947+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-25T15:34:33.040720648+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"} -{"Time":"2026-10-25T15:34:33.04083286+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic({0x6b8120?, 0x6eff80?})\n"} -{"Time":"2026-10-25T15:34:33.040912921+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"} -{"Time":"2026-10-25T15:34:33.040981652+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"example.com/panicking.TestPanics(0x228226cd0488?)\n"} -{"Time":"2026-10-25T15:34:33.041059903+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/tmp/fix-1901b/panicking/p_test.go:10 +0x53\n"} -{"Time":"2026-10-25T15:34:33.041132704+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner(0x228226cd0488, 0x6d5bc8)\n"} -{"Time":"2026-10-25T15:34:33.041215586+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:33.041221966+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:34:33.041225556+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:34:33.041984088+01:00","Action":"fail","Package":"example.com/panicking","Test":"TestPanics","Elapsed":0} -{"Time":"2026-10-25T15:34:33.041996638+01:00","Action":"output","Package":"example.com/panicking","Output":"FAIL\texample.com/panicking\t0.010s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.042044079+01:00","Action":"fail","Package":"example.com/panicking","Elapsed":0.011} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/passfirst.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/passfirst.jsonl deleted file mode 100644 index a8286d9ce..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/passfirst.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"Time":"2026-10-26T19:40:29.574068887+01:00","Action":"start","Package":"example.com/passfirst/a"} -{"Time":"2026-10-26T19:40:29.574347185+01:00","Action":"start","Package":"example.com/passfirst/b"} -{"Time":"2026-10-26T19:40:29.576503518+01:00","Action":"run","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses"} -{"Time":"2026-10-26T19:40:29.576557657+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"=== RUN TestTheFirstPackagePasses\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576589367+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"--- PASS: TestTheFirstPackagePasses (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576594637+01:00","Action":"pass","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576602487+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576614477+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst"} -{"Time":"2026-10-26T19:40:29.576626897+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"=== RUN TestTheSecondPackagePassesFirst\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576631187+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"--- PASS: TestTheSecondPackagePassesFirst (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576635567+01:00","Action":"pass","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576638427+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails"} -{"Time":"2026-10-26T19:40:29.576640647+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"=== RUN TestTheSecondPackageFails\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576643477+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:8: judged against the seat as it stands\n"} -{"Time":"2026-10-26T19:40:29.576646497+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:9: the seat has 2 holders, want 1\n","OutputType":"error"} -{"Time":"2026-10-26T19:40:29.576650087+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"--- FAIL: TestTheSecondPackageFails (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576652797+01:00","Action":"fail","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576655177+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.577365541+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"ok \texample.com/passfirst/a\t0.003s\n"} -{"Time":"2026-10-26T19:40:29.577382561+01:00","Action":"pass","Package":"example.com/passfirst/a","Elapsed":0.003} -{"Time":"2026-10-26T19:40:29.577380641+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\texample.com/passfirst/b\t0.003s\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.5774131+01:00","Action":"fail","Package":"example.com/passfirst/b","Elapsed":0.003} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/passthenfail.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/passthenfail.jsonl deleted file mode 100644 index 55fa9309c..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/passthenfail.jsonl +++ /dev/null @@ -1,15 +0,0 @@ -{"Time":"2026-10-25T15:34:34.919624192+01:00","Action":"start","Package":"example.com/passthenfail"} -{"Time":"2026-10-25T15:34:34.922861224+01:00","Action":"run","Package":"example.com/passthenfail","Test":"TestFine"} -{"Time":"2026-10-25T15:34:34.922932965+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.922969205+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.922979956+01:00","Action":"pass","Package":"example.com/passthenfail","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:34.922989416+01:00","Action":"output","Package":"example.com/passthenfail","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.925022528+01:00","Action":"output","Package":"example.com/passthenfail","Output":"panic: the bus closed after the tests: nats: connection closed\n"} -{"Time":"2026-10-25T15:34:34.925043388+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\n"} -{"Time":"2026-10-25T15:34:34.925046848+01:00","Action":"output","Package":"example.com/passthenfail","Output":"goroutine 20 [running]:\n"} -{"Time":"2026-10-25T15:34:34.925051338+01:00","Action":"output","Package":"example.com/passthenfail","Output":"example.com/passthenfail.TestMain.func1()\n"} -{"Time":"2026-10-25T15:34:34.925055878+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"} -{"Time":"2026-10-25T15:34:34.925061428+01:00","Action":"output","Package":"example.com/passthenfail","Output":"created by example.com/passthenfail.TestMain in goroutine 1\n"} -{"Time":"2026-10-25T15:34:34.925065028+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"} -{"Time":"2026-10-25T15:34:34.925390203+01:00","Action":"output","Package":"example.com/passthenfail","Output":"FAIL\texample.com/passthenfail\t0.005s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.925402904+01:00","Action":"fail","Package":"example.com/passthenfail","Elapsed":0.006} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/race.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/race.jsonl deleted file mode 100644 index 93e9ac17d..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/race.jsonl +++ /dev/null @@ -1,53 +0,0 @@ -{"Time":"2026-10-25T15:50:42.908557309+01:00","Action":"start","Package":"example.com/race"} -{"Time":"2026-10-25T15:50:42.924298679+01:00","Action":"run","Package":"example.com/race","Test":"TestBefore"} -{"Time":"2026-10-25T15:50:42.92438195+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.924648842+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.924685133+01:00","Action":"pass","Package":"example.com/race","Test":"TestBefore","Elapsed":0} -{"Time":"2026-10-25T15:50:42.924728573+01:00","Action":"run","Package":"example.com/race","Test":"TestRacesOnTheSeat"} -{"Time":"2026-10-25T15:50:42.924733573+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"=== RUN TestRacesOnTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.92637298+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"} -{"Time":"2026-10-25T15:50:42.92641567+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"WARNING: DATA RACE\n"} -{"Time":"2026-10-25T15:50:42.926444061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Write at 0x000000833468 by goroutine 10:\n"} -{"Time":"2026-10-25T15:50:42.926462621+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat.func1()\n"} -{"Time":"2026-10-25T15:50:42.926467121+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"} -{"Time":"2026-10-25T15:50:42.926483061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926509741+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Previous write at 0x000000833468 by goroutine 9:\n"} -{"Time":"2026-10-25T15:50:42.926530142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"} -{"Time":"2026-10-25T15:50:42.926534952+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:14 +0x30\n"} -{"Time":"2026-10-25T15:50:42.926550462+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926554612+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926571142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"} -{"Time":"2026-10-25T15:50:42.926575812+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"} -{"Time":"2026-10-25T15:50:42.926592202+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926607792+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 10 (running) created at:\n"} -{"Time":"2026-10-25T15:50:42.926624292+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"} -{"Time":"2026-10-25T15:50:42.926628923+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"} -{"Time":"2026-10-25T15:50:42.926643853+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926648103+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926674843+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"} -{"Time":"2026-10-25T15:50:42.926680673+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"} -{"Time":"2026-10-25T15:50:42.926695433+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926711063+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 9 (running) created at:\n"} -{"Time":"2026-10-25T15:50:42.926726614+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run()\n"} -{"Time":"2026-10-25T15:50:42.926730814+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0xb12\n"} -{"Time":"2026-10-25T15:50:42.926746484+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests.func1()\n"} -{"Time":"2026-10-25T15:50:42.926750764+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2744 +0x84\n"} -{"Time":"2026-10-25T15:50:42.926765354+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926769844+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926791794+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests()\n"} -{"Time":"2026-10-25T15:50:42.926796704+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2742 +0x9e9\n"} -{"Time":"2026-10-25T15:50:42.926811544+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*M).Run()\n"} -{"Time":"2026-10-25T15:50:42.926816694+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2602 +0xf44\n"} -{"Time":"2026-10-25T15:50:42.926831695+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:42.926835935+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" _testmain.go:50 +0x164\n"} -{"Time":"2026-10-25T15:50:42.926850905+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"} -{"Time":"2026-10-25T15:50:42.935320241+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.go:1867: race detected during execution of test\n","OutputType":"error"} -{"Time":"2026-10-25T15:50:42.935520113+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"--- FAIL: TestRacesOnTheSeat (0.01s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.935592973+01:00","Action":"fail","Package":"example.com/race","Test":"TestRacesOnTheSeat","Elapsed":0.01} -{"Time":"2026-10-25T15:50:42.935722225+01:00","Action":"run","Package":"example.com/race","Test":"TestAfter"} -{"Time":"2026-10-25T15:50:42.935728845+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"=== RUN TestAfter\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.935995928+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"--- PASS: TestAfter (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.936006328+01:00","Action":"pass","Package":"example.com/race","Test":"TestAfter","Elapsed":0} -{"Time":"2026-10-25T15:50:42.936009978+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.937730745+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\texample.com/race\t0.028s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.937802066+01:00","Action":"fail","Package":"example.com/race","Elapsed":0.029} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/racemain.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/racemain.jsonl deleted file mode 100644 index 8a2a5c076..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/racemain.jsonl +++ /dev/null @@ -1,27 +0,0 @@ -{"Time":"2026-10-25T15:50:43.551701745+01:00","Action":"start","Package":"example.com/racemain"} -{"Time":"2026-10-25T15:50:43.570709708+01:00","Action":"run","Package":"example.com/racemain","Test":"TestFine"} -{"Time":"2026-10-25T15:50:43.570806059+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.57085525+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.57086149+01:00","Action":"pass","Package":"example.com/racemain","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:50:43.57086974+01:00","Action":"output","Package":"example.com/racemain","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.572155623+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"} -{"Time":"2026-10-25T15:50:43.572168083+01:00","Action":"output","Package":"example.com/racemain","Output":"WARNING: DATA RACE\n"} -{"Time":"2026-10-25T15:50:43.572171933+01:00","Action":"output","Package":"example.com/racemain","Output":"Write at 0x000000836428 by goroutine 9:\n"} -{"Time":"2026-10-25T15:50:43.572176703+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain.func1()\n"} -{"Time":"2026-10-25T15:50:43.572180053+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x24\n"} -{"Time":"2026-10-25T15:50:43.572184923+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"} -{"Time":"2026-10-25T15:50:43.572189613+01:00","Action":"output","Package":"example.com/racemain","Output":"Previous write at 0x000000836428 by main goroutine:\n"} -{"Time":"2026-10-25T15:50:43.572192993+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"} -{"Time":"2026-10-25T15:50:43.572198573+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:16 +0x44\n"} -{"Time":"2026-10-25T15:50:43.572202433+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:43.572205623+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"} -{"Time":"2026-10-25T15:50:43.572208443+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"} -{"Time":"2026-10-25T15:50:43.572211573+01:00","Action":"output","Package":"example.com/racemain","Output":"Goroutine 9 (running) created at:\n"} -{"Time":"2026-10-25T15:50:43.572214803+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"} -{"Time":"2026-10-25T15:50:43.572218043+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x37\n"} -{"Time":"2026-10-25T15:50:43.572225414+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:43.572228654+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"} -{"Time":"2026-10-25T15:50:43.572231664+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"} -{"Time":"2026-10-25T15:50:44.581262249+01:00","Action":"output","Package":"example.com/racemain","Output":"Found 1 data race(s)\n"} -{"Time":"2026-10-25T15:50:44.584693204+01:00","Action":"output","Package":"example.com/racemain","Output":"FAIL\texample.com/racemain\t1.031s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:44.584737425+01:00","Action":"fail","Package":"example.com/racemain","Elapsed":1.033} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/subpanic.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/subpanic.jsonl deleted file mode 100644 index ec85af784..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/subpanic.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"Time":"2026-10-26T20:15:37.791161045+01:00","Action":"start","Package":"example.com/subpanic"} -{"Time":"2026-10-26T20:15:37.793205666+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable"} -{"Time":"2026-10-26T20:15:37.793257158+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"=== RUN TestTable\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793288149+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/passes"} -{"Time":"2026-10-26T20:15:37.793290669+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"=== RUN TestTable/passes\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793298009+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"--- PASS: TestTable/passes (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793311679+01:00","Action":"pass","Package":"example.com/subpanic","Test":"TestTable/passes","Elapsed":0} -{"Time":"2026-10-26T20:15:37.79331977+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/panics"} -{"Time":"2026-10-26T20:15:37.79332377+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"=== RUN TestTable/panics\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.79332773+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"--- FAIL: TestTable/panics (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.79333111+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable/panics","Elapsed":0} -{"Time":"2026-10-26T20:15:37.79333383+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"--- FAIL: TestTable (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.795322529+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"} -{"Time":"2026-10-26T20:15:37.795331489+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\n"} -{"Time":"2026-10-26T20:15:37.795340579+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"goroutine 9 [running]:\n"} -{"Time":"2026-10-26T20:15:37.79534387+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1.2({0x6b7010, 0x6eef80})\n"} -{"Time":"2026-10-26T20:15:37.79534657+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"} -{"Time":"2026-10-26T20:15:37.79534907+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-26T20:15:37.79535172+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"} -{"Time":"2026-10-26T20:15:37.79535625+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic({0x6b7010?, 0x6eef80?})\n"} -{"Time":"2026-10-26T20:15:37.79535879+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"} -{"Time":"2026-10-26T20:15:37.79536129+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"example.com/subpanic.TestTable.func2(0x3b39436666c8?)\n"} -{"Time":"2026-10-26T20:15:37.79536452+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/tmp/1901k/go/subpanic/p_test.go:7 +0x28\n"} -{"Time":"2026-10-26T20:15:37.79536782+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner(0x3b39436666c8, 0x6d4b58)\n"} -{"Time":"2026-10-26T20:15:37.7953704+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-26T20:15:37.79537289+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"created by testing.(*T).Run in goroutine 7\n"} -{"Time":"2026-10-26T20:15:37.795381681+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-26T20:15:37.795731441+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable","Elapsed":0} -{"Time":"2026-10-26T20:15:37.795759332+01:00","Action":"output","Package":"example.com/subpanic","Output":"FAIL\texample.com/subpanic\t0.004s\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.795783503+01:00","Action":"fail","Package":"example.com/subpanic","Elapsed":0.005} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/subtests.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/subtests.jsonl deleted file mode 100644 index b12cb5aca..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/subtests.jsonl +++ /dev/null @@ -1,17 +0,0 @@ -{"Time":"2026-10-26T19:40:29.768101399+01:00","Action":"start","Package":"example.com/subtests"} -{"Time":"2026-10-26T19:40:29.770109733+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable"} -{"Time":"2026-10-26T19:40:29.770152272+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"=== RUN TestTheTable\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770189372+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/passes"} -{"Time":"2026-10-26T19:40:29.770191882+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"=== RUN TestTheTable/passes\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770197442+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"--- PASS: TestTheTable/passes (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770204352+01:00","Action":"pass","Package":"example.com/subtests","Test":"TestTheTable/passes","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770209622+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/fails"} -{"Time":"2026-10-26T19:40:29.770211832+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"=== RUN TestTheTable/fails\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770214722+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":" s_test.go:7: got 3 machines, want 4\n","OutputType":"error"} -{"Time":"2026-10-26T19:40:29.770218032+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"--- FAIL: TestTheTable/fails (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770220732+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable/fails","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770223522+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"--- FAIL: TestTheTable (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770226292+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770228582+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770561979+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\texample.com/subtests\t0.002s\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770581629+01:00","Action":"fail","Package":"example.com/subtests","Elapsed":0.002} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/go/timeout.jsonl b/modules/mesh-build-go/buildstep/conformance/captured/go/timeout.jsonl deleted file mode 100644 index 12a2c12fc..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/go/timeout.jsonl +++ /dev/null @@ -1,43 +0,0 @@ -{"Time":"2026-10-25T15:34:36.049580618+01:00","Action":"start","Package":"example.com/timeout"} -{"Time":"2026-10-25T15:34:36.053250986+01:00","Action":"run","Package":"example.com/timeout","Test":"TestQuick"} -{"Time":"2026-10-25T15:34:36.053354018+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"=== RUN TestQuick\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.053459789+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"--- PASS: TestQuick (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.05348499+01:00","Action":"pass","Package":"example.com/timeout","Test":"TestQuick","Elapsed":0} -{"Time":"2026-10-25T15:34:36.0535114+01:00","Action":"run","Package":"example.com/timeout","Test":"TestWaitsForever"} -{"Time":"2026-10-25T15:34:36.05351549+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"=== RUN TestWaitsForever\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.053567451+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":" p_test.go:11: waiting for the bus\n"} -{"Time":"2026-10-25T15:34:38.064693307+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"panic: test timed out after 2s\n"} -{"Time":"2026-10-25T15:34:38.06487122+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\trunning tests:\n"} -{"Time":"2026-10-25T15:34:38.06491684+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t\tTestWaitsForever (2s)\n"} -{"Time":"2026-10-25T15:34:38.065034382+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.065108573+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 33 [running]:\n"} -{"Time":"2026-10-25T15:34:38.065154754+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).startAlarm.func1()\n"} -{"Time":"2026-10-25T15:34:38.065386208+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"} -{"Time":"2026-10-25T15:34:38.065431878+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by time.goFunc\n"} -{"Time":"2026-10-25T15:34:38.0655151+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"} -{"Time":"2026-10-25T15:34:38.06553471+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.065847805+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 1 [chan receive]:\n"} -{"Time":"2026-10-25T15:34:38.066038298+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*T).Run(0x3a0209bc8008, {0x5580aa?, 0x3a0209a9caa0?}, 0x6d5b10)\n"} -{"Time":"2026-10-25T15:34:38.066047488+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2268 +0x4f2\n"} -{"Time":"2026-10-25T15:34:38.066051368+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests.func1(0x3a0209bc8008)\n"} -{"Time":"2026-10-25T15:34:38.066056128+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2744 +0x37\n"} -{"Time":"2026-10-25T15:34:38.066059478+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8008, 0x3a0209a9cbc8)\n"} -{"Time":"2026-10-25T15:34:38.066064958+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:38.066069599+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests({0x559017, 0x13}, {0x559017, 0x13}, 0x3a0209b8e0d8, {0x6f19f0, 0x2, 0x2}, {0xc2afa2bf832a3f56, 0x773908b5, ...})\n"} -{"Time":"2026-10-25T15:34:38.066083309+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"} -{"Time":"2026-10-25T15:34:38.066086879+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).Run(0x3a0209b8c1e0)\n"} -{"Time":"2026-10-25T15:34:38.066090269+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"} -{"Time":"2026-10-25T15:34:38.066093429+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"main.main()\n"} -{"Time":"2026-10-25T15:34:38.066097569+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t_testmain.go:48 +0x9b\n"} -{"Time":"2026-10-25T15:34:38.066100639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.066103769+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 20 [sleep]:\n"} -{"Time":"2026-10-25T15:34:38.066106969+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:34:38.066110279+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:34:38.066113639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"example.com/timeout.TestWaitsForever(0x3a0209bc8488?)\n"} -{"Time":"2026-10-25T15:34:38.066117109+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/tmp/fix-1901b/timeout/p_test.go:12 +0x48\n"} -{"Time":"2026-10-25T15:34:38.066120239+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8488, 0x6d5b10)\n"} -{"Time":"2026-10-25T15:34:38.066123509+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:38.066126839+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:34:38.066129989+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:34:38.066986453+01:00","Action":"output","Package":"example.com/timeout","Output":"FAIL\texample.com/timeout\t2.017s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:38.067002113+01:00","Action":"fail","Package":"example.com/timeout","Elapsed":2.017} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/crash.console b/modules/mesh-build-go/buildstep/conformance/captured/node/crash.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/crash.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/crash.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/crash.tap deleted file mode 100644 index bea3c6515..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/crash.tap +++ /dev/null @@ -1,41 +0,0 @@ -TAP version 13 -# Subtest: passes before the crash -ok 1 - passes before the crash - --- - duration_ms: 0.746694 - type: 'test' - ... -# Subtest: throws after it returned -ok 2 - throws after it returned - --- - duration_ms: 0.763364 - type: 'test' - ... -# Subtest: waits after the throw -ok 3 - waits after the throw - --- - duration_ms: 49.953893 - type: 'test' - ... -# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event. -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 155.864774 - type: 'test' - location: '/tmp/1901k/node/crash/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..4 -# tests 4 -# suites 0 -# pass 3 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 168.479173 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.console b/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.tap deleted file mode 100644 index 89dbfb8fc..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/logcrash.tap +++ /dev/null @@ -1,42 +0,0 @@ -TAP version 13 -# retrying after Error: the store was not ready yet -# Subtest: passes and logs -ok 1 - passes and logs - --- - duration_ms: 1.184595 - type: 'test' - ... -# Subtest: throws after it returned -ok 2 - throws after it returned - --- - duration_ms: 0.748862 - type: 'test' - ... -# Subtest: waits after the throw -ok 3 - waits after the throw - --- - duration_ms: 51.019875 - type: 'test' - ... -# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event. -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 172.91715 - type: 'test' - location: '/tmp/1901k/node/logcrash/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..4 -# tests 4 -# suites 0 -# pass 3 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 185.744334 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/suite.console b/modules/mesh-build-go/buildstep/conformance/captured/node/suite.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/suite.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/suite.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/suite.tap deleted file mode 100644 index 06e976116..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/suite.tap +++ /dev/null @@ -1,61 +0,0 @@ -TAP version 13 -# Subtest: the seat - # Subtest: passes inside - ok 1 - passes inside - --- - duration_ms: 0.577396 - type: 'test' - ... - # Subtest: fails inside - not ok 2 - fails inside - --- - duration_ms: 1.621198 - type: 'test' - location: '/tmp/1901k/node/suite/test/a.test.ts:5:3' - failureType: 'testCodeFailure' - error: |- - Expected values to be strictly deep-equal: - + actual - expected - -  { - + holders: 2 - - holders: 1 -  } - - code: 'ERR_ASSERTION' - name: 'AssertionError' - expected: - holders: 1 - actual: - holders: 2 - operator: 'deepStrictEqual' - stack: |- - TestContext. (file:///tmp/1901k/node/suite/test/a.test.ts:5:37) - Test.runInAsyncScope (node:async_hooks:226:14) - Test.run (node:internal/test_runner/test:1402:25) - Suite.processPendingSubtests (node:internal/test_runner/test:974:18) - Test.postRun (node:internal/test_runner/test:1542:19) - Test.run (node:internal/test_runner/test:1467:12) - async Promise.all (index 0) - async Suite.run (node:internal/test_runner/test:1905:7) - async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) - ... - 1..2 -not ok 1 - the seat - --- - duration_ms: 3.295664 - type: 'suite' - location: '/tmp/1901k/node/suite/test/a.test.ts:3:1' - failureType: 'subtestsFailed' - error: '1 subtest failed' - code: 'ERR_TEST_FAILURE' - ... -1..1 -# tests 2 -# suites 1 -# pass 1 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 115.948112 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.console b/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.tap deleted file mode 100644 index ceadd12c2..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/syntax.tap +++ /dev/null @@ -1,47 +0,0 @@ -TAP version 13 -# Subtest: passes in the first file -ok 1 - passes in the first file - --- - duration_ms: 0.637465 - type: 'test' - ... -# node:internal/modules/run_main:111 -# triggerUncaughtException( -# ^ -# file:///tmp/1901k/node/syntax/test/b.test.ts:2 -# test('never loads', () => { -# SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ',', got '' -#  at parseTypeScript (node:internal/modules/typescript:61:36) -#  at processTypeScriptCode (node:internal/modules/typescript:129:37) -#  at Object.stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22) -#  at stripTypeScriptModuleTypes (node:internal/modules/esm/translators:35:49) -#  at ModuleLoader. (node:internal/modules/esm/translators:688:29) -#  at \#translate (node:internal/modules/esm/loader:441:20) -#  at afterLoad (node:internal/modules/esm/loader:509:29) -#  at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12) -#  at \#getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36) -#  at afterResolve (node:internal/modules/esm/loader:614:52) { -# code: 'ERR_INVALID_TYPESCRIPT_SYNTAX' -# } -# Node.js v26.12.1 -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 87.629815 - type: 'test' - location: '/tmp/1901k/node/syntax/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..2 -# tests 2 -# suites 0 -# pass 1 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 106.551435 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.console b/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.tap deleted file mode 100644 index dd87e1717..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/timeout.tap +++ /dev/null @@ -1,26 +0,0 @@ -TAP version 13 -# Subtest: passes before the wait -ok 1 - passes before the wait - --- - duration_ms: 0.683015 - type: 'test' - ... -# Subtest: waits for an answer that never comes -not ok 2 - waits for an answer that never comes - --- - duration_ms: 201.103775 - type: 'test' - location: '/tmp/1901k/node/timeout/test/a.test.ts:3:1' - failureType: 'testTimeoutFailure' - error: 'test timed out after 200ms' - code: 'ERR_TEST_FAILURE' - ... -1..2 -# tests 2 -# suites 0 -# pass 1 -# fail 0 -# cancelled 1 -# skipped 0 -# todo 0 -# duration_ms 308.920061 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.console b/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.console deleted file mode 100644 index 90a8ef65e..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.tap b/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.tap deleted file mode 100644 index 8e0a85b83..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/node/two-files.tap +++ /dev/null @@ -1,49 +0,0 @@ -TAP version 13 -# Subtest: passes in the first file -ok 1 - passes in the first file - --- - duration_ms: 0.740124 - type: 'test' - ... -# judged against the seat as it stands -# Subtest: passes in the second file first -ok 2 - passes in the second file first - --- - duration_ms: 0.916463 - type: 'test' - ... -# Subtest: fails in the second file -not ok 3 - fails in the second file - --- - duration_ms: 1.719236 - type: 'test' - location: '/tmp/1901k/node/two-files/test/b.test.ts:4:1' - failureType: 'testCodeFailure' - error: |- - one plus one is not three - - 2 !== 3 - - code: 'ERR_ASSERTION' - name: 'AssertionError' - expected: 3 - actual: 2 - operator: 'strictEqual' - stack: |- - TestContext. (file:///tmp/1901k/node/two-files/test/b.test.ts:6:10) - Test.runInAsyncScope (node:async_hooks:226:14) - Test.run (node:internal/test_runner/test:1402:25) - Test.processPendingSubtests (node:internal/test_runner/test:974:18) - Test.postRun (node:internal/test_runner/test:1542:19) - Test.run (node:internal/test_runner/test:1467:12) - async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) - ... -1..3 -# tests 3 -# suites 0 -# pass 2 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 135.931121 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-run.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-run.txt deleted file mode 100644 index 2bac81e5a..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-run.txt +++ /dev/null @@ -1 +0,0 @@ -Error: building at STEP "RUN false": exit status 1 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-timeout.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-timeout.txt deleted file mode 100644 index 8fb9a99fa..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/buildah-bud-timeout.txt +++ /dev/null @@ -1 +0,0 @@ -Error: creating build container: initializing source docker://alpine: pinging container registry registry-1.docker.io: Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on 10.10.0.3:53: i/o timeout diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-closed.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-closed.txt deleted file mode 100644 index 259bd3a57..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-closed.txt +++ /dev/null @@ -1,2 +0,0 @@ -Connection closed by 10.10.0.1 port 222 -fatal: Could not read from remote repository. diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-kex.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-kex.txt deleted file mode 100644 index 50a6d9b0a..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-kex.txt +++ /dev/null @@ -1,6 +0,0 @@ -kex_exchange_identification: read: Connection reset by peer -Connection reset by 10.10.0.1 port 222 -fatal: Could not read from remote repository. - -Please make sure you have the correct access rights -and the repository exists. diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-noref.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-noref.txt deleted file mode 100644 index 8f5ec1a62..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-noref.txt +++ /dev/null @@ -1 +0,0 @@ -fatal: couldn't find remote ref 0123456789abcdef0123456789abcdef01234567 diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-resolve.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-resolve.txt deleted file mode 100644 index 3c1742f80..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/git-fetch-resolve.txt +++ /dev/null @@ -1,2 +0,0 @@ -ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution -fatal: Could not read from remote repository. diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-checksum.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-checksum.txt deleted file mode 100644 index a40e58961..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-checksum.txt +++ /dev/null @@ -1 +0,0 @@ -{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"verifying module: checksum mismatch"} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-dial.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-dial.txt deleted file mode 100644 index 568886bee..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-dial.txt +++ /dev/null @@ -1 +0,0 @@ -{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp 10.10.0.1:5100: i/o timeout"} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-lookup.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-lookup.txt deleted file mode 100644 index 4d523f910..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-lookup.txt +++ /dev/null @@ -1 +0,0 @@ -{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp: lookup git.novox.be on 10.10.0.3:53: read udp 10.10.0.3:42292->10.10.0.3:53: i/o timeout"} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-unknown.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-unknown.txt deleted file mode 100644 index 1603ebfe5..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/go-download-unknown.txt +++ /dev/null @@ -1 +0,0 @@ -{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"invalid version: unknown revision abc"} diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-404.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-404.txt deleted file mode 100644 index 53e44d285..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-404.txt +++ /dev/null @@ -1,2 +0,0 @@ -npm error code E404 -npm error 404 Not Found - GET https://git.novox.be/api/packages/novox/npm/@novox%2fx diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-eaiagain.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-eaiagain.txt deleted file mode 100644 index c47a48568..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-eaiagain.txt +++ /dev/null @@ -1,3 +0,0 @@ -npm error code EAI_AGAIN -npm error syscall getaddrinfo -npm error errno EAI_AGAIN diff --git a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-econnreset.txt b/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-econnreset.txt deleted file mode 100644 index 1657f055c..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/passing/npm-ci-econnreset.txt +++ /dev/null @@ -1,4 +0,0 @@ -npm error code ECONNRESET -npm error syscall read -npm error errno -104 -npm error network aborted diff --git a/modules/mesh-build-go/buildstep/conformance/captured/tsc/typeerror.console b/modules/mesh-build-go/buildstep/conformance/captured/tsc/typeerror.console deleted file mode 100644 index ac7713ed5..000000000 --- a/modules/mesh-build-go/buildstep/conformance/captured/tsc/typeerror.console +++ /dev/null @@ -1 +0,0 @@ -seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'. diff --git a/modules/mesh-build-go/buildstep/conformance/conformance.go b/modules/mesh-build-go/buildstep/conformance/conformance.go deleted file mode 100644 index 2a5225416..000000000 --- a/modules/mesh-build-go/buildstep/conformance/conformance.go +++ /dev/null @@ -1,279 +0,0 @@ -// Package conformance is the kit every build module's tests run (novox/hq issue 1901, its thorough fix): real output of -// the test runners, captured with the build nodes' own toolchains (captured/README.md), and what a build module must say -// of each failure in it under the failure contract (buildstep.Failure). A build module's test calls Run with its own parse -// path, the code that turns a runner's output into the failures of its test result; the kit fails the test when a -// failure is not named, its cause is missing or wrong, or a passing line stands in its place. -// -// A catalogue check (mesh-catalog checks/failurekit) refuses a build module with a test step whose tests do not call Run, -// and any build module whose tests do not call LeftRunning, the kit's case of steps a previous process left running -// (leftrunning.go, issue 1890). -package conformance - -import ( - "embed" - "fmt" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-delivery/sdk/buildstep" -) - -// The runners the kit holds output of. -const ( - // GoTest is `go test -json -count=1 ./...`: Output.Stdout is its output (go1.27.3). - GoTest = "go test -json" - // NodeTest is a package's `npm test` running `node --test --experimental-strip-types` with - // NODE_OPTIONS=buildstep.NodeTestOptions(): Output.Report is the TAP file, Output.Stdout the script's own output - // and stderr, Output.Dir the package's directory as it was captured (node v26.12.1, npm 12.2.1). - NodeTest = "node --test" - // TypeCheck is tsc's type-check (`tsc --noEmit`, typescript 5.9.3): Output.Stdout is what it wrote; its failure - // names the test "type-check". - TypeCheck = "tsc" -) - -// Output is one captured run of a runner, as a build module's parse path takes it. -type Output struct { - Stdout, Stderr string - // Report is what a runner wrote to a report file of its own (node --test's TAP), "" for the others. - Report string - // Dir is the directory the run was captured in: a node test's file is said relative to it. - Dir string -} - -// Parse is a build module's own parse path: the failures its test result would carry for one run that failed. -type Parse func(Output) []buildstep.Failure - -// Case is one captured run and what its failure must say. -type Case struct { - Name string - Runner string - // Test is the failing test the failure names (for a package that stopped, the tests that were running; "" for none). - Test string - // File, when set, is where the failing test is. - File string - // Cause holds what the failure's cause must contain. - Cause []string - // Passing are the names of tests that passed in the run, and what they logged: no failure's words may hold them. - Passing []string -} - -//go:embed captured -var captured embed.FS - -// Cases are the kit's cases of a runner. -func Cases(runner string) []Case { - var out []Case - for _, c := range cases { - if c.Runner == runner { - out = append(out, c) - } - } - return out -} - -var cases = []Case{ - {Name: "passfirst", Runner: GoTest, Test: "TestTheSecondPackageFails", Cause: []string{"the seat has 2 holders, want 1"}, - Passing: []string{"TestTheFirstPackagePasses", "TestTheSecondPackagePassesFirst"}}, - {Name: "subtests", Runner: GoTest, Test: "TestTheTable/fails", Cause: []string{"got 3 machines, want 4"}, Passing: []string{"TestTheTable/passes"}}, - {Name: "logthenfatal", Runner: GoTest, Test: "TestThree", Cause: []string{"3 subscription(s) derive to a subject no emitter owns:"}}, - {Name: "panicking", Runner: GoTest, Test: "TestPanics", Cause: []string{"panic: assignment to entry in nil map"}}, - {Name: "timeout", Runner: GoTest, Test: "TestWaitsForever", Cause: []string{"panic: test timed out after 2s", "TestWaitsForever"}}, - {Name: "deeptimeout", Runner: GoTest, Test: "TestHoldsTheSeat, TestWaitsForTheBus", Cause: []string{"panic: test timed out after 2s", "TestHoldsTheSeat", "TestWaitsForTheBus"}}, - {Name: "buildfail", Runner: GoTest, Cause: []string{`cannot use "seat" (untyped string constant) as int value`}}, - {Name: "exiting", Runner: GoTest, Test: "TestExits", Cause: []string{"exited", "TestExits"}}, - {Name: "killed", Runner: GoTest, Test: "TestKilled", Cause: []string{"signal: killed"}}, - {Name: "passthenfail", Runner: GoTest, Cause: []string{"panic: the bus closed after the tests: nats: connection closed"}}, - {Name: "mainexit", Runner: GoTest, Cause: []string{"passed", "exited"}}, - {Name: "race", Runner: GoTest, Test: "TestRacesOnTheSeat", Cause: []string{"DATA RACE", "Write at", "example.com/race.TestRacesOnTheSeat.func1()", "p_test.go:13", "created at example.com/race.TestRacesOnTheSeat()"}}, - {Name: "subpanic", Runner: GoTest, Test: "TestTable/panics", Cause: []string{"panic: assignment to entry in nil map"}, Passing: []string{"TestTable/passes"}}, - {Name: "racemain", Runner: GoTest, Cause: []string{"DATA RACE", "Write at", "example.com/racemain.TestMain.func1()", "p_test.go:15", "created at example.com/racemain.TestMain()"}}, - - {Name: "two-files", Runner: NodeTest, Test: "fails in the second file", File: "test/b.test.ts:4:1", Cause: []string{"one plus one is not three"}, - Passing: []string{"passes in the first file", "passes in the second file first"}}, - {Name: "suite", Runner: NodeTest, Test: "fails inside", File: "test/a.test.ts:5:3", Cause: []string{"Expected values to be strictly deep-equal:"}, - Passing: []string{"passes inside"}}, - {Name: "crash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"}, - Passing: []string{"passes before the crash"}}, - {Name: "logcrash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"}, - Passing: []string{"passes and logs", "retrying after Error: the store was not ready yet"}}, - {Name: "timeout", Runner: NodeTest, Test: "waits for an answer that never comes", File: "test/a.test.ts:3:1", Cause: []string{"test timed out after 200ms"}, - Passing: []string{"passes before the wait"}}, - {Name: "syntax", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"SyntaxError", "Expected ',', got ''"}, - Passing: []string{"passes in the first file"}}, - - {Name: "typeerror", Runner: TypeCheck, Test: "type-check", Cause: []string{"seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'."}}, -} - -// Load is the captured output of a case. -func Load(c Case) (Output, error) { - switch c.Runner { - case GoTest: - b, err := captured.ReadFile("captured/go/" + c.Name + ".jsonl") - return Output{Stdout: string(b)}, err - case NodeTest: - report, err := captured.ReadFile("captured/node/" + c.Name + ".tap") - if err != nil { - return Output{}, err - } - console, err := captured.ReadFile("captured/node/" + c.Name + ".console") - return Output{Report: string(report), Stdout: string(console), Dir: "/tmp/1901k/node/" + c.Name}, err - default: - b, err := captured.ReadFile("captured/tsc/" + c.Name + ".console") - return Output{Stdout: string(b), Dir: "/tmp/1901k/tsc/" + c.Name}, err - } -} - -// Run runs every case of runner through parse, each as a subtest, and fails a case whose failure does not keep the -// contract: exactly one failure, naming the failing test (and its file, where the runner says it), with a cause that -// holds what the case says and is no passing line, an output that starts with the cause, stays within its bound and -// holds no passing line and no passing test's name. -func Run(t *testing.T, runner string, parse Parse) { - t.Helper() - cs := Cases(runner) - if len(cs) == 0 { - t.Fatalf("the kit holds no case of the runner %q", runner) - } - for _, c := range cs { - t.Run(c.Name, func(t *testing.T) { - out, err := Load(c) - if err != nil { - t.Fatal(err) - } - for _, problem := range Judge(c, parse(out)) { - t.Error(problem) - } - }) - } -} - -// Judge says what is wrong with the failures a parse path made of a case; nothing when they keep the contract. -func Judge(c Case, failed []buildstep.Failure) []string { - var problems []string - say := func(s string) { problems = append(problems, c.Runner+" "+c.Name+": "+s) } - if len(failed) != 1 { - say(fmt.Sprintf("want one failure, got %d: %s", len(failed), describe(failed))) - if len(failed) == 0 { - return problems - } - } - f := failed[0] - if f.Test != c.Test { - say("the failure names the test " + quote(f.Test) + ", want " + quote(c.Test)) - } - if c.File != "" && f.File != c.File { - say("the failure's file is " + quote(f.File) + ", want " + quote(c.File)) - } - if why := buildstep.Hidden(f); why != "" { - say("the failure hides its cause: " + why) - } - for _, want := range c.Cause { - if !strings.Contains(f.Cause, want) { - say("the cause " + quote(f.Cause) + " does not hold " + quote(want)) - } - } - if first := strings.SplitN(f.Output, "\n", 2)[0]; strings.TrimSpace(first) != strings.TrimSpace(f.Cause) { - say("the output starts with " + quote(first) + ", not with the cause") - } - if len(f.Output) > buildstep.OutputBytes+buildstep.LineBytes+64 { - say(fmt.Sprintf("the output holds %d bytes, over its bound", len(f.Output))) - } - for _, l := range strings.Split(f.Output, "\n") { - if buildstep.PassingLine(l) { - say("the output keeps the passing line " + quote(l)) - } - } - for _, p := range c.Passing { - if strings.Contains(f.Output, p+"\n") || strings.HasSuffix(f.Output, p) || strings.Contains(f.Cause, p) || strings.Contains(f.Output, "- "+p) { - say("the failure's words hold the passing test " + quote(p)) - } - } - return problems -} - -func describe(failed []buildstep.Failure) string { - var s []string - for _, f := range failed { - s = append(s, quote(f.Package+" "+f.Test)+" ("+quote(f.Cause)+")") - } - return strings.Join(s, ", ") -} - -func quote(s string) string { return fmt.Sprintf("%q", s) } - -// The runners whose network failures the kit holds words of (Passing). -const ( - GitFetch = "git fetch" - GoDownload = "go mod download" - NpmCI = "npm ci" - BuildahBud = "buildah bud" -) - -// PassingCase is the words of one runner and whether they are a transport fault: a network that did not answer, which the -// bus gives the ask again for (novox/hq ADR 0664 §1), or a failure of the source, which fails the step at once. -type PassingCase struct { - Name, Runner string - Transport bool -} - -// PassingCases are the kit's cases of the network's failures (captured/passing). -var PassingCases = []PassingCase{ - {"git-fetch-kex", GitFetch, true}, {"git-fetch-closed", GitFetch, true}, {"git-fetch-resolve", GitFetch, true}, {"git-fetch-noref", GitFetch, false}, - {"go-download-lookup", GoDownload, true}, {"go-download-dial", GoDownload, true}, {"go-download-unknown", GoDownload, false}, {"go-download-checksum", GoDownload, false}, - {"npm-ci-econnreset", NpmCI, true}, {"npm-ci-eaiagain", NpmCI, true}, {"npm-ci-404", NpmCI, false}, - {"buildah-bud-timeout", BuildahBud, true}, {"buildah-bud-run", BuildahBud, false}, -} - -// Failed is a build module's own path from a runner's words to the error its step ends with: the same code its step runs on -// a failed command, fed the words of a runner's failed run. -type Failed func(runner, words string) error - -// Passing runs every case of the runners through failed, each as a subtest, and fails a build module whose step does not -// say a network's failure as a buildstep.Passing (the bus then gives the ask again) or says a failure of the source as one. -// A build module names the runners its steps use; the case's words are in captured/passing. -func Passing(t *testing.T, runners []string, failed Failed) { - t.Helper() - ran := 0 - for _, c := range PassingCases { - if !slices.Contains(runners, c.Runner) { - continue - } - ran++ - t.Run(c.Name, func(t *testing.T) { - if problem := judgePassing(c, failed); problem != "" { - t.Error(problem) - } - }) - } - if ran == 0 { - t.Fatalf("the kit holds no passing case of the runners %v", runners) - } -} - -// JudgePassing is the problems of failed over every case of the runners: what Passing fails a build module for. -func JudgePassing(runners []string, failed Failed) []string { - var out []string - for _, c := range PassingCases { - if slices.Contains(runners, c.Runner) { - if p := judgePassing(c, failed); p != "" { - out = append(out, p) - } - } - } - return out -} - -func judgePassing(c PassingCase, failed Failed) string { - words, err := captured.ReadFile("captured/passing/" + c.Name + ".txt") - if err != nil { - return err.Error() - } - got := failed(c.Runner, string(words)) - switch { - case got == nil: - return c.Name + ": the step passed on a failed run" - case buildstep.IsPassing(got) != c.Transport: - return fmt.Sprintf("%s: the step ends %q, a transport fault %v, want %v", c.Name, got, buildstep.IsPassing(got), c.Transport) - } - return "" -} diff --git a/modules/mesh-build-go/buildstep/conformance/conformance_test.go b/modules/mesh-build-go/buildstep/conformance/conformance_test.go deleted file mode 100644 index 06ab998b6..000000000 --- a/modules/mesh-build-go/buildstep/conformance/conformance_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package conformance - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" -) - -// The failure contract (issue 1901): the SDK's own parsers keep it on every captured case of the kit (1901/AC6); the -// kit refuses what the build modules sent before it; the contract's own rules; and the Go reader's older guarantees. -func TestTheFailureContract(t *testing.T) { - t.Run("the SDK parsers keep it", func(t *testing.T) { - Run(t, GoTest, func(o Output) []sdkstep.Failure { return buildstep.ReadGoTest(o.Stdout, ".").Failed }) - Run(t, NodeTest, func(o Output) []sdkstep.Failure { return buildstep.ReadTAP(".", "test", o.Dir, o.Report, o.Stdout) }) - Run(t, TypeCheck, func(o Output) []sdkstep.Failure { - return []sdkstep.Failure{sdkstep.ConsoleFailure(".", "type-check", o.Stdout+o.Stderr)} - }) - }) - - // ADR 0664 §1 (issue 1748): the shared wrapper keeps the passing cases; a step that never says a network's failure as a - // transport fault, or says every failure as one, is refused. - t.Run("the passing cases", func(t *testing.T) { - all := []string{GitFetch, GoDownload, NpmCI, BuildahBud} - Passing(t, all, func(_, words string) error { return buildstep.Transport(errors.New(words)) }) - if p := JudgePassing(all, func(_, words string) error { return errors.New(words) }); len(p) != 8 { - t.Errorf("a step that wraps nothing: %d problems, want 8 (one per transport case):\n%s", len(p), strings.Join(p, "\n")) - } - if p := JudgePassing(all, func(_, words string) error { return &sdkstep.Passing{Err: errors.New(words)} }); len(p) != 5 { - t.Errorf("a step that wraps everything: %d problems, want 5 (one per source failure):\n%s", len(p), strings.Join(p, "\n")) - } - }) - - // 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends - // everything. - t.Run("the left-running case", func(t *testing.T) { - LeftRunning(t, func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) { - buildrun.EndStaleAtStart(ctx, scan, node, before, logf) - }) - if p := JudgeLeftRunning(func(context.Context, buildrun.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 { - t.Errorf("a sweep that ends nothing: %d problems, want 11 (three steps not ended and unsaid, one owner unnamed, two asks not deleted and unsaid):\n%s", len(p), strings.Join(p, "\n")) - } - wrongNode := func(ctx context.Context, scan buildrun.Scan, _ string, before time.Time, logf func(string, ...any)) { - buildrun.EndStaleAtStart(ctx, scan, "kitother", before, logf) - } - if p := JudgeLeftRunning(wrongNode); len(p) == 0 { - t.Error("a sweep of another node's steps is not refused") - } - }) - - // node's output cut to its first fifteen lines with no cause, whose first line was a passing test of the first file - // (mesh-build-node's lines() before the contract), and a Go failure keeping its first line. - t.Run("the kit refuses a failure that hides its cause", func(t *testing.T) { - firstLines := func(s string) string { - var keep []string - for _, l := range strings.Split(s, "\n") { - if l = strings.TrimSpace(l); l != "" && len(keep) < 15 { - keep = append(keep, l) - } - } - return strings.Join(keep, "\n") - } - for _, c := range []struct { - runner, name string - old func(Output) []sdkstep.Failure - }{ - {NodeTest, "two-files", func(o Output) []sdkstep.Failure { - return []sdkstep.Failure{{Package: ".", Test: "test", Output: firstLines(o.Report)}} - }}, - {GoTest, "passfirst", func(o Output) []sdkstep.Failure { - return []sdkstep.Failure{{Package: "example.com/passfirst/b", Test: "TestTheSecondPackageFails", Cause: "ok \texample.com/passfirst/a", Output: "--- PASS: TestTheSecondPackagePassesFirst"}} - }}, - } { - for _, k := range Cases(c.runner) { - if k.Name != c.name { - continue - } - out, err := Load(k) - if err != nil { - t.Fatal(err) - } - if problems := Judge(k, c.old(out)); len(problems) == 0 { - t.Errorf("%s %s: the kit passed a failure that hides its cause", c.runner, c.name) - } - } - } - }) - - // What a passing line is (a test's own line starting "ok " is none), when a failure hides its cause, and that a long - // failure keeps its last lines within the bound and starts with a real line for an older checker, which reads only - // the first line of the output cut to 120 characters (1901/AC1, AC3). - t.Run("its rules", func(t *testing.T) { - for _, c := range []struct { - line string - passing bool - }{ - {"✔ the same merge has the same id (8.0ms)", true}, {"ok 1 - passes", true}, {" ok 3 - inside", true}, - {"ok \texample.com/a\t0.01s", true}, {"PASS", true}, {"--- PASS: TestA (0.00s)", true}, - {" --- PASS: TestA/sub (0.00s)", true}, {"✖ fails (1ms)", false}, {"not ok 2 - fails", false}, - {"okay, the seat moved", false}, {"ok then: the store refused the write", false}, {"ok the seat", false}, {"FAIL", false}, - } { - if sdkstep.PassingLine(c.line) != c.passing { - t.Errorf("PassingLine(%q) = %v, want %v", c.line, !c.passing, c.passing) - } - } - if sdkstep.Hidden(sdkstep.Failure{Cause: "the seat has 2 holders"}) != "" || sdkstep.Hidden(sdkstep.Failure{}) == "" || - sdkstep.Hidden(sdkstep.Failure{Cause: "✔ passes (1ms)"}) == "" { - t.Error("Hidden does not tell a real cause from none and from a passing line") - } - var lines []string - for i := 0; i < 400; i++ { - lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40)) - } - lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)") - oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine before issue 1901) - for _, l := range strings.Split(out, "\n") { - if l = strings.TrimSpace(l); l != "" { - return l[:min(len(l), 120)] - } - } - return "" - } - for _, cause := range []string{"the real cause", ""} { - out := sdkstep.Clip(lines, cause) - if len(out) > sdkstep.OutputBytes+sdkstep.LineBytes { - t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), sdkstep.OutputBytes) - } - if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") { - t.Errorf("1901/AC1: the last lines or the marker are lost: %q...%q", out[:40], out[len(out)-60:]) - } - if got := oldFirst(out); strings.HasPrefix(got, "... (") || got == "" || (cause != "" && got != cause) { - t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got) - } - } - }) - - // The Go reader (moved from mesh-build-go): an older checker, which knows no "cause" field, still reads a failure and - // gets at least the lines it kept (1901/AC3); a test's indented log line saying "panic:" is not the cause, go's own at - // column 0 is (1901/AC4); and each package counts its own skipped tests (issue 890). - t.Run("the Go reader", func(t *testing.T) { - read := func(name string) string { - raw, err := captured.ReadFile("captured/go/" + name + ".jsonl") - if err != nil { - t.Fatal(err) - } - return string(raw) - } - b, _ := json.Marshal(buildstep.ReadGoTest(read("logthenfatal"), ".").Failed) - var old []struct { - Output string `json:"output,omitempty"` - } - if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") { - t.Errorf("1901/AC3: an older checker reads %+v, %v", old, err) - } - lines := strings.SplitAfter(read("deeptimeout"), "\n") - added := `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" p_test.go:9: the child said:\n"}` + "\n" + - `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" panic: child retried and recovered\n"}` + "\n" + - `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" WARNING: DATA RACE\n"}` + "\n" - if got := buildstep.ReadGoTest(strings.Join(lines[:10], "")+added+strings.Join(lines[10:], ""), ".").Failed; len(got) != 1 || !strings.HasPrefix(got[0].Cause, "panic: test timed out after 2s") { - t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", got) - } - res := buildstep.ReadGoTest(`{"Action":"run","Package":"m/a","Test":"TestA"} -{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0} -{"Action":"run","Package":"m/b","Test":"TestB"} -{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0} -{"Action":"pass","Package":"m/a","Elapsed":0} -{"Action":"pass","Package":"m/b","Elapsed":0} -`, ".") - got := map[string]int{} - for _, p := range res.Packages { - got[p.Package] = p.Skipped - } - if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 { - t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got) - } - }) -} diff --git a/modules/mesh-build-go/buildstep/conformance/leftrunning.go b/modules/mesh-build-go/buildstep/conformance/leftrunning.go deleted file mode 100644 index 5e5bfa303..000000000 --- a/modules/mesh-build-go/buildstep/conformance/leftrunning.go +++ /dev/null @@ -1,212 +0,0 @@ -package conformance - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "testing" - "time" - - buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// **A step left running by a previous process** (novox/hq issue 1890, note 21): when a delivery replaced mesh-build-go -// on a node while a check ask ran, the new process's start sweep let the ask's key stand as running, and it held one -// of the node's build slots until a hand end. Every build module runs this case (LeftRunning) through the start sweep -// its main runs: the hand-started steps a previous process left are ended at once and loud, its asks' keys deleted (the -// stream redelivers the ask, novox/hq ADR 0525 §6, and its asker keeps waiting for that), what this process runs kept. A -// catalogue check (mesh-catalog checks/failurekit) refuses a build module whose tests do not call LeftRunning. - -// Sweep is a build module's own start sweep, as its main runs it once at its start: it ends node's steps in scan that -// no process of the module runs any more (buildrun.EndStaleAtStart, here with the runtime already serving), before -// being the start of the process, saying each end with logf. -type Sweep func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) - -// LeftNode is the node the case's steps are of. -const LeftNode = "kitnode" - -// leftCase is one step in the steps state before the sweep, and whether the sweep must end it. -type leftCase struct { - id, tool, owner, state string - node string - age time.Duration // started this long before the process began (negative: after it) - ended bool - deleted bool // a gone process's ask: its key is deleted, not ended -} - -// gone is a previous process's owner on the same node. -var gone = "b00t-of-the-kit.4242.mvp0000" - -func leftCases() []leftCase { - return []leftCase{ - {id: "mvpwlkzi7e6ca8", tool: "check ask", owner: gone, age: 5 * time.Minute, deleted: true}, // note 21's ghost - {id: "mvpwlkzi7e6ca9", tool: "delivery ask", owner: gone, age: -time.Second, deleted: true}, - {id: "gonehand1", tool: "build_x_test", owner: gone, age: time.Minute, ended: true}, - {id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's - {id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound - {id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still - {id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildrun.Owner, age: time.Minute}, // this process's - {id: "minehand1", tool: "build_x_test", owner: buildrun.Owner, age: -time.Second}, - {id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: sdkstep.StepPassed}, - {id: "otherhand1", tool: "build_x_test", owner: gone, age: time.Hour, node: "kitother"}, - } -} - -// LeftRunning runs the case through sweep and fails the test for each step the sweep ended or kept wrongly, or ended -// without saying it. -func LeftRunning(t *testing.T, sweep Sweep) { - t.Helper() - for _, problem := range JudgeLeftRunning(sweep) { - t.Error(problem) - } -} - -// JudgeLeftRunning says what is wrong with what sweep did to the case; nothing when it keeps the rule. -func JudgeLeftRunning(sweep Sweep) []string { - st := &steps{vals: map[string][]byte{}, revs: map[string]uint64{}} - before := time.Now() - for _, c := range leftCases() { - node, state := c.node, c.state - if node == "" { - node = LeftNode - } - if state == "" { - state = sdkstep.StepRunning - } - key, _ := sdkstep.StepKey(node, c.id) - raw, err := sdkstep.EncodeStep(sdkstep.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner}) - if err != nil { - return []string{"the kit's step " + c.id + ": " + err.Error()} - } - st.set(key, raw) - } - served := make(chan struct{}) - close(served) - was := buildrun.StaleServed - buildrun.StaleServed = func() <-chan struct{} { return served } - defer func() { buildrun.StaleServed = was }() - var mu sync.Mutex - var said []string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - sweep(ctx, st.scan(), LeftNode, before, func(f string, a ...any) { - mu.Lock() - defer mu.Unlock() - said = append(said, fmt.Sprintf(f, a...)) - }) - mu.Lock() - log := strings.Join(said, "\n") - mu.Unlock() - var problems []string - for _, c := range leftCases() { - node := c.node - if node == "" { - node = LeftNode - } - key, _ := sdkstep.StepKey(node, c.id) - raw := st.get(key) - if c.deleted { - if raw != nil { - problems = append(problems, fmt.Sprintf("left running: the ask %s of the gone owner %q is still held after the sweep (%s); "+ - "its key is deleted, never ended: the stream redelivers it (ADR 0525 §6)", key, c.owner, raw)) - } - if !strings.Contains(log, key) || !strings.Contains(log, c.owner) { - problems = append(problems, "left running: the delete of "+key+" is not said with its owner "+c.owner) - } - continue - } - if raw == nil { - problems = append(problems, "left running: the step "+key+" is gone after the sweep, want it kept or ended") - continue - } - s, err := sdkstep.DecodeStep(raw) - if err != nil { - problems = append(problems, key+": "+err.Error()) - continue - } - want := c.state - if want == "" { - want = sdkstep.StepRunning - } - if c.ended { - want = sdkstep.StepFailed - } - if s.State != want { - problems = append(problems, fmt.Sprintf("left running: the step %s (%s, owner %q, %s before the start) is %s after the sweep, want %s", key, c.tool, c.owner, c.age, s.State, want)) - } - if c.ended && !strings.Contains(log, key) { - problems = append(problems, "left running: the end of "+key+" is not said") - } - if c.ended && c.owner != "" && !strings.Contains(log, c.owner) { - problems = append(problems, "left running: the end of "+key+" does not name its owner "+c.owner) - } - } - sort.Strings(problems) - return problems -} - -// steps is a steps state with revisions, whose Update lands only at the revision read (stdio.ErrChanged otherwise). -type steps struct { - mu sync.Mutex - vals map[string][]byte - revs map[string]uint64 -} - -func (s *steps) set(key string, raw []byte) { - s.mu.Lock() - defer s.mu.Unlock() - s.vals[key], s.revs[key] = raw, s.revs[key]+1 -} - -func (s *steps) get(key string) []byte { - s.mu.Lock() - defer s.mu.Unlock() - return s.vals[key] -} - -func (s *steps) scan() buildrun.Scan { - return buildrun.Scan{ - Keys: func() ([]string, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []string - for k := range s.vals { - out = append(out, k) - } - return out, nil - }, - Get: func(key string) (json.RawMessage, uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.vals[key], s.revs[key], nil - }, - Delete: func(key string, revision uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.revs[key] != revision { - return stdio.ErrChanged - } - delete(s.vals, key) - s.revs[key]++ - return nil - }, - Update: func(key string, value any, revision uint64) (uint64, error) { - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.revs[key] != revision { - return 0, stdio.ErrChanged - } - s.vals[key], s.revs[key] = raw, revision+1 - return revision + 1, nil - }, - } -} diff --git a/modules/mesh-build-go/buildstep/cpu.go b/modules/mesh-build-go/buildstep/cpu.go deleted file mode 100644 index d0e41b805..000000000 --- a/modules/mesh-build-go/buildstep/cpu.go +++ /dev/null @@ -1,14 +0,0 @@ -package buildstep - -import ( - "os" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu" -) - -// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share from the -// word the composer gives the module (buildcpu.ShareWord), divided by the module's own capacity, own, over the machine's -// processors. Without the word, no thread limit: Threads is 0. -func NodeLimits(own int) buildcpu.Limits { - return buildcpu.FromWords(os.Getenv, own) -} diff --git a/modules/mesh-build-go/buildstep/digestshape_test.go b/modules/mesh-build-go/buildstep/digestshape_test.go deleted file mode 100644 index 9b2aedc12..000000000 --- a/modules/mesh-build-go/buildstep/digestshape_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "sync" - "testing" - "time" - - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - "git.novox.be/novox/mesh-sdk/go/kvlimit" -) - -// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq -// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends -// in a credential word, or "". A step's end is refused when it answers a field. -func credentialField(value json.RawMessage) string { - endings := []string{"password", "passwd", "secret", "token", "credential", "credentials", - "authorization", "apikey", "privatekey", "accesskey", "cookie"} - var v any - if json.Unmarshal(value, &v) != nil { - return "" - } - var walk func(any) string - walk = func(v any) string { - switch t := v.(type) { - case map[string]any: - keys := make([]string, 0, len(t)) - for k := range t { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k)) - for _, end := range endings { - if strings.HasSuffix(norm, end) { - return k - } - } - if found := walk(t[k]); found != "" { - return found - } - } - case []any: - for _, x := range t { - if found := walk(x); found != "" { - return found - } - } - } - return "" - } - return walk(v) -} - -// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took. -type runtimeState struct { - mu sync.Mutex - took map[string][]sdkstep.Step - refused int -} - -func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]sdkstep.Step{}} } - -func (s *runtimeState) put(key string, value any) (uint64, error) { - raw, _ := value.(json.RawMessage) - if f := credentialField(raw); f != "" { - s.mu.Lock() - s.refused++ - s.mu.Unlock() - return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f) - } - st, err := sdkstep.DecodeStep(raw) - if err != nil { - return 0, err - } - s.mu.Lock() - defer s.mu.Unlock() - s.took[key] = append(s.took[key], st) - return uint64(len(s.took[key])), nil -} - -func (s *runtimeState) last(key string) (sdkstep.Step, bool) { - s.mu.Lock() - defer s.mu.Unlock() - t := s.took[key] - if len(t) == 0 { - return sdkstep.Step{}, false - } - return t[len(t)-1], true -} - -func fastEnds(t *testing.T) { - attempts, retry := EndAttempts, EndRetry - EndAttempts, EndRetry = 2, time.Millisecond - t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry }) -} - -// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the -// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package -// and its digest read back from the stored end. -func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) { - fastEnds(t) - r := &recorder{ - fetch: map[string]any{"workspace": "/w"}, - digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"}, - tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true}, - } - out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, - Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) - if err != nil { - t.Fatal(err) - } - old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}}) - if credentialField(old) != "x/internal/token" { - t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old) - } - st := newRuntimeState() - if err := finish(st.put, "shanks.a1", sdkstep.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil { - t.Fatal(err) - } - s, ok := st.last("shanks.a1") - if !ok || s.State != sdkstep.StepPassed || st.refused != 0 { - t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused) - } - var a sdkstep.CheckAnswer - if err := json.Unmarshal(s.Result, &a); err != nil { - t.Fatal(err) - } - if len(a.Levels) != 1 || a.Levels[0].Digest == nil { - t.Fatalf("levels %+v, want the unit level with its digest", a.Levels) - } - want := []sdkstep.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}} - if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) { - t.Errorf("packages %v, want %v", got, want) - } - if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) { - t.Errorf("the stored end does not name the package as a value: %s", s.Result) - } -} - -// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small -// failed end without the result that names the write's error; and when no end can be written at all it answers an error. -func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { - fastEnds(t) - big := errors.New("the value carries a field \"x/internal/token\", which names a credential") - var refused int - var stored []sdkstep.Step - put := func(key string, value any) (uint64, error) { - st, _ := sdkstep.DecodeStep(value.(json.RawMessage)) - if len(st.Result) > 0 { - refused++ - return 0, big - } - stored = append(stored, st) - return 1, nil - } - var logged []string - logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) } - err := finish(put, "shanks.a2", sdkstep.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf) - if err != nil { - t.Fatalf("finish answered %v, want the small end written", err) - } - if refused != EndAttempts || len(stored) != 1 { - t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts) - } - s := stored[0] - if s.State != sdkstep.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") || - !strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") { - t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s) - } - if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) { - t.Errorf("logged %q, want the refusal said", logged) - } - none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") } - if err := finish(none, "shanks.a3", sdkstep.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") { - t.Errorf("finish with no write possible answered %v, want the error", err) - } -} - -// 1921 note 91: given a check's answer over what the steps state takes per value, the bulk of it the fetch's taken -// versions, when finish writes it, then the end is written passed without the taken versions, its levels and the rest of -// its fetch kept, the reason saying how many were left out and why; an over-large answer with no taken versions, and one -// still too large without them, end failed as before. -func TestAnAnswerOverTheStateBoundLeavesOutItsTakenVersions(t *testing.T) { - fastEnds(t) - answer := func(taken int) map[string]any { - var packages []map[string]any - for i := range taken { - packages = append(packages, map[string]any{"dir": fmt.Sprintf("modules/m%d", i), "package": "git.novox.be/novox/mesh-sdk/go", "said": "the ask names it"}) - } - return map[string]any{ - "fetch": map[string]any{"workspace": "/w", "changed": []string{"modules/m0/module.json"}, "packages": packages}, - "levels": []any{map[string]any{"level": "unit", "tests": []any{map[string]any{"passed": true}}}}, - } - } - for _, tc := range []struct { - name string - taken int - limit int - wantState string - }{ - {"its taken versions left out", 40, 1500, sdkstep.StepPassed}, - {"no taken versions to leave out", 0, 200, sdkstep.StepFailed}, - {"still too large without them", 40, 200, sdkstep.StepFailed}, - } { - t.Run(tc.name, func(t *testing.T) { - var stored []sdkstep.Step - put := func(key string, value any) (uint64, error) { - raw := value.(json.RawMessage) - if err := kvlimit.Check("the state steps", key, len(raw), int64(tc.limit)); err != nil && strings.Contains(string(raw), `"result"`) { - return 0, err - } - st, _ := sdkstep.DecodeStep(raw) - stored = append(stored, st) - return 1, nil - } - if err := finish(put, "shanks.a4", sdkstep.Step{ID: "a4", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, answer(tc.taken), nil, t.Logf); err != nil { - t.Fatal(err) - } - if len(stored) != 1 || stored[0].State != tc.wantState { - t.Fatalf("stored %+v, want one end %s", stored, tc.wantState) - } - s := stored[0] - if tc.wantState == sdkstep.StepFailed { - if len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") { - t.Errorf("the end %+v, want the small failed end", s) - } - return - } - var a sdkstep.CheckAnswer - if err := json.Unmarshal(s.Result, &a); err != nil { - t.Fatal(err) - } - if len(a.Fetch.Packages) != 0 || a.Fetch.Workspace != "/w" || len(a.Fetch.Changed) != 1 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 { - t.Errorf("the stored answer %s, want its fetch and levels without the taken versions", s.Result) - } - if !strings.Contains(s.Reason, "the fetch's 40 taken versions are left out") || !strings.Contains(s.Reason, "journal names each") || - !strings.Contains(s.Reason, "bytes the bucket takes") { - t.Errorf("the reason %q, want how many were left out, why and where each is said", s.Reason) - } - }) - } -} - -// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small -// failed end is written; when no end can be written at all, it is given back, never acknowledged as done. -func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { - fastEnds(t) - for _, tc := range []struct { - name string - smallOK bool - wantAcked bool - }{{"the small end written", true, true}, {"no end written", false, false}} { - t.Run(tc.name, func(t *testing.T) { - src := NewFakeAsks() - a := testAsk(sdkstep.AskCheck) - _ = src.PublishAsk(a) - var mu sync.Mutex - var ends []sdkstep.Step - put := func(key string, value any) (uint64, error) { - st, err := sdkstep.DecodeStep(value.(json.RawMessage)) - if err != nil { - return 0, err - } - if st.State != sdkstep.StepRunning && (len(st.Result) > 0 || !tc.smallOK) { - return 0, errors.New("refused") - } - mu.Lock() - ends = append(ends, st) - mu.Unlock() - return 1, nil - } - p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Logf: t.Logf, - Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go p.Pull(ctx) - waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 }) - cancel() - acked, termed, naked := src.Fates() - if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 { - t.Fatalf("acked %v naked %v termed %v", acked, naked, termed) - } - mu.Lock() - defer mu.Unlock() - if tc.smallOK { - last := ends[len(ends)-1] - if last.State != sdkstep.StepFailed || !strings.Contains(last.Reason, "refused") { - t.Errorf("the end %+v, want the small failed end", last) - } - } - }) - } -} diff --git a/modules/mesh-build-go/buildstep/fetch.go b/modules/mesh-build-go/buildstep/fetch.go deleted file mode 100644 index fb8d53a6f..000000000 --- a/modules/mesh-build-go/buildstep/fetch.go +++ /dev/null @@ -1,208 +0,0 @@ -package buildstep - -import ( - "context" - "fmt" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -// Option changes FetchCommit. -type Option func(*fetchOptions) - -type fetchOptions struct { - allowFile bool - mirror string -} - -// AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production -// code does not pass it, and FetchCommit then refuses anything but ssh://. -func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } } - -// WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps -// in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates -// names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never -// a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it. -func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } } - -// useMirror writes the alternates file of the repository in dir, when a mirror is set. -func useMirror(o fetchOptions, dir string) error { - if o.mirror == "" { - return nil - } - info := filepath.Join(dir, ".git", "objects", "info") - if err := os.MkdirAll(info, 0o755); err != nil { - return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) - } - line := filepath.Join(o.mirror, "objects") + "\n" - if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil { - return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) - } - return nil -} - -var ( - commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) - refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`) -) - -// gitFailure is a failed git command said in git's own words; one whose words are a network's (a lookup, a timeout, ssh's key -// exchange cut by a forge's per-source penalty) is a sdkstep.Passing, so the ask is given again after a delay (ADR 0664 §1). -func gitFailure(verb, said string) error { - err := fmt.Errorf("git %s: %s", verb, said) - if sdkstep.PassingCause(said) { - return &sdkstep.Passing{Err: err} - } - return err -} - -// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached -// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh:////.git, the ssh name being the -// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names -// the pull request's head (refs/pull//head) when the commit is only there. Every git command runs through run -// as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no -// credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40 -// hex digits are refused before anything runs. -func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error { - var o fetchOptions - for _, opt := range opts { - opt(&o) - } - if err := checkFetch(o, dir, remote, commit, ref); err != nil { - return err - } - env := []string{"GIT_TERMINAL_PROMPT=0"} - git := func(args ...string) (string, error) { - full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) - r := run(ctx, env, "git", full...) - if r.Status != 0 || r.Err != "" { - return "", gitFailure(args[0], dforgekey.Said(r.Err+"\n"+r.Stderr)) - } - return r.Stdout, nil - } - if _, err := git("init", "-q"); err != nil { - return err - } - if err := useMirror(o, dir); err != nil { - return err - } - if _, err := git("remote", "add", "origin", remote); err != nil { - return err - } - if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil { - if ref == "" { - return err - } - if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil { - return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) - } - if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil { - return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr) - } - } - if _, err := git("checkout", "-q", "--detach", commit); err != nil { - return err - } - out, err := git("rev-parse", "HEAD") - if err != nil { - return err - } - if head := strings.TrimSpace(out); head != commit { - return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit) - } - return nil -} - -// UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare, -// origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as -// FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror -// keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers -// serialise updates of one mirror. -func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error { - var o fetchOptions - for _, opt := range opts { - opt(&o) - } - if !filepath.IsAbs(mirror) { - return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror) - } - if err := checkFetch(o, mirror, remote, commit, ref); err != nil { - return err - } - env := []string{"GIT_TERMINAL_PROMPT=0"} - git := func(args ...string) error { - r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...) - if r.Status != 0 || r.Err != "" { - verb := args[0] - if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C - verb = args[2] - } - return gitFailure(verb, dforgekey.Said(r.Err+"\n"+r.Stderr)) - } - return nil - } - in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) } - if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil { - if err := git("init", "-q", "--bare", mirror); err != nil { - return err - } - if err := in("remote", "add", "origin", remote); err != nil { - return err - } - } else if err := in("remote", "set-url", "origin", remote); err != nil { - return err - } - if err := in("fetch", "--no-tags", "origin", commit); err != nil { - if ref == "" { - return err - } - if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil { - return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) - } - } - if err := in("cat-file", "-e", commit+"^{commit}"); err != nil { - return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err) - } - return nil -} - -func checkFetch(o fetchOptions, dir, remote, commit, ref string) error { - if dir == "" { - return fmt.Errorf("buildstep: no workspace directory") - } - if o.mirror != "" { - if !filepath.IsAbs(o.mirror) { - return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror) - } - if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() { - return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror) - } - } - if !commitOK.MatchString(commit) { - return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit) - } - if ref != "" && !refOK.MatchString(ref) { - return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref) - } - u, err := url.Parse(remote) - if err != nil { - return fmt.Errorf("buildstep: the remote is not a URL: %w", err) - } - if u.User != nil { - return fmt.Errorf("buildstep: the remote carries user information; the key is the credential") - } - switch { - case u.Scheme == "ssh" && u.Host != "": - case u.Scheme == "file" && o.allowFile: - default: - return fmt.Errorf("buildstep: the remote %q is not ssh://", remote) - } - return nil -} diff --git a/modules/mesh-build-go/buildstep/fetch_integration_test.go b/modules/mesh-build-go/buildstep/fetch_integration_test.go deleted file mode 100644 index 5f782a1f6..000000000 --- a/modules/mesh-build-go/buildstep/fetch_integration_test.go +++ /dev/null @@ -1,59 +0,0 @@ -//go:build integration - -package buildstep - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -func gitIn(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid"}, args...)...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return strings.TrimSpace(string(out)) -} - -// 1474/AC5: a local bare repository with a commit only under refs/pull/1/head (a file:// remote, allowed by the -// test-only option): the workspace's HEAD is the commit. The forge itself is proven live after the build -// modules' delivery (ADR 0466 §5). -func TestFetchCommitFromAPullRefOnly(t *testing.T) { - root := t.TempDir() - bare := filepath.Join(root, "r.git") - work := filepath.Join(root, "work") - for _, d := range []string{bare, work} { - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } - } - gitIn(t, bare, "init", "-q", "--bare") - gitIn(t, work, "init", "-q") - if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - gitIn(t, work, "add", "f") - gitIn(t, work, "commit", "-q", "-m", "pull request head") - commit := gitIn(t, work, "rev-parse", "HEAD") - gitIn(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head") - - ws := filepath.Join(root, "ws") - if err := os.MkdirAll(ws, 0o755); err != nil { - t.Fatal(err) - } - err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, "file://"+bare, commit, "refs/pull/1/head", AllowFileRemote()) - if err != nil { - t.Fatal(err) - } - if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit { - t.Errorf("HEAD = %s, want %s", head, commit) - } -} diff --git a/modules/mesh-build-go/buildstep/fetch_test.go b/modules/mesh-build-go/buildstep/fetch_test.go deleted file mode 100644 index eb2b916c1..000000000 --- a/modules/mesh-build-go/buildstep/fetch_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package buildstep - -import ( - "context" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -const sha = "0123456789abcdef0123456789abcdef01234567" - -// fakeGit records every command and answers by a rule. -type fakeGit struct { - calls []string - envs [][]string - rule func(args string) forgekey.Ran -} - -func (f *fakeGit) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { - line := name + " " + strings.Join(args, " ") - f.calls = append(f.calls, line) - f.envs = append(f.envs, env) - if f.rule != nil { - if r := f.rule(line); r.Status != 0 || r.Stdout != "" || r.Err != "" { - return r - } - } - if len(args) > 4 && args[4] == "rev-parse" { - return forgekey.Ran{Stdout: sha + "\n"} - } - return forgekey.Ran{} -} - -const pre = "git -c credential.helper= -C /w " - -// 1474/AC1: the git commands are exactly the sequence, none with a credential or an https remote. -func TestFetchCommitRunsTheSequence(t *testing.T) { - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/novox/mesh-sdk.git", sha, ""); err != nil { - t.Fatal(err) - } - want := []string{ - pre + "init -q", - pre + "remote add origin ssh://mesh-forge/novox/mesh-sdk.git", - pre + "fetch --no-tags --depth 1 origin " + sha, - pre + "checkout -q --detach " + sha, - pre + "rev-parse HEAD", - } - if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { - t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) - } - for i, c := range f.calls { - if strings.Contains(c, "https://") || strings.Contains(c, "@") || !strings.Contains(c, "credential.helper= ") { - t.Errorf("command %q carries a credential or https", c) - } - if len(f.envs[i]) != 1 || f.envs[i][0] != "GIT_TERMINAL_PROMPT=0" { - t.Errorf("env %v", f.envs[i]) - } - } -} - -// 1474/AC2: the depth-1 fetch fails and a pull ref is given: the ref is fetched and the commit verified; with no -// ref, the fetch's failure is the answer. -func TestFetchCommitFallsBackToThePullRef(t *testing.T) { - f := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.Contains(l, "--depth 1") { - return forgekey.Ran{Status: 128, Stderr: "fatal: couldn't find remote ref " + sha + "\n"} - } - return forgekey.Ran{} - }} - if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err != nil { - t.Fatal(err) - } - want := []string{pre + "fetch --no-tags origin refs/pull/1/head", pre + "cat-file -e " + sha + "^{commit}", pre + "checkout -q --detach " + sha} - got := strings.Join(f.calls[3:6], "\n") - if got != strings.Join(want, "\n") { - t.Errorf("after the failed fetch:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) - } - - g := &fakeGit{rule: f.rule} - err := FetchCommit(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", sha, "") - if err == nil || !strings.Contains(err.Error(), "couldn't find remote ref") || len(g.calls) != 3 { - t.Errorf("without a ref: %v after %d commands", err, len(g.calls)) - } - - h := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.Contains(l, "--depth 1") || strings.Contains(l, "cat-file") { - return forgekey.Ran{Status: 1, Stderr: "no"} - } - return forgekey.Ran{} - }} - if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil { - t.Error("a commit that is not in the ref was accepted") - } - - // ADR 0664 §1 (issue 1748): the words a forge's sshd penalty leaves (captured on ace, 2026-11-09) are a transport fault the bus - // gives the ask again for; a missing commit is not. - for _, c := range []struct { - said string - passing bool - }{ - {"kex_exchange_identification: read: Connection reset by peer\nfatal: Could not read from remote repository.", true}, - {"Connection closed by 10.10.0.1 port 222", true}, - {"ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution", true}, - {"fatal: couldn't find remote ref " + sha, false}, - } { - x := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.Contains(l, " fetch ") { - return forgekey.Ran{Status: 128, Stderr: c.said} - } - return forgekey.Ran{} - }} - if err := FetchCommit(context.Background(), x.run, "/w", "ssh://mesh-forge/o/r.git", sha, ""); err == nil || sdkstep.IsPassing(err) != c.passing { - t.Errorf("%q: %v, passing %v, want %v", c.said, err, err != nil && sdkstep.IsPassing(err), c.passing) - } - } -} - -// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both. -func TestFetchCommitAnswersAHeadThatIsNotTheCommit(t *testing.T) { - other := strings.Repeat("a", 40) - f := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.HasSuffix(l, "rev-parse HEAD") { - return forgekey.Ran{Stdout: other + "\n"} - } - return forgekey.Ran{} - }} - err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "") - if err == nil || !strings.Contains(err.Error(), other) || !strings.Contains(err.Error(), sha) { - t.Errorf("error = %v, want both shas", err) - } -} - -// 1474/AC4: an https remote, user information, a file remote without the option, or a commit that is not 40 -// hex digits is refused before anything runs. -func TestFetchCommitRefusesBeforeRunningAnything(t *testing.T) { - for _, c := range []struct{ remote, commit, ref string }{ - {"https://forge.example/o/r.git", sha, ""}, - {"http://forge.example/o/r.git", sha, ""}, - {"ssh://git@mesh-forge/o/r.git", sha, ""}, - {"ssh://user:pw@mesh-forge/o/r.git", sha, ""}, - {"file:///tmp/r.git", sha, ""}, - {"/tmp/r.git", sha, ""}, - {"ssh://mesh-forge/o/r.git", "abc123", ""}, - {"ssh://mesh-forge/o/r.git", strings.ToUpper(sha), ""}, - {"ssh://mesh-forge/o/r.git", "main", ""}, - {"ssh://mesh-forge/o/r.git", sha, "--upload-pack=x"}, - } { - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", c.remote, c.commit, c.ref); err == nil || len(f.calls) != 0 { - t.Errorf("%+v: err %v, %d commands", c, err, len(f.calls)) - } - } - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", "file:///tmp/r.git", sha, "", AllowFileRemote()); err != nil { - t.Errorf("a file remote with the option: %v", err) - } -} diff --git a/modules/mesh-build-go/buildstep/generated.go b/modules/mesh-build-go/buildstep/generated.go deleted file mode 100644 index a3121c061..000000000 --- a/modules/mesh-build-go/buildstep/generated.go +++ /dev/null @@ -1,164 +0,0 @@ -package buildstep - -// **Regenerate and compare** (CAT-BG5, CAT-BN2; novox/hq ADR 0608 §5, design 26 *Contracts modules*). A module whose -// package artifact names `generated-from` holds contracts only: every file under its generated roots is written by the -// seat generator (package seatgen beside this file, command cmd/seatgen) from the definitions in that directory. A build module regenerates them with the generator -// it was built with (its version is this package's requirement, so a new generator is a new build module and a new -// fingerprint of what it builds) and names each file out of step: one that differs, one missing, one under a generated -// root the generator did not write. The test step fails on any; a trunk build's ask answers them in its out-of-step -// (sdkstep.BuildAnswer.OutOfStep), so the builder's build end raises generated-types-out-of-step even where no test -// step runs. Each build module compares its own roots: mesh-build-go `go`, mesh-build-node `ts`. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// GeneratedRootGo and GeneratedRootTS are the generated roots of a contracts module, one per build module. -const ( - GeneratedRootGo = "go" - GeneratedRootTS = "ts" -) - -var goModuleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`) - -// OutOfStep is every file under root, a path from the module's directory, that the generator writes otherwise than the -// module holds it, for each `generated-from` its package artifacts name, sorted and each once; none for a module naming -// none. The generated trees' names are read from the seats already generated in (generatedNames). -func OutOfStep(moduleDir, root string) ([]string, error) { - raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) - if errors.Is(err, os.ErrNotExist) { - return nil, nil // no manifest here: nothing names generated-from - } - if err != nil { - return nil, fmt.Errorf("the module's manifest cannot be read for its generated files: %w", err) - } - var m catalogue.Manifest - if err := json.Unmarshal(raw, &m); err != nil { - return nil, fmt.Errorf("the module's manifest does not parse: %w", err) - } - froms := map[string]bool{} - if m.Build != nil { - for _, a := range m.Build.Artifacts { - if a.GeneratedFrom != "" { - froms[filepath.Clean(a.GeneratedFrom)] = true - } - } - } - seen := map[string]bool{} - for from := range froms { - if filepath.IsAbs(from) || from == ".." || strings.HasPrefix(from, "../") { - return nil, fmt.Errorf("generated-from %q is not inside the module", from) - } - dir := filepath.Join(moduleDir, from) - goModule, tsPackage, err := generatedNames(dir) - if err != nil { - return nil, err - } - g, err := seatgen.Load(dir, goModule, tsPackage) - if err != nil { - return nil, fmt.Errorf("the seat definitions in %s do not load: %w", from, err) - } - files, err := seatgen.Generate(g) - if err != nil { - return nil, fmt.Errorf("the seat definitions in %s do not generate: %w", from, err) - } - out, err := seatgen.Compare(dir, files) - if err != nil { - return nil, err - } - for _, line := range out { - p, _, _ := strings.Cut(line, ": ") - if p == root || strings.HasPrefix(p, root+"/") { - seen[filepath.ToSlash(filepath.Join(from, p))] = true - } - } - } - paths := make([]string, 0, len(seen)) - for p := range seen { - paths = append(paths, p) - } - sort.Strings(paths) - return paths, nil -} - -// generatedNames reads the names the generated trees are rooted at from the seats already generated in dir: a seat's Go -// module is `/seats/` and its npm package `-` (a contracts module has no root -// package of either kind, ADR 0635 §1). A module with no seat generated yet gives none, and the generator says so only -// if a seat is defined whole. -func generatedNames(dir string) (goModule, tsPackage string, err error) { - names, _ := filepath.Glob(filepath.Join(dir, "seats", "*", "seat.json")) - for _, p := range names { - seat := filepath.Base(filepath.Dir(p)) - pkg := strings.ReplaceAll(seat, "-", "") - if goModule == "" { - if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "seats", pkg, "go.mod")); err == nil { - if m := goModuleLine.FindSubmatch(raw); m != nil { - goModule = strings.TrimSuffix(string(m[1]), "/seats/"+pkg) - } - } - } - if tsPackage == "" { - if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "seats", pkg, "package.json")); err == nil { - var p struct { - Name string `json:"name"` - } - if json.Unmarshal(raw, &p) == nil { - tsPackage = strings.TrimSuffix(p.Name, "-"+seat) - } - } - } - } - return goModule, tsPackage, nil -} - -// ModuleDirs is every directory in tree that holds a module.json, in any folder (ADR 0634 §8: a module is found by its -// manifest, whether it lies at the root, under modules/ or in a folder of another module), the tree's own first, then -// sorted. Hidden directories, node_modules and testdata are not entered. -func ModuleDirs(tree string) ([]string, error) { - var out []string - err := filepath.WalkDir(tree, func(p string, e os.DirEntry, err error) error { - if err != nil { - return err - } - if e.IsDir() { - if n := e.Name(); p != tree && (strings.HasPrefix(n, ".") || n == "node_modules" || n == "testdata") { - return filepath.SkipDir - } - return nil - } - if e.Name() == "module.json" { - out = append(out, filepath.Dir(p)) - } - return nil - }) - sort.Strings(out) - return out, err -} - -// CompareStep is AskSteps.Compare for a build module comparing root: it reads {"workspace", "module"}, the workspace's -// tree where tree says it is (the build module's own layout), and answers {"out-of-step": [...]}. -func CompareStep(root string, tree func(workspace string) string) StepFunc { - return func(_ context.Context, args map[string]any) (any, error) { - ws, _ := args["workspace"].(string) - mod, _ := args["module"].(string) - if ws == "" { - return nil, Refuse("the compare names no workspace") - } - paths, err := OutOfStep(filepath.Join(tree(ws), filepath.FromSlash(mod)), root) - if err != nil { - return nil, err - } - return map[string]any{"out-of-step": paths}, nil - } -} diff --git a/modules/mesh-build-go/buildstep/generated_test.go b/modules/mesh-build-go/buildstep/generated_test.go deleted file mode 100644 index a1eb3efcf..000000000 --- a/modules/mesh-build-go/buildstep/generated_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package buildstep - -import ( - "context" - "errors" - "os" - "path/filepath" - "reflect" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" -) - -// contracts writes a contracts module of one seat, its generated files as the generator writes them, and answers its -// directory and the generated paths. -func contracts(t *testing.T, manifest string) (string, []string) { - t.Helper() - dir := t.TempDir() - for p, text := range map[string]string{ - "module.json": manifest, - "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"whole","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"},"answer":{"type":"object"}}]}`, - // A definition at the seat-list grain is read and generates nothing (ADR 0635 §1, §2). - "seats/other/seat.json": `{"format":1,"seat":"other","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"ask","input":{"type":"object"}}]}`, - } { - writeContract(t, filepath.Join(dir, p), text) - } - m, err := seatgen.Load(dir, "example.org/c/go", "@example/c") - if err != nil { - t.Fatal(err) - } - files, err := seatgen.Generate(m) - if err != nil { - t.Fatal(err) - } - var paths []string - for p, b := range files { - writeContract(t, filepath.Join(dir, p), string(b)) - paths = append(paths, p) - } - return dir, paths -} - -func writeContract(t *testing.T, path, text string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(text), 0o644); err != nil { - t.Fatal(err) - } -} - -// **CAT-BG5, CAT-BN2**: a contracts module's generated files are regenerated and compared, each build module its own -// root; a file that differs, one missing and one the generator did not write are each named; a clean tree and a module -// naming no generated-from name none; a build ask answers them in its out-of-step. -func TestAContractsModulesGeneratedFilesAreComparedWithTheGenerator(t *testing.T) { - marked := `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}` - dir, paths := contracts(t, marked) - var goFile, tsFile string - for _, p := range paths { - if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, ".go") && goFile == "" { - goFile = p - } - if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, ".ts") && tsFile == "" { - tsFile = p - } - } - for _, p := range paths { - if strings.Contains(p, "other") || p == "go/go.mod" || p == "ts/package.json" { - t.Errorf("%s is generated: a seat-list definition generates nothing and a contracts module has no root package", p) - } - } - if goFile == "" || tsFile == "" { - t.Fatalf("the generator wrote no Go or no TypeScript file: %v", paths) - } - for _, root := range []string{GeneratedRootGo, GeneratedRootTS} { - if got, err := OutOfStep(dir, root); err != nil || len(got) != 0 { - t.Fatalf("a clean tree, root %s: %v %v", root, got, err) - } - } - writeContract(t, filepath.Join(dir, goFile), "package changed\n") - writeContract(t, filepath.Join(dir, "go/extra.go"), "package extra\n") - writeContract(t, filepath.Join(dir, tsFile), "// changed\n") - got, err := OutOfStep(dir, GeneratedRootGo) - if want := sorted(goFile, "go/extra.go"); err != nil || !reflect.DeepEqual(got, want) { - t.Errorf("Go root: %v %v, want %v", got, err, want) - } - if got, _ := OutOfStep(dir, GeneratedRootTS); !reflect.DeepEqual(got, []string{tsFile}) { - t.Errorf("TypeScript root: %v, want %s alone", got, tsFile) - } - _ = os.Remove(filepath.Join(dir, goFile)) - if got, _ := OutOfStep(dir, GeneratedRootGo); !reflect.DeepEqual(got, sorted(goFile, "go/extra.go")) { - t.Errorf("a missing file is not named: %v", got) - } - plain, _ := contracts(t, `{"module":"p","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`) - writeContract(t, filepath.Join(plain, "go/extra.go"), "package extra\n") - if got, err := OutOfStep(plain, GeneratedRootGo); err != nil || len(got) != 0 { - t.Errorf("a module naming no generated-from compared: %v %v", got, err) - } - - // The build ask answers them beside what it built, and with a failed build too. - built := 0 - steps := AskSteps{Compare: CompareStep(GeneratedRootGo, func(ws string) string { return ws }), - Fetch: func(context.Context, map[string]any) (any, error) { - return map[string]any{"workspace": filepath.Dir(dir)}, nil - }, - Build: func(context.Context, map[string]any) (any, error) { - if built++; built > 1 { - return nil, errors.New("the second artifact does not build") - } - return map[string]any{"digest": "sha256:aa"}, nil - }} - job := sdkstep.BuildJob{Module: "c", Path: filepath.Base(dir), Fetch: &sdkstep.Fetch{Repository: "novox/c", Commit: "0123456789abcdef0123456789abcdef01234567"}, - Artifacts: []sdkstep.AskArtifact{{Artifact: "go"}}} - out, err := RunAsk(context.Background(), steps, ask(job)) - if a, ok := out.(sdkstep.BuildAnswer); err != nil || !ok || !reflect.DeepEqual(a.OutOfStep, sorted(goFile, "go/extra.go")) || len(a.Artifacts) != 1 { - t.Errorf("the build ask's out-of-step: %+v %v", out, err) - } - job.Artifacts = append(job.Artifacts, sdkstep.AskArtifact{Artifact: "go-other"}) - built = 0 - out, err = RunAsk(context.Background(), steps, ask(job)) - if a, ok := out.(sdkstep.BuildAnswer); err == nil || !ok || len(a.OutOfStep) != 2 { - t.Errorf("a failed build answered no out-of-step: %+v %v", out, err) - } -} - -func sorted(paths ...string) []string { - sort.Strings(paths) - return paths -} diff --git a/modules/mesh-build-go/buildstep/go.mod b/modules/mesh-build-go/buildstep/go.mod deleted file mode 100644 index 77b28476c..000000000 --- a/modules/mesh-build-go/buildstep/go.mod +++ /dev/null @@ -1,40 +0,0 @@ -module git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep - -go 1.26.0 - -require ( - git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 - github.com/nats-io/nats.go v1.54.0 -) - -require ( - github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect - github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.1 // indirect - github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.2 // indirect - github.com/nats-io/nats-server/v2 v2.11.17 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.16.0 // indirect -) - -require ( - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect -) diff --git a/modules/mesh-build-go/buildstep/gotest.go b/modules/mesh-build-go/buildstep/gotest.go deleted file mode 100644 index 9c15d72c2..000000000 --- a/modules/mesh-build-go/buildstep/gotest.go +++ /dev/null @@ -1,410 +0,0 @@ -package buildstep - -// The reader of `go test -json` (moved whole from mesh-catalog's mesh-build-go, issue 1901's thorough fix): per package -// what ran and for how long, and each failure under the contract (failure.go), its real cause too when the failure is no -// test's assertion (a panic, a timeout, a build error, an exit, a kill, a race). - -import ( - "bufio" - "encoding/json" - "fmt" - sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" - "regexp" - "slices" - "strings" -) - -// GoPackage is one package's part of a Go test level. -type GoPackage struct { - Dir string `json:"dir"` - Package string `json:"package"` - Passed bool `json:"passed"` - Elapsed float64 `json:"elapsed"` - Tests int `json:"tests"` - // Skipped is how many of the package's tests skipped. - Skipped int `json:"skipped,omitempty"` - // NoTests is a package with no test files (at this level: a file with the other level's build tag is none). - NoTests bool `json:"no_tests,omitempty"` -} - -// TestTime is one test and the seconds it took. -type TestTime struct { - Package string `json:"package"` - Test string `json:"test"` - Elapsed float64 `json:"elapsed"` -} - -// GoTest is what one go.mod directory's `go test -json` said. -type GoTest struct { - Packages []GoPackage - Tests int - Skipped int - Times []TestTime - Failed []sdkstep.Failure - // Elapsed is the slowest package's running time in seconds. - Elapsed float64 - // Noise is the lines that were no event: what go said when it could not run the tests at all. - Noise []string -} - -// ReadGoTest reads the output of `go test -json` run in the go.mod directory dir. -func ReadGoTest(out, dir string) GoTest { - r := &goReader{dir: dir, pkgs: map[string]*GoPackage{}, builds: map[string][]string{}, pkgOut: map[string][]string{}, - testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{}, - running: map[string][]string{}, passed: map[string]bool{}, built: map[string]bool{}} - sc := bufio.NewScanner(strings.NewReader(out)) - sc.Buffer(make([]byte, 1<<20), 1<<26) - for sc.Scan() { - var ev goEvent - if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { - if l := strings.TrimSpace(sc.Text()); l != "" { - r.res.Noise = append(r.res.Noise, l) - } - continue - } - r.event(ev) - } - r.finish() - return r.res -} - -// goEvent is one line of `go test -json` (test2json, and the build events go test adds). -type goEvent struct { - Action string - Package string - Test string - Output string - OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines - ImportPath string - FailedBuild string - Elapsed float64 -} - -type goReader struct { - res GoTest - dir string - pkgs map[string]*GoPackage - order []string - builds map[string][]string // build output by import path - pkgOut map[string][]string - testOut map[string][]string // by package + "\x00" + test - causes map[string]string // the first error line of a test, by the same key - failedIn map[string]bool // packages with a failed test - // running is the tests that started and have not ended, by package, in the order they started: when a package - // fails with no failed test, these were running when its test program stopped. - running map[string][]string - // passed says a package's test program printed "PASS" before it failed; built says it failed to build. - passed, built map[string]bool -} - -func (r *goReader) pkg(name string) *GoPackage { - p := r.pkgs[name] - if p == nil { - p = &GoPackage{Dir: r.dir, Package: name} - r.pkgs[name] = p - r.order = append(r.order, name) - } - return p -} - -func keepLine(lines []string, out string) []string { - if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") { - lines = append(lines, t) - } - return lines -} - -func (r *goReader) event(ev goEvent) { - switch { - case ev.Package == "" && ev.ImportPath != "": - if ev.Action == "build-output" { - r.builds[ev.ImportPath] = keepLine(r.builds[ev.ImportPath], ev.Output) - } - case ev.Package == "": - case ev.Test == "": - p := r.pkg(ev.Package) - switch ev.Action { - case "output": - // go test's own summary of a package is never a failure's words: a "PASS" before a "FAIL " says only - // that the tests passed and the program failed after them. - switch l := strings.TrimRight(ev.Output, "\r\n"); { - case l == "PASS": - r.passed[ev.Package] = true - case goSummaryLine(l): - default: - r.pkgOut[ev.Package] = keepLine(r.pkgOut[ev.Package], ev.Output) - } - case "pass": - p.Passed, p.Elapsed = true, ev.Elapsed - case "fail": - p.Passed, p.Elapsed = false, ev.Elapsed - if ev.FailedBuild != "" { - r.built[ev.Package] = true - r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...) - } - case "skip": - p.Passed, p.NoTests = true, true - } - default: - key := ev.Package + "\x00" + ev.Test - switch ev.Action { - case "output": - r.testOut[key] = keepLine(r.testOut[key], ev.Output) - if ev.OutputType == "error" && r.causes[key] == "" { - r.causes[key] = goErrorWords(ev.Output) - } - case "run": - r.running[ev.Package] = append(r.running[ev.Package], ev.Test) - case "pass", "fail", "skip": - r.running[ev.Package] = slices.DeleteFunc(r.running[ev.Package], func(t string) bool { return t == ev.Test }) - p := r.pkg(ev.Package) - p.Tests++ - r.res.Tests++ - if ev.Action == "skip" { - r.res.Skipped++ - p.Skipped++ - } - r.res.Times = append(r.res.Times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed}) - if ev.Action == "fail" { - r.failedIn[ev.Package] = true - if r.subtestFailed(ev.Package, ev.Test) { - return // a parent failed only by its subtest is that subtest's failure, said once - } - if r.subtestTakesTheCause(ev.Package, ev.Test) { - return // a subtest's panic, which go files under its parent, is said on the subtest - } - r.res.Failed = append(r.res.Failed, r.testFailure(ev.Package, ev.Test)) - } - } - } -} - -// subtestFailed says a failure of test under pkg was already reported for one of its subtests (they end first), each with -// a cause of its own, and the test reported no error and no runtime cause (a subtest's panic) of its own. -func (r *goReader) subtestFailed(pkg, test string) bool { - key := pkg + "\x00" + test - if r.causes[key] != "" || goRuntimeCause(r.testOut[key]) != "" { - return false - } - for _, f := range r.res.Failed { - if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") { - return true - } - } - return false -} - -// noMessage is the cause of a test that failed and said nothing of why. -const noMessage = "the test failed without an error message (t.Fail or t.FailNow)" - -// subtestTakesTheCause gives a parent's runtime cause (a subtest's panic: go test -json files its lines under the -// parent) to the subtests of that parent that failed with no message of their own, and says it did. A parent with an -// error line of its own, or whose failed subtests each said why, is reported as itself. -func (r *goReader) subtestTakesTheCause(pkg, test string) bool { - key := pkg + "\x00" + test - if r.causes[key] != "" { - return false - } - cause := goRuntimeCause(r.testOut[key]) - if cause == "" { - return false - } - took := false - for i, f := range r.res.Failed { - if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage { - r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, sdkstep.Clip(r.testOut[key], cause) - took = true - } - } - return took -} - -// testFailure is a test that failed: its cause is its first error line, the race it ran into, the runtime's line, or -// words saying it failed without a message. -func (r *goReader) testFailure(pkg, test string) sdkstep.Failure { - key := pkg + "\x00" + test - cause := r.causes[key] - if race := goRaceCause(r.testOut[key]); race != "" && (cause == "" || strings.HasPrefix(cause, "race detected")) { - cause = race // the race itself, not testing's "race detected during execution of test" - } else if cause == "" { - cause = goRuntimeCause(r.testOut[key]) - } - if cause == "" { - cause = noMessage - } - return sdkstep.Failure{Package: pkg, Test: test, Output: sdkstep.Clip(r.testOut[key], cause), Cause: cause} -} - -// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone, -// "ok \t\t