package agent import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/contract" "github.com/google/uuid" "google.golang.org/protobuf/proto" ) func validateUploadFailureFact(record UploadAttempt, fact *agentv1.ExecutionFact) error { if record.State != "attempted" || record.Binding == nil || record.Asset == nil || record.Result.SizeBytes != 0 || fact == nil || fact.Binding == nil || fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS || !proto.Equal(record.Binding, fact.Binding) || fact.SourceBootId == "" || fact.SourceSequence == 0 || fact.ObservedAtUnixMs <= 0 { return errors.New("Mock failure requires a matching uncompleted upload attempt") } parsed, err := uuid.Parse(fact.FactId) if err != nil || parsed.Version() != 4 { return errors.New("Mock failure fact ID must be UUID v4") } failure, err := contract.DecodeLocalMockRecordingFailure(fact.PayloadJson) if err != nil { return err } if failure.UploadID != record.UploadID || failure.RecordingID != record.Asset.AssetId { return errors.New("Mock failure payload does not match claimed upload") } sum := sha256.Sum256(fact.PayloadJson) if hex.EncodeToString(sum[:]) != fact.ContentSha256 { return errors.New("Mock failure payload checksum mismatch") } return nil } // RecordUploadFailureFact persists a single terminal failure identity before // any network report. Unknown PUT results never enter this path and cannot be // replayed as a second upload after restart. func (s *Spool) RecordUploadFailureFact(id string, fact *agentv1.ExecutionFact) error { s.mu.Lock() defer s.mu.Unlock() record, err := s.LoadUploadAttempt(id) if err != nil { return err } if err := validateUploadFailureFact(record, fact); err != nil { return err } if record.FailureFact != nil { if !proto.Equal(record.FailureFact, fact) { return errors.New("Mock upload failure fact identity changed") } return nil } record.FailureFact = proto.Clone(fact).(*agentv1.ExecutionFact) return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) } func (s *Spool) CompleteUploadFailureReport(id, factID string) error { s.mu.Lock() defer s.mu.Unlock() record, err := s.LoadUploadAttempt(id) if err != nil { return err } if record.FailureFact == nil || record.FailureFact.FactId != factID || record.State != "attempted" { return errors.New("failure report acknowledgement does not match upload") } if record.FailureDelivered { return nil } record.FailureDelivered = true return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) } // PendingUploadFailures retries only the fact notification. It never requests // another token, reads the source recording or repeats an uncertain PUT. func (s *Spool) PendingUploadFailures() ([]UploadAttempt, error) { entries, err := os.ReadDir(filepath.Join(s.root, ".uploads")) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var pending []UploadAttempt for _, entry := range entries { if !entry.IsDir() { return nil, fmt.Errorf("unexpected upload journal entry %q", entry.Name()) } record, err := s.LoadUploadAttempt(entry.Name()) if err != nil { return nil, err } if record.FailureFact == nil || record.FailureDelivered { continue } if err := validateUploadFailureFact(record, record.FailureFact); err != nil { return nil, fmt.Errorf("invalid persisted Mock failure for %s: %w", record.UploadID, err) } pending = append(pending, record) } return pending, nil }