Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/changes/unreleased/2468-collections-factory-job-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
kind: added
title: Engines whose collections hold Factory jobs announce collections.factory_job
pr: 2468
surface: [app]
invalidates: []
---

A client requires `collections.factory_job` in `hello.features` before it relies
on adding a `factory_job` ref to a collection. An engine that announces only
`collections` may refuse that kind with `bad_params`.
4 changes: 4 additions & 0 deletions docs/design/app-v3/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ keeps the value in a private file the agent's commands read (the model's own
Collection refs include `factory_job`, with a canonical positive decimal job `id`
and a required `factory` field. The pair addresses the same job as `factory.job`.
Other ref kinds have no `factory`. Several collections can reference the same job.
Engines whose collections hold `factory_job` refs announce
`collections.factory_job` in `hello.features`. A client requires it before it
relies on adding a job: an engine that announces only `collections` may refuse
the kind with `bad_params`.
Factory job targets carry the recorded title, state word and repository; an
unavailable Factory produces an `unknown` target and status. A missing job
produces a `deleted` target. Resolution uses one bounded batch across nested
Expand Down
17 changes: 17 additions & 0 deletions internal/appwire/collections_factory_job.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package appwire

// FeatureCollectionsFactoryJob names an engine whose collections hold
// factory_job references: a Factory job by its factory and job number. A
// client requires it before it relies on adding a job to a collection: an
// engine that announces only "collections" may refuse the kind with bad_params.
const FeatureCollectionsFactoryJob = "collections.factory_job"

// withCollectionsFactoryJobFeature is hello carrying collections.factory_job
// when the engine serves collections: this wire checks the factory_job kind
// and passes it to every collector.
func withCollectionsFactoryJobFeature(hello Hello, e Engine) Hello {
if _, ok := e.(Collector); !ok {
return hello
}
return withFeature(hello, FeatureCollectionsFactoryJob)
}
38 changes: 38 additions & 0 deletions internal/appwire/collections_factory_job_feature_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package appwire

import (
"slices"
"testing"
)

// An engine announces collections.factory_job only when it serves
// collections, so a client can tell before it adds a job.
func TestCollectionsFactoryJobFeatureFollowsCollections(t *testing.T) {
if h := withCollectionsFactoryJobFeature(Hello{}, &fakeEngine{}); len(h.Features) != 0 {
t.Fatalf("an engine without collections announces %v", h.Features)
}
h := withCollectionsFactoryJobFeature(Hello{}, &fakeCollector{fakeEngine: &fakeEngine{}})
if len(h.Features) != 1 || h.Features[0] != FeatureCollectionsFactoryJob {
t.Fatalf("a collector announces %v", h.Features)
}
}

// The announced feature is served: a factory_job ref with its factory reaches
// the collector, and one without a factory is refused before it.
func TestCollectionsFactoryJobFeatureIsServed(t *testing.T) {
w, engine := startCollectorWire(t)
if !slices.Contains(features(w.next()), FeatureCollectionsFactoryJob) {
t.Fatal("hello lacks collections.factory_job")
}
w.send(`{"id":1,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"factory_job","id":"7","factory":"f"}]}}`)
if got := w.next(); got["error"] != nil {
t.Fatalf("factory_job add = %v", got)
}
if len(engine.added) != 1 || engine.added[0].Kind != RefFactoryJob || engine.added[0].ID != "7" || engine.added[0].Factory != "f" {
t.Fatalf("collector got %+v", engine.added)
}
w.send(`{"id":2,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"factory_job","id":"7"}]}}`)
if got := w.next(); got["error"] == nil || got["error"].(map[string]any)["code"] != CodeBadParams {
t.Fatalf("factory_job without a factory = %v", got)
}
}
4 changes: 2 additions & 2 deletions internal/appwire/collections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@ func features(hello map[string]any) []any {
// engine without collections answers its methods unknown_method.
func TestCollectionsFeatureFollowsTheEngine(t *testing.T) {
w, _ := startCollectorWire(t)
if got := features(w.next()); len(got) != 3 || got[0] != "x" || got[1] != FeatureAgentEffortRungs || got[2] != FeatureCollections {
if got := features(w.next()); len(got) != 4 || got[0] != "x" || got[1] != FeatureAgentEffortRungs || got[2] != FeatureCollections || got[3] != FeatureCollectionsFactoryJob {
t.Fatalf("features = %v", got)
}
plain := startWire(t, &fakeEngine{})
for _, f := range features(plain.next()) {
if f == FeatureCollections {
if f == FeatureCollections || f == FeatureCollectionsFactoryJob {
t.Fatal("an engine without collections claims them")
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/appwire/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En
hello = withFactoryFeaturesFeature(hello, engine)
hello = withFactoryRepoIssuesFeature(hello, engine)
hello = withFactoryGroupPolicyFeature(hello, engine)
hello = withCollectionsFactoryJobFeature(hello, engine)
s.Emit(EventHello, hello)
if warmer, ok := engine.(Warmer); ok {
go warmer.Warm()
Expand Down
Loading