From a27ee8f7da2e6a81e1dd38ba1a05686a1e733182 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 9 Oct 2026 04:28:23 +0000 Subject: [PATCH] Expose profile and prompt identity listings --- docs/roadmap/implementation.md | 6 +- internal/engine/engine.go | 15 ++++ listing.go | 67 ++++++++++++++++++ listing_contract_test.go | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 listing.go create mode 100644 listing_contract_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 373528b..aa2405f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -124,7 +124,11 @@ return owned lists without rereading ConfigKit or requiring runnable targets. ## Stage 2 — Wire The Public Facade And Its Basic Contract -**Status: Pending.** Depends on Stage 1. +**Status: Complete (2026-10-09).** Verified with +`go test ./internal/engine .`, `go vet ./internal/engine .`, and +`git diff --check`. External contract tests cover both listing methods, +inspection of listed identities, invalid engines, cancellation, and expired +deadlines. Depends on Stage 1. **Scope:** `internal/engine`, root API/conversion, and focused facade tests. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 655d354..f27084a 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -83,6 +83,12 @@ func (o *Options) SetSchemaFile(path string) error { type Engine struct { runner *usecase.Runner inspection configuration.Inspection + listing identityInventory +} + +type identityInventory interface { + ListProfiles(context.Context) ([]string, error) + ListPrompts(context.Context) ([]domain.PromptIdentity, error) } // New assembles an engine from facade-selected configuration and collaborators. @@ -135,6 +141,7 @@ func New(cfg Config, options Options) (*Engine, error) { return &Engine{ runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries), inspection: snapshot.Inspection(), + listing: snapshot, }, nil } @@ -187,6 +194,14 @@ func (e *Engine) InspectRequirements( func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) { return e.runner.InspectProfile(ctx, id) } + +func (e *Engine) ListProfiles(ctx context.Context) ([]string, error) { + return e.listing.ListProfiles(ctx) +} + +func (e *Engine) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) { + return e.listing.ListPrompts(ctx) +} func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) { return e.runner.Prepare(ctx, request) } diff --git a/listing.go b/listing.go new file mode 100644 index 0000000..a38115f --- /dev/null +++ b/listing.go @@ -0,0 +1,67 @@ +package promptkit + +import ( + "context" + "fmt" +) + +// PromptIdentity identifies one prompt ID and exact version in the effective +// configuration captured when an Engine is constructed. It has no stable JSON +// representation. +type PromptIdentity struct { + ID string + Version string +} + +// ListProfiles returns profile IDs in the effective configuration captured +// when the engine was constructed. Maintained and consumer profiles are +// included; an inherited profile retains its own ID. IDs are preserved exactly +// and sorted lexically. The returned slice is caller-owned and safe to mutate +// while the engine is used concurrently. Empty inventories succeed. +// +// Listing reports membership, not execution readiness. It does not reload +// configuration or perform resource, credential, provider, or execution work. +// Profile IDs are application data and may be sensitive. 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) ListProfiles(ctx context.Context) ([]string, error) { + if e == nil || e.internal == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + + profiles, err := e.internal.ListProfiles(ctx) + if err != nil { + return nil, mapPublicError(err) + } + return profiles, nil +} + +// ListPrompts returns every prompt ID and exact version in the effective +// configuration captured when the engine was constructed, sorted lexically by +// ID and then version. Versions are not interpreted or prioritized. The +// returned slice is caller-owned and safe to mutate while the engine is used +// concurrently. Empty inventories succeed. +// +// Listing reports membership, not execution readiness. It does not reload +// configuration or perform resource, credential, provider, or execution work. +// Prompt identities are application data and may be sensitive. 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) ListPrompts(ctx context.Context) ([]PromptIdentity, error) { + if e == nil || e.internal == nil { + return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) + } + + identities, err := e.internal.ListPrompts(ctx) + if err != nil { + return nil, mapPublicError(err) + } + public := make([]PromptIdentity, len(identities)) + for i, identity := range identities { + public[i] = PromptIdentity{ID: identity.ID, Version: identity.Version} + } + 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 new file mode 100644 index 0000000..5b422f5 --- /dev/null +++ b/listing_contract_test.go @@ -0,0 +1,125 @@ +package promptkit_test + +import ( + "context" + "errors" + "testing" + "time" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestListIdentitiesBasicContract(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{ + Source: configkit.Mapping("listing", map[string]any{ + "profiles": map[string]any{"configured-profile": map[string]any{"model": "example-model"}}, + "prompts": map[string]any{"example-prompt": map[string]any{ + "2": listingContractPrompt(), + "10": listingContractPrompt(), + }}, + }), + }}}) + if err != nil { + t.Fatal(err) + } + + profiles, err := engine.ListProfiles(context.Background()) + if err != nil { + t.Fatal(err) + } + if !containsListingProfile(profiles, "configured-profile") { + t.Fatalf("profile listing %#v does not include configured profile", profiles) + } + + prompts, err := engine.ListPrompts(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []promptkit.PromptIdentity{ + {ID: "example-prompt", Version: "10"}, + {ID: "example-prompt", Version: "2"}, + } + if len(prompts) != len(want) { + t.Fatalf("prompt listing = %#v, want %#v", prompts, want) + } + for i := range want { + if prompts[i] != want[i] { + t.Fatalf("prompt listing = %#v, want %#v", prompts, want) + } + } + + profile, err := engine.InspectProfile(context.Background(), "configured-profile") + if err != nil || profile == nil || profile.ProfileID != "configured-profile" { + t.Fatalf("listed profile could not be inspected: (%+v, %v)", profile, err) + } + prompt, err := engine.InspectPrompt(context.Background(), prompts[0].ID, prompts[0].Version) + if err != nil || prompt == nil || prompt.PromptID != prompts[0].ID || prompt.PromptVersion != prompts[0].Version { + t.Fatalf("listed prompt could not be inspected: (%+v, %v)", prompt, err) + } +} + +func TestListIdentitiesInvalidEngine(t *testing.T) { + var nilEngine *promptkit.Engine + var zeroEngine promptkit.Engine + for _, engine := range []*promptkit.Engine{nilEngine, &zeroEngine} { + if _, err := engine.ListProfiles(context.Background()); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("ListProfiles on invalid engine: %v", err) + } + if _, err := engine.ListPrompts(context.Background()); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("ListPrompts on invalid engine: %v", err) + } + } +} + +func TestListIdentitiesPreserveContextErrorsWithoutPartialResults(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{ + Source: configkit.Mapping("listing", map[string]any{ + "profiles": map[string]any{"profile": map[string]any{}}, + "prompts": map[string]any{"example": map[string]any{"v1": listingContractPrompt()}}, + }), + }}}) + if err != nil { + t.Fatal(err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + expired, expire := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer expire() + for _, test := range []struct { + name string + ctx context.Context + want error + }{ + {name: "canceled", ctx: canceled, want: context.Canceled}, + {name: "expired", ctx: expired, want: context.DeadlineExceeded}, + } { + t.Run(test.name, func(t *testing.T) { + profiles, err := engine.ListProfiles(test.ctx) + if !errors.Is(err, test.want) || len(profiles) != 0 { + t.Errorf("ListProfiles = %#v, %v; want no results and %v", profiles, err, test.want) + } + prompts, err := engine.ListPrompts(test.ctx) + if !errors.Is(err, test.want) || len(prompts) != 0 { + t.Errorf("ListPrompts = %#v, %v; want no results and %v", prompts, err, test.want) + } + }) + } +} + +func listingContractPrompt() map[string]any { + return map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "Hello."}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + } +} + +func containsListingProfile(profiles []string, id string) bool { + for _, profile := range profiles { + if profile == id { + return true + } + } + return false +}