package agent import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" agentpb "git.ipao.vip/rogee/go-sip/gen/agent" "git.ipao.vip/rogee/go-sip/internal/contract" "github.com/google/uuid" "google.golang.org/protobuf/proto" ) type MockFailureFactClient interface { ReportExecutionEvent(context.Context, *agentpb.ReportExecutionEventRequest) (*agentpb.ReportExecutionEventResponse, error) } // MockUploadFailureCode returns empty for uncertain PUT outcomes. In particular // a transport error, HTTP 408/429 or 5xx never claims that OSS rejected the // object; the Dispatcher retains the unknown outcome until its deadline. func MockUploadFailureCode(cause error) string { switch { case errors.Is(cause, ErrUploadGrantExpired): return "upload_authorization_expired" case errors.Is(cause, ErrUploadGrantInvalid): return "upload_authorization_failed" case errors.Is(cause, ErrUploadChecksumMismatch): return "checksum_mismatch" case errors.Is(cause, os.ErrNotExist): return "upload_failed" } var response *UploadHTTPError if errors.As(cause, &response) && response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests { if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { return "upload_authorization_failed" } return "upload_failed" } return "" } func validateMockFailureActiveMeta(meta *agentpb.RequestMeta) error { if meta == nil || meta.ProtocolVersion != "agent.v1" || meta.AgentId == "" || meta.CellId == "" || meta.BootId == "" || meta.DispatcherEpoch == "" || meta.SessionGeneration == 0 { return errors.New("Mock failure requires the current activated Agent session") } return nil } // ReportMockUploadFailure persists a known, explicit failure before trying to // notify the Dispatcher. It never retries a PUT or claims an uncertain result. // The caller supplies metadata from its activated Agent session, not a newly // invented boot identity. A pending fact is retried with the same ID after // restart, while the request metadata uses the new active session. func (s *Spool) ReportMockUploadFailure(ctx context.Context, client MockFailureFactClient, active *agentpb.RequestMeta, uploadID string, cause error) (bool, error) { code := MockUploadFailureCode(cause) if code == "" { return false, nil } if err := validateMockFailureActiveMeta(active); err != nil { return true, err } record, err := s.LoadUploadAttempt(uploadID) if err != nil { return true, err } if record.FailureFact == nil { if record.Binding == nil || record.Asset == nil { return true, errors.New("Mock failure has no bound recording attempt") } payload, err := json.Marshal(contract.LocalMockRecordingFailure{ SchemaVersion: contract.LocalMockRecordingFailureVersion, UploadID: record.UploadID, RecordingID: record.Asset.AssetId, ErrorCode: code, }) if err != nil { return true, err } sum := sha256.Sum256(payload) fact := &agentpb.ExecutionFact{ FactId: uuid.NewString(), Binding: proto.Clone(record.Binding).(*agentpb.ExecutionBinding), Kind: agentpb.FactKind_FACT_KIND_RECORDING_PROGRESS, PayloadJson: payload, ContentSha256: hex.EncodeToString(sum[:]), ObservedAtUnixMs: s.now().UTC().UnixMilli(), SourceBootId: active.BootId, SourceSequence: 1, } if err := s.RecordUploadFailureFact(uploadID, fact); err != nil { return true, fmt.Errorf("persist Mock failure before RPC: %w", err) } record, err = s.LoadUploadAttempt(uploadID) if err != nil { return true, err } } return true, s.deliverMockFailure(ctx, client, active, record) } func (s *Spool) RecoverMockUploadFailures(ctx context.Context, client MockFailureFactClient, active *agentpb.RequestMeta) error { pending, err := s.PendingUploadFailures() if err != nil || len(pending) == 0 { return err } if err := validateMockFailureActiveMeta(active); err != nil { return err } for _, record := range pending { if err := s.deliverMockFailure(ctx, client, active, record); err != nil { return fmt.Errorf("recover Mock recording failure %s: %w", record.UploadID, err) } } return nil } func (s *Spool) deliverMockFailure(ctx context.Context, client MockFailureFactClient, active *agentpb.RequestMeta, record UploadAttempt) error { if client == nil || record.FailureFact == nil { return errors.New("Mock failure reporter or durable fact is missing") } if err := validateMockFailureActiveMeta(active); err != nil { return err } meta := proto.Clone(active).(*agentpb.RequestMeta) meta.RequestId = record.FailureFact.FactId meta.OperationId = record.FailureFact.FactId meta.IdempotencyKey = "mock-upload-failure:" + record.FailureFact.FactId response, err := client.ReportExecutionEvent(ctx, &agentpb.ReportExecutionEventRequest{ Meta: meta, Fact: proto.Clone(record.FailureFact).(*agentpb.ExecutionFact), }) if err != nil { return err } receipt := response.GetReceipt() if receipt.GetResult() != agentpb.ResultCode_RESULT_CODE_ACCEPTED || receipt.GetFactId() != record.FailureFact.FactId || receipt.GetContentSha256() != record.FailureFact.ContentSha256 { return errors.New("Dispatcher did not acknowledge the original Mock failure fact") } return s.CompleteUploadFailureReport(record.UploadID, record.FailureFact.FactId) }