package sourcescan import ( "context" "encoding/json" "errors" "sort" "time" ppconnector "knowledge-governance/internal/connectors/pp" "knowledge-governance/internal/governance" "knowledge-governance/internal/jobs" ) type PPScanner interface { Scan(context.Context) (ppconnector.Snapshot, string, error) } type PPScanStore interface { StartSourceScan(context.Context, string, time.Time) (governance.SourceScan, error) LoadPPCurrentItems(context.Context, string) (map[string]ppconnector.CurrentItem, error) CompleteSourceScan(context.Context, string, governance.SourceScanResult, time.Time) error FailSourceScan(context.Context, string, string, time.Time) error } type PPHandlerConfig struct { Store PPScanStore Scanner PPScanner Now func() time.Time } type PPHandler struct { store PPScanStore scanner PPScanner now func() time.Time } func NewPPHandler(config PPHandlerConfig) (*PPHandler, error) { if config.Store == nil || config.Scanner == nil { return nil, errors.New("PP scan store and scanner are required") } if config.Now == nil { config.Now = time.Now } return &PPHandler{store: config.Store, scanner: config.Scanner, now: config.Now}, nil } func (h *PPHandler) Handle(ctx context.Context, job jobs.Job) error { var payload struct { ScanID string `json:"scan_id"` } if err := json.Unmarshal(job.Payload, &payload); err != nil || payload.ScanID == "" { return errors.New("invalid source scan payload") } now := h.now() scan, err := h.store.StartSourceScan(ctx, payload.ScanID, now) if err != nil { return err } if scan.State == governance.SourceScanCompleted { return nil } if scan.State != governance.SourceScanRunning { return errors.New("source scan is not running") } if scan.SourceType != governance.SourceTypePP { return h.store.FailSourceScan(ctx, scan.ID, "source_type_unsupported", now) } snapshot, snapshotSHA, err := h.scanner.Scan(ctx) if err != nil || !governance.ValidSHA256(snapshotSHA) { return h.store.FailSourceScan(ctx, scan.ID, "pp_snapshot_invalid", now) } current, err := h.store.LoadPPCurrentItems(ctx, scan.SourceConnectionID) if err != nil { return err } reconciliation, err := ppconnector.PlanReconciliation(snapshot, current) if err != nil { return h.store.FailSourceScan(ctx, scan.ID, "pp_snapshot_invalid", now) } observed := make(map[string]struct{}, len(snapshot.Items)) changes := make([]governance.SourceScanChange, 0) for _, item := range snapshot.Items { observed[item.SourceURLHash] = struct{}{} if item.Status == ppconnector.StatusOverLimit { continue } size := item.Bytes stored, exists := current[item.SourceURLHash] if !exists { changes = append(changes, governance.SourceScanChange{ StableExternalID: item.SourceURLHash, ChangeType: governance.SourceScanAdded, ObservedRevision: item.SHA256, ObservedSizeBytes: &size, }) } else if stored.Revision != item.SHA256 { changes = append(changes, governance.SourceScanChange{ StableExternalID: item.SourceURLHash, ChangeType: governance.SourceScanUpdated, PreviousRevision: stored.Revision, ObservedRevision: item.SHA256, ObservedSizeBytes: &size, }) } } missingIDs := make([]string, 0) for sourceID := range current { if _, ok := observed[sourceID]; !ok { missingIDs = append(missingIDs, sourceID) } } sort.Strings(missingIDs) for _, sourceID := range missingIDs { changes = append(changes, governance.SourceScanChange{ StableExternalID: sourceID, ChangeType: governance.SourceScanUnavailable, PreviousRevision: current[sourceID].Revision, }) } result := governance.SourceScanResult{ SnapshotSHA256: snapshotSHA, ObservedItems: len(snapshot.Items), UnchangedItems: reconciliation.Unchanged, AddedItems: reconciliation.Adopted, UpdatedItems: reconciliation.UploadRequired, OverLimitItems: reconciliation.OverLimit, MissingItems: len(missingIDs), PlannedUploads: reconciliation.UploadRequired, Changes: changes, } return h.store.CompleteSourceScan(ctx, scan.ID, result, now) }