diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e6cfe8f..efa3318 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -120,6 +120,11 @@ Go 1.27.3, including nested capture and deferred unsupported/cyclic errors. category and matchable cause. Finish facade behavior in Stage 4; do not add temporary mapping paths just for this intermediate stage. +Completion: operation errors retain their owning category and original cause, +including direct prompt/profile lookup categories. Focused use-case tests pass +on Go 1.27.3. Public projection and built-in preparation classification remain +for their respective later units. + ## Stage 4: Preserve Categories Through Public Error Projection **Catalog:** 7, completion. **Files:** `errors.go` and public error contracts/tests. diff --git a/internal/usecase/operation_error.go b/internal/usecase/operation_error.go new file mode 100644 index 0000000..c190740 --- /dev/null +++ b/internal/usecase/operation_error.go @@ -0,0 +1,38 @@ +package usecase + +import ( + "errors" + "fmt" + + "go.maximumdirect.net/promptkit/internal/profile" + "go.maximumdirect.net/promptkit/internal/promptdef" +) + +// OperationError identifies the operation that owns a failure while retaining +// the original cause for inspection. +type OperationError struct { + Category error + Cause error +} + +func (e *OperationError) Error() string { return fmt.Sprintf("%v: %v", e.Category, e.Cause) } +func (e *OperationError) Unwrap() error { return e.Cause } +func (e *OperationError) Is(target error) bool { return target == e.Category } + +func promptLookupError(cause error) error { + category := ErrPromptLoad + if errors.Is(cause, promptdef.ErrPromptDefinitionNotFound) { + category = promptdef.ErrPromptDefinitionNotFound + cause = fmt.Errorf("%w: %w", ErrPromptLoad, cause) + } + return &OperationError{Category: category, Cause: cause} +} + +func profileLookupError(cause error) error { + category := ErrProfileLoad + if errors.Is(cause, profile.ErrProfileNotFound) { + category = profile.ErrProfileNotFound + cause = fmt.Errorf("%w: %w", ErrProfileLoad, cause) + } + return &OperationError{Category: category, Cause: cause} +} diff --git a/internal/usecase/operation_error_test.go b/internal/usecase/operation_error_test.go new file mode 100644 index 0000000..b055b64 --- /dev/null +++ b/internal/usecase/operation_error_test.go @@ -0,0 +1,34 @@ +package usecase + +import ( + "errors" + "fmt" + "testing" + + "go.maximumdirect.net/promptkit/internal/profile" + "go.maximumdirect.net/promptkit/internal/promptdef" +) + +func TestOperationErrorRetainsOwnerAndCause(t *testing.T) { + cause := fmt.Errorf("nested: %w", promptdef.ErrPromptDefinitionNotFound) + outer := &OperationError{Category: ErrLLMGenerate, Cause: cause} + if !errors.Is(outer, ErrLLMGenerate) || !errors.Is(outer, promptdef.ErrPromptDefinitionNotFound) || errors.Unwrap(outer) != cause { + t.Fatalf("operation error = %v", outer) + } + for _, test := range []struct { + err error + category error + }{ + {promptLookupError(cause), promptdef.ErrPromptDefinitionNotFound}, + {profileLookupError(profile.ErrProfileNotFound), profile.ErrProfileNotFound}, + {promptLookupError(contextFailure), ErrPromptLoad}, + {profileLookupError(contextFailure), ErrProfileLoad}, + } { + var operation *OperationError + if !errors.As(test.err, &operation) || operation.Category != test.category { + t.Errorf("operation = %+v", operation) + } + } +} + +var contextFailure = errors.New("lookup failed") diff --git a/internal/usecase/profile_inspection.go b/internal/usecase/profile_inspection.go index c129dbc..1b1dee9 100644 --- a/internal/usecase/profile_inspection.go +++ b/internal/usecase/profile_inspection.go @@ -39,7 +39,7 @@ func (r *Runner) resolveProfileSelection( } selectedProfile, err := r.profiles.GetProfile(ctx, lookupID) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) + return nil, profileLookupError(err) } if selectedProfile == nil { return nil, fmt.Errorf("%w: profile repository returned nil profile", ErrProfileLoad) @@ -64,7 +64,7 @@ func (r *Runner) resolveProfileIdentity(ctx context.Context, profileID string) ( if indexed, ok := r.profiles.(profileIdentityRepository); ok { identity, err := indexed.GetProfileIdentity(ctx, lookupID) if err != nil { - return "", fmt.Errorf("%w: %w", ErrProfileLoad, err) + return "", profileLookupError(err) } return identity, nil } @@ -108,7 +108,7 @@ func (r *Runner) InspectProfile( } select { case <-ctx.Done(): - return nil, fmt.Errorf("%w: %w", ErrProfileLoad, ctx.Err()) + return nil, &OperationError{Category: ErrProfileLoad, Cause: ctx.Err()} default: } @@ -120,7 +120,7 @@ func (r *Runner) InspectProfile( target.APIKey = "" params, err := jsonvalue.CopyMap(target.ExtraParams) if err != nil { - return nil, fmt.Errorf("%w: profile extra parameters: %w", ErrProfileLoad, err) + return nil, &OperationError{Category: ErrProfileLoad, Cause: fmt.Errorf("profile extra parameters: %w", err)} } target.ExtraParams = params diff --git a/internal/usecase/prompt_inspection.go b/internal/usecase/prompt_inspection.go index 1611ec6..47477ee 100644 --- a/internal/usecase/prompt_inspection.go +++ b/internal/usecase/prompt_inspection.go @@ -36,7 +36,7 @@ func (r *Runner) resolvePromptDefinition(ctx context.Context, promptID string) ( definition, err = r.promptDefs.GetPromptDefinition(ctx, promptID) } if err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + return nil, promptLookupError(err) } if definition == nil { return nil, fmt.Errorf("%w: prompt repository returned nil definition", ErrPromptLoad) @@ -61,7 +61,7 @@ func (r *Runner) resolvePromptInspection(ctx context.Context, promptID string) ( if indexed, ok := r.promptDefs.(promptInspectionRepository); ok { inspection, err := indexed.GetPromptInspection(ctx, promptID) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + return nil, promptLookupError(err) } if inspection == nil { return nil, fmt.Errorf("%w: prompt repository returned nil inspection", ErrPromptLoad) @@ -90,7 +90,7 @@ func (r *Runner) InspectPrompt(ctx context.Context, promptID string) (*domain.Pr } select { case <-ctx.Done(): - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, ctx.Err()) + return nil, &OperationError{Category: ErrPromptLoad, Cause: ctx.Err()} default: } diff --git a/internal/usecase/requirements_inspection.go b/internal/usecase/requirements_inspection.go index 776ff28..a33eee7 100644 --- a/internal/usecase/requirements_inspection.go +++ b/internal/usecase/requirements_inspection.go @@ -36,7 +36,7 @@ func (r *Runner) InspectRequirements( return nil, fmt.Errorf("%w: session_id: %v", ErrInvalidRequest, err) } if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + return nil, &OperationError{Category: ErrPromptLoad, Cause: err} } selection, err := r.resolvePromptInspection(ctx, req.PromptID) @@ -44,7 +44,7 @@ func (r *Runner) InspectRequirements( return nil, err } if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) + return nil, &OperationError{Category: ErrProfileLoad, Cause: err} } selectedProfileID := req.ProfileID if strings.TrimSpace(selectedProfileID) == "" { @@ -58,7 +58,7 @@ func (r *Runner) InspectRequirements( return nil, err } if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + return nil, &OperationError{Category: ErrPromptLoad, Cause: err} } repository, ok := r.promptDefs.(requirementSnapshotRepository) @@ -72,7 +72,7 @@ func (r *Runner) InspectRequirements( definition := &domain.PromptDefinition{ID: selection.PromptID} inspection, err := prompt.AnalyzeRequirements(ctx, definition, snapshot, req.Vars, directSessionID != "") if err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err) + return nil, &OperationError{Category: ErrPromptLoad, Cause: err} } inspection.ProfileID = profileIdentity return &inspection, nil diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 67d0af2..c31585b 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -204,7 +204,7 @@ func (r *Runner) executePreparedRun( outputArtifact := buildOutputArtifact(genResp.Content, prepared.OutputContract.Format) validationResult, err := validateArtifact(ctx, &outputArtifact, 0) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrValidation, err) + return nil, &OperationError{Category: ErrValidation, Cause: err} } if r.shouldAttemptRepair(prepared.OutputContract, validationResult) { @@ -238,7 +238,7 @@ func (r *Runner) executePreparedRun( validationResult, err = validateArtifact(ctx, &outputArtifact, attemptsUsed) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrValidation, err) + return nil, &OperationError{Category: ErrValidation, Cause: err} } } } @@ -269,10 +269,7 @@ func (r *Runner) executePreparedRun( } func wrapGenerationError(err error) error { - if errors.Is(err, llm.ErrInvalidRequest) { - return fmt.Errorf("%w: %w", ErrInvalidRequest, err) - } - return fmt.Errorf("%w: %w", ErrLLMGenerate, err) + return &OperationError{Category: ErrLLMGenerate, Cause: err} } func addTokenUsage(total, next domain.TokenUsage) domain.TokenUsage { @@ -408,7 +405,7 @@ func (r *Runner) prepareValidation( } plan, err := preparer.PrepareValidation(ctx, contract) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrValidation, err) + return nil, &OperationError{Category: ErrValidation, Cause: err} } if plan == nil { return nil, fmt.Errorf("%w: validator returned nil prepared validation", ErrValidation) @@ -450,7 +447,7 @@ func (r *Runner) completePreparationWithStructuredOutput( for name, ref := range req.Inputs { art, readErr := r.artifacts.Read(ctx, ref) if readErr != nil { - return nil, fmt.Errorf("%w: input %q: %w", ErrArtifactLoad, name, readErr) + return nil, &OperationError{Category: ErrArtifactLoad, Cause: fmt.Errorf("input %q: %w", name, readErr)} } if art.Name == "" { art.Name = name @@ -467,7 +464,7 @@ func (r *Runner) completePreparationWithStructuredOutput( } renderedPrompt, err := r.renderer.Render(ctx, definitionToRender, resolvedInputs, req.Vars) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrPromptRender, err) + return nil, &OperationError{Category: ErrPromptRender, Cause: err} } effectivePrompt := *renderedPrompt if state.directSessionID != "" { diff --git a/internal/usecase/smoke_selection.go b/internal/usecase/smoke_selection.go index 1296950..4a84de0 100644 --- a/internal/usecase/smoke_selection.go +++ b/internal/usecase/smoke_selection.go @@ -144,7 +144,7 @@ func (r *Runner) resolveSmokeBackend(id string) (domain.Backend, error) { } backend, err := lookup.GetBackend(id) if err != nil { - return domain.Backend{}, fmt.Errorf("%w: backend %q: %v", ErrProfileLoad, id, err) + return domain.Backend{}, &OperationError{Category: ErrProfileLoad, Cause: fmt.Errorf("backend %q: %w", id, err)} } return backend, nil }