Skip to main content

studio_worker/
auto_register.rs

1//! Auto-register state machine — the only registration path.
2//!
3//! On first launch the worker POSTs `/workers/register-request`
4//! to the studio with a self-generated install id + a registration
5//! secret (only its SHA-256 hash leaves the box), then polls
6//! `/workers/register-requests/<id>` every 30s for the operator's
7//! decision.  On Approved we persist `worker_id` + `auth_token` to
8//! `config.toml` and fall through to the normal heartbeat / claim
9//! loops.  On Rejected we surface the reason; the user clears state
10//! with `studio-worker register --reset` to retry.
11//!
12//! `tick()` does at most one HTTP round-trip per call so the outer
13//! orchestrator can sleep between polls.  All persistence goes
14//! through `config::save` so a crash mid-flight leaves consistent
15//! on-disk state.
16
17use 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
34/// Tracing target for the registration state machine.  Stable so
35/// operators can filter the worker's most-asked-about flow ("why is my
36/// worker stuck unregistered?") with
37/// `RUST_LOG=studio_worker::auto_register=debug`.
38const TRACE_TARGET: &str = "studio_worker::auto_register";
39
40/// What `tick()` returns + what the UI Status tab reads.  Distinct
41/// from the persisted config fields, which carry the raw building
42/// blocks (`install_id`, `registration_request_id`, …).
43#[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    /// First-launch default; no request in flight, no worker_id.
51    Pristine,
52    /// Studio has a Pending row for us; we're polling for the
53    /// operator's decision.
54    Pending {
55        request_id: String,
56        /// First time we saw this request in the Pending state.
57        since: DateTime<Utc>,
58    },
59    /// `worker_id` + `auth_token` are in config; ready for the
60    /// normal heartbeat / claim loops.
61    Approved,
62    /// Operator rejected the request.  Worker stops trying;
63    /// `studio-worker register --reset` clears the state.
64    Rejected { reason: String },
65}
66
67/// Shared in-memory mirror of `RegistrationState` for the UI to read
68/// (the persisted source of truth is `Config`).
69pub type SharedRegistration = Arc<Mutex<RegistrationState>>;
70
71pub fn shared_initial() -> SharedRegistration {
72    Arc::new(Mutex::new(RegistrationState::Pristine))
73}
74
75/// One iteration of the state machine.
76///
77/// Reads the current `Config` snapshot, decides what to do, performs
78/// at most one HTTP call, persists changes via `config::save`,
79/// mirrors the new state into `observers`, and returns it.
80///
81/// Idempotent: re-running with the same on-disk state and a
82/// pending-returning studio is a no-op on disk.
83pub async fn tick(
84    cfg: &SharedConfig,
85    config_path: &Path,
86    observers: &SharedRegistration,
87) -> RegistrationState {
88    // Fast path: already registered.
89    {
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_id + secret are present before doing any HTTP.
98    ensure_install_state(cfg, config_path);
99
100    // Branch on whether we already have a request id.
101    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    // Pre-allocate the secret only if we also have no request id.
136    // Otherwise the existing pair is still valid.
137    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    // Bind the cloned value in its own statement so the `cfg.lock()`
164    // guard releases at the `;`.  Holding it across the `match` would
165    // deadlock the non-reentrant mutex the moment the `None` arm below
166    // re-locks to store a freshly generated secret.
167    let existing_secret = cfg.lock().registration_secret.clone();
168    let secret = match existing_secret {
169        Some(s) => s,
170        None => {
171            // Should never happen post-ensure_install_state, but be safe.
172            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    // Build the capabilities snapshot the operator will see.
180    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    // Persist requestId.
224    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
248/// The instant this `request_id` first entered the Pending state, read
249/// back from the shared observer.  Falls back to `now` for a fresh
250/// request (or a different id), so the UI's "pending since Xs ago"
251/// counts up from the real first sighting instead of resetting every
252/// poll.
253fn 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    // Preserve the instant we first saw *this* request go Pending, so
281    // the UI's "pending since" is the real wait time.  Resetting it to
282    // `now` on every 30s poll (the old behaviour) made it perpetually
283    // read "0s ago".
284    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            // 404: studio doesn't know this request id anymore.  Drop
315            // the stale id + secret so the next tick creates fresh.
316            {
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        // Same request id: the original instant is preserved so the
418        // UI's "pending since" counts up from the real first sighting
419        // instead of resetting to 0 on every 30s poll.
420        assert_eq!(pending_since(&observers, "rr-1"), first);
421        // A different request id: fresh clock, not the stale instant.
422        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(); // Pristine
428        let before = Utc::now();
429        let since = pending_since(&observers, "rr-1");
430        assert!(since >= before, "a fresh pending starts from ~now");
431    }
432}