iop/apps/agent/internal/projectlog/sink.go

556 lines
16 KiB
Go

// 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 = &quota
}
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
}