diff --git a/contracts/go/clients/meshcatalogclient/catalog.go b/contracts/go/clients/meshcatalogclient/catalog.go index 95403eb4..a3c9afe5 100644 --- a/contracts/go/clients/meshcatalogclient/catalog.go +++ b/contracts/go/clients/meshcatalogclient/catalog.go @@ -7,7 +7,6 @@ import ( "fmt" "maps" "strconv" - "strings" "sync" "time" @@ -188,7 +187,7 @@ func (c *Catalog) ask(_ context.Context, after string) (views.Answer, error) { } c.mu.Lock() if after == "" || c.paging == nil { - c.paging = &Half{Revision: a.Revision, Entries: map[string]seat.CatalogEntry{}} + c.paging = &Half{Revision: *a.Revision, Entries: map[string]seat.CatalogEntry{}} } else if c.paging.Revision.Revision != a.Revision.Revision { was := c.paging.Revision.Revision c.paging = nil @@ -216,26 +215,27 @@ func (c *Catalog) ask(_ context.Context, after string) (views.Answer, error) { return views.Answer{V: 1, Owner: seat.Seat, State: c.Follower.State, Revision: uint64(rev), Created: catalogCreated, Values: values, Next: a.Next, At: time.Now()}, nil } -// errNoneYet is the seat's answer `none yet`: the branch has no revision yet. +// errNoneYet is the seat's answer `none-yet`: the branch has no revision yet (design 47 G §530). var errNoneYet = errors.New("none yet") func askRevision(call Call, in seat.RevisionInput) (seat.RevisionAnswer, error) { raw, err := call(RevisionVerb, in) if err != nil { - if strings.Contains(err.Error(), views.NoneYet) { - return seat.RevisionAnswer{}, errNoneYet - } return seat.RevisionAnswer{}, err } answer, said, ok := buscalls.Unwrap(raw) if !ok { - if strings.Contains(said, views.NoneYet) { - return seat.RevisionAnswer{}, errNoneYet - } return seat.RevisionAnswer{}, fmt.Errorf("%s answered: %s", RevisionVerb, said) } var a seat.RevisionAnswer - if err := json.Unmarshal(answer, &a); err != nil || a.Revision.Revision <= 0 { + if err := json.Unmarshal(answer, &a); err != nil { + return seat.RevisionAnswer{}, fmt.Errorf("%s answered no revision: %s", RevisionVerb, clipBytes(answer)) + } + // An answer is told by its fields, never by its words (§530): `none-yet` is a ready, empty view. + if a.NoneYet { + return seat.RevisionAnswer{}, errNoneYet + } + if a.Revision == nil || a.Revision.Revision <= 0 { return seat.RevisionAnswer{}, fmt.Errorf("%s answered no revision: %s", RevisionVerb, clipBytes(answer)) } return a, nil diff --git a/contracts/go/clients/meshcatalogclient/catalog_test.go b/contracts/go/clients/meshcatalogclient/catalog_test.go index 9ec9030c..a1c7404b 100644 --- a/contracts/go/clients/meshcatalogclient/catalog_test.go +++ b/contracts/go/clients/meshcatalogclient/catalog_test.go @@ -1,6 +1,7 @@ package meshcatalogclient import ( + "encoding/json" "errors" "strings" "testing" @@ -165,3 +166,31 @@ func TestCandidatesKeyEscapesTheGroupAndReadsBack(t *testing.T) { t.Error("an empty group keys") } } + +// CT15 (design 47 G §476, §493, §648): the seat's `bodies` give the types the indexer writes and the runner reads: a +// take-in.taken names its branch, source revision and entries (an entry of a merged message names its pull request), +// a take-in.pending its branch, and a revision answer for a branch with no revision yet is `none-yet` alone. +func TestTakeInBodiesAndNoneYetAreTheSeatsTypes(t *testing.T) { + var taken seat.TakeInTaken + body := `{"v":1,"kind":"taken","key":"m.00000000000000000007","what":"revision","branch":"beta","source":3590,"since":"2026-11-11T10:00:00Z",` + + `"merges":[{"repository":"novox/mesh-sdk","number":9,"merge":"cc"}],` + + `"entries":[{"module":"nats","commit":"cc","fingerprint":"src2:aa","repository":"novox/mesh-sdk","pull":9},{"module":"zsh","commit":"cc","fingerprint":"src2:bb"}]}` + if err := json.Unmarshal([]byte(body), &taken); err != nil { + t.Fatal(err) + } + if taken.Branch != "beta" || taken.Source != 3590 || len(taken.Entries) != 2 || taken.Entries[0].Pull != 9 || taken.Entries[1].Repository != "" { + t.Errorf("take-in.taken read as %+v", taken) + } + var pending seat.TakeInPending + if err := json.Unmarshal([]byte(`{"v":1,"kind":"pending","key":"m.1","what":"revision","branch":"beta","since":"2026-11-11T10:00:00Z","merges":[]}`), &pending); err != nil || pending.Branch != "beta" { + t.Errorf("take-in.pending read as %+v, %v", pending, err) + } + var none seat.RevisionAnswer + if err := json.Unmarshal([]byte(`{"none-yet":true}`), &none); err != nil || !none.NoneYet || none.Revision != nil { + t.Errorf("a none-yet answer read as %+v, %v", none, err) + } + raw, _ := json.Marshal(seat.RevisionAnswer{NoneYet: true}) + if string(raw) != `{"none-yet":true}` { + t.Errorf("a none-yet answer is written as %s", raw) + } +} diff --git a/contracts/go/clients/meshcatalogclient/catalog_view_test.go b/contracts/go/clients/meshcatalogclient/catalog_view_test.go index 7af15ae0..f62ee9d3 100644 --- a/contracts/go/clients/meshcatalogclient/catalog_view_test.go +++ b/contracts/go/clients/meshcatalogclient/catalog_view_test.go @@ -28,14 +28,14 @@ func (f *fakeRevisions) call(key string, body any) (json.RawMessage, error) { defer f.mu.Unlock() rev, ok := f.newest[in.Branch] if ok && rev == 0 { - return nil, fmt.Errorf("revision of %s: none yet", in.Branch) + return json.Marshal(seat.RevisionAnswer{NoneYet: true}) } if !ok { return nil, fmt.Errorf("asked for the branch %q", in.Branch) } f.asks[in.Branch]++ return json.Marshal(seat.RevisionAnswer{ - Revision: seat.CatalogRevision{Revision: rev, Kind: KindTrunk, Branch: in.Branch}, + Revision: &seat.CatalogRevision{Revision: rev, Kind: KindTrunk, Branch: in.Branch}, Entries: []seat.CatalogEntry{{Module: fmt.Sprintf("m-of-%s-%d", in.Branch, rev)}}, }) } diff --git a/contracts/go/seats/meshcatalog/contract.go b/contracts/go/seats/meshcatalog/contract.go index dc2f44d9..f5925f4a 100644 --- a/contracts/go/seats/meshcatalog/contract.go +++ b/contracts/go/seats/meshcatalog/contract.go @@ -1060,6 +1060,108 @@ type VersionEntry struct { Version string `json:"version"` } +// TakeInEntry is one module of a taken-in revision (design 47 G section 476, section 648). +type TakeInEntry struct { + Commit string `json:"commit"` + // the module's build-input fingerprint at the commit + Fingerprint string `json:"fingerprint"` + Module string `json:"module"` + // from a merged message; absent for a pushed one (design 47 G section 648) + Pull int64 `json:"pull,omitempty"` + // from a merged message; absent for a pushed one (design 47 G section 648) + Repository string `json:"repository,omitempty"` +} + +// TakeInMember is one pull request at one commit. +type TakeInMember struct { + Head string `json:"head"` + Number int64 `json:"number"` + Repository string `json:"repository"` +} + +// TakeInMerge is one merge a take-in event holds: the repository, its pull request and the merge commit the forge made of it. +type TakeInMerge struct { + Merge string `json:"merge"` + Number int64 `json:"number"` + Repository string `json:"repository"` +} + +// TakeInPending is the body of the indexer's take-in.pending: a wait for a revision or a group, with the watched branch (design 47 G section 493). +type TakeInPending struct { + // the watched branch the revision is of (design 47 G section 476, section 493) + Branch string `json:"branch,omitempty"` + // who acted by hand, on a taken event + By string `json:"by,omitempty"` + // a hand drop: the merges are not taken in + Dropped bool `json:"dropped,omitempty"` + // the delivery group, for a group being gathered + Group string `json:"group,omitempty"` + // the wait's key: m. of its oldest merge, or g. for a group being gathered + Key string `json:"key"` + // the event's kind, pending or taken + Kind string `json:"kind"` + // the keys of merges whose message the forge bridge's stream no longer holds + Lost []string `json:"lost,omitempty"` + // every approved member of a group with its head + Members []TakeInMember `json:"members,omitempty"` + // the merges it holds, oldest first + Merges []TakeInMerge `json:"merges"` + // why the indexer holds a merge it does not take in although no indexer build is named + OwnBuildUnknown string `json:"own-build-unknown,omitempty"` + // why the manifest of the indexer the revision names was refused + Refused string `json:"refused,omitempty"` + // when the wait began + Since time.Time `json:"since"` + // the layout version + V int64 `json:"v"` + // the indexer build a revision waits for + WaitsFor string `json:"waits-for,omitempty"` + // what waits: a revision or a group + What string `json:"what"` + // why they acted by hand, on a taken event + Why string `json:"why,omitempty"` +} + +// TakeInTaken is the body of the indexer's take-in.taken: a wait ended, with the branch, the source revision and the entries (design 47 G section 476, section 493). +type TakeInTaken struct { + // the watched branch the revision is of (design 47 G section 476, section 493) + Branch string `json:"branch,omitempty"` + // who acted by hand, on a taken event + By string `json:"by,omitempty"` + // a hand drop: the merges are not taken in + Dropped bool `json:"dropped,omitempty"` + // one entry per module of that revision; none for a hand drop (design 47 G section 476, section 493) + Entries []TakeInEntry `json:"entries,omitempty"` + // the delivery group, for a group being gathered + Group string `json:"group,omitempty"` + // the wait's key: m. of its oldest merge, or g. for a group being gathered + Key string `json:"key"` + // the event's kind, pending or taken + Kind string `json:"kind"` + // the keys of merges whose message the forge bridge's stream no longer holds + Lost []string `json:"lost,omitempty"` + // every approved member of a group with its head + Members []TakeInMember `json:"members,omitempty"` + // the merges it holds, oldest first + Merges []TakeInMerge `json:"merges"` + // why the indexer holds a merge it does not take in although no indexer build is named + OwnBuildUnknown string `json:"own-build-unknown,omitempty"` + // why the manifest of the indexer the revision names was refused + Refused string `json:"refused,omitempty"` + // when the wait began + Since time.Time `json:"since"` + // the source revision taken in (design 47 G section 476) + Source int64 `json:"source,omitempty"` + // the layout version + V int64 `json:"v"` + // the indexer build a revision waits for + WaitsFor string `json:"waits-for,omitempty"` + // what waits: a revision or a group + What string `json:"what"` + // why they acted by hand, on a taken event + Why string `json:"why,omitempty"` +} + // ModulesInput is what modules takes. type ModulesInput struct { } @@ -1180,10 +1282,12 @@ type RevisionInput struct { // RevisionAnswer is what revision answers. type RevisionAnswer struct { // this page's entries whole, by module name - Entries []CatalogEntry `json:"entries"` + Entries []CatalogEntry `json:"entries,omitempty"` // the module to ask after for the next page; absent on the last - Next string `json:"next,omitempty"` - Revision CatalogRevision `json:"revision"` + Next string `json:"next,omitempty"` + // true for a watched branch with no revision yet: no revision, no entries and no error follow, and a reader holds the branch's view ready and empty (design 47 G section 530) + NoneYet bool `json:"none-yet,omitempty"` + Revision *CatalogRevision `json:"revision,omitempty"` } // RegistrationsInput is what registrations takes. diff --git a/contracts/go/seats/meshcatalog/contract_test.go b/contracts/go/seats/meshcatalog/contract_test.go index e6bca015..570a3914 100644 --- a/contracts/go/seats/meshcatalog/contract_test.go +++ b/contracts/go/seats/meshcatalog/contract_test.go @@ -141,6 +141,8 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v RevisionAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "revision answers its last page", subject: "", given: "{\"entries\":[{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}],\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"definitions\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"seat\":\"node-build-go\"}],\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"mesh-build-go\"},{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\"}],\"kind\":\"trunk\",\"revision\":12,\"source\":3589,\"trunk\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"repository\":\"novox/mesh-catalog\"}],\"v\":1}}", wire: "{\"entries\":[{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}],\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"definitions\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"seat\":\"node-build-go\"}],\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"mesh-build-go\"},{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\"}],\"kind\":\"trunk\",\"revision\":12,\"source\":3589,\"trunk\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"repository\":\"novox/mesh-catalog\"}],\"v\":1}}", decode: func(b []byte) (any, error) { var v RevisionAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "revision answers none yet for a branch with no revision", subject: "", given: "{\"none-yet\":true}", wire: "{\"none-yet\":true}", + decode: func(b []byte) (any, error) { var v RevisionAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "registrations of one module with its history", subject: "", given: "{\"history\":true,\"module\":\"nats\"}", wire: "{\"history\":true,\"module\":\"nats\"}", decode: func(b []byte) (any, error) { var v RegistrationsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "registrations answers one with its versions", subject: "", given: "{\"history\":[{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":1,\"warrant\":\"ask-m2kx9a1b3c4d5e\"},{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"artifacts\":{\"program\":{\"unit\":\"bypass\"}},\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":2,\"warrant\":\"ask-m2kx9a1b3c4d5e\"}],\"registrations\":[{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"artifacts\":{\"program\":{\"unit\":\"bypass\"}},\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":2,\"warrant\":\"ask-m2kx9a1b3c4d5e\"}]}", wire: "{\"history\":[{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":1,\"warrant\":\"ask-m2kx9a1b3c4d5e\"},{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"artifacts\":{\"program\":{\"unit\":\"bypass\"}},\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":2,\"warrant\":\"ask-m2kx9a1b3c4d5e\"}],\"registrations\":[{\"at\":\"2026-11-10T10:00:00Z\",\"branches\":[\"main\"],\"by\":\"operator@shanks\",\"module\":\"nats\",\"origin\":\"novox/mesh-catalog\",\"path\":\"modules/nats/module.json\",\"policy\":{\"artifacts\":{\"program\":{\"unit\":\"bypass\"}},\"steps\":{\"approval\":\"bypass\",\"composition\":\"required\",\"integration\":\"required\",\"lint\":\"required\",\"review\":\"optional\",\"unit\":\"required\"}},\"v\":1,\"version\":2,\"warrant\":\"ask-m2kx9a1b3c4d5e\"}]}", diff --git a/contracts/seats/mesh-catalog/fixtures/verbs.json b/contracts/seats/mesh-catalog/fixtures/verbs.json index 97fa1c2c..2d712038 100644 --- a/contracts/seats/mesh-catalog/fixtures/verbs.json +++ b/contracts/seats/mesh-catalog/fixtures/verbs.json @@ -152,6 +152,13 @@ ] } }, + { + "name": "revision answers none yet for a branch with no revision", + "of": "verb:revision.answer", + "given": { + "none-yet": true + } + }, { "name": "registrations of one module with its history", "of": "verb:registrations.input", diff --git a/contracts/seats/mesh-catalog/seat.json b/contracts/seats/mesh-catalog/seat.json index 8b986d9b..0c4e1c88 100644 --- a/contracts/seats/mesh-catalog/seat.json +++ b/contracts/seats/mesh-catalog/seat.json @@ -250,7 +250,7 @@ }, { "name": "revision", - "description": "The catalog half of the revision view (ADR 0656 \u00a76): one catalog graph revision with its entries whole, the trunk heads, the seat definitions by digest, and each manifest's verdict and fingerprint; the trunk's newest when no revision and no candidate is named, or a candidate's for its group and heads. Answered by the holder from what it holds, in its own runtime (ADR 0663), in pages of entries. Reads only.", + "description": "The catalog half of the revision view (ADR 0656 \u00a76): one catalog graph revision with its entries whole, the trunk heads, the seat definitions by digest, and each manifest's verdict and fingerprint; the trunk's newest when no revision and no candidate is named, or a candidate's for its group and heads. Answered by the holder from what it holds, in its own runtime (ADR 0663), in pages of entries. Reads only. A watched branch with no revision yet answers {none-yet: true} alone (design 47 G section 530).", "input": { "type": "object", "properties": { @@ -292,12 +292,12 @@ "next": { "type": "string", "description": "the module to ask after for the next page; absent on the last" + }, + "none-yet": { + "type": "boolean", + "description": "true for a watched branch with no revision yet: no revision, no entries and no error follow, and a reader holds the branch's view ready and empty (design 47 G section 530)" } - }, - "required": [ - "revision", - "entries" - ] + } }, "class": "read" }, @@ -2735,5 +2735,263 @@ "language" ] } + }, + "bodies": { + "TakeInMerge": { + "type": "object", + "description": "one merge a take-in event holds: the repository, its pull request and the merge commit the forge made of it", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "merge": { + "type": "string" + } + }, + "required": [ + "repository", + "number", + "merge" + ] + }, + "TakeInMember": { + "type": "object", + "description": "one pull request at one commit", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + } + }, + "required": [ + "repository", + "number", + "head" + ] + }, + "TakeInEntry": { + "type": "object", + "description": "one module of a taken-in revision (design 47 G section 476, section 648)", + "properties": { + "module": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "fingerprint": { + "type": "string", + "description": "the module's build-input fingerprint at the commit" + }, + "repository": { + "type": "string", + "description": "from a merged message; absent for a pushed one (design 47 G section 648)" + }, + "pull": { + "type": "integer", + "description": "from a merged message; absent for a pushed one (design 47 G section 648)" + } + }, + "required": [ + "module", + "commit", + "fingerprint" + ] + }, + "TakeInPending": { + "type": "object", + "description": "the body of the indexer's take-in.pending: a wait for a revision or a group, with the watched branch (design 47 G section 493)", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "kind": { + "type": "string", + "description": "the event's kind, pending or taken" + }, + "key": { + "type": "string", + "description": "the wait's key: m. of its oldest merge, or g. for a group being gathered" + }, + "what": { + "type": "string", + "description": "what waits: a revision or a group" + }, + "merges": { + "type": "array", + "items": { + "$ref": "#/bodies/TakeInMerge" + }, + "description": "the merges it holds, oldest first" + }, + "group": { + "type": "string", + "description": "the delivery group, for a group being gathered" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/bodies/TakeInMember" + }, + "description": "every approved member of a group with its head" + }, + "waits-for": { + "type": "string", + "description": "the indexer build a revision waits for" + }, + "since": { + "type": "string", + "description": "when the wait began", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "who acted by hand, on a taken event" + }, + "why": { + "type": "string", + "description": "why they acted by hand, on a taken event" + }, + "dropped": { + "type": "boolean", + "description": "a hand drop: the merges are not taken in" + }, + "lost": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the keys of merges whose message the forge bridge's stream no longer holds" + }, + "refused": { + "type": "string", + "description": "why the manifest of the indexer the revision names was refused" + }, + "own-build-unknown": { + "type": "string", + "description": "why the indexer holds a merge it does not take in although no indexer build is named" + }, + "branch": { + "type": "string", + "description": "the watched branch the revision is of (design 47 G section 476, section 493)" + } + }, + "required": [ + "v", + "kind", + "key", + "what", + "merges", + "since" + ] + }, + "TakeInTaken": { + "type": "object", + "description": "the body of the indexer's take-in.taken: a wait ended, with the branch, the source revision and the entries (design 47 G section 476, section 493)", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "kind": { + "type": "string", + "description": "the event's kind, pending or taken" + }, + "key": { + "type": "string", + "description": "the wait's key: m. of its oldest merge, or g. for a group being gathered" + }, + "what": { + "type": "string", + "description": "what waits: a revision or a group" + }, + "merges": { + "type": "array", + "items": { + "$ref": "#/bodies/TakeInMerge" + }, + "description": "the merges it holds, oldest first" + }, + "group": { + "type": "string", + "description": "the delivery group, for a group being gathered" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/bodies/TakeInMember" + }, + "description": "every approved member of a group with its head" + }, + "waits-for": { + "type": "string", + "description": "the indexer build a revision waits for" + }, + "since": { + "type": "string", + "description": "when the wait began", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "who acted by hand, on a taken event" + }, + "why": { + "type": "string", + "description": "why they acted by hand, on a taken event" + }, + "dropped": { + "type": "boolean", + "description": "a hand drop: the merges are not taken in" + }, + "lost": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the keys of merges whose message the forge bridge's stream no longer holds" + }, + "refused": { + "type": "string", + "description": "why the manifest of the indexer the revision names was refused" + }, + "own-build-unknown": { + "type": "string", + "description": "why the indexer holds a merge it does not take in although no indexer build is named" + }, + "branch": { + "type": "string", + "description": "the watched branch the revision is of (design 47 G section 476, section 493)" + }, + "source": { + "type": "integer", + "description": "the source revision taken in (design 47 G section 476)" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/bodies/TakeInEntry" + }, + "description": "one entry per module of that revision; none for a hand drop (design 47 G section 476, section 493)" + } + }, + "required": [ + "v", + "kind", + "key", + "what", + "merges", + "since" + ] + } } } diff --git a/contracts/ts/seats/meshcatalog/index.d.ts b/contracts/ts/seats/meshcatalog/index.d.ts index 9c74f608..4f20e01d 100644 --- a/contracts/ts/seats/meshcatalog/index.d.ts +++ b/contracts/ts/seats/meshcatalog/index.d.ts @@ -516,6 +516,108 @@ export interface VersionEntry { "version": string; } +/** one module of a taken-in revision (design 47 G section 476, section 648) */ +export interface TakeInEntry { + "commit": string; + /** the module's build-input fingerprint at the commit */ + "fingerprint": string; + "module": string; + /** from a merged message; absent for a pushed one (design 47 G section 648) */ + "pull"?: number; + /** from a merged message; absent for a pushed one (design 47 G section 648) */ + "repository"?: string; +} + +/** one pull request at one commit */ +export interface TakeInMember { + "head": string; + "number": number; + "repository": string; +} + +/** one merge a take-in event holds: the repository, its pull request and the merge commit the forge made of it */ +export interface TakeInMerge { + "merge": string; + "number": number; + "repository": string; +} + +/** the body of the indexer's take-in.pending: a wait for a revision or a group, with the watched branch (design 47 G section 493) */ +export interface TakeInPending { + /** the watched branch the revision is of (design 47 G section 476, section 493) */ + "branch"?: string; + /** who acted by hand, on a taken event */ + "by"?: string; + /** a hand drop: the merges are not taken in */ + "dropped"?: boolean; + /** the delivery group, for a group being gathered */ + "group"?: string; + /** the wait's key: m. of its oldest merge, or g. for a group being gathered */ + "key": string; + /** the event's kind, pending or taken */ + "kind": string; + /** the keys of merges whose message the forge bridge's stream no longer holds */ + "lost"?: string[]; + /** every approved member of a group with its head */ + "members"?: TakeInMember[]; + /** the merges it holds, oldest first */ + "merges": TakeInMerge[]; + /** why the indexer holds a merge it does not take in although no indexer build is named */ + "own-build-unknown"?: string; + /** why the manifest of the indexer the revision names was refused */ + "refused"?: string; + /** when the wait began */ + "since": string; + /** the layout version */ + "v": number; + /** the indexer build a revision waits for */ + "waits-for"?: string; + /** what waits: a revision or a group */ + "what": string; + /** why they acted by hand, on a taken event */ + "why"?: string; +} + +/** the body of the indexer's take-in.taken: a wait ended, with the branch, the source revision and the entries (design 47 G section 476, section 493) */ +export interface TakeInTaken { + /** the watched branch the revision is of (design 47 G section 476, section 493) */ + "branch"?: string; + /** who acted by hand, on a taken event */ + "by"?: string; + /** a hand drop: the merges are not taken in */ + "dropped"?: boolean; + /** one entry per module of that revision; none for a hand drop (design 47 G section 476, section 493) */ + "entries"?: TakeInEntry[]; + /** the delivery group, for a group being gathered */ + "group"?: string; + /** the wait's key: m. of its oldest merge, or g. for a group being gathered */ + "key": string; + /** the event's kind, pending or taken */ + "kind": string; + /** the keys of merges whose message the forge bridge's stream no longer holds */ + "lost"?: string[]; + /** every approved member of a group with its head */ + "members"?: TakeInMember[]; + /** the merges it holds, oldest first */ + "merges": TakeInMerge[]; + /** why the indexer holds a merge it does not take in although no indexer build is named */ + "own-build-unknown"?: string; + /** why the manifest of the indexer the revision names was refused */ + "refused"?: string; + /** when the wait began */ + "since": string; + /** the source revision taken in (design 47 G section 476) */ + "source"?: number; + /** the layout version */ + "v": number; + /** the indexer build a revision waits for */ + "waits-for"?: string; + /** what waits: a revision or a group */ + "what": string; + /** why they acted by hand, on a taken event */ + "why"?: string; +} + /** is what modules takes. */ export interface ModulesInput { } @@ -632,10 +734,12 @@ export interface RevisionInput { /** is what revision answers. */ export interface RevisionAnswer { /** this page's entries whole, by module name */ - "entries": CatalogEntry[]; + "entries"?: CatalogEntry[]; /** the module to ask after for the next page; absent on the last */ "next"?: string; - "revision": CatalogRevision; + /** true for a watched branch with no revision yet: no revision, no entries and no error follow, and a reader holds the branch's view ready and empty (design 47 G section 530) */ + "none-yet"?: boolean; + "revision"?: CatalogRevision; } /** is what registrations takes. */ diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index e10e9312..850cce86 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -1870,7 +1870,7 @@ func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) { b.cat.mu.Lock() defer b.cat.mu.Unlock() if b.cat.rev[branch] == 0 { - _ = m.Respond([]byte(`{"error":"` + views.NoneYet + `"}`)) + _ = m.Respond([]byte(`{"result":{"none-yet":true},"node":"n0"}`)) return } rev := meshcatalog.CatalogRevision{Kind: "trunk", Branch: in.Branch, Revision: b.cat.rev[branch]} @@ -1879,7 +1879,7 @@ func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) { entries = append(entries, b.cat.entries[branch][name]) rev.Entries = append(rev.Entries, meshcatalog.EntryRef{Module: name, Digest: "sha256:" + strings.Repeat("0", 64)}) } - raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: rev, Entries: entries}, "node": "n0"}) + raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: &rev, Entries: entries}, "node": "n0"}) _ = m.Respond(raw) } for _, subject := range []string{"mesh.seat.mesh-catalog.tool.revision", "mesh.seat.mesh-catalog.call.revision.*", "mesh.seat.mesh-catalog.tool.revision.*"} { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 1490cc38..c435dfad 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -540,7 +540,7 @@ func startBed(t *testing.T) *bed { for repo, commit := range trunkView.Load().(map[string]string) { heads = append(heads, meshcatalog.Head{Repository: repo, Commit: commit}) } - body, _ := json.Marshal(meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Kind: "trunk", Revision: 1, Trunk: heads}}) + body, _ := json.Marshal(meshcatalog.RevisionAnswer{Revision: &meshcatalog.CatalogRevision{Kind: "trunk", Revision: 1, Trunk: heads}}) reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(body), "node": "ctl"}) _ = m.Respond(reply) }); err != nil { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/trunk_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/trunk_test.go index 47929f13..b2b738ec 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/trunk_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/trunk_test.go @@ -18,7 +18,7 @@ func TestTrunkHeadsComeFromTheCatalogHalf(t *testing.T) { if key != "seat:mesh-catalog.revision" { t.Fatalf("asked %s", key) } - return json.Marshal(meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Kind: kind, Trunk: heads}}) + return json.Marshal(meshcatalog.RevisionAnswer{Revision: &meshcatalog.CatalogRevision{Kind: kind, Trunk: heads}}) } } trunks := &check.Trunks{} diff --git a/modules/mesh-delivery-checker/internal/check/trunkview.go b/modules/mesh-delivery-checker/internal/check/trunkview.go index d316a77c..75a1638d 100644 --- a/modules/mesh-delivery-checker/internal/check/trunkview.go +++ b/modules/mesh-delivery-checker/internal/check/trunkview.go @@ -17,6 +17,9 @@ func TrunkHeads(raw json.RawMessage) (map[string]string, error) { if err := json.Unmarshal(raw, &a); err != nil { return nil, fmt.Errorf("the catalog half's revision does not read: %w", err) } + if a.Revision == nil { + return nil, fmt.Errorf("the catalog half's revision answers no revision (none-yet: %v)", a.NoneYet) + } if a.Revision.Kind != "trunk" { return nil, fmt.Errorf("the catalog half's revision %d is a %q revision, not a trunk's", a.Revision.Revision, a.Revision.Kind) } diff --git a/modules/mesh-delivery-indexer/internal/index/revision_verb.go b/modules/mesh-delivery-indexer/internal/index/revision_verb.go index 82ffac84..e9a72a65 100644 --- a/modules/mesh-delivery-indexer/internal/index/revision_verb.go +++ b/modules/mesh-delivery-indexer/internal/index/revision_verb.go @@ -72,7 +72,7 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata } } slices.SortFunc(entries, func(a, b meshcatalog.CatalogEntry) int { return strings.Compare(a.Module, b.Module) }) - out.Revision, out.Entries = rev, []meshcatalog.CatalogEntry{} + out.Revision, out.Entries = &rev, []meshcatalog.CatalogEntry{} size := 0 for _, e := range entries { if e.Module <= in.After { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go index 8864369f..5944e570 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go @@ -187,7 +187,7 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) { // catalogAnswer is the seat mesh-catalog's newest trunk revision: a with a fingerprint and the source revision it moved at, // b with none and the reason. var catalogAnswer = meshcatalog.RevisionAnswer{ - Revision: meshcatalog.CatalogRevision{Revision: 4, Kind: meshcatalogclient.KindTrunk, Source: 7}, + Revision: &meshcatalog.CatalogRevision{Revision: 4, Kind: meshcatalogclient.KindTrunk, Source: 7}, Entries: []meshcatalog.CatalogEntry{ {Module: "a", Repository: "novox/r", Commit: "c1", Path: ".", Manifest: map[string]any{"module": "a"}, Fp: fp2A, Inputs: &meshcatalog.EntryInputs{Src: 7}}, {Module: "b", Repository: "novox/r", Commit: "c2", Path: "modules/b", Manifest: map[string]any{"module": "b"}, None: "builder's fingerprint not yet known"}, diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index 9c25d562..a4bb8fd6 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -77,7 +77,7 @@ func serveCatalog(t testing.TB, ctx context.Context, js jetstream.JetStream, nc } answer := func(m *nats.Msg) { h.mu.Lock() - a := meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Revision: h.rev, Kind: meshcatalogclient.KindTrunk}} + a := meshcatalog.RevisionAnswer{Revision: &meshcatalog.CatalogRevision{Revision: h.rev, Kind: meshcatalogclient.KindTrunk}} for _, e := range h.entries { a.Entries = append(a.Entries, e) }