1use std::path::Path;
18use std::sync::Arc;
19
20use anyhow::Result;
21use chrono::{DateTime, Utc};
22use parking_lot::Mutex;
23
24use crate::{
25 config::{self, SharedConfig},
26 engine,
27 http::ApiClient,
28 runtime::build_capabilities,
29 secrets::{new_secret_hex, new_uuid, sha256_hex},
30 types::{AutoRegisterRequest, RegisterStatus},
31 AGENT_VERSION,
32};
33
34const TRACE_TARGET: &str = "studio_worker::auto_register";
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(
45 tag = "state",
46 rename_all = "snake_case",
47 rename_all_fields = "camelCase"
48)]
49pub enum RegistrationState {
50 Pristine,
52 Pending {
55 request_id: String,
56 since: DateTime<Utc>,
58 },
59 Approved,
62 Rejected { reason: String },
65}
66
67pub type SharedRegistration = Arc<Mutex<RegistrationState>>;
70
71pub fn shared_initial() -> SharedRegistration {
72 Arc::new(Mutex::new(RegistrationState::Pristine))
73}
74
75pub async fn tick(
84 cfg: &SharedConfig,
85 config_path: &Path,
86 observers: &SharedRegistration,
87) -> RegistrationState {
88 {
90 let snap = cfg.lock();
91 if snap.worker_id.is_some() && snap.auth_token.is_some() {
92 *observers.lock() = RegistrationState::Approved;
93 return RegistrationState::Approved;
94 }
95 }
96
97 ensure_install_state(cfg, config_path);
99
100 let (api_base_url, request_id, secret, install_id) = {
102 let snap = cfg.lock();
103 (
104 snap.api_base_url.clone(),
105 snap.registration_request_id.clone(),
106 snap.registration_secret.clone(),
107 snap.install_id.clone(),
108 )
109 };
110
111 match (request_id, secret) {
112 (Some(rid), Some(sec)) => {
113 poll_existing(cfg, config_path, observers, api_base_url, rid, sec).await
114 }
115 _ => {
116 create_request(
117 cfg,
118 config_path,
119 observers,
120 api_base_url,
121 install_id.expect("ensure_install_state seeds install_id"),
122 )
123 .await
124 }
125 }
126}
127
128fn ensure_install_state(cfg: &SharedConfig, config_path: &Path) {
129 let mut snap = cfg.lock();
130 let mut dirty = false;
131 if snap.install_id.is_none() {
132 snap.install_id = Some(new_uuid());
133 dirty = true;
134 }
135 if snap.registration_request_id.is_none() && snap.registration_secret.is_none() {
138 snap.registration_secret = Some(new_secret_hex());
139 dirty = true;
140 }
141 if dirty {
142 let snapshot = snap.clone();
143 drop(snap);
144 if let Err(e) = config::save(&snapshot, config_path) {
145 tracing::warn!(
146 target: TRACE_TARGET,
147 op = "ensure-install",
148 config_path = %config_path.display(),
149 error = %e,
150 "failed to persist install state"
151 );
152 }
153 }
154}
155
156async fn create_request(
157 cfg: &SharedConfig,
158 config_path: &Path,
159 observers: &SharedRegistration,
160 api_base_url: String,
161 install_id: String,
162) -> RegistrationState {
163 let existing_secret = cfg.lock().registration_secret.clone();
168 let secret = match existing_secret {
169 Some(s) => s,
170 None => {
171 let s = new_secret_hex();
173 cfg.lock().registration_secret = Some(s.clone());
174 s
175 }
176 };
177 let secret_hash = sha256_hex(&secret);
178
179 let payload = match build_payload(cfg, install_id.clone(), secret_hash) {
181 Ok(p) => p,
182 Err(e) => {
183 tracing::warn!(
184 target: TRACE_TARGET,
185 op = "register-request",
186 error = %e,
187 "engine build failed during register-request"
188 );
189 return RegistrationState::Pristine;
190 }
191 };
192
193 let api_base_url_for_task = api_base_url.clone();
194 let payload_for_task = payload.clone();
195 let result = tokio::task::spawn_blocking(move || -> Result<_> {
196 let api = ApiClient::new(api_base_url_for_task)?;
197 api.register_request(&payload_for_task)
198 })
199 .await;
200
201 let response = match result {
202 Ok(Ok(r)) => r,
203 Ok(Err(e)) => {
204 tracing::warn!(
205 target: TRACE_TARGET,
206 op = "register-request",
207 error = %e,
208 "register-request HTTP failed; will retry next tick"
209 );
210 return RegistrationState::Pristine;
211 }
212 Err(e) => {
213 tracing::warn!(
214 target: TRACE_TARGET,
215 op = "register-request",
216 error = %e,
217 "register-request task panic; will retry next tick"
218 );
219 return RegistrationState::Pristine;
220 }
221 };
222
223 let now = Utc::now();
225 {
226 let mut snap = cfg.lock();
227 snap.registration_request_id = Some(response.request_id.clone());
228 let snapshot = snap.clone();
229 drop(snap);
230 if let Err(e) = config::save(&snapshot, config_path) {
231 tracing::warn!(
232 target: TRACE_TARGET,
233 op = "register-request",
234 config_path = %config_path.display(),
235 error = %e,
236 "failed to persist request_id"
237 );
238 }
239 }
240 let state = RegistrationState::Pending {
241 request_id: response.request_id,
242 since: now,
243 };
244 *observers.lock() = state.clone();
245 state
246}
247
248fn pending_since(observers: &SharedRegistration, request_id: &str) -> DateTime<Utc> {
254 match &*observers.lock() {
255 RegistrationState::Pending {
256 request_id: prev,
257 since,
258 } if prev == request_id => *since,
259 _ => Utc::now(),
260 }
261}
262
263async fn poll_existing(
264 cfg: &SharedConfig,
265 config_path: &Path,
266 observers: &SharedRegistration,
267 api_base_url: String,
268 request_id: String,
269 secret: String,
270) -> RegistrationState {
271 let api_base_url_for_task = api_base_url.clone();
272 let request_id_for_task = request_id.clone();
273 let secret_for_task = secret.clone();
274 let result = tokio::task::spawn_blocking(move || -> Result<_> {
275 let api = ApiClient::new(api_base_url_for_task)?;
276 api.poll_register_status(&request_id_for_task, &secret_for_task)
277 })
278 .await;
279
280 let since = pending_since(observers, &request_id);
285
286 let outcome = match result {
287 Ok(Ok(o)) => o,
288 Ok(Err(e)) => {
289 tracing::warn!(
290 target: TRACE_TARGET,
291 op = "poll",
292 error = %e,
293 "poll failed; will retry next tick"
294 );
295 let state = RegistrationState::Pending { request_id, since };
296 *observers.lock() = state.clone();
297 return state;
298 }
299 Err(e) => {
300 tracing::warn!(
301 target: TRACE_TARGET,
302 op = "poll",
303 error = %e,
304 "poll task panic; will retry next tick"
305 );
306 let state = RegistrationState::Pending { request_id, since };
307 *observers.lock() = state.clone();
308 return state;
309 }
310 };
311
312 match outcome {
313 None => {
314 {
317 let mut snap = cfg.lock();
318 snap.registration_request_id = None;
319 snap.registration_secret = None;
320 let snapshot = snap.clone();
321 drop(snap);
322 if let Err(e) = config::save(&snapshot, config_path) {
323 tracing::warn!(
324 target: TRACE_TARGET,
325 op = "poll",
326 config_path = %config_path.display(),
327 error = %e,
328 "failed to persist cleared request state after stale 404; the stale request id stays on disk until the next successful save"
329 );
330 }
331 }
332 *observers.lock() = RegistrationState::Pristine;
333 RegistrationState::Pristine
334 }
335 Some(RegisterStatus::Pending) => {
336 let state = RegistrationState::Pending { request_id, since };
337 *observers.lock() = state.clone();
338 state
339 }
340 Some(RegisterStatus::Approved {
341 worker_id,
342 auth_token,
343 }) => {
344 {
345 let mut snap = cfg.lock();
346 snap.worker_id = Some(worker_id);
347 snap.auth_token = Some(auth_token);
348 snap.registration_request_id = None;
349 snap.registration_secret = None;
350 let snapshot = snap.clone();
351 drop(snap);
352 if let Err(e) = config::save(&snapshot, config_path) {
353 tracing::error!(
354 target: TRACE_TARGET,
355 op = "poll",
356 config_path = %config_path.display(),
357 error = %e,
358 "failed to persist approved credentials; this session is registered in memory but the worker will re-register from scratch on the next restart"
359 );
360 }
361 }
362 *observers.lock() = RegistrationState::Approved;
363 RegistrationState::Approved
364 }
365 Some(RegisterStatus::Rejected { reason }) => {
366 {
367 let mut snap = cfg.lock();
368 snap.registration_request_id = None;
369 snap.registration_secret = None;
370 let snapshot = snap.clone();
371 drop(snap);
372 if let Err(e) = config::save(&snapshot, config_path) {
373 tracing::warn!(
374 target: TRACE_TARGET,
375 op = "poll",
376 config_path = %config_path.display(),
377 error = %e,
378 "failed to persist cleared request state after rejection; the stale request id stays on disk until the next successful save"
379 );
380 }
381 }
382 let state = RegistrationState::Rejected { reason };
383 *observers.lock() = state.clone();
384 state
385 }
386 }
387}
388
389fn build_payload(
390 cfg: &SharedConfig,
391 install_id: String,
392 registration_secret_hash: String,
393) -> Result<AutoRegisterRequest> {
394 let snap = cfg.lock().clone();
395 let engine_handle = engine::build(&snap)?;
396 let capabilities = build_capabilities(&snap, &*engine_handle);
397 Ok(AutoRegisterRequest {
398 install_id,
399 registration_secret_hash,
400 capabilities,
401 user_agent: format!("studio-worker/{AGENT_VERSION}"),
402 })
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn pending_since_preserves_the_first_sighting_for_the_same_request() {
411 let observers = shared_initial();
412 let first = Utc::now() - chrono::Duration::seconds(90);
413 *observers.lock() = RegistrationState::Pending {
414 request_id: "rr-1".into(),
415 since: first,
416 };
417 assert_eq!(pending_since(&observers, "rr-1"), first);
421 assert_ne!(pending_since(&observers, "rr-2"), first);
423 }
424
425 #[test]
426 fn pending_since_starts_fresh_from_non_pending_states() {
427 let observers = shared_initial(); let before = Utc::now();
429 let since = pending_since(&observers, "rr-1");
430 assert!(since >= before, "a fresh pending starts from ~now");
431 }
432}