Skip to main content

hivemind/core/
events.rs

1//! Event definitions and types.
2//!
3//! All state in Hivemind is derived from events. Events are immutable,
4//! append-only, and form the single source of truth.
5
6use crate::core::diff::ChangeType;
7use crate::core::enforcement::ScopeViolation;
8use crate::core::error::HivemindError;
9use crate::core::flow::{RetryMode, TaskExecState};
10use crate::core::graph::GraphTask;
11use crate::core::scope::{RepoAccessMode, Scope};
12use crate::core::verification::CheckConfig;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use uuid::Uuid;
18
19const fn default_max_parallel_tasks() -> u16 {
20    1
21}
22
23/// Unique identifier for an event.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct EventId(Uuid);
26
27impl EventId {
28    /// Creates a new unique event ID.
29    #[must_use]
30    pub fn new() -> Self {
31        Self(Uuid::new_v4())
32    }
33
34    /// Creates a unique event ID that is ordered by the provided sequence.
35    ///
36    /// This preserves UUID wire format while allowing stores to guarantee a
37    /// monotonic ordering property for event IDs within a log.
38    #[must_use]
39    pub fn from_ordered_u64(sequence: u64) -> Self {
40        let mut bytes = *Uuid::new_v4().as_bytes();
41        bytes[..8].copy_from_slice(&sequence.to_be_bytes());
42        Self(Uuid::from_bytes(bytes))
43    }
44
45    /// Returns the inner UUID.
46    #[must_use]
47    pub fn as_uuid(&self) -> Uuid {
48        self.0
49    }
50}
51
52impl Default for EventId {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl std::fmt::Display for EventId {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        write!(f, "{}", self.0)
61    }
62}
63
64/// Correlation IDs for tracing event relationships.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct CorrelationIds {
67    /// Project this event belongs to.
68    pub project_id: Option<Uuid>,
69    #[serde(default)]
70    pub graph_id: Option<Uuid>,
71    /// Flow this event belongs to.
72    pub flow_id: Option<Uuid>,
73    /// Task this event belongs to.
74    pub task_id: Option<Uuid>,
75    /// Attempt this event belongs to.
76    pub attempt_id: Option<Uuid>,
77}
78
79impl CorrelationIds {
80    /// Creates empty correlation IDs.
81    #[must_use]
82    pub fn none() -> Self {
83        Self {
84            project_id: None,
85            graph_id: None,
86            flow_id: None,
87            task_id: None,
88            attempt_id: None,
89        }
90    }
91
92    /// Creates correlation IDs with only a project ID.
93    #[must_use]
94    pub fn for_project(project_id: Uuid) -> Self {
95        Self {
96            project_id: Some(project_id),
97            graph_id: None,
98            flow_id: None,
99            task_id: None,
100            attempt_id: None,
101        }
102    }
103
104    #[must_use]
105    pub fn for_graph(project_id: Uuid, graph_id: Uuid) -> Self {
106        Self {
107            project_id: Some(project_id),
108            graph_id: Some(graph_id),
109            flow_id: None,
110            task_id: None,
111            attempt_id: None,
112        }
113    }
114
115    /// Creates correlation IDs with project and task.
116    #[must_use]
117    pub fn for_task(project_id: Uuid, task_id: Uuid) -> Self {
118        Self {
119            project_id: Some(project_id),
120            graph_id: None,
121            flow_id: None,
122            task_id: Some(task_id),
123            attempt_id: None,
124        }
125    }
126
127    #[must_use]
128    pub fn for_flow(project_id: Uuid, flow_id: Uuid) -> Self {
129        Self {
130            project_id: Some(project_id),
131            graph_id: None,
132            flow_id: Some(flow_id),
133            task_id: None,
134            attempt_id: None,
135        }
136    }
137
138    #[must_use]
139    pub fn for_graph_flow(project_id: Uuid, graph_id: Uuid, flow_id: Uuid) -> Self {
140        Self {
141            project_id: Some(project_id),
142            graph_id: Some(graph_id),
143            flow_id: Some(flow_id),
144            task_id: None,
145            attempt_id: None,
146        }
147    }
148
149    #[must_use]
150    pub fn for_flow_task(project_id: Uuid, flow_id: Uuid, task_id: Uuid) -> Self {
151        Self {
152            project_id: Some(project_id),
153            graph_id: None,
154            flow_id: Some(flow_id),
155            task_id: Some(task_id),
156            attempt_id: None,
157        }
158    }
159
160    #[must_use]
161    pub fn for_graph_flow_task(
162        project_id: Uuid,
163        graph_id: Uuid,
164        flow_id: Uuid,
165        task_id: Uuid,
166    ) -> Self {
167        Self {
168            project_id: Some(project_id),
169            graph_id: Some(graph_id),
170            flow_id: Some(flow_id),
171            task_id: Some(task_id),
172            attempt_id: None,
173        }
174    }
175
176    #[must_use]
177    pub fn for_graph_flow_task_attempt(
178        project_id: Uuid,
179        graph_id: Uuid,
180        flow_id: Uuid,
181        task_id: Uuid,
182        attempt_id: Uuid,
183    ) -> Self {
184        Self {
185            project_id: Some(project_id),
186            graph_id: Some(graph_id),
187            flow_id: Some(flow_id),
188            task_id: Some(task_id),
189            attempt_id: Some(attempt_id),
190        }
191    }
192}
193
194/// Event metadata common to all events.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct EventMetadata {
197    /// Unique event identifier.
198    pub id: EventId,
199    /// When the event occurred.
200    pub timestamp: DateTime<Utc>,
201    /// Correlation IDs for tracing.
202    pub correlation: CorrelationIds,
203    /// Sequence number within the event stream (assigned by store).
204    pub sequence: Option<u64>,
205}
206
207impl EventMetadata {
208    /// Creates new metadata with current timestamp.
209    #[must_use]
210    pub fn new(correlation: CorrelationIds) -> Self {
211        Self {
212            id: EventId::new(),
213            timestamp: Utc::now(),
214            correlation,
215            sequence: None,
216        }
217    }
218}
219
220/// Payload types for different events.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(tag = "type", rename_all = "snake_case")]
223pub enum EventPayload {
224    /// A failure occurred and was recorded.
225    ErrorOccurred {
226        error: HivemindError,
227    },
228
229    /// A new project was created.
230    ProjectCreated {
231        id: Uuid,
232        name: String,
233        description: Option<String>,
234    },
235    /// A project was updated.
236    ProjectUpdated {
237        id: Uuid,
238        name: Option<String>,
239        description: Option<String>,
240    },
241    ProjectRuntimeConfigured {
242        project_id: Uuid,
243        adapter_name: String,
244        binary_path: String,
245        #[serde(default)]
246        model: Option<String>,
247        #[serde(default)]
248        args: Vec<String>,
249        #[serde(default)]
250        env: HashMap<String, String>,
251        timeout_ms: u64,
252        #[serde(default = "default_max_parallel_tasks")]
253        max_parallel_tasks: u16,
254    },
255    /// A new task was created.
256    TaskCreated {
257        id: Uuid,
258        project_id: Uuid,
259        title: String,
260        description: Option<String>,
261        #[serde(default)]
262        scope: Option<Scope>,
263    },
264    /// A task was updated.
265    TaskUpdated {
266        id: Uuid,
267        title: Option<String>,
268        description: Option<String>,
269    },
270    TaskRuntimeConfigured {
271        task_id: Uuid,
272        adapter_name: String,
273        binary_path: String,
274        #[serde(default)]
275        model: Option<String>,
276        #[serde(default)]
277        args: Vec<String>,
278        #[serde(default)]
279        env: HashMap<String, String>,
280        timeout_ms: u64,
281    },
282    TaskRuntimeCleared {
283        task_id: Uuid,
284    },
285    /// A task was closed.
286    TaskClosed {
287        id: Uuid,
288        #[serde(default)]
289        reason: Option<String>,
290    },
291    /// A repository was attached to a project.
292    RepositoryAttached {
293        project_id: Uuid,
294        path: String,
295        name: String,
296        #[serde(default)]
297        access_mode: RepoAccessMode,
298    },
299    /// A repository was detached from a project.
300    RepositoryDetached {
301        project_id: Uuid,
302        name: String,
303    },
304
305    TaskGraphCreated {
306        graph_id: Uuid,
307        project_id: Uuid,
308        name: String,
309        #[serde(default)]
310        description: Option<String>,
311    },
312    TaskAddedToGraph {
313        graph_id: Uuid,
314        task: GraphTask,
315    },
316    DependencyAdded {
317        graph_id: Uuid,
318        from_task: Uuid,
319        to_task: Uuid,
320    },
321    GraphTaskCheckAdded {
322        graph_id: Uuid,
323        task_id: Uuid,
324        check: CheckConfig,
325    },
326    ScopeAssigned {
327        graph_id: Uuid,
328        task_id: Uuid,
329        scope: Scope,
330    },
331
332    TaskGraphValidated {
333        graph_id: Uuid,
334        project_id: Uuid,
335        valid: bool,
336        #[serde(default)]
337        issues: Vec<String>,
338    },
339
340    TaskGraphLocked {
341        graph_id: Uuid,
342        project_id: Uuid,
343    },
344
345    TaskFlowCreated {
346        flow_id: Uuid,
347        graph_id: Uuid,
348        project_id: Uuid,
349        #[serde(default)]
350        name: Option<String>,
351        task_ids: Vec<Uuid>,
352    },
353    TaskFlowStarted {
354        flow_id: Uuid,
355        #[serde(default)]
356        base_revision: Option<String>,
357    },
358    TaskFlowPaused {
359        flow_id: Uuid,
360        #[serde(default)]
361        running_tasks: Vec<Uuid>,
362    },
363    TaskFlowResumed {
364        flow_id: Uuid,
365    },
366    TaskFlowCompleted {
367        flow_id: Uuid,
368    },
369    TaskFlowAborted {
370        flow_id: Uuid,
371        #[serde(default)]
372        reason: Option<String>,
373        forced: bool,
374    },
375
376    TaskReady {
377        flow_id: Uuid,
378        task_id: Uuid,
379    },
380    TaskBlocked {
381        flow_id: Uuid,
382        task_id: Uuid,
383        #[serde(default)]
384        reason: Option<String>,
385    },
386    ScopeConflictDetected {
387        flow_id: Uuid,
388        task_id: Uuid,
389        conflicting_task_id: Uuid,
390        severity: String,
391        action: String,
392        reason: String,
393    },
394    TaskSchedulingDeferred {
395        flow_id: Uuid,
396        task_id: Uuid,
397        reason: String,
398    },
399    TaskExecutionStateChanged {
400        flow_id: Uuid,
401        task_id: Uuid,
402        from: TaskExecState,
403        to: TaskExecState,
404    },
405
406    TaskExecutionStarted {
407        flow_id: Uuid,
408        task_id: Uuid,
409        attempt_id: Uuid,
410        attempt_number: u32,
411    },
412    TaskExecutionSucceeded {
413        flow_id: Uuid,
414        task_id: Uuid,
415        #[serde(default)]
416        attempt_id: Option<Uuid>,
417    },
418    TaskExecutionFailed {
419        flow_id: Uuid,
420        task_id: Uuid,
421        #[serde(default)]
422        attempt_id: Option<Uuid>,
423        #[serde(default)]
424        reason: Option<String>,
425    },
426
427    AttemptStarted {
428        flow_id: Uuid,
429        task_id: Uuid,
430        attempt_id: Uuid,
431        attempt_number: u32,
432    },
433
434    BaselineCaptured {
435        flow_id: Uuid,
436        task_id: Uuid,
437        attempt_id: Uuid,
438        baseline_id: Uuid,
439        #[serde(default)]
440        git_head: Option<String>,
441        file_count: usize,
442    },
443
444    FileModified {
445        flow_id: Uuid,
446        task_id: Uuid,
447        attempt_id: Uuid,
448        path: String,
449        change_type: ChangeType,
450        #[serde(default)]
451        old_hash: Option<String>,
452        #[serde(default)]
453        new_hash: Option<String>,
454    },
455
456    DiffComputed {
457        flow_id: Uuid,
458        task_id: Uuid,
459        attempt_id: Uuid,
460        diff_id: Uuid,
461        baseline_id: Uuid,
462        change_count: usize,
463    },
464
465    CheckStarted {
466        flow_id: Uuid,
467        task_id: Uuid,
468        attempt_id: Uuid,
469        check_name: String,
470        required: bool,
471    },
472
473    CheckCompleted {
474        flow_id: Uuid,
475        task_id: Uuid,
476        attempt_id: Uuid,
477        check_name: String,
478        passed: bool,
479        exit_code: i32,
480        output: String,
481        duration_ms: u64,
482        required: bool,
483    },
484
485    MergeCheckStarted {
486        flow_id: Uuid,
487        #[serde(default)]
488        task_id: Option<Uuid>,
489        check_name: String,
490        required: bool,
491    },
492
493    MergeCheckCompleted {
494        flow_id: Uuid,
495        #[serde(default)]
496        task_id: Option<Uuid>,
497        check_name: String,
498        passed: bool,
499        exit_code: i32,
500        output: String,
501        duration_ms: u64,
502        required: bool,
503    },
504
505    TaskExecutionFrozen {
506        flow_id: Uuid,
507        task_id: Uuid,
508        #[serde(default)]
509        commit_sha: Option<String>,
510    },
511
512    TaskIntegratedIntoFlow {
513        flow_id: Uuid,
514        task_id: Uuid,
515        #[serde(default)]
516        commit_sha: Option<String>,
517    },
518
519    MergeConflictDetected {
520        flow_id: Uuid,
521        #[serde(default)]
522        task_id: Option<Uuid>,
523        details: String,
524    },
525
526    FlowFrozenForMerge {
527        flow_id: Uuid,
528    },
529
530    FlowIntegrationLockAcquired {
531        flow_id: Uuid,
532        operation: String,
533    },
534
535    CheckpointDeclared {
536        flow_id: Uuid,
537        task_id: Uuid,
538        attempt_id: Uuid,
539        checkpoint_id: String,
540        order: u32,
541        total: u32,
542    },
543
544    CheckpointActivated {
545        flow_id: Uuid,
546        task_id: Uuid,
547        attempt_id: Uuid,
548        checkpoint_id: String,
549        order: u32,
550    },
551
552    CheckpointCompleted {
553        flow_id: Uuid,
554        task_id: Uuid,
555        attempt_id: Uuid,
556        checkpoint_id: String,
557        order: u32,
558        commit_hash: String,
559        timestamp: DateTime<Utc>,
560        #[serde(default)]
561        summary: Option<String>,
562    },
563
564    AllCheckpointsCompleted {
565        flow_id: Uuid,
566        task_id: Uuid,
567        attempt_id: Uuid,
568    },
569
570    CheckpointCommitCreated {
571        flow_id: Uuid,
572        task_id: Uuid,
573        attempt_id: Uuid,
574        commit_sha: String,
575    },
576
577    ScopeValidated {
578        flow_id: Uuid,
579        task_id: Uuid,
580        attempt_id: Uuid,
581        verification_id: Uuid,
582        verified_at: DateTime<Utc>,
583        scope: Scope,
584    },
585
586    ScopeViolationDetected {
587        flow_id: Uuid,
588        task_id: Uuid,
589        attempt_id: Uuid,
590        verification_id: Uuid,
591        verified_at: DateTime<Utc>,
592        scope: Scope,
593        #[serde(default)]
594        violations: Vec<ScopeViolation>,
595    },
596
597    RetryContextAssembled {
598        flow_id: Uuid,
599        task_id: Uuid,
600        attempt_id: Uuid,
601        attempt_number: u32,
602        max_attempts: u32,
603        #[serde(default)]
604        prior_attempt_ids: Vec<Uuid>,
605        #[serde(default)]
606        required_check_failures: Vec<String>,
607        #[serde(default)]
608        optional_check_failures: Vec<String>,
609        #[serde(default)]
610        runtime_exit_code: Option<i32>,
611        #[serde(default)]
612        runtime_terminated_reason: Option<String>,
613        context: String,
614    },
615
616    TaskRetryRequested {
617        task_id: Uuid,
618        reset_count: bool,
619        #[serde(default)]
620        retry_mode: RetryMode,
621    },
622    TaskAborted {
623        task_id: Uuid,
624        #[serde(default)]
625        reason: Option<String>,
626    },
627
628    HumanOverride {
629        task_id: Uuid,
630        override_type: String,
631        decision: String,
632        reason: String,
633        #[serde(default)]
634        user: Option<String>,
635    },
636
637    MergePrepared {
638        flow_id: Uuid,
639        #[serde(default)]
640        target_branch: Option<String>,
641        #[serde(default)]
642        conflicts: Vec<String>,
643    },
644    MergeApproved {
645        flow_id: Uuid,
646        #[serde(default)]
647        user: Option<String>,
648    },
649    MergeCompleted {
650        flow_id: Uuid,
651        #[serde(default)]
652        commits: Vec<String>,
653    },
654    RuntimeStarted {
655        adapter_name: String,
656        task_id: Uuid,
657        attempt_id: Uuid,
658    },
659    RuntimeOutputChunk {
660        attempt_id: Uuid,
661        stream: RuntimeOutputStream,
662        content: String,
663    },
664    RuntimeInputProvided {
665        attempt_id: Uuid,
666        content: String,
667    },
668    RuntimeInterrupted {
669        attempt_id: Uuid,
670    },
671    RuntimeExited {
672        attempt_id: Uuid,
673        exit_code: i32,
674        duration_ms: u64,
675    },
676    RuntimeTerminated {
677        attempt_id: Uuid,
678        reason: String,
679    },
680    RuntimeFilesystemObserved {
681        attempt_id: Uuid,
682        #[serde(default)]
683        files_created: Vec<PathBuf>,
684        #[serde(default)]
685        files_modified: Vec<PathBuf>,
686        #[serde(default)]
687        files_deleted: Vec<PathBuf>,
688    },
689    RuntimeCommandObserved {
690        attempt_id: Uuid,
691        stream: RuntimeOutputStream,
692        command: String,
693    },
694    RuntimeToolCallObserved {
695        attempt_id: Uuid,
696        stream: RuntimeOutputStream,
697        tool_name: String,
698        details: String,
699    },
700    RuntimeTodoSnapshotUpdated {
701        attempt_id: Uuid,
702        stream: RuntimeOutputStream,
703        #[serde(default)]
704        items: Vec<String>,
705    },
706    RuntimeNarrativeOutputObserved {
707        attempt_id: Uuid,
708        stream: RuntimeOutputStream,
709        content: String,
710    },
711
712    #[serde(other)]
713    Unknown,
714}
715
716/// Output stream for runtime output events.
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[serde(rename_all = "lowercase")]
719pub enum RuntimeOutputStream {
720    Stdout,
721    Stderr,
722}
723
724/// A complete event with metadata and payload.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct Event {
727    /// Event metadata.
728    pub metadata: EventMetadata,
729    /// Event payload.
730    pub payload: EventPayload,
731}
732
733impl Event {
734    /// Creates a new event with the given payload and correlation.
735    #[must_use]
736    pub fn new(payload: EventPayload, correlation: CorrelationIds) -> Self {
737        Self {
738            metadata: EventMetadata::new(correlation),
739            payload,
740        }
741    }
742
743    /// Returns the event ID.
744    #[must_use]
745    pub fn id(&self) -> EventId {
746        self.metadata.id
747    }
748
749    /// Returns the event timestamp.
750    #[must_use]
751    pub fn timestamp(&self) -> DateTime<Utc> {
752        self.metadata.timestamp
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn event_id_is_unique() {
762        let id1 = EventId::new();
763        let id2 = EventId::new();
764        assert_ne!(id1, id2);
765    }
766
767    #[test]
768    fn event_serialization_roundtrip() {
769        let event = Event::new(
770            EventPayload::ProjectCreated {
771                id: Uuid::new_v4(),
772                name: "test-project".to_string(),
773                description: Some("A test project".to_string()),
774            },
775            CorrelationIds::none(),
776        );
777
778        let json = serde_json::to_string(&event).expect("serialize");
779        let restored: Event = serde_json::from_str(&json).expect("deserialize");
780
781        assert_eq!(event.payload, restored.payload);
782    }
783
784    #[test]
785    fn correlation_ids_for_project() {
786        let project_id = Uuid::new_v4();
787        let corr = CorrelationIds::for_project(project_id);
788        assert_eq!(corr.project_id, Some(project_id));
789        assert!(corr.task_id.is_none());
790    }
791}