Skip to main content

dpm_model/run/
activity.rs

1//! High-volume run activity: bounded telemetry kept apart from lifecycle facts.
2//!
3//! Every record carries the reporter's own per-run `source_sequence`, strictly increasing within a
4//! run. That sequence is the idempotency identity and what keeps retries safe under bounded
5//! retention: the store remembers only one high-water number per run, so a retry of a record that
6//! retention already removed is recognised as expired instead of being recorded again as new.
7
8use crate::{ActorId, RunId};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13/// Longest activity text kept; longer text is truncated and flagged, and a digest of the whole
14/// text stays with the record so a retry is still compared against everything that was sent.
15pub const MAX_ACTIVITY_TEXT_BYTES: usize = 4096;
16
17/// Most activity records one request may carry.
18pub const MAX_ACTIVITY_BATCH: usize = 100;
19
20/// What a reported activity record describes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ActivityKind {
24    /// A tool call began.
25    ToolStarted,
26    /// A tool call produced a result or failed.
27    ToolResult,
28    /// The executor reported public progress.
29    Progress,
30    /// The executor asked for input or approval.
31    InputRequested,
32    /// The executor is alive. A heartbeat is a receipt, nothing more.
33    Heartbeat,
34}
35
36/// An activity kind word no record has.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38#[error(
39    "unknown activity kind {0:?}; expected tool_started, tool_result, progress, input_requested or heartbeat"
40)]
41pub struct ActivityKindParseError(String);
42
43impl ActivityKind {
44    /// The lowercase word every adapter spells the kind with.
45    #[must_use]
46    pub fn word(self) -> &'static str {
47        match self {
48            Self::ToolStarted => "tool_started",
49            Self::ToolResult => "tool_result",
50            Self::Progress => "progress",
51            Self::InputRequested => "input_requested",
52            Self::Heartbeat => "heartbeat",
53        }
54    }
55}
56
57impl std::str::FromStr for ActivityKind {
58    type Err = ActivityKindParseError;
59
60    fn from_str(word: &str) -> Result<Self, Self::Err> {
61        [
62            Self::ToolStarted,
63            Self::ToolResult,
64            Self::Progress,
65            Self::InputRequested,
66            Self::Heartbeat,
67        ]
68        .into_iter()
69        .find(|kind| kind.word() == word)
70        .ok_or_else(|| ActivityKindParseError(word.into()))
71    }
72}
73
74/// The SHA-256 of `text` as 64 lowercase hexadecimal digits.
75fn sha256_hex(text: &str) -> String {
76    Sha256::digest(text.as_bytes())
77        .iter()
78        .map(|byte| format!("{byte:02x}"))
79        .collect()
80}
81
82/// What a caller asks to record as one activity record.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct ActivityInput {
86    /// The run the activity belongs to.
87    pub run: RunId,
88    /// The reporter's own sequence number for this record within the run, at least 1 and strictly
89    /// increasing; gaps are allowed. It is the idempotency identity: resending a record with the
90    /// same sequence and content is harmless while the record is retained, and a sequence at or
91    /// below the run's high-water mark that is no longer retained is refused as expired.
92    pub source_sequence: u64,
93    /// What the record describes.
94    pub kind: ActivityKind,
95    /// Public text such as a tool name or progress note; never hidden reasoning.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub text: Option<String>,
98    /// When the source says it happened. It is a claim, never used to judge freshness.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub observed_at: Option<DateTime<Utc>>,
101}
102
103/// An activity record's text after the size bound, with whether it was cut and what it was.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct NormalizedActivity {
106    /// Text within [`MAX_ACTIVITY_TEXT_BYTES`].
107    pub text: Option<String>,
108    /// Whether the supplied text was longer and was cut at a character boundary.
109    pub truncated: bool,
110    /// SHA-256, in lowercase hex, of the whole supplied text; the identity of the text even when
111    /// only a prefix is kept.
112    pub text_digest: Option<String>,
113}
114
115impl ActivityInput {
116    /// Apply the size bound and fingerprint the whole text, so a retry is compared against
117    /// everything that was sent and not only the part that was kept.
118    #[must_use]
119    pub fn normalized(&self) -> NormalizedActivity {
120        let Some(text) = &self.text else {
121            return NormalizedActivity {
122                text: None,
123                truncated: false,
124                text_digest: None,
125            };
126        };
127        let text_digest = Some(sha256_hex(text));
128        if text.len() <= MAX_ACTIVITY_TEXT_BYTES {
129            return NormalizedActivity {
130                text: Some(text.clone()),
131                truncated: false,
132                text_digest,
133            };
134        }
135        let mut kept = String::new();
136        for character in text.chars() {
137            if kept.len().saturating_add(character.len_utf8()) > MAX_ACTIVITY_TEXT_BYTES {
138                break;
139            }
140            kept.push(character);
141        }
142        NormalizedActivity {
143            text: Some(kept),
144            truncated: true,
145            text_digest,
146        }
147    }
148}
149
150/// One recorded activity record.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct ActivityRecord {
154    /// The run the activity belongs to.
155    pub run: RunId,
156    /// The reporter's sequence number within the run.
157    pub source_sequence: u64,
158    /// What the record describes.
159    pub kind: ActivityKind,
160    /// Public text, within the size bound.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub text: Option<String>,
163    /// Whether the reported text was cut to the size bound.
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub truncated: bool,
166    /// SHA-256 of the whole reported text, in lowercase hex.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub text_digest: Option<String>,
169    /// When the source says it happened.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub observed_at: Option<DateTime<Utc>>,
172    /// The principal that reported it.
173    pub recorded_by: ActorId,
174    /// When DPM received it, from DPM's own clock; freshness is judged from this alone.
175    pub recorded_at: DateTime<Utc>,
176}
177
178/// An activity record addressed by its position in the activity feed.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct ActivityEntry {
181    /// Feed cursor value, never reused.
182    pub sequence: u64,
183    /// The record.
184    #[serde(flatten)]
185    pub record: ActivityRecord,
186}
187
188/// Records the feed no longer holds because retention removed them.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190pub struct ActivityGap {
191    /// The cursor the client asked to continue from.
192    pub requested_after: u64,
193    /// The first sequence the feed can still serve.
194    pub resumes_at: u64,
195    /// Sequences between them, across every run in the feed, that are gone.
196    pub lost: u64,
197}
198
199/// A bounded page of the activity feed.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct ActivityPage {
202    /// Records in ascending feed order.
203    pub entries: Vec<ActivityEntry>,
204    /// Cursor for the next request; unchanged for an empty page without a gap.
205    pub next_after_sequence: u64,
206    /// Newest sequence ever assigned, so a client can tell it is caught up or was reset.
207    pub head_sequence: u64,
208    /// Present when the requested cursor precedes what retention kept; the client must treat the
209    /// feed as discontinuous rather than as complete.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub gap: Option<ActivityGap>,
212}
213
214/// What one run's activity amounts to, kept so retention cannot erase the fact of its silence or
215/// the boundary that makes retries safe.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct ActivityTally {
218    /// Records ever received for the run. The count wraps at `u64::MAX`.
219    pub recorded: u64,
220    /// Records the feed still holds for it.
221    pub retained: u64,
222    /// The highest `source_sequence` accepted; zero before any activity. A record at or below it
223    /// that is no longer retained is expired, never recorded again.
224    pub source_high_water: u64,
225    /// The latest record received, even when retention has since removed it.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub latest: Option<LatestActivity>,
228}
229
230/// The most recent activity record a run received.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct LatestActivity {
233    /// What it described.
234    pub kind: ActivityKind,
235    /// The reporter's sequence number for it.
236    pub source_sequence: u64,
237    /// When DPM received it.
238    pub recorded_at: DateTime<Utc>,
239}