storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects

This commit is contained in:
dave
2026-03-28 13:26:29 +00:00
parent 9feed0f882
commit 0b50c66caa
10 changed files with 1217 additions and 59 deletions
+5 -1
View File
@@ -14,6 +14,7 @@ pub mod settings;
pub mod workflow;
pub mod project;
pub mod wizard;
pub mod ws;
use agents::AgentsApi;
@@ -131,6 +132,7 @@ type ApiTuple = (
SettingsApi,
HealthApi,
BotCommandApi,
wizard::WizardApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx: ctx.clone() },
);
let api_service =
@@ -161,7 +164,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx },
BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx },
);
let docs_service =
+303
View File
@@ -0,0 +1,303 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
use crate::io::wizard::{StepStatus, WizardState, WizardStep};
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Tags)]
enum WizardTags {
Wizard,
}
/// Response for a single wizard step.
#[derive(Serialize, Object)]
struct StepResponse {
step: String,
label: String,
status: String,
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
/// Full wizard state response.
#[derive(Serialize, Object)]
struct WizardResponse {
steps: Vec<StepResponse>,
current_step_index: usize,
completed: bool,
}
/// Request body for confirming/skipping a step or submitting content.
#[derive(Deserialize, Object)]
struct StepActionPayload {
/// Optional content to store for the step (e.g., generated spec).
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
impl From<&WizardState> for WizardResponse {
fn from(state: &WizardState) -> Self {
WizardResponse {
steps: state
.steps
.iter()
.map(|s| StepResponse {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect(),
current_step_index: state.current_step_index(),
completed: state.completed,
}
}
}
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
let quoted = format!("\"{step_str}\"");
serde_json::from_str::<WizardStep>(&quoted)
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
}
pub struct WizardApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "WizardTags::Wizard")]
impl WizardApi {
/// Get the current wizard state.
///
/// Returns the full setup wizard progress including all steps and their
/// statuses. Returns 404 if no wizard is active.
#[oai(path = "/wizard", method = "get")]
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
Ok(Json(WizardResponse::from(&state)))
}
/// Set a step's content and mark it as awaiting confirmation.
///
/// Used after the agent generates content for a step. The content is
/// stored for preview and the step is marked as awaiting user confirmation.
#[oai(path = "/wizard/step/:step/content", method = "put")]
async fn set_step_content(
&self,
step: Path<String>,
payload: Json<StepActionPayload>,
) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(
wizard_step,
StepStatus::AwaitingConfirmation,
payload.0.content,
);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Confirm a step and advance to the next.
///
/// The step must be the current active step. Returns the updated wizard state.
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.confirm_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Skip a step and advance to the next.
///
/// The step must be the current active step.
#[oai(path = "/wizard/step/:step/skip", method = "post")]
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.skip_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Mark a step as generating (agent is working on it).
#[oai(path = "/wizard/step/:step/generating", method = "post")]
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(wizard_step, StepStatus::Generating, None);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use poem::http::StatusCode;
use poem::test::TestClient;
use poem_openapi::OpenApiService;
use tempfile::TempDir;
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".storkit")).unwrap();
let ctx = Arc::new(AppContext::new_test(root.clone()));
let api = WizardApi { ctx };
let service = OpenApiService::new(api, "test", "0.1.0");
let client = TestClient::new(service);
(dir, client)
}
#[tokio::test]
async fn get_wizard_returns_404_when_no_wizard() {
let (_dir, client) = setup();
let resp = client.get("/wizard").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_wizard_returns_state_when_active() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 1);
assert!(!body["completed"].as_bool().unwrap());
assert_eq!(body["steps"].as_array().unwrap().len(), 6);
assert_eq!(body["steps"][0]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/confirm").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 2);
assert_eq!(body["steps"][1]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_wrong_step_returns_error() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Try to confirm step 3 (stack) when current is step 2 (context)
let resp = client.post("/wizard/step/stack/confirm").send().await;
resp.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn skip_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/skip").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "skipped");
assert_eq!(body["current_step_index"], 2);
}
#[tokio::test]
async fn set_step_content_marks_awaiting_confirmation() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.put("/wizard/step/context/content")
.body_json(&serde_json::json!({
"content": "# My Project\n\nA great project."
}))
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
assert_eq!(
body["steps"][1]["content"],
"# My Project\n\nA great project."
);
}
#[tokio::test]
async fn mark_generating_updates_step() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/context/generating")
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "generating");
}
#[tokio::test]
async fn unknown_step_returns_404() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/nonexistent/confirm")
.send()
.await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn full_wizard_flow_completes() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Steps 2-6 (scaffold is already confirmed)
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
for step in steps {
let resp = client
.post(format!("/wizard/step/{step}/confirm"))
.send()
.await;
resp.assert_status_is_ok();
}
// Check final state
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert!(body["completed"].as_bool().unwrap());
}
}
+47
View File
@@ -2,6 +2,7 @@ use crate::http::context::{AppContext, PermissionDecision};
use crate::http::workflow::{PipelineState, load_pipeline_state};
use crate::io::onboarding;
use crate::io::watcher::WatcherEvent;
use crate::io::wizard;
use crate::llm::chat;
use crate::llm::types::Message;
use crate::log_buffer;
@@ -46,6 +47,16 @@ enum WsRequest {
},
}
/// Serialisable summary of a single wizard step for WebSocket broadcast.
#[derive(Serialize, Clone)]
pub struct WizardStepInfo {
pub step: String,
pub label: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
/// WebSocket response messages sent by the server.
@@ -125,6 +136,13 @@ enum WsResponse {
OnboardingStatus {
needs_onboarding: bool,
},
/// Sent on connect when a setup wizard is active. Contains the full
/// wizard state so the frontend can render the step-by-step UI.
WizardState {
steps: Vec<WizardStepInfo>,
current_step_index: usize,
completed: bool,
},
/// Streaming token from a `/btw` side question response.
SideQuestionToken {
content: String,
@@ -219,6 +237,35 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
});
}
// Push wizard state if an active wizard exists.
{
if let Ok(root) = ctx.state.get_project_root()
&& let Some(ws) = wizard::WizardState::load(&root)
{
let steps: Vec<WizardStepInfo> = ws
.steps
.iter()
.map(|s| WizardStepInfo {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect();
let _ = tx.send(WsResponse::WizardState {
steps,
current_step_index: ws.current_step_index(),
completed: ws.completed,
});
}
}
// Push recent server log entries so the client has history on connect.
{
let entries = log_buffer::global().get_recent_entries(100, None, None);