package diagnostics
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
)
type Code string
const (
CodeMissingCredential Code = "missing_credential"
CodeConfigCredential Code = "config_credential"
CodeLiveAuthFailure Code = "live_auth_failure"
CodeLiveTransportFailure Code = "live_transport_failure"
CodeLiveAPIFailure Code = "live_api_failure"
CodeConfigurationError Code = "configuration_error"
CodeInvalidAPIBaseURL Code = "invalid_api_base_url"
CodeUnsupportedMockPayload Code = "unsupported_mock_payload"
CodeFixtureReadOnly Code = "fixture_read_only"
CodeFixtureFallbackDetected Code = "fixture_fallback_detected"
CodeUnsupportedCapability Code = "unsupported_capability"
CodeCacheBusy Code = "cache_busy"
CodeCacheSchemaBlocked Code = "cache_schema_blocked"
CodeSchemaDecode Code = "schema_decode"
CodeAPIFailure Code = "api_validation"
CodePushMirrorSyncInProgress Code = "push_mirror_sync_in_progress"
CodeInvalidQuery Code = "invalid_query"
CodePRReviewAnchorMismatch Code = "pr_review_anchor_mismatch"
CodeWriteConfirmationMissing Code = "write_confirmation_incomplete"
CodeDiscussionReplyUnavailable Code = "discussion_reply_unavailable"
CodeParentPRNotCached Code = "parent_pr_not_cached"
CodeWriteAmbiguousRemote Code = "write_ambiguous_remote"
CodeWriteAmbiguousReadback Code = "write_ambiguous_readback_failed"
CodeWriteConflict Code = "write_conflict"
CodeOperationCancelled Code = "operation_cancelled"
CodeOperationTimeout Code = "operation_timeout"
)
type Diagnostic struct {
Code Code `json:"failure_class"`
Message string `json:"message"`
ExitClass string `json:"exit_class"`
HTTPAttempted bool `json:"http_attempted"`
Retryable bool `json:"retryable"`
Context map[string]string `json:"context,omitempty"`
}
type CommandContext struct {
ProviderMode string
Command string
SelectedAPIBaseURL string
RepositoryBindingID string
CachePathPresent bool
AuditPathPresent bool
CacheReadiness string
CacheSchemaDetected int
CacheSchemaExpected int
PayloadKind string
PayloadSource string
RequiredField string
HTTPStatus int
HTTPAttempted bool
SensitiveTerms []string
SensitiveContextKeys []string
BroaderConfigurationInvalid bool
MissingCredential bool
InvalidSelectedAPIBaseURL bool
UnsupportedPayload bool
FixtureFallbackSentinel bool
FixtureReadOnly bool
TransportFailure bool
APIFailure bool
SchemaDecodeFailure bool
MalformedSuccess bool
LocalPayloadTooLarge bool
FailureSource string
RetryableProviderFailure bool
}
type Classifier struct {
Filter Filter
}
func NewClassifier(sensitiveTerms ...string) Classifier {
return Classifier{Filter: NewFilter(sensitiveTerms...)}
}
func Classify(err error, ctx CommandContext) Diagnostic {
return NewClassifier(ctx.SensitiveTerms...).Classify(err, ctx)
}
func (c Classifier) Classify(err error, ctx CommandContext) Diagnostic {
filter := c.Filter
if len(ctx.SensitiveTerms) > 0 {
filter.SensitiveTerms = append(filter.SensitiveTerms, ctx.SensitiveTerms...)
}
attempted, known := HTTPAttemptEvidence(err)
if known {
ctx.HTTPAttempted = attempted
}
code := classifyCode(err, ctx)
d := Diagnostic{Code: code, Message: messageFor(code, err), ExitClass: exitClassFor(code), HTTPAttempted: httpAttemptedFor(code, ctx), Retryable: retryableFor(code) || ctx.RetryableProviderFailure}
if known {
d.HTTPAttempted = attempted
}
d.Message = filter.RedactText(d.Message)
d.Context = redactedContext(filter, ctx)
return d
}
func (d Diagnostic) Error() string {
if d.Message == "" {
return string(d.Code)
}
return d.Message
}
func (d Diagnostic) JSON() ([]byte, error) {
return json.Marshal(d)
}
func classifyCode(err error, ctx CommandContext) Code {
if hasCode(err, "cache_schema_blocked") {
return CodeCacheSchemaBlocked
}
if hasCode(err, "pr_review_anchor_mismatch") {
return CodePRReviewAnchorMismatch
}
if hasCode(err, "write_confirmation_incomplete") {
return CodeWriteConfirmationMissing
}
if hasCode(err, "write_ambiguous_remote") {
return CodeWriteAmbiguousRemote
}
if hasCode(err, "write_ambiguous_readback_failed") {
return CodeWriteAmbiguousReadback
}
if hasCode(err, "write_conflict") {
return CodeWriteConflict
}
if hasCode(err, "invalid_query") {
return CodeInvalidQuery
}
if hasCode(err, "discussion_reply_unavailable") {
return CodeDiscussionReplyUnavailable
}
if hasCode(err, "parent_pr_not_cached") {
return CodeParentPRNotCached
}
if ctx.ProviderMode != "live-http" {
if ctx.FixtureReadOnly || hasCode(err, "fixture_read_only") || hasCode(err, "write_fixture_read_only") {
return CodeFixtureReadOnly
}
return codeFromError(err)
}
if ctx.BroaderConfigurationInvalid || hasCode(err, "configuration_error") || (!ctx.HTTPAttempted && isConfigurationInputBug(err)) {
return CodeConfigCredential
}
if ctx.MissingCredential || hasCode(err, "missing_credential") || hasCode(err, "write_missing_credential") {
return CodeConfigCredential
}
if ctx.InvalidSelectedAPIBaseURL || hasCode(err, "invalid_api_base_url") {
return CodeConfigCredential
}
if attempted, known := HTTPAttemptEvidence(err); known && !attempted {
if code := operationInterruptionCode(err); code != "" {
return code
}
}
if hasCode(err, "push_mirror_sync_in_progress") && ctx.HTTPAttempted {
return CodePushMirrorSyncInProgress
}
if (ctx.HTTPStatus == http.StatusUnauthorized || ctx.HTTPStatus == http.StatusForbidden || hasCode(err, "live_auth_failure") || hasCode(err, "auth_expired") || hasCode(err, "forbidden") || hasCode(err, "write_unauthorized")) && ctx.HTTPAttempted {
return CodeAPIFailure
}
if hasCode(err, "auth_expired") || hasCode(err, "forbidden") || hasCode(err, "write_unauthorized") || hasCode(err, "live_auth_failure") {
return CodeConfigCredential
}
if ctx.HTTPStatus >= 200 && ctx.HTTPStatus <= 299 && (ctx.SchemaDecodeFailure || ctx.MalformedSuccess || hasCode(err, "schema_decode") || hasCode(err, "malformed_response") || hasCode(err, "unexpected_content_type")) {
return CodeSchemaDecode
}
if ctx.HTTPStatus >= 400 && ctx.HTTPStatus <= 499 && ctx.HTTPStatus != http.StatusUnauthorized && ctx.HTTPStatus != http.StatusForbidden && ctx.HTTPAttempted {
return CodeAPIFailure
}
if ctx.FailureSource == "remote_status" && ctx.HTTPAttempted {
return CodeAPIFailure
}
if ctx.LocalPayloadTooLarge || ctx.FailureSource == "local_body_limit" || ctx.FailureSource == "local_decode_boundary" || ctx.FailureSource == "partial_response" || hasCode(err, "partial_response") || hasCode(err, "unsupported_mock_payload") || hasCode(err, "live_graph_invalid") || hasCode(err, "validation_failed") {
return CodeSchemaDecode
}
if (ctx.APIFailure || hasCode(err, "live_api_failure") || hasCode(err, "write_provider_error")) && ctx.HTTPAttempted {
return CodeAPIFailure
}
if (hasCode(err, "api_validation") || hasCode(err, "not_found") || hasCode(err, "remote_conflict") || hasCode(err, "remote_collision") || hasCode(err, "remote_not_found") || hasCode(err, "rate_limited")) && ctx.HTTPAttempted {
return CodeAPIFailure
}
if ctx.UnsupportedPayload || hasCode(err, "unsupported_mock_payload") || hasCode(err, "live_graph_invalid") || hasCode(err, "validation_failed") {
return CodeSchemaDecode
}
if hasCode(err, "schema_decode") || hasCode(err, "malformed_response") || hasCode(err, "unexpected_content_type") {
return CodeSchemaDecode
}
if attempted, known := HTTPTransportFailureEvidence(err); known && !attempted {
if code := operationInterruptionCode(err); code != "" {
return code
}
}
if (ctx.TransportFailure || hasCode(err, "live_transport_failure") || hasCode(err, "network_unavailable") || hasCode(err, "write_network_unavailable")) && ctx.HTTPAttempted {
return CodeLiveTransportFailure
}
if ctx.HTTPStatus >= 500 && ctx.HTTPAttempted {
return CodeLiveTransportFailure
}
if attempted, known := HTTPAttemptEvidence(err); known && attempted {
if code := operationInterruptionCode(err); code != "" {
return code
}
}
if ctx.FixtureFallbackSentinel || hasCode(err, "fixture_fallback_detected") || hasCode(err, "write_fixture_fallback_detected") || hasCode(err, "fixture_read_only") || hasCode(err, "write_fixture_read_only") {
return CodeFixtureFallbackDetected
}
if hasCode(err, "unsupported_capability") {
return CodeUnsupportedCapability
}
if hasCode(err, "cache_busy") {
return CodeCacheBusy
}
return CodeConfigurationError
}
func operationInterruptionCode(err error) Code {
var interrupted interface{ OperationInterruption() string }
interruption := ""
if errors.As(err, &interrupted) {
interruption = interrupted.OperationInterruption()
}
if errors.Is(err, context.Canceled) || interruption == "cancelled" {
return CodeOperationCancelled
}
if errors.Is(err, context.DeadlineExceeded) || interruption == "timeout" {
return CodeOperationTimeout
}
return ""
}
func codeFromError(err error) Code {
for _, code := range diagnosticCodes(err) {
switch code {
case "missing_credential":
return CodeMissingCredential
case "live_auth_failure":
return CodeAPIFailure
case "live_transport_failure", "network_unavailable", "write_network_unavailable":
return CodeLiveTransportFailure
case "live_api_failure", "write_provider_error":
return CodeAPIFailure
case "configuration_error":
return CodeConfigurationError
case "invalid_api_base_url":
return CodeInvalidAPIBaseURL
case "unsupported_mock_payload", "live_graph_invalid", "validation_failed":
return CodeSchemaDecode
case "fixture_read_only", "write_fixture_read_only":
return CodeFixtureReadOnly
case "fixture_fallback_detected", "write_fixture_fallback_detected":
return CodeFixtureFallbackDetected
case "unsupported_capability":
return CodeUnsupportedCapability
case "cache_busy":
return CodeCacheBusy
case "cache_schema_blocked":
return CodeCacheSchemaBlocked
case "schema_decode", "partial_response", "malformed_response", "unexpected_content_type":
return CodeSchemaDecode
case "auth_expired", "forbidden", "write_unauthorized", "not_found", "remote_conflict", "remote_collision", "remote_not_found", "rate_limited":
return CodeAPIFailure
case "push_mirror_sync_in_progress":
return CodePushMirrorSyncInProgress
case "invalid_query":
return CodeInvalidQuery
case "pr_review_anchor_mismatch":
return CodePRReviewAnchorMismatch
case "write_confirmation_incomplete":
return CodeWriteConfirmationMissing
case "discussion_reply_unavailable":
return CodeDiscussionReplyUnavailable
case "parent_pr_not_cached":
return CodeParentPRNotCached
case "write_ambiguous_remote":
return CodeWriteAmbiguousRemote
case "write_ambiguous_readback_failed":
return CodeWriteAmbiguousReadback
case "write_conflict":
return CodeWriteConflict
}
}
return CodeConfigurationError
}
func hasCode(err error, want string) bool {
for _, code := range diagnosticCodes(err) {
if code == want {
return true
}
}
return false
}
func diagnosticCodes(err error) []string {
var out []string
var walk func(error)
walk = func(err error) {
if err == nil {
return
}
if coded, ok := err.(interface{ DiagnosticCode() string }); ok {
if code := strings.TrimSpace(coded.DiagnosticCode()); code != "" {
out = append(out, code)
}
}
switch unwrapped := err.(type) {
case interface{ Unwrap() []error }:
for _, child := range unwrapped.Unwrap() {
walk(child)
}
case interface{ Unwrap() error }:
walk(unwrapped.Unwrap())
}
}
walk(err)
return out
}
func isConfigurationInputBug(err error) bool {
if err == nil {
return false
}
for _, code := range diagnosticCodes(err) {
if code == "invalid_query" || code == "repo_required" || code == "not_found" {
return true
}
}
return false
}
func httpAttemptedFor(code Code, ctx CommandContext) bool {
switch code {
case CodeLiveAuthFailure, CodeLiveTransportFailure, CodeLiveAPIFailure, CodeAPIFailure, CodeSchemaDecode, CodePushMirrorSyncInProgress, CodePRReviewAnchorMismatch, CodeWriteConfirmationMissing, CodeDiscussionReplyUnavailable, CodeWriteAmbiguousRemote, CodeWriteAmbiguousReadback, CodeWriteConflict:
return ctx.HTTPAttempted
case CodeConfigCredential:
return false
default:
return false
}
}
func retryableFor(code Code) bool {
return code == CodeLiveTransportFailure || code == CodeCacheBusy || code == CodePushMirrorSyncInProgress || code == CodeWriteAmbiguousRemote || code == CodeWriteAmbiguousReadback
}
func exitClassFor(code Code) string {
switch code {
case CodeMissingCredential, CodeLiveAuthFailure:
return "auth"
case CodeConfigCredential:
return "configuration"
case CodeLiveTransportFailure:
return "transport"
case CodeOperationCancelled, CodeOperationTimeout:
return "operation"
case CodeLiveAPIFailure, CodeAPIFailure, CodePushMirrorSyncInProgress:
return "provider"
case CodePRReviewAnchorMismatch, CodeWriteConfirmationMissing, CodeWriteAmbiguousRemote, CodeWriteAmbiguousReadback, CodeWriteConflict:
return "provider"
case CodeInvalidQuery:
return "input"
case CodeDiscussionReplyUnavailable:
return "capability"
case CodeParentPRNotCached:
return "cache"
case CodeUnsupportedMockPayload:
return "payload"
case CodeFixtureFallbackDetected, CodeFixtureReadOnly:
return "fixture"
case CodeUnsupportedCapability:
return "capability"
case CodeSchemaDecode:
return "schema"
case CodeCacheBusy, CodeCacheSchemaBlocked:
return "cache"
default:
return "configuration"
}
}
func messageFor(code Code, err error) string {
base := string(code)
switch code {
case CodeMissingCredential:
base += ": live provider requires a configured credential"
case CodeConfigCredential:
base += ": live provider requires valid configuration and credential"
case CodeLiveAuthFailure:
base += ": live provider rejected credentials"
case CodeLiveTransportFailure:
base += ": live provider request failed before a valid API response"
case CodeOperationCancelled:
base += ": operation cancelled"
case CodeOperationTimeout:
base += ": operation deadline expired"
case CodeLiveAPIFailure:
base += ": live provider returned an API failure"
case CodeAPIFailure:
base += ": live provider returned an API validation failure"
case CodeConfigurationError:
base += ": live command configuration is invalid"
case CodeInvalidAPIBaseURL:
base += ": selected live api_base_url is invalid"
case CodeUnsupportedMockPayload:
base += ": live provider payload is unsupported"
case CodeFixtureReadOnly:
base += ": fixture provider is read-only"
case CodeFixtureFallbackDetected:
base += ": live route reached fixture behavior"
case CodeUnsupportedCapability:
base += ": requested capability is not supported for this provider"
case CodeSchemaDecode:
base += ": response schema decode failure"
case CodeCacheBusy:
base += ": cache is busy, retry later"
case CodeCacheSchemaBlocked:
base += ": selected local cache schema blocks this command; live credential/provider readiness is separate from cache schema readiness"
case CodePushMirrorSyncInProgress:
base += ": mirror synchronization is already running or cooling down"
case CodeInvalidQuery:
base += ": command arguments are invalid"
case CodePRReviewAnchorMismatch:
base += ": GitCode created the review comment on a different path or line"
case CodeWriteConfirmationMissing:
base += ": remote write anchor could not be safely confirmed"
case CodeDiscussionReplyUnavailable:
base += ": provider discussion identity is unavailable for a safe reply"
case CodeParentPRNotCached:
base += ": inline review write requires its parent pull request in the selected cache"
case CodeWriteAmbiguousRemote:
base += ": remote write outcome is ambiguous; retry the same idempotency key for GET-only recovery"
case CodeWriteAmbiguousReadback:
base += ": canonical GET-only recovery could not complete"
case CodeWriteConflict:
base += ": canonical issue state changed across a safely rejected update"
}
if err != nil {
return base + ": " + err.Error()
}
return base
}
func redactedContext(filter Filter, ctx CommandContext) map[string]string {
values := map[string]string{}
put := func(key, value string) {
if strings.TrimSpace(value) != "" {
values[key] = filter.RedactText(value)
}
}
put("provider_mode", ctx.ProviderMode)
put("command", ctx.Command)
put("api_base_url", filter.RedactURL(ctx.SelectedAPIBaseURL))
put("repository_binding", ctx.RepositoryBindingID)
put("payload_kind", ctx.PayloadKind)
put("payload_source", ctx.PayloadSource)
put("required_field", ctx.RequiredField)
if ctx.HTTPStatus != 0 {
put("http_status", fmt.Sprintf("%d", ctx.HTTPStatus))
}
put("cache_path_present", fmt.Sprintf("%t", ctx.CachePathPresent))
put("audit_path_present", fmt.Sprintf("%t", ctx.AuditPathPresent))
put("cache_readiness", ctx.CacheReadiness)
if ctx.CacheSchemaDetected != 0 {
put("cache_schema_detected", fmt.Sprintf("%d", ctx.CacheSchemaDetected))
}
if ctx.CacheSchemaExpected != 0 {
put("cache_schema_expected", fmt.Sprintf("%d", ctx.CacheSchemaExpected))
}
for _, key := range ctx.SensitiveContextKeys {
delete(values, key)
}
if len(values) == 0 {
return nil
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
out := make(map[string]string, len(values))
for _, key := range keys {
out[key] = values[key]
}
return out
}