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}