diff --git a/attractor-web/src/api.ts b/attractor-web/src/api.ts new file mode 100644 index 000000000..e36cf0d2e --- /dev/null +++ b/attractor-web/src/api.ts @@ -0,0 +1,139 @@ +// Pipeline API types and fetch wrappers + +export type PipelineStatus = "running" | "completed" | "failed" | "cancelled"; + +export interface PipelineStatusResponse { + id: string; + status: PipelineStatus; + error?: string; +} + +export interface StartPipelineResponse { + id: string; +} + +export interface ApiQuestionOption { + key: string; + label: string; +} + +export interface ApiQuestion { + id: string; + text: string; + question_type: string; + options: ApiQuestionOption[]; + allow_freeform: boolean; +} + +export interface SubmitAnswerResponse { + accepted: boolean; +} + +export interface Checkpoint { + timestamp: string; + current_node: string; + completed_nodes: string[]; + node_retries: Record; + context_values: Record; + logs: string[]; + node_outcomes: Record; + next_node_id?: string; +} + +export interface Outcome { + status: string; + preferred_label?: string; + suggested_next_ids: string[]; + context_updates: Record; + notes?: string; + failure_reason?: string; +} + +export type PipelineEvent = + | { PipelineStarted: { name: string; id: string } } + | { PipelineCompleted: { duration_ms: number; artifact_count: number } } + | { PipelineFailed: { error: string; duration_ms: number } } + | { StageStarted: { name: string; index: number } } + | { StageCompleted: { name: string; index: number; duration_ms: number } } + | { StageFailed: { name: string; index: number; error: string; will_retry: boolean } } + | { StageRetrying: { name: string; index: number; attempt: number; delay_ms: number } } + | { ParallelStarted: { branch_count: number } } + | { ParallelBranchStarted: { branch: string; index: number } } + | { ParallelBranchCompleted: { branch: string; index: number; duration_ms: number; success: boolean } } + | { ParallelCompleted: { duration_ms: number; success_count: number; failure_count: number } } + | { InterviewStarted: { question: string; stage: string } } + | { InterviewCompleted: { question: string; answer: string; duration_ms: number } } + | { InterviewTimeout: { question: string; stage: string; duration_ms: number } } + | { CheckpointSaved: { node_id: string } }; + +export type ContextSnapshot = Record; + +const API_BASE = "/api"; + +export async function startPipeline(dotSource: string): Promise { + const res = await fetch(`${API_BASE}/pipelines`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ dot_source: dotSource }), + }); + if (!res.ok) throw new Error(`Start failed: ${res.status}`); + return res.json() as Promise; +} + +export async function getPipelineStatus(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}`); + if (!res.ok) throw new Error(`Status failed: ${res.status}`); + return res.json() as Promise; +} + +export async function getQuestions(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}/questions`); + if (!res.ok) throw new Error(`Questions failed: ${res.status}`); + return res.json() as Promise; +} + +export async function submitAnswer( + pipelineId: string, + questionId: string, + value: string, + selectedOptionKey?: string, +): Promise { + const body: Record = { value }; + if (selectedOptionKey !== undefined) { + body.selected_option_key = selectedOptionKey; + } + const res = await fetch(`${API_BASE}/pipelines/${pipelineId}/questions/${questionId}/answer`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Answer failed: ${res.status}`); + return res.json() as Promise; +} + +export async function getCheckpoint(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}/checkpoint`); + if (!res.ok) throw new Error(`Checkpoint failed: ${res.status}`); + return res.json() as Promise; +} + +export async function getContext(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}/context`); + if (!res.ok) throw new Error(`Context failed: ${res.status}`); + return res.json() as Promise; +} + +export async function cancelPipeline(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}/cancel`, { method: "POST" }); + if (!res.ok) throw new Error(`Cancel failed: ${res.status}`); +} + +export async function getGraph(id: string): Promise { + const res = await fetch(`${API_BASE}/pipelines/${id}/graph`); + if (!res.ok) throw new Error(`Graph failed: ${res.status}`); + return res.text(); +} + +export function eventsUrl(id: string): string { + return `${API_BASE}/pipelines/${id}/events`; +} diff --git a/crates/attractor/src/server.rs b/crates/attractor/src/server.rs index ca80e9c8a..22b35d715 100644 --- a/crates/attractor/src/server.rs +++ b/crates/attractor/src/server.rs @@ -19,7 +19,7 @@ use crate::engine::{PipelineEngine, RunConfig}; use crate::event::{EventEmitter, PipelineEvent}; use crate::handler::HandlerRegistry; use crate::interviewer::web::WebInterviewer; -use crate::interviewer::{Answer, AnswerValue, Interviewer}; +use crate::interviewer::{Answer, Interviewer, QuestionOption}; /// Status of a managed pipeline. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -31,12 +31,21 @@ pub enum PipelineStatus { Cancelled, } +/// An option for a multiple-choice question exposed via the API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiQuestionOption { + pub key: String, + pub label: String, +} + /// A pending question exposed via the API. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiQuestion { pub id: String, pub text: String, pub question_type: String, + pub options: Vec, + pub allow_freeform: bool, } /// Snapshot of a managed pipeline. @@ -83,6 +92,7 @@ pub struct PipelineStatusResponse { #[derive(Debug, Deserialize)] pub struct SubmitAnswerRequest { pub value: String, + pub selected_option_key: Option, } /// Response for answer submission. @@ -253,8 +263,18 @@ async fn get_questions( .into_iter() .map(|pq| ApiQuestion { id: pq.id, - text: pq.question.text, + text: pq.question.text.clone(), question_type: format!("{:?}", pq.question.question_type), + options: pq + .question + .options + .iter() + .map(|o| ApiQuestionOption { + key: o.key.clone(), + label: o.label.clone(), + }) + .collect(), + allow_freeform: pq.question.allow_freeform, }) .collect(); (StatusCode::OK, Json(questions)).into_response() @@ -271,10 +291,33 @@ async fn submit_answer( let pipelines = state.pipelines.lock().expect("pipelines lock poisoned"); match pipelines.get(&id) { Some(pipeline) => { - let answer = Answer { - value: AnswerValue::Text(req.value.clone()), - selected_option: None, - text: Some(req.value), + let answer = match &req.selected_option_key { + Some(key) => { + let option = pipeline + .interviewer + .pending_questions() + .iter() + .find(|pq| pq.id == qid) + .and_then(|pq| pq.question.options.iter().find(|o| o.key == *key)) + .cloned(); + match option { + Some(opt) => Answer::selected( + key.clone(), + QuestionOption { + key: opt.key, + label: opt.label, + }, + ), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid option key"})), + ) + .into_response(); + } + } + } + None => Answer::text(req.value), }; let accepted = pipeline.interviewer.submit_answer(&qid, answer); (StatusCode::OK, Json(SubmitAnswerResponse { accepted })).into_response()