// Package projectlog provides immutable, versioned presentation and recovery // project-log record schemas and validation matrices for the standalone host. package projectlog import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "reflect" "sort" "sync" "time" "iop/packages/go/agenttask" ) var ( ErrNilStore = errors.New("projectlog: store is required for sink") ErrNilEvidenceResolver = errors.New("projectlog: event evidence resolver is required") ErrMissingEventID = errors.New("projectlog: manager event id is required") ErrEvidenceUnavailable = errors.New("projectlog: durable event evidence is unavailable") ErrEvidenceIdentityDrift = errors.New("projectlog: event identity disagrees with durable evidence") ) // EventEvidence is the exact durable manager snapshot used to project an // event. Work is nil only for project-scoped events. type EventEvidence struct { StateRevision agenttask.StateRevision Project agenttask.ProjectRecord Work *agenttask.WorkRecord } // EventEvidenceResolver binds an emitted manager event to the durable state // revision and work record that made the event meaningful. type EventEvidenceResolver interface { ResolveEventEvidence(context.Context, agenttask.Event) (EventEvidence, error) } // StateStoreEvidenceResolver resolves event evidence from the shared // AgentTaskManager StateStore without synthesizing missing runtime identity. type StateStoreEvidenceResolver struct { state agenttask.StateStore } func NewStateStoreEvidenceResolver(state agenttask.StateStore) (*StateStoreEvidenceResolver, error) { if state == nil { return nil, ErrNilEvidenceResolver } return &StateStoreEvidenceResolver{state: state}, nil } func (r *StateStoreEvidenceResolver) ResolveEventEvidence( ctx context.Context, event agenttask.Event, ) (EventEvidence, error) { state, revision, err := r.state.Load(ctx) if err != nil { return EventEvidence{}, fmt.Errorf("%w: load manager state: %v", ErrEvidenceUnavailable, err) } if delivery, ok := state.PendingEvents[event.EventID]; ok { pendingFingerprint, err := eventFingerprint(delivery.Event) if err != nil { return EventEvidence{}, fmt.Errorf("%w: fingerprint pending event: %v", ErrEvidenceUnavailable, err) } incomingFingerprint, err := eventFingerprint(event) if err != nil { return EventEvidence{}, fmt.Errorf("%w: fingerprint incoming event: %v", ErrEvidenceUnavailable, err) } if pendingFingerprint != incomingFingerprint || delivery.Event.EventID != event.EventID || delivery.Project == nil { return EventEvidence{}, fmt.Errorf( "%w: pending delivery %q does not match the emitted event", ErrEvidenceIdentityDrift, event.EventID, ) } return resolveEventEvidence( event, delivery.EvidenceRevision, *delivery.Project, delivery.Work, ) } project, ok := state.Projects[event.ProjectID] if !ok { return EventEvidence{}, fmt.Errorf( "%w: project %q is absent", ErrEvidenceUnavailable, event.ProjectID, ) } var work *agenttask.WorkRecord if event.WorkUnitID != "" { value, ok := project.Works[event.WorkUnitID] if !ok { return EventEvidence{}, fmt.Errorf( "%w: work unit %q is absent", ErrEvidenceUnavailable, event.WorkUnitID, ) } work = &value } return resolveEventEvidence(event, revision, project, work) } func resolveEventEvidence( event agenttask.Event, revision agenttask.StateRevision, project agenttask.ProjectRecord, work *agenttask.WorkRecord, ) (EventEvidence, error) { if project.ProjectID != event.ProjectID { return EventEvidence{}, fmt.Errorf( "%w: project id %q != %q", ErrEvidenceIdentityDrift, project.ProjectID, event.ProjectID, ) } if event.WorkspaceID != "" && project.WorkspaceID != event.WorkspaceID { return EventEvidence{}, fmt.Errorf( "%w: workspace id %q != %q", ErrEvidenceIdentityDrift, project.WorkspaceID, event.WorkspaceID, ) } evidence := EventEvidence{StateRevision: revision, Project: project} if event.WorkUnitID == "" { if work != nil { return EventEvidence{}, fmt.Errorf( "%w: project event unexpectedly has work evidence", ErrEvidenceIdentityDrift, ) } return evidence, nil } if work == nil { return EventEvidence{}, fmt.Errorf( "%w: work unit %q is absent", ErrEvidenceUnavailable, event.WorkUnitID, ) } projectWork, ok := project.Works[event.WorkUnitID] if !ok || !reflect.DeepEqual(projectWork, *work) { return EventEvidence{}, fmt.Errorf( "%w: project and work evidence disagree for %q", ErrEvidenceIdentityDrift, event.WorkUnitID, ) } if work.Unit.ID != event.WorkUnitID { return EventEvidence{}, fmt.Errorf( "%w: work unit id %q != %q", ErrEvidenceIdentityDrift, work.Unit.ID, event.WorkUnitID, ) } if event.AttemptID != "" && work.AttemptID != event.AttemptID { return EventEvidence{}, fmt.Errorf( "%w: attempt id %q != %q", ErrEvidenceIdentityDrift, work.AttemptID, event.AttemptID, ) } if event.Ordinal != 0 && work.DispatchOrdinal != event.Ordinal { return EventEvidence{}, fmt.Errorf( "%w: dispatch ordinal %d != %d", ErrEvidenceIdentityDrift, work.DispatchOrdinal, event.Ordinal, ) } if event.State != "" && work.State != event.State { return EventEvidence{}, fmt.Errorf( "%w: work state %q != %q", ErrEvidenceIdentityDrift, work.State, event.State, ) } if event.ProviderID != "" && (work.Target == nil || work.Target.ProviderID != event.ProviderID) { return EventEvidence{}, fmt.Errorf( "%w: provider id does not match durable target", ErrEvidenceIdentityDrift, ) } if event.ProfileID != "" && (work.Target == nil || work.Target.ProfileID != event.ProfileID) { return EventEvidence{}, fmt.Errorf( "%w: profile id does not match durable target", ErrEvidenceIdentityDrift, ) } if err := validateEventChangeSetEvidence(event, *work); err != nil { return EventEvidence{}, err } value := *work evidence.Work = &value return evidence, nil } // Sink adapts agenttask.EventSink by mapping incoming manager events to // durable ProjectLogRecord entries and appending them to the CAS Store. type Sink struct { store *Store evidence EventEvidenceResolver mu sync.Mutex } // NewSink creates a new event sink bound to a project log Store. func NewSink(store *Store, evidence EventEvidenceResolver) (*Sink, error) { if store == nil { return nil, ErrNilStore } if evidence == nil { return nil, ErrNilEvidenceResolver } return &Sink{store: store, evidence: evidence}, nil } // Emit normalizes an agenttask.Event into a ProjectLogRecord and appends it // to the durable journal store. func (s *Sink) Emit(ctx context.Context, event agenttask.Event) error { s.mu.Lock() defer s.mu.Unlock() recordID, err := opaqueRecordID(event.EventID) if err != nil { return fmt.Errorf("projectlog: derive event record id: %w", err) } fingerprint, err := eventFingerprint(event) if err != nil { return fmt.Errorf("projectlog: fingerprint event: %w", err) } replayed, err := s.store.CheckEventReplay( ctx, event.WorkUnitID, recordID, fingerprint, ) if err != nil { return err } if replayed { return nil } evidence, err := s.evidence.ResolveEventEvidence(ctx, event) if err != nil { return fmt.Errorf("projectlog: resolve event evidence: %w", err) } rec, err := mapEventToRecord( event, evidence, s.store.projectID, s.store.workspaceID, ) if err != nil { return fmt.Errorf("projectlog: map event to record: %w", err) } _, err = s.store.AppendEventRecord(ctx, rec, fingerprint) return err } // WorkLogEntry is the normalized, redacted presentation of one timeline step. type WorkLogEntry struct { Sequence uint64 `json:"sequence"` LoopOrdinal uint64 `json:"loop_ordinal"` DispatchOrdinal agenttask.DispatchOrdinal `json:"dispatch_ordinal"` WorkUnitID agenttask.WorkUnitID `json:"work_unit_id,omitempty"` AttemptID agenttask.AttemptID `json:"attempt_id,omitempty"` RoleStage string `json:"role_stage"` EventType agenttask.EventType `json:"event_type"` State agenttask.WorkState `json:"state,omitempty"` Result string `json:"result,omitempty"` Locators []agenttask.LocatorRecord `json:"locators,omitempty"` Message string `json:"message,omitempty"` Timestamp time.Time `json:"timestamp"` Terminal bool `json:"terminal"` } // WorkLogProjection is the complete, deterministic timeline view of a task journal. type WorkLogProjection struct { ProjectID agenttask.ProjectID `json:"project_id"` WorkspaceID agenttask.WorkspaceID `json:"workspace_id"` Entries []WorkLogEntry `json:"entries"` } // ProjectWorkLog builds a pure, deterministic WORK_LOG projection from durable journal records. // It excludes raw provider output streams while preserving chronological sequence order, // loop/dispatch ordinals, role/stage, result status, and locator identity. func ProjectWorkLog(records []ProjectLogRecord) WorkLogProjection { if len(records) == 0 { return WorkLogProjection{} } proj := WorkLogProjection{ ProjectID: records[0].ProjectID, WorkspaceID: records[0].WorkspaceID, Entries: make([]WorkLogEntry, 0, len(records)), } for _, rec := range records { roleStage := deriveRoleStage(rec) result := deriveResult(rec) locators := make([]agenttask.LocatorRecord, len(rec.Locators)) copy(locators, rec.Locators) entry := WorkLogEntry{ Sequence: rec.Sequence, LoopOrdinal: rec.LoopOrdinal, DispatchOrdinal: rec.DispatchOrdinal, WorkUnitID: rec.WorkUnitID, AttemptID: rec.AttemptID, RoleStage: roleStage, EventType: rec.EventType, State: rec.State, Result: result, Locators: locators, Message: SanitizeMessage(rec.Message), Timestamp: rec.Timestamp, Terminal: rec.Terminal, } proj.Entries = append(proj.Entries, entry) } return proj } func mapEventToRecord( event agenttask.Event, evidence EventEvidence, fallbackProjectID agenttask.ProjectID, fallbackWorkspaceID agenttask.WorkspaceID, ) (ProjectLogRecord, error) { recordID, err := opaqueRecordID(event.EventID) if err != nil { return ProjectLogRecord{}, err } projectID := evidence.Project.ProjectID workspaceID := evidence.Project.WorkspaceID if projectID == "" { projectID = event.ProjectID } if workspaceID == "" { workspaceID = event.WorkspaceID } if projectID != fallbackProjectID || workspaceID != fallbackWorkspaceID { return ProjectLogRecord{}, fmt.Errorf( "%w: resolved project/workspace %q/%q does not match sink %q/%q", ErrEvidenceIdentityDrift, projectID, workspaceID, fallbackProjectID, fallbackWorkspaceID, ) } record := ProjectLogRecord{ SchemaVersion: RecordSchemaVersion, RecordID: recordID, ProjectID: projectID, WorkspaceID: workspaceID, CommandID: event.CommandID, EventType: event.Type, Message: SanitizeMessage(event.Detail), Timestamp: event.Timestamp, } if evidence.Project.Intent != nil { if record.CommandID != "" && record.CommandID != evidence.Project.Intent.CommandID { return ProjectLogRecord{}, fmt.Errorf( "%w: command id %q != %q", ErrEvidenceIdentityDrift, record.CommandID, evidence.Project.Intent.CommandID, ) } if record.CommandID == "" { record.CommandID = evidence.Project.Intent.CommandID } } if evidence.Work == nil { return record, nil } work := *evidence.Work record.LoopOrdinal = uint64(work.Attempt) record.DispatchOrdinal = work.DispatchOrdinal record.WorkUnitID = work.Unit.ID record.AttemptID = work.AttemptID record.State = work.State if work.State != "" { record.StateRevision = evidence.StateRevision record.Terminal = work.State.Terminal() } if work.Target != nil { record.RouteStatus = &RouteStatus{ ProviderID: work.Target.ProviderID, ProfileID: work.Target.ProfileID, ModelID: work.Target.ModelID, ProfileRevision: work.Target.ProfileRevision, ConfigRevision: string(work.Target.ConfigRevision), } } record.Locators = sortedLocators(work.Locators) if work.ChangeSet != nil { record.ChangeSetID = work.ChangeSet.ID record.ChangeSetRevision = work.ChangeSet.Revision } record.IntegrationAttempt = work.IntegrationAttempt if work.Integration != nil { if record.ChangeSetID == "" { record.ChangeSetID = work.Integration.ChangeSet.ID record.ChangeSetRevision = work.Integration.ChangeSet.Revision } record.IntegrationAttempt = work.Integration.Attempt record.IntegrationOutcome = work.Integration.Outcome } switch { case event.Type == agenttask.EventReviewResult && work.Review != nil: record.Result = string(work.Review.Verdict) case work.Integration != nil && work.Integration.Outcome != "": record.Result = string(work.Integration.Outcome) } if work.Blocker != nil { record.BlockerCode = work.Blocker.Code record.Result = string(work.Blocker.Code) } else if work.Integration != nil && work.Integration.Blocker != nil { record.BlockerCode = work.Integration.Blocker.Code record.Result = string(work.Integration.Blocker.Code) } if count := len(work.AttemptObservations); count > 0 { quota := work.AttemptObservations[count-1].Observation.Quota record.QuotaObservation = "a } return record, nil } func opaqueRecordID(eventID string) (string, error) { if eventID == "" { return "", ErrMissingEventID } sum := sha256.Sum256([]byte(eventID)) return "evt-sha256-" + hex.EncodeToString(sum[:]), nil } func eventFingerprint(event agenttask.Event) (string, error) { event.Timestamp = time.Time{} payload, err := json.Marshal(event) if err != nil { return "", fmt.Errorf("projectlog: encode stable logical event fingerprint: %w", err) } sum := sha256.Sum256(payload) return "sha256:" + hex.EncodeToString(sum[:]), nil } func sortedLocators(locators map[agenttask.LocatorKind]agenttask.LocatorRecord) []agenttask.LocatorRecord { if len(locators) == 0 { return nil } kinds := make([]agenttask.LocatorKind, 0, len(locators)) for kind := range locators { kinds = append(kinds, kind) } sort.Slice(kinds, func(left, right int) bool { return kinds[left] < kinds[right] }) result := make([]agenttask.LocatorRecord, 0, len(kinds)) for _, kind := range kinds { result = append(result, locators[kind]) } return result } func validateEventChangeSetEvidence(event agenttask.Event, work agenttask.WorkRecord) error { if event.ChangeSetID != "" { if work.ChangeSet == nil || work.ChangeSet.ID != event.ChangeSetID || work.ChangeSet.Revision != event.ChangeSetRevision { return fmt.Errorf( "%w: event change set %q/%q does not match durable work", ErrEvidenceIdentityDrift, event.ChangeSetID, event.ChangeSetRevision, ) } } if event.IntegrationAttempt != 0 && work.IntegrationAttempt != event.IntegrationAttempt { return fmt.Errorf( "%w: integration attempt %d != %d", ErrEvidenceIdentityDrift, work.IntegrationAttempt, event.IntegrationAttempt, ) } return nil } func deriveRoleStage(rec ProjectLogRecord) string { switch rec.EventType { case agenttask.EventDispatchStarted: return "dispatch" case agenttask.EventReviewResult: return "review" case agenttask.EventIntegrationResult: return "integration" case agenttask.EventFollowup: return "followup" case agenttask.EventBlocked: return "blocked" case agenttask.EventCompleted: return "completed" default: if rec.State != "" { return string(rec.State) } return string(rec.EventType) } } func deriveResult(rec ProjectLogRecord) string { if rec.Result != "" { return rec.Result } if rec.BlockerCode != "" { return string(rec.BlockerCode) } if rec.State != "" { return string(rec.State) } return string(rec.EventType) } // SanitizeMessage removes or redacts sensitive key patterns and environment tokens // from event detail messages so they can be safely stored and projected. func SanitizeMessage(msg string) string { if msg == "" { return "" } if len(msg) > MaxMessageLength { msg = msg[:MaxMessageLength] } if containsSensitiveText(msg) { return "[REDACTED]" } return msg }