From 8669294cf6a6005b9943d7084c92499c73888e66 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Oct 2026 06:25:14 +0000 Subject: [PATCH] Add namespace and collection inventories --- docs/consumers/pkg-promptkit.md | 8 ++- docs/internal/overview.md | 2 +- docs/roadmap/implementation.md | 10 +++ engine.go | 3 +- internal/configuration/listing.go | 44 +++++++++++++ internal/configuration/load.go | 31 +++++++++ internal/engine/engine.go | 17 +++++ listing.go | 78 ++++++++++++++++++++++ listing_contract_test.go | 105 ++++++++++++++++++++++++++++++ 9 files changed, 295 insertions(+), 3 deletions(-) diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 6a25174..caa9f16 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -146,7 +146,7 @@ engines and prepared handles retain their captured definitions; schema and request artifact resources still follow their runtime lifecycle. Inspection does not watch or reload sources automatically. -## Discover Available Profiles And Prompts +## Discover Available Resources And Collections Use [`Engine.ListProfiles`](../../listing.go), [`Engine.ListPrompts`](../../listing.go), and @@ -181,6 +181,12 @@ if len(prompts) > 0 { } ``` +Use [`Engine.ListNamespaces`](../../listing.go) to discover resource-bearing +namespaces across the entire captured configuration, independent of view +scopes. [`Engine.ListCollections`](../../listing.go) reports effective +collection declarations, including metadata-only entries; it does not infer +collection metadata from resource membership or source history. + These lists describe effective membership, not ConfigKit contribution history, and do not promise that a profile is ready to execute. Use inspection for details and preparation to resolve request resources. The linked method GoDoc diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d0d399b..20a7902 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -16,7 +16,7 @@ contributor workflow and validation. | `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) | | `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) | | `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) | -| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values including optional diagnostic metadata, captures indexed prompt definitions, referenced message content, compiled templates, input metadata snapshots, sorted identity-only backend, profile, and prompt inventories, backend and presence-aware profile snapshots, and an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) | +| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values including optional diagnostic metadata, captures indexed prompt definitions, referenced message content, compiled templates, input metadata snapshots, sorted identity-only backend, profile, and prompt inventories, resource namespace sets, effective collection declarations, backend and presence-aware profile snapshots, and an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) | | `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) | | `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) | | `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify caller-supplied execution, inspection, and diagnostic selections without rewriting captured references. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates identity listing directly to the captured configuration inventory rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 13b9016..e36daf4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -711,6 +711,16 @@ deduplication/sorting, unqualified exclusion, metadata-only collections, scope-independent enumeration, effective overrides, cancellation, and ownership. Run configuration/root tests; no file access or generation should be needed. +Completion: added global resource-namespace and effective collection inventory +APIs. Namespace results are distinct, sorted, kind-selectable, and exclude +unqualified resources; collection results preserve absent/null versus empty +release labels and include metadata-only declarations. Inventories remain +independent of namespace views and return caller-owned values. Validation passed: +`GOWORK=off go test ./...`, focused +`GOWORK=off go test -race . -run 'TestResourceAndCollectionInventories' -count=1`, +`GOWORK=off go vet ./...`, `GOWORK=off python3 scripts/check-examples.py`, +and `git diff --check`. + ## Stage 16: Maintain Complete Consumer And Migration Examples Scope: runnable/synthetic examples and precise migration guidance. diff --git a/engine.go b/engine.go index 9c6f407..964f700 100644 --- a/engine.go +++ b/engine.go @@ -89,7 +89,8 @@ var ( // [Engine.InspectPrompt], // [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles], -// [Engine.ListPrompts], and [Engine.ListBackends]. Each Engine owns +// [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and +// [Engine.ListCollections]. Each Engine owns // independent backend-capacity pools that coordinate Run, RunPrepared, and // inference SmokeTest admission and model generation. Injected collaborators // may still be invoked concurrently across different backend pools or for diff --git a/internal/configuration/listing.go b/internal/configuration/listing.go index 2ae64b7..f8e7f55 100644 --- a/internal/configuration/listing.go +++ b/internal/configuration/listing.go @@ -6,6 +6,12 @@ import ( "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) +// CollectionIdentity is the captured metadata for one final collection entry. +type CollectionIdentity struct { + Namespace string + ReleaseVersion *string +} + func (state *loaded) ListProfiles(ctx context.Context) ([]string, error) { if err := ctx.Err(); err != nil { return nil, err @@ -28,6 +34,44 @@ func (state *loaded) ListBackends(ctx context.Context) ([]string, error) { return backends, nil } +func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + selected := kinds + if len(selected) == 0 { + selected = []string{"backend", "profile", "prompt"} + } + set := make(map[string]struct{}) + for _, kind := range selected { + for _, namespace := range state.namespaces[kind] { + set[namespace] = struct{}{} + } + } + namespaces := sortedKeys(set) + if err := ctx.Err(); err != nil { + return nil, err + } + return namespaces, nil +} + +func (state *loaded) ListCollections(ctx context.Context) ([]CollectionIdentity, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + collections := make([]CollectionIdentity, len(state.collections)) + for index, collection := range state.collections { + collections[index] = CollectionIdentity{ + Namespace: collection.Namespace, + ReleaseVersion: stringPointerValue(collection.ReleaseVersion), + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + return collections, nil +} + func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) { if err := ctx.Err(); err != nil { return nil, err diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 052e097..1ed5c46 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -127,6 +127,8 @@ type loaded struct { backendIDs []string profileIDs []string promptIDs []domain.PromptIdentity + namespaces map[string][]string + collections []CollectionIdentity } type resourceBases struct { @@ -266,9 +268,38 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) for _, id := range sortedKeys(state.prompts) { state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: stringPointerValue(state.prompts[id].definition.Version)}) } + state.namespaces = map[string][]string{ + "backend": resourceNamespaces(state.backendIDs), + "profile": resourceNamespaces(state.profileIDs), + "prompt": promptNamespaces(state.promptIDs), + } + for _, namespace := range sortedKeys(result.Config.Collections) { + collection := result.Config.Collections[namespace] + state.collections = append(state.collections, CollectionIdentity{ + Namespace: namespace, ReleaseVersion: stringPointerValue(collection.ReleaseVersion), + }) + } return state, nil } +func resourceNamespaces(ids []string) []string { + namespaces := make(map[string]struct{}) + for _, id := range ids { + if namespace, _, qualified := strings.Cut(id, "/"); qualified { + namespaces[namespace] = struct{}{} + } + } + return sortedKeys(namespaces) +} + +func promptNamespaces(prompts []domain.PromptIdentity) []string { + ids := make([]string, len(prompts)) + for index := range prompts { + ids[index] = prompts[index].ID + } + return resourceNamespaces(ids) +} + func checkConfigurationVersion(version configkit.Version, validation *configkit.Validation) error { if !version.Present { return nil diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 6a115da..5f8717c 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -97,6 +97,8 @@ type identityInventory interface { ListBackends(context.Context) ([]string, error) ListProfiles(context.Context) ([]string, error) ListPrompts(context.Context) ([]domain.PromptIdentity, error) + ListNamespaces(context.Context, []string) ([]string, error) + ListCollections(context.Context) ([]configuration.CollectionIdentity, error) } // ListingFilterMode selects namespace membership for one identity listing. @@ -277,6 +279,21 @@ func (e *Engine) ListPrompts(ctx context.Context, filters ...ListingFilter) ([]d return filtered, nil } +func (e *Engine) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) { + for _, kind := range kinds { + switch kind { + case "prompt", "profile", "backend": + default: + return nil, fmt.Errorf("%w: unknown resource kind %q", usecase.ErrInvalidRequest, kind) + } + } + return e.listing.ListNamespaces(ctx, kinds) +} + +func (e *Engine) ListCollections(ctx context.Context) ([]configuration.CollectionIdentity, error) { + return e.listing.ListCollections(ctx) +} + func filterIdentityList( ctx context.Context, filters []ListingFilter, diff --git a/listing.go b/listing.go index 026092e..9eb0683 100644 --- a/listing.go +++ b/listing.go @@ -29,6 +29,27 @@ type NamespaceFilter struct { Name string } +// ResourceKind selects one identity type for [Engine.ListNamespaces]. +type ResourceKind string + +const ( + // ResourcePrompt selects namespaces containing prompt IDs. + ResourcePrompt ResourceKind = "prompt" + // ResourceProfile selects namespaces containing profile IDs. + ResourceProfile ResourceKind = "profile" + // ResourceBackend selects namespaces containing backend IDs. + ResourceBackend ResourceKind = "backend" +) + +// CollectionInfo describes one effective collections declaration. +type CollectionInfo struct { + // Namespace is the exact collection key. + Namespace string + // ReleaseVersion is nil when absent or null and points to the exact value + // when present, including an explicitly empty string. + ReleaseVersion *string +} + // PromptIdentity identifies one prompt ID and its optional descriptive version // label in the effective configuration captured when an Engine is constructed. // A nil Version means no label was declared; a non-nil empty string is an @@ -141,3 +162,60 @@ func internalListingFilters(filters []NamespaceFilter) []internalengine.ListingF } return internal } + +// ListNamespaces returns the sorted, distinct namespaces that contain the +// selected resource kinds. With no kinds, it returns the union across prompts, +// profiles, and backends. Repeated kinds have no effect; an unknown kind +// returns an error matching ErrInvalidRequest. The inventory is global and is +// not restricted by this engine view's scopes. Unqualified resource IDs do +// not contribute a namespace. +// +// The returned slice is caller-owned. A nil or zero-value engine returns an +// error matching ErrInvalidConfig. Cancellation or deadline expiration +// returns the context error and no partial result. +func (e *Engine) ListNamespaces(ctx context.Context, kinds ...ResourceKind) ([]string, error) { + if e == nil || e.internal == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + internalKinds := make([]string, len(kinds)) + for index, kind := range kinds { + internalKinds[index] = string(kind) + } + namespaces, err := e.internal.ListNamespaces(ctx, internalKinds) + if err != nil { + return nil, mapPublicError(err) + } + return namespaces, nil +} + +// ListCollections returns the sorted collection declarations from the +// effective configuration captured when the engine was constructed. It +// includes metadata-only and resource-only declarations and is independent of +// namespace view scopes. It does not infer collections from resource IDs or +// configuration source history. +// +// The returned slice and each ReleaseVersion pointer are caller-owned. Nil +// means the label was absent or null; a non-nil empty string is preserved. A +// nil or zero-value engine returns an error matching ErrInvalidConfig. +// Cancellation or deadline expiration returns the context error and no partial +// result. +func (e *Engine) ListCollections(ctx context.Context) ([]CollectionInfo, error) { + if e == nil || e.internal == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + collections, err := e.internal.ListCollections(ctx) + if err != nil { + return nil, mapPublicError(err) + } + public := make([]CollectionInfo, len(collections)) + for index, collection := range collections { + public[index] = CollectionInfo{ + Namespace: collection.Namespace, + ReleaseVersion: copyStringPtr(collection.ReleaseVersion), + } + } + if err := ctx.Err(); err != nil { + return nil, mapPublicError(err) + } + return public, nil +} diff --git a/listing_contract_test.go b/listing_contract_test.go index 98e9e55..0523cc3 100644 --- a/listing_contract_test.go +++ b/listing_contract_test.go @@ -239,6 +239,105 @@ func newNamespaceListingEngine(t *testing.T) *promptkit.Engine { return engine } +func TestResourceAndCollectionInventories(t *testing.T) { + underlay := configkit.Input{Source: configkit.Mapping("inventory defaults", map[string]any{ + "backends": map[string]any{"root-backend": map[string]any{"endpoint": "https://root.example.test/v1"}}, + "profiles": map[string]any{"root-profile": map[string]any{"endpoint": "https://root.example.test/v1", "model": "root-model"}}, + "prompts": map[string]any{"root-prompt": listingContractPrompt()}, + "collections": map[string]any{ + "alpha": map[string]any{ + "backends": map[string]any{"api": map[string]any{"endpoint": "https://alpha.example.test/v1"}}, + "profiles": map[string]any{"profile": map[string]any{"backend": "alpha/api", "model": "alpha-model"}}, + "prompts": map[string]any{"prompt": listingContractPrompt()}, + }, + "alpha-beta": map[string]any{"prompts": map[string]any{"prompt": listingContractPrompt()}}, + "beta": map[string]any{"backends": map[string]any{"api": map[string]any{"endpoint": "https://beta.example.test/v1"}}}, + "resource-only": map[string]any{"profiles": map[string]any{"profile": map[string]any{"endpoint": "https://resource.example.test/v1", "model": "resource-model"}}}, + "metadata-only": map[string]any{"release_version": "v1"}, + "empty-label": map[string]any{"release_version": ""}, + "null-label": map[string]any{"release_version": nil}, + "override": map[string]any{"release_version": "old"}, + }, + })} + main := configkit.Input{Source: configkit.Mapping("inventory overlay", map[string]any{ + "collections": map[string]any{"override": map[string]any{"release_version": "current"}}, + })} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Main: &main, + }}) + if err != nil { + var loadErr *configkit.LoadError + if errors.As(err, &loadErr) { + t.Fatalf("construct inventory engine: %v: %+v", err, loadErr.Diagnostics()) + } + t.Fatalf("construct inventory engine: %v", err) + } + scoped, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "missing", Profile: "beta", Backend: "alpha"}) + if err != nil { + t.Fatal(err) + } + + for _, check := range []struct { + name string + kinds []promptkit.ResourceKind + want []string + }{ + {name: "union", want: []string{"alpha", "alpha-beta", "beta", "resource-only"}}, + {name: "prompt", kinds: []promptkit.ResourceKind{promptkit.ResourcePrompt}, want: []string{"alpha", "alpha-beta"}}, + {name: "profile", kinds: []promptkit.ResourceKind{promptkit.ResourceProfile}, want: []string{"alpha", "resource-only"}}, + {name: "backend", kinds: []promptkit.ResourceKind{promptkit.ResourceBackend}, want: []string{"alpha", "beta"}}, + {name: "repeated union", kinds: []promptkit.ResourceKind{promptkit.ResourcePrompt, promptkit.ResourceBackend, promptkit.ResourcePrompt}, want: []string{"alpha", "alpha-beta", "beta"}}, + } { + t.Run(check.name, func(t *testing.T) { + got, err := scoped.ListNamespaces(context.Background(), check.kinds...) + if err != nil || !reflect.DeepEqual(got, check.want) { + t.Fatalf("namespace inventory = %#v, %v; want %#v", got, err, check.want) + } + got[0] = "caller change" + again, err := scoped.ListNamespaces(context.Background(), check.kinds...) + if err != nil || !reflect.DeepEqual(again, check.want) { + t.Fatalf("namespace inventory mutation escaped: %#v, %v", again, err) + } + }) + } + if _, err := scoped.ListNamespaces(context.Background(), promptkit.ResourceKind("schema")); !errors.Is(err, promptkit.ErrInvalidRequest) { + t.Fatalf("unknown resource kind error = %v, want ErrInvalidRequest", err) + } + + collections, err := scoped.ListCollections(context.Background()) + if err != nil { + t.Fatal(err) + } + wantCollections := []promptkit.CollectionInfo{ + {Namespace: "alpha"}, + {Namespace: "alpha-beta"}, + {Namespace: "beta"}, + {Namespace: "empty-label", ReleaseVersion: stringPointerForTest("")}, + {Namespace: "metadata-only", ReleaseVersion: stringPointerForTest("v1")}, + {Namespace: "null-label"}, + {Namespace: "override", ReleaseVersion: stringPointerForTest("current")}, + {Namespace: "resource-only"}, + } + if !reflect.DeepEqual(collections, wantCollections) { + t.Fatalf("collection inventory = %#v, want %#v", collections, wantCollections) + } + *collections[3].ReleaseVersion = "caller change" + collections[0].Namespace = "caller change" + collections, err = scoped.ListCollections(context.Background()) + if err != nil || !reflect.DeepEqual(collections, wantCollections) { + t.Fatalf("collection inventory mutation escaped: %#v, %v", collections, err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if got, err := scoped.ListNamespaces(canceled); !errors.Is(err, context.Canceled) || len(got) != 0 { + t.Errorf("canceled namespace inventory = %#v, %v", got, err) + } + if got, err := scoped.ListCollections(canceled); !errors.Is(err, context.Canceled) || len(got) != 0 { + t.Errorf("canceled collection inventory = %#v, %v", got, err) + } +} + func TestListIdentitiesInvalidEngine(t *testing.T) { var nilEngine *promptkit.Engine var zeroEngine promptkit.Engine @@ -252,6 +351,12 @@ func TestListIdentitiesInvalidEngine(t *testing.T) { if _, err := engine.ListBackends(context.Background()); !errors.Is(err, promptkit.ErrInvalidConfig) { t.Errorf("ListBackends on invalid engine: %v", err) } + if _, err := engine.ListNamespaces(context.Background()); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("ListNamespaces on invalid engine: %v", err) + } + if _, err := engine.ListCollections(context.Background()); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("ListCollections on invalid engine: %v", err) + } } }