1use super::*;
2
3pub struct RemoteDashboardWorkerPoller {
4 pub targets: tokio::sync::watch::Sender<Vec<WorkerPollTarget>>,
5 pub updates: SessionManagerUpdates,
6 pub control: SessionManagerControl,
7 pub shutdown: SessionManagerShutdown,
8 pub state: tokio::sync::watch::Receiver<RuntimeStateUpdate>,
9 pub reviews: tokio::sync::watch::Receiver<Vec<crate::review_host::RuntimeReviewView>>,
11 pub notices: tokio::sync::watch::Receiver<Vec<daemon::RuntimeNotice>>,
13 pub config: tokio::sync::watch::Receiver<mj_core::config::Config>,
14}
15
16#[derive(Debug, Clone, Default)]
18pub struct RuntimeStateUpdate {
19 pub workspace_names: std::collections::BTreeMap<String, String>,
20 pub revision: u64,
21 pub records: Vec<SessionRecord>,
22 pub lifecycles: Vec<daemon::RuntimeLifecycleView>,
23 pub moves: Vec<mj_core::state::MoveOperation>,
24 pub subagents: Vec<SubagentRecord>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
38pub(super) struct PublishedView {
39 pub(super) projection_ordinal: u64,
40 pub(super) projection_digest: String,
41 pub(super) operational: Option<mj_core::relay::RelayOperationalState>,
42 pub(super) connected: bool,
43 pub(super) error: Option<String>,
44}
45
46impl PublishedView {
47 pub(super) fn of(runtime: &crate::daemon::RuntimeSessionView) -> Self {
48 Self {
49 projection_ordinal: runtime.projection_ordinal,
50 projection_digest: runtime.projection_digest.clone(),
51 operational: runtime.operational.clone(),
52 connected: runtime.connected,
53 error: runtime.error.as_ref().map(|error| format!("{error:?}")),
54 }
55 }
56
57 pub(super) fn matches(&self, runtime: &crate::daemon::RuntimeSessionView) -> bool {
58 *self == Self::of(runtime)
59 }
60}
61
62pub(super) const PROJECTION_CONVERGENCE_RETRIES: u8 = 20;
63pub(super) const PROJECTION_CONVERGENCE_RETRY_DELAY: Duration = Duration::from_millis(50);
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub(super) struct ProjectionMismatch {
67 pub(super) published_ordinal: u64,
68 pub(super) published_digest: String,
69 pub(super) durable_ordinal: u64,
70 pub(super) durable_digest: String,
71}
72
73#[derive(Default)]
74pub(super) struct ProjectionConvergence {
75 pub(super) attempts: std::collections::BTreeMap<String, (ProjectionMismatch, u8)>,
76}
77
78impl ProjectionConvergence {
79 pub(super) fn converged(&mut self, session_id: &str) {
80 self.attempts.remove(session_id);
81 }
82
83 pub(super) fn should_retry(&mut self, session_id: &str, mismatch: ProjectionMismatch) -> bool {
87 let entry = self
88 .attempts
89 .entry(session_id.to_owned())
90 .or_insert_with(|| (mismatch.clone(), 0));
91 if entry.0 != mismatch {
92 *entry = (mismatch, 0);
93 }
94 entry.1 = entry.1.saturating_add(1);
95 entry.1 <= PROJECTION_CONVERGENCE_RETRIES
96 }
97}
98
99pub enum RuntimeFeedUpdate {
102 Snapshot(Box<daemon::RuntimeSnapshot>),
103 Session {
104 session_id: String,
105 view: Box<ManagedSessionView>,
106 },
107 Error(String),
108}
109
110pub struct RuntimeFeed {
113 pub updates: tokio::sync::mpsc::Receiver<RuntimeFeedUpdate>,
114 pub(super) task: tokio::task::JoinHandle<()>,
115}
116
117impl Drop for RuntimeFeed {
118 fn drop(&mut self) {
119 self.task.abort();
120 }
121}
122
123pub(super) type StoredProjection = Option<(MaterializedSession, mj_core::state::ProjectionWindow)>;
124
125pub(super) async fn load_runtime_projection(session_id: String) -> Result<StoredProjection> {
126 static READERS: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
127 std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4)));
128 let permit = Arc::clone(&READERS)
129 .acquire_owned()
130 .await
131 .context("projection readers stopped")?;
132 tokio::task::spawn_blocking(move || {
133 let _permit = permit;
134 let result = crate::database::load_materialized_projection_tail(
135 &session_id,
136 crate::database::PROJECTION_TAIL_ITEMS,
137 );
138 if let Err(error) = &result {
140 tracing::warn!(%session_id, %error, "could not load runtime projection");
141 }
142 result
143 })
144 .await
145 .context("projection load task failed")?
146}
147
148pub(super) fn spawn_runtime_feed_with<P, PF, L, LF>(
149 workspace_id: String,
150 poll: P,
151 load: L,
152) -> RuntimeFeed
153where
154 P: Fn(String, u64) -> PF + Send + 'static,
155 PF: Future<Output = Result<daemon::RuntimeSnapshot>> + Send,
156 L: Fn(String) -> LF + Clone + Send + 'static,
157 LF: Future<Output = Result<StoredProjection>> + Send + 'static,
158{
159 let (tx, updates) = tokio::sync::mpsc::channel(32);
160 let task = tokio::spawn(async move {
161 let result = run_runtime_feed(workspace_id, poll, load, &tx).await;
162 if let Err(error) = result {
163 let message = format!("Runtime feed stopped: {error:#}");
164 tracing::error!(%message);
165 let _ = tx.send(RuntimeFeedUpdate::Error(message)).await;
166 }
167 });
168 RuntimeFeed { updates, task }
169}
170
171pub(super) async fn run_runtime_feed<P, PF, L, LF>(
172 workspace_id: String,
173 poll: P,
174 load: L,
175 tx: &tokio::sync::mpsc::Sender<RuntimeFeedUpdate>,
176) -> Result<()>
177where
178 P: Fn(String, u64) -> PF,
179 PF: Future<Output = Result<daemon::RuntimeSnapshot>>,
180 L: Fn(String) -> LF + Clone + Send + 'static,
181 LF: Future<Output = Result<StoredProjection>> + Send + 'static,
182{
183 let mut revision = 0;
184 let mut convergence = ProjectionConvergence::default();
185 let mut published = std::collections::BTreeMap::<String, PublishedView>::new();
186 loop {
187 let mut snapshot = match poll(workspace_id.clone(), revision).await {
188 Ok(snapshot) => snapshot,
189 Err(error) => {
190 if tx
191 .send(RuntimeFeedUpdate::Error(format!(
192 "Could not refresh sessions: {error:#}"
193 )))
194 .await
195 .is_err()
196 {
197 return Ok(());
198 }
199 tokio::time::sleep(Duration::from_millis(250)).await;
200 continue;
201 }
202 };
203 let snapshot_revision = snapshot.revision;
204 let sessions = std::mem::take(&mut snapshot.sessions);
205 published.retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
206 convergence
207 .attempts
208 .retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
209 if tx
210 .send(RuntimeFeedUpdate::Snapshot(Box::new(snapshot)))
211 .await
212 .is_err()
213 {
214 return Ok(());
215 }
216 let mut pending = sessions
217 .into_iter()
218 .filter(|runtime| {
219 !published
220 .get(&runtime.session_id)
221 .is_some_and(|last| last.matches(runtime))
222 })
223 .collect::<std::collections::VecDeque<_>>();
224 let mut tasks = tokio::task::JoinSet::new();
225 let mut retry = false;
226 while !pending.is_empty() || !tasks.is_empty() {
227 while tasks.len() < 4 {
230 let Some(runtime) = pending.pop_front() else {
231 break;
232 };
233 let load = load.clone();
234 tasks.spawn(async move {
235 let stored = if runtime.operational.is_some() {
236 load(runtime.session_id.clone()).await
237 } else {
238 Ok(None)
239 };
240 (runtime, stored)
241 });
242 }
243 let Some(result) = tasks.join_next().await else {
244 break;
245 };
246 let (runtime, stored) = result.context("join runtime projection reader")?;
247 let session_id = runtime.session_id.clone();
248 let fingerprint = PublishedView::of(&runtime);
249 let Some(view) = runtime_projection_view(runtime, stored, &mut convergence) else {
250 retry = true;
251 continue;
252 };
253 if view.snapshot.is_some() {
254 published.insert(session_id.clone(), fingerprint);
255 } else {
256 published.remove(&session_id);
257 }
258 if tx
259 .send(RuntimeFeedUpdate::Session {
260 session_id,
261 view: Box::new(view),
262 })
263 .await
264 .is_err()
265 {
266 return Ok(());
267 }
268 }
269 if retry {
270 tokio::time::sleep(PROJECTION_CONVERGENCE_RETRY_DELAY).await;
271 } else {
272 revision = revision.max(snapshot_revision);
273 }
274 }
275}
276
277pub(super) fn runtime_projection_view(
278 runtime: daemon::RuntimeSessionView,
279 stored: Result<StoredProjection>,
280 convergence: &mut ProjectionConvergence,
281) -> Option<ManagedSessionView> {
282 let Some(operational) = runtime.operational else {
283 return Some(ManagedSessionView {
284 snapshot: None,
285 connected: runtime.connected,
286 error: runtime.error,
287 });
288 };
289 let detail = match stored {
290 Ok(Some((materialized, window)))
291 if materialized.applied_event_ordinal > runtime.projection_ordinal
292 || (materialized.applied_event_ordinal == runtime.projection_ordinal
293 && materialized.applied_event_digest == runtime.projection_digest) =>
294 {
295 convergence.converged(&runtime.session_id);
296 return Some(ManagedSessionView {
297 snapshot: Some(ManagedSessionSnapshot {
298 materialized,
299 window,
300 operational,
301 latest_credential_sync_signal: runtime.latest_credential_sync_signal,
302 worker_build: None,
303 subagent_requests: Vec::new(),
304 subagent_results: Vec::new(),
305 }),
306 connected: runtime.connected,
307 error: runtime.error,
308 });
309 }
310 Ok(Some((materialized, _))) => {
311 let mismatch = ProjectionMismatch {
312 published_ordinal: runtime.projection_ordinal,
313 published_digest: runtime.projection_digest,
314 durable_ordinal: materialized.applied_event_ordinal,
315 durable_digest: materialized.applied_event_digest.clone(),
316 };
317 if convergence.should_retry(&runtime.session_id, mismatch) {
318 return None;
319 }
320 if materialized.applied_event_ordinal < runtime.projection_ordinal {
321 format!(
322 "daemon published projection {} but SQLite contains only {} after a bounded convergence retry",
323 runtime.projection_ordinal, materialized.applied_event_ordinal
324 )
325 } else {
326 format!(
327 "daemon and SQLite projection digests differ at ordinal {} after a bounded convergence retry",
328 runtime.projection_ordinal
329 )
330 }
331 }
332 Ok(None) => "daemon published a session with no durable projection".into(),
333 Err(error) => format!("load daemon-owned projection: {error:#}"),
334 };
335 Some(ManagedSessionView {
336 snapshot: None,
337 connected: false,
338 error: Some(ViewError::ProjectionIntegrity(detail)),
339 })
340}