556 lines
16 KiB
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 = "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
|
|
}
|