Skip to main content

zeroclaw_gateway/
api_quickstart.rs

1//! HTTP routes for the Quickstart flow.
2//!
3//! Thin wrapper over `zeroclaw_runtime::quickstart::{validate_only, apply}`.
4//! Routes:
5//!
6//! - `GET  /api/quickstart/state`     — current Quickstart state (completed flag + live-config slices for each step's "Use existing" section).
7//! - `POST /api/quickstart/validate`  — run `validate_only` against the submitted `BuilderSubmission`; returns `{ ok: true }` or `{ ok: false, errors: [...] }`.
8//! - `POST /api/quickstart/apply`     — atomically apply the submission, then signal an in-place daemon reload through the existing `reload_tx` watch channel (same mechanism `/admin/reload` uses); returns the `AppliedAgent` summary or a structured error list.
9//!
10//! All business logic lives in `zeroclaw-runtime`; this module is route
11//! plumbing only.
12
13use axum::{
14    Json,
15    extract::State,
16    http::{HeaderMap, StatusCode},
17    response::IntoResponse,
18};
19use serde::{Deserialize, Serialize};
20use zeroclaw_config::presets::BuilderSubmission;
21use zeroclaw_runtime::quickstart::{
22    AppliedAgent, QuickstartError, QuickstartStep, Surface, apply_with_surface, record_dismissed,
23    validate_only_with_surface,
24};
25
26use super::AppState;
27use super::api::require_auth;
28
29#[derive(Debug, Serialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum ValidateResult {
32    Ok,
33    Errors { errors: Vec<QuickstartError> },
34}
35
36#[derive(Debug, Serialize)]
37#[serde(tag = "kind", rename_all = "snake_case")]
38pub enum ApplyResult {
39    Applied {
40        agent: AppliedAgent,
41        /// `true` when the in-place daemon reload was signalled (the
42        /// supervisor will drain and re-init subsystems). `false` means
43        /// apply succeeded but no daemon supervisor is attached (e.g.
44        /// `zeroclaw gateway start` standalone) — the caller must
45        /// restart the process to pick up the change.
46        daemon_restarted: bool,
47    },
48    Errors {
49        errors: Vec<QuickstartError>,
50    },
51}
52
53/// `GET /api/quickstart/state` — minimal payload the Quickstart UI
54/// needs to render every step's "Use existing" section without
55/// pulling the entire config. Response shape is owned by
56/// `zeroclaw_runtime::quickstart::QuickstartState`; both transports
57/// build the body via [`zeroclaw_runtime::quickstart::snapshot_state`] so they cannot drift.
58pub async fn handle_state(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
59    if let Err(e) = require_auth(&state, &headers) {
60        return e.into_response();
61    }
62    let cfg = state.config.read().clone();
63    let body = zeroclaw_runtime::quickstart::snapshot_state(&cfg);
64    (StatusCode::OK, Json(body)).into_response()
65}
66
67#[derive(Debug, Deserialize)]
68pub struct FieldsRequest {
69    pub section: zeroclaw_runtime::quickstart::FieldSection,
70    pub type_key: String,
71}
72
73#[derive(Debug, Serialize)]
74pub struct FieldsResult {
75    pub fields: Vec<zeroclaw_runtime::quickstart::FieldDescriptor>,
76}
77
78pub async fn handle_fields(
79    State(state): State<AppState>,
80    headers: HeaderMap,
81    Json(req): Json<FieldsRequest>,
82) -> impl IntoResponse {
83    if let Err(e) = require_auth(&state, &headers) {
84        return e.into_response();
85    }
86    let body = FieldsResult {
87        fields: zeroclaw_runtime::quickstart::field_shape(req.section, &req.type_key),
88    };
89    (StatusCode::OK, Json(body)).into_response()
90}
91
92pub async fn handle_validate(
93    State(state): State<AppState>,
94    headers: HeaderMap,
95    Json(submission): Json<BuilderSubmission>,
96) -> impl IntoResponse {
97    if let Err(e) = require_auth(&state, &headers) {
98        return e.into_response();
99    }
100    let cfg = state.config.read().clone();
101    let body = match validate_only_with_surface(&submission, &cfg, Surface::Web) {
102        Ok(()) => ValidateResult::Ok,
103        Err(errors) => ValidateResult::Errors { errors },
104    };
105    (StatusCode::OK, Json(body)).into_response()
106}
107
108#[derive(Debug, Deserialize)]
109pub struct DismissRequest {
110    pub run_id: String,
111    /// Surface name as emitted in earlier events for this run. Echoed
112    /// into the dismiss event so the SSE stream can correlate the
113    /// dismissal back to the same `(run_id, surface)` pair. Deserialised
114    /// straight into the typed enum (snake_case wire form) — no
115    /// string-literal `match` at the route boundary.
116    pub surface: Surface,
117    /// Furthest step the user reached. `None` = didn't progress past
118    /// the first selector.
119    #[serde(default)]
120    pub last_step: Option<QuickstartStep>,
121}
122
123pub async fn handle_dismiss(
124    State(state): State<AppState>,
125    headers: HeaderMap,
126    Json(req): Json<DismissRequest>,
127) -> impl IntoResponse {
128    if let Err(e) = require_auth(&state, &headers) {
129        return e.into_response();
130    }
131    record_dismissed(&req.run_id, req.surface, req.last_step);
132    (StatusCode::NO_CONTENT, ()).into_response()
133}
134
135pub async fn handle_apply(
136    State(state): State<AppState>,
137    headers: HeaderMap,
138    Json(submission): Json<BuilderSubmission>,
139) -> impl IntoResponse {
140    if let Err(e) = require_auth(&state, &headers) {
141        return e.into_response();
142    }
143    let mut working = state.config.read().clone();
144    let result = apply_with_surface(submission, &mut working, Surface::Web).await;
145    let body = match result {
146        Ok(agent) => {
147            *state.config.write() = working;
148            state
149                .pending_reload
150                .store(true, std::sync::atomic::Ordering::Relaxed);
151            let reload_signalled = signal_daemon_reload(&state);
152            ApplyResult::Applied {
153                agent,
154                daemon_restarted: reload_signalled,
155            }
156        }
157        Err(errors) => ApplyResult::Errors { errors },
158    };
159    (StatusCode::OK, Json(body)).into_response()
160}
161
162/// Signal the in-place daemon reload using the same `reload_tx` watch
163/// channel `/admin/reload` uses. The daemon supervisor reacts by
164/// draining the current gateway/channels/scheduler and bringing them
165/// back up against the new in-memory config — no process kill, no
166/// PID respawn, no service-manager dependency.
167fn signal_daemon_reload(state: &AppState) -> bool {
168    let Some(reload_tx) = state.reload_tx.clone() else {
169        ::zeroclaw_log::record!(
170            WARN,
171            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
172                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
173                .with_attrs(::serde_json::json!({
174                    "reason": "no_supervisor",
175                })),
176            "quickstart: daemon reload not available (standalone gateway)"
177        );
178        return false;
179    };
180    ::zeroclaw_log::record!(
181        INFO,
182        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Start),
183        "quickstart: daemon reload signalled"
184    );
185    let shutdown_tx = state.shutdown_tx.clone();
186    state
187        .pending_reload
188        .store(false, std::sync::atomic::Ordering::Relaxed);
189    let started = std::time::Instant::now();
190    zeroclaw_spawn::spawn!(async move {
191        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
192        let _ = shutdown_tx.send(true);
193        let _ = reload_tx.send(true);
194        ::zeroclaw_log::record!(
195            INFO,
196            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
197                .with_outcome(::zeroclaw_log::EventOutcome::Success)
198                .with_attrs(::serde_json::json!({
199                    "elapsed_ms": started.elapsed().as_millis() as u64,
200                })),
201            "quickstart: daemon reload dispatched"
202        );
203    });
204    true
205}
206
207// Per-family alias collection lives in
208// `zeroclaw_runtime::quickstart::snapshot_state` so both transports
209// share one implementation.