diff --git a/docs/internal/runner.md b/docs/internal/runner.md index 21c2344..a7ba6ef 100644 --- a/docs/internal/runner.md +++ b/docs/internal/runner.md @@ -71,7 +71,8 @@ Primary runner error classes: - `ErrInvalidRequest`: invalid run request envelope. - `ErrProfileRequired`: specific invalid-request reason when neither request `profile_id` nor prompt `default_profile` is available. - `ErrAPIKeyEnvMissing`: specific invalid-request reason when `api_key_env` is set but the named environment variable is unset/empty. -- `ErrProfileLoad`: prompt/profile repository load failures. +- `ErrPromptLoad`: prompt-definition repository load failures. +- `ErrProfileLoad`: execution-profile repository load failures. - `ErrArtifactLoad`: artifact read failures. - `ErrPromptRender`: template render failures. - `ErrLLMGenerate`: outbound model request failures. diff --git a/engine_test.go b/engine_test.go index 91f6cf0..d858a4e 100644 --- a/engine_test.go +++ b/engine_test.go @@ -593,6 +593,59 @@ unknown_field: true } } +func TestPromptRepositoryReadFailureMapsToPromptLoad(t *testing.T) { + missingPromptDir := filepath.Join(t.TempDir(), "missing-prompts") + engine, err := scriptorium.NewEngine(scriptorium.Config{ + PromptDir: missingPromptDir, + ProfileDir: "./examples/profiles", + SchemaDir: "./examples/schemas", + }) + if err != nil { + t.Fatalf("expected engine construction to succeed, got %v", err) + } + + _, err = engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.Inline("Rin opens the gate."), + "glossary": scriptorium.Inline("gate: A guarded passage."), + }, + }) + if !errors.Is(err, scriptorium.ErrPromptLoad) { + t.Fatalf("expected ErrPromptLoad, got %v", err) + } + if errors.Is(err, scriptorium.ErrProfileLoad) { + t.Fatalf("did not expect ErrProfileLoad, got %v", err) + } +} + +func TestSelectedProfileRepositoryReadFailureMapsToProfileLoad(t *testing.T) { + missingProfileDir := filepath.Join(t.TempDir(), "missing-profiles") + engine, err := scriptorium.NewEngine(scriptorium.Config{ + PromptDir: "./examples/prompts", + ProfileDir: missingProfileDir, + SchemaDir: "./examples/schemas", + }) + if err != nil { + t.Fatalf("expected engine construction to succeed, got %v", err) + } + + _, err = engine.Prepare(context.Background(), scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + ProfileID: "local-fast", + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.Inline("Rin opens the gate."), + "glossary": scriptorium.Inline("gate: A guarded passage."), + }, + }) + if !errors.Is(err, scriptorium.ErrProfileLoad) { + t.Fatalf("expected ErrProfileLoad, got %v", err) + } + if errors.Is(err, scriptorium.ErrPromptLoad) { + t.Fatalf("did not expect ErrPromptLoad, got %v", err) + } +} + func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") engine, err := scriptorium.NewEngine(scriptorium.Config{ diff --git a/errors.go b/errors.go index 298adba..492ef51 100644 --- a/errors.go +++ b/errors.go @@ -49,6 +49,10 @@ func publicErrorFor(err error) error { return ErrPromptNotFound case errors.Is(err, profile.ErrProfileNotFound): return ErrProfileNotFound + case errors.Is(err, usecase.ErrPromptLoad): + return ErrPromptLoad + case errors.Is(err, usecase.ErrProfileLoad): + return ErrProfileLoad case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition): return ErrPromptLoad case isProfileLoadCause(err): @@ -63,8 +67,6 @@ func publicErrorFor(err error) error { return ErrValidation case errors.Is(err, usecase.ErrInvalidRequest): return ErrInvalidRequest - case errors.Is(err, usecase.ErrProfileLoad): - return ErrPromptLoad default: return nil } diff --git a/internal/adapter/http/handler.go b/internal/adapter/http/handler.go index 1052670..242c64a 100644 --- a/internal/adapter/http/handler.go +++ b/internal/adapter/http/handler.go @@ -175,7 +175,7 @@ func mapRunError(err error) (int, string, string) { return http.StatusNotFound, "profile_not_found", "execution profile not found" case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition): return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" - case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile): + case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile), errors.Is(err, profile.ErrRawAPIKeyNotAllowed): return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" case errors.Is(err, usecase.ErrProfileRequired): return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set" @@ -183,8 +183,10 @@ func mapRunError(err error) (int, string, string) { return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing" case errors.Is(err, usecase.ErrInvalidRequest): return http.StatusBadRequest, "invalid_request", "invalid run request" - case errors.Is(err, usecase.ErrProfileLoad): + case errors.Is(err, usecase.ErrPromptLoad): return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" + case errors.Is(err, usecase.ErrProfileLoad): + return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" case errors.Is(err, usecase.ErrArtifactLoad): return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact" case errors.Is(err, usecase.ErrPromptRender): diff --git a/internal/adapter/http/handler_test.go b/internal/adapter/http/handler_test.go index cf7bc37..7e2fdea 100644 --- a/internal/adapter/http/handler_test.go +++ b/internal/adapter/http/handler_test.go @@ -563,11 +563,13 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) { message string avoidCause string }{ - {name: "prompt not found", err: wrap(usecase.ErrProfileLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"}, - {name: "prompt load invalid", err: wrap(usecase.ErrProfileLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"}, + {name: "prompt not found", err: wrap(usecase.ErrPromptLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"}, + {name: "prompt load invalid", err: wrap(usecase.ErrPromptLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"}, + {name: "prompt load generic", err: wrap(usecase.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"}, {name: "missing profile/default", err: wrap(usecase.ErrInvalidRequest, usecase.ErrProfileRequired), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"}, {name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"}, {name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"}, + {name: "profile load generic", err: wrap(usecase.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"}, {name: "api key env missing", err: wrap(usecase.ErrInvalidRequest, usecase.ErrAPIKeyEnvMissing), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"}, {name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"}, {name: "prompt render", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"}, diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 71b548d..c7dc40c 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -28,7 +28,8 @@ var ( ErrProfileRequired = errors.New("profile selection is required") ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable") ErrAPIKeyRequired = errors.New("api key is required") - ErrProfileLoad = errors.New("failed to load prompt definition") + ErrPromptLoad = errors.New("failed to load prompt definition") + ErrProfileLoad = errors.New("failed to load execution profile") ErrArtifactLoad = errors.New("failed to load artifact") ErrPromptRender = errors.New("failed to render prompt") ErrLLMGenerate = errors.New("failed to generate output") @@ -172,11 +173,11 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr def, err := r.promptDefs.GetPromptDefinition(ctx, req.PromptID, req.PromptVersion) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) + return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) } promptDefinitionHash, err := hashPromptDefinition(def) if err != nil { - return nil, fmt.Errorf("%w: failed to hash prompt definition: %v", ErrProfileLoad, err) + return nil, fmt.Errorf("%w: failed to hash prompt definition: %v", ErrPromptLoad, err) } selectedProfileID := strings.TrimSpace(req.ProfileID) diff --git a/internal/usecase/runner_test.go b/internal/usecase/runner_test.go index 6b3859d..2ef2af8 100644 --- a/internal/usecase/runner_test.go +++ b/internal/usecase/runner_test.go @@ -253,6 +253,17 @@ func TestRunnerPrepareSelectedProfileDoesNotExistFails(t *testing.T) { } } +func TestRunnerPreparePromptLoadFailure(t *testing.T) { + runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil) + _, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p"}) + if !errors.Is(err, ErrPromptLoad) { + t.Fatalf("expected ErrPromptLoad, got %v", err) + } + if errors.Is(err, ErrProfileLoad) { + t.Fatalf("did not expect ErrProfileLoad, got %v", err) + } +} + func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) { promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)} execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{ @@ -1323,8 +1334,11 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) { func TestRunnerRunPromptLoadFailure(t *testing.T) { runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil) _, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p"}) - if !errors.Is(err, ErrProfileLoad) { - t.Fatalf("expected ErrProfileLoad, got %v", err) + if !errors.Is(err, ErrPromptLoad) { + t.Fatalf("expected ErrPromptLoad, got %v", err) + } + if errors.Is(err, ErrProfileLoad) { + t.Fatalf("did not expect ErrProfileLoad, got %v", err) } }