Skip to main content

mj_core/
storage.rs

1//! Data exchanged with the controller store, independent of its SQLite implementation.
2use crate::elicitation::ElicitationRequest;
3use crate::state::*;
4use crate::usage::ProviderCost;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::sync::Arc;
8/// A deterministic projection integrity violation. Retrying cannot fix it, so
9/// callers must report it separately from transport failures.
10#[derive(Debug)]
11pub struct ProjectionIntegrityError(pub String);
12
13impl std::fmt::Display for ProjectionIntegrityError {
14    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        formatter.write_str(&self.0)
16    }
17}
18
19impl std::error::Error for ProjectionIntegrityError {}
20
21/// A store this build cannot safely read and write.
22///
23/// Carried as a typed cause rather than a message so the daemon can tell a
24/// store that moved underneath it from a transport failure. It survives every
25/// `anyhow` hop to the caller, which finds it with `error.chain()`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct StoreSchemaMismatch {
28    pub found: i64,
29    pub supported: i64,
30    pub reason: StoreSchemaMismatchReason,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum StoreSchemaMismatchReason {
35    NeedsMigration,
36    Incompatible { minimum_compatible: i64 },
37    InvalidCompatibilityMetadata,
38    Rollback { previous: i64 },
39}
40
41impl std::fmt::Display for StoreSchemaMismatch {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let Self {
44            found,
45            supported,
46            reason,
47        } = self;
48        match reason {
49            StoreSchemaMismatchReason::Incompatible { minimum_compatible } => write!(
50                formatter,
51                "Mjolnir database schema {found} requires at least build schema {minimum_compatible} for reads and writes; this build supports {supported}; upgrade Mjolnir, run this build with an isolated data directory (--instance NAME or MJ_DATA_DIR), or restore a backup made by the older build"
52            ),
53            StoreSchemaMismatchReason::NeedsMigration => write!(
54                formatter,
55                "Mjolnir database schema {found} is not the supported schema {supported}; start the Mjolnir daemon to migrate it"
56            ),
57            StoreSchemaMismatchReason::InvalidCompatibilityMetadata => write!(
58                formatter,
59                "Mjolnir database schema {found} has missing or invalid compatibility metadata; refusing access from build schema {supported}"
60            ),
61            StoreSchemaMismatchReason::Rollback { previous } => write!(
62                formatter,
63                "Mjolnir database schema rolled back from {previous} to {found} underneath this writer; refusing writes from build schema {supported}"
64            ),
65        }
66    }
67}
68
69impl std::error::Error for StoreSchemaMismatch {}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum HistoryScope {
73    Project,
74    Session,
75    All,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct PromptHistoryEntry {
80    pub id: i64,
81    pub session_id: String,
82    pub text: String,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ProjectionApplyOutcome {
87    Applied,
88    AlreadyApplied,
89}
90
91#[derive(Debug, Clone, PartialEq)]
92pub enum TranscriptMutation {
93    Upsert(TranscriptItem),
94    Remove { stable_id: String },
95}
96
97/// Changes derived from one relay event. `None` leaves a scalar untouched;
98/// the nested option on `session_title` permits explicitly clearing it.
99#[derive(Debug, Clone, PartialEq, Default)]
100pub struct MaterializedSessionMutation {
101    /// Relay receipt time for this event. Persistence and the actor cache both
102    /// take a monotonic maximum so removing detail rows cannot move activity
103    /// backwards.
104    pub last_activity_at_ms: Option<i64>,
105    pub execution: Option<MaterializedExecutionState>,
106    pub session_title: Option<Option<String>>,
107    pub configuration: Option<BTreeMap<String, serde_json::Value>>,
108    pub transcript: Vec<TranscriptMutation>,
109    pub queued_prompts: Option<Vec<MaterializedQueuedPrompt>>,
110    pub pending_elicitations: Option<Vec<crate::elicitation::ElicitationRequest>>,
111    /// The nested option distinguishes "unchanged" from "cleared", which is
112    /// how a completed turn removes the running turn.
113    pub active_turn: Option<Option<MaterializedTurn>>,
114    /// A finished turn is only ever replaced, never cleared.
115    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
116    pub config_results: Vec<(String, Option<String>)>,
117    pub provider_cost: Option<crate::usage::ProviderCost>,
118    pub api_events: Vec<ApiEventData>,
119}
120
121/// One viewer's stored state for one session.
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
123pub struct ClientSessionState {
124    pub draft: String,
125    pub through_event_ordinal: u64,
126}
127
128/// A terminal's current composer and the shared value it originally inherited.
129/// The inherited value is retired on detach, never replaced by client-local text.
130#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
131pub struct DetachedSessionDraft {
132    pub text: String,
133    pub inherited_input: Option<String>,
134}
135
136/// What one turn produced, without loading the transcript around it.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct TurnSummary {
139    /// One-based count of turn starts up to and including this one, which is
140    /// what a caller means by "turn 3 of this session".
141    pub turn_number: u64,
142    pub turn_started_at_ms: i64,
143    /// Newest change at or after the turn start, so the caller can measure how
144    /// long the turn took.
145    pub last_changed_at_ms: i64,
146    /// The last nonempty agent message the turn produced, flattened to text.
147    pub final_message: Option<String>,
148}
149
150/// One page of a session's transcript, ordered by the sequence a reader pages
151/// by rather than by creation order.
152#[derive(Debug, Clone)]
153pub struct TranscriptPage {
154    pub items: Vec<Arc<TranscriptItem>>,
155    /// The newest sequence in the whole transcript, so a caller can tell
156    /// whether this page reached the end without asking for another one.
157    pub latest_seq: u64,
158    pub next_after_seq: u64,
159    pub execution: MaterializedExecutionState,
160}
161
162/// What one retention pass reclaimed.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub struct TranscriptRetention {
165    pub items: usize,
166    pub bytes: usize,
167    /// Rows this pass left for the next one, because of
168    /// `RETENTION_BATCH_ITEMS`.
169    pub remaining: bool,
170}
171
172/// A second-opinion review that was still open when the UI last stopped.
173#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
174pub struct StoredReview {
175    pub workflow: crate::second_opinion::ReviewWorkflow,
176    /// Reviewer lifetime this review belongs to. It is bumped when native
177    /// continuity is lost, so a resumed session starts a new conversation
178    /// rather than pretending to reload one that is gone.
179    pub generation: u64,
180    /// The primary's transcript frontier when the context request went out.
181    pub context_baseline: u64,
182    /// Whether the reviewer's native session is known to be gone.
183    pub native_lost: bool,
184    /// What the controller has read of the reviewer's conversation. The
185    /// reviewer's own journal is the source, but it dies with the target, so
186    /// this copy is what keeps a finished review readable afterwards.
187    pub reviewer_transcript: Vec<std::sync::Arc<crate::state::TranscriptItem>>,
188}
189
190/// How far this session has been reviewed.
191///
192/// `baselines` are Git tree ids by repository root: the working tree as of the
193/// last completed review. They advance only when a review resolves, which is
194/// what makes a cancelled review lossless -- the next one covers both turns.
195#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
196pub struct TurnReviewState {
197    pub baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
198    pub reviewed_through_ordinal: u64,
199    /// The last forwarded verdict, which turns the next review into a
200    /// verification pass. Cleared once that pass consumes it.
201    pub prior_review: Option<crate::review::lanes::PriorReviewContext>,
202    /// A review that was running when the daemon stopped. On recovery it is
203    /// cleared without advancing the baseline.
204    pub active: Option<String>,
205    /// A corrective prompt that was submitted while its relay acceptance was
206    /// still ambiguous. It survives a daemon restart so the exact command can
207    /// be retried and reconciled without losing the findings.
208    #[serde(default)]
209    pub pending_forward: Option<crate::review::driver::PendingForward>,
210}
211
212/// What a bounded prompt search found, and whether it stopped early.
213///
214/// The flag is not decoration. Without it a caller cannot tell twenty matches
215/// from the first twenty of many, and will present a partial answer as a whole
216/// one.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct BoundedPromptHistory {
219    pub entries: Vec<PromptHistoryEntry>,
220    pub truncated: bool,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct UsageCoverage {
225    pub recorded_turns: u64,
226    pub full_turn_reports: u64,
227    pub last_request_reports: u64,
228    pub unspecified_reports: u64,
229    pub missing_reports: u64,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct UsageCounterTotal {
234    pub tokens: u64,
235    /// Number of full-turn reports supplying this particular counter.
236    pub reported_turns: u64,
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct UsagePage {
241    pub session_id: String,
242    pub turns: Vec<MaterializedTurnOutcome>,
243    pub next_after_seq: u64,
244    pub latest_seq: u64,
245    /// Totals include only reports whose scope is known to be a whole turn.
246    pub totals: BTreeMap<String, UsageCounterTotal>,
247    pub coverage: UsageCoverage,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub provider_session_cost: Option<ProviderCost>,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct ApiEvent {
254    pub seq: u64,
255    pub session_id: String,
256    pub recorded_at_ms: i64,
257    #[serde(flatten)]
258    pub event: ApiEventData,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[serde(tag = "type", content = "data", rename_all = "snake_case")]
263pub enum ApiEventData {
264    TurnStarted {
265        turn: MaterializedTurn,
266    },
267    TurnEnded {
268        turn: MaterializedTurnOutcome,
269    },
270    Error {
271        message: String,
272        command_id: Option<String>,
273    },
274    InputRequired {
275        request: ElicitationRequest,
276        turn_id: Option<u64>,
277    },
278    InputResolved {
279        elicitation_id: String,
280        turn_id: Option<u64>,
281        action: String,
282    },
283    ActivityChanged {
284        activity: ApiActivityState,
285    },
286}
287
288impl ApiEventData {
289    pub fn kind(&self) -> &'static str {
290        match self {
291            Self::TurnStarted { .. } => "turn_started",
292            Self::TurnEnded { .. } => "turn_ended",
293            Self::Error { .. } => "error",
294            Self::InputRequired { .. } => "input_required",
295            Self::InputResolved { .. } => "input_resolved",
296            Self::ActivityChanged { .. } => "activity_changed",
297        }
298    }
299}
300
301/// These are the same structured facts rendered by the web and terminal UIs.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct ApiActivityState {
304    pub state: String,
305    pub details: Option<ApiActivityDetails>,
306    pub is_idle: bool,
307    pub waiting_for_input: bool,
308    pub capacity_retry: bool,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct ApiActivityDetails {
314    pub kind: ApiActivityKind,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub turn_started_at_ms: Option<i64>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub step_started_at_ms: Option<i64>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub background_started_at_ms: Option<i64>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub idle_since_ms: Option<i64>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub label: Option<String>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "lowercase")]
329pub enum ApiActivityKind {
330    Turn,
331    Step,
332    Background,
333    Idle,
334    Lifecycle,
335}
336
337#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
338pub struct ApiEventFilter {
339    pub session_id: Option<String>,
340    pub workspace_id: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344pub struct ApiEventPage {
345    pub events: Vec<ApiEvent>,
346    pub next_after_seq: u64,
347    pub latest_seq: u64,
348}