//! The session loop: next square (fast loop, docs/04), completion write to the //! user's PDS, resonance channel (docs/02). All fragments; every write is //! optimistically mirrored into the AppView (Jetstream will idempotently //! re-deliver). use axum::extract::{Form, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use jacquard::client::AgentSessionExt; use jacquard::types::string::Datetime; use maud::{html, Markup}; use serde::Deserialize; use smol_str::SmolStr; use crate::atproto::records::{ChainRef, Completion, Mode, Resonance}; use crate::bank::ServedSquare; use crate::infer::fast::{self, ChainState}; use crate::state::SharedState; use crate::web::auth; use crate::web::pages::shell; fn internal(e: anyhow::Error) -> Response { tracing::error!("session route error: {e:#}"); (StatusCode::INTERNAL_SERVER_ERROR, format!("something broke: {e}")).into_response() } pub async fn session_page(State(state): State, jar: CookieJar) -> Response { if auth::viewer(&state, &jar).await.is_none() { return Redirect::to("/").into_response(); } shell( "session", true, html! { div #stage hx-get="/session/next" hx-trigger="load" hx-swap="innerHTML" { p { "drawing a square…" } } p class="frame-note" { "Refusing, joking, or answering “does not compute” are all recorded as answers — " "a square that fails to parse under your quilt is as diagnostic as one you complete fluently." } }, ) .into_response() } async fn chain_state(state: &SharedState, cookie: &str) -> anyhow::Result { let row: Option<(Option, Option, i64, Option)> = sqlx::query_as( "SELECT chain_parent_uri, chain_parent_cid, chain_depth, last_term FROM web_sessions WHERE cookie = ?", ) .bind(cookie) .fetch_optional(&state.pool) .await?; let (parent_uri, parent_cid, depth, last_term) = row.unwrap_or((None, None, 0, None)); Ok(ChainState { parent_uri, parent_cid, depth, last_term }) } fn render_square_form(sq: &ServedSquare, chain_depth: i64) -> Markup { let term = |v: &Option, slot: &str| -> Markup { if sq.open_slot == slot { html! { span class="open" { "?" } } } else { html! { span { (v.as_deref().unwrap_or("?")) } } } }; html! { form hx-post="/session/complete" hx-target="#stage" hx-swap="innerHTML" { div class="square" { (term(&sq.a, "a")) " : " (term(&sq.b, "b")) " :: " (term(&sq.c, "c")) " : " (term(&sq.d, "d")) } input type="hidden" name="square_uri" value=(sq.uri); input type="hidden" name="square_cid" value=(sq.cid); input type="hidden" name="slot" value=(sq.open_slot); input type="hidden" name="shown_at" value=(crate::db::now()); input type="hidden" name="chain_depth" value=(chain_depth); div style="text-align:center" { input type="text" name="text" placeholder="complete the fourth term" autocomplete="off" autofocus; } div class="modes" { button type="submit" name="mode" value="completed" { "answer" } button type="submit" name="mode" value="refused" class="alt" { "refuse" } button type="submit" name="mode" value="noparse" class="alt" { "does not compute" } button type="submit" name="mode" value="joke" class="alt" { "(that was a joke)" } } } } } pub async fn next_fragment(State(state): State, jar: CookieJar) -> Response { let Some(viewer) = auth::viewer(&state, &jar).await else { return (StatusCode::UNAUTHORIZED, "log in first").into_response(); }; let chain = match chain_state(&state, &viewer.cookie).await { Ok(c) => c, Err(e) => return internal(e), }; match fast::next_square(&state.pool, &state.publisher, &viewer.did, &chain).await { Ok(sq) => render_square_form(&sq, chain.depth).into_response(), Err(e) => internal(e), } } #[derive(Deserialize)] pub struct CompleteForm { square_uri: String, square_cid: String, slot: String, #[serde(default)] text: String, mode: String, shown_at: i64, #[serde(default)] #[allow(dead_code)] // echoed by the form; chain state is authoritative server-side chain_depth: i64, } fn latency_bucket(shown_at: i64) -> &'static str { match crate::db::now() - shown_at { i64::MIN..=1 => "lt2s", 2..=4 => "lt5s", 5..=14 => "lt15s", 15..=59 => "lt60s", _ => "gte60s", } } pub async fn complete( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let Some(viewer) = auth::viewer(&state, &jar).await else { return (StatusCode::UNAUTHORIZED, "log in first").into_response(); }; match complete_inner(&state, &viewer, form).await { Ok(markup) => markup.into_response(), Err(e) => internal(e), } } async fn complete_inner( state: &SharedState, viewer: &auth::Viewer, form: CompleteForm, ) -> anyhow::Result { let mode = Mode::parse(&form.mode).unwrap_or(Mode::Completed); let text = form.text.trim(); let mode = if mode == Mode::Completed && text.is_empty() { Mode::Refused // empty answer submitted as "answer" reads as refusal } else { mode }; let chain = chain_state(state, &viewer.cookie).await?; let square_ref = ServedSquare { uri: form.square_uri.clone(), cid: form.square_cid.clone(), a: None, b: None, c: None, d: None, open_slot: form.slot.clone(), } .strong_ref()?; let chain_ref = match (&chain.parent_uri, &chain.parent_cid) { (Some(uri), Some(cid)) if chain.depth > 0 => Some(ChainRef { parent: jacquard::api::com_atproto::repo::strong_ref::StrongRef { uri: jacquard::types::string::AtUri::new(SmolStr::new(uri)) .map_err(|e| anyhow::anyhow!("{e}"))?, cid: jacquard::types::string::Cid::new_owned(cid.as_bytes()) .map_err(|e| anyhow::anyhow!("{e}"))?, extra_data: None, }, depth: chain.depth, }), _ => None, }; let record = Completion { square: square_ref, slot: SmolStr::new(&form.slot), text: (mode == Mode::Completed).then(|| SmolStr::new(text)), mode, chain: chain_ref, latency_bucket: Some(SmolStr::new(latency_bucket(form.shown_at))), created_at: Datetime::now(), }; // The thin slice through the riskiest dependency (docs/07 step 1): // write the record to the user's own PDS. let agent = viewer.agent(state).await?; let record_json = serde_json::to_value(&record)?; let out = agent .create_record(record, None) .await .map_err(|e| anyhow::anyhow!("PDS write failed: {e}"))?; let uri = out.uri.to_string(); let cid = out.cid.to_string(); // Optimistic mirror (read-after-write); Jetstream re-delivery is idempotent. crate::ingest::jetstream::index_record( state, &viewer.did, "app.ideonomics.completion", &uri, &cid, &record_json, ) .await?; // Advance the chain: the user's own completion becomes a term (docs/02). if mode == Mode::Completed { sqlx::query( "UPDATE web_sessions SET chain_parent_uri = ?, chain_parent_cid = ?, chain_depth = chain_depth + 1, last_term = ? WHERE cookie = ?", ) .bind(&uri) .bind(&cid) .bind(text) .bind(&viewer.cookie) .execute(&state.pool) .await?; } // Resonance channel: rival completions of the same square (docs/02). let rivals: Vec<(String, String)> = sqlx::query_as( "SELECT uri, text FROM completions WHERE square_uri = ? AND did != ? AND mode = 'completed' AND text IS NOT NULL ORDER BY indexed_at DESC LIMIT 3", ) .bind(&form.square_uri) .bind(&viewer.did) .fetch_all(&state.pool) .await?; let new_chain = chain_state(state, &viewer.cookie).await?; let next = fast::next_square(&state.pool, &state.publisher, &viewer.did, &new_chain).await?; Ok(html! { @if !rivals.is_empty() { div class="rivals" { p { "Others completed that square differently. Which of these lands? Which obscurely irritates?" } @for (rival_uri, rival_text) in &rivals { div class="rival" { span class="text" { "“" (rival_text) "”" } @for (reaction, label) in [("lands","lands"),("funny","funny"),("irritates","irritates"),("flat","flat")] { form hx-post="/session/resonate" hx-swap="outerHTML" style="display:inline" { input type="hidden" name="completion_uri" value=(rival_uri); input type="hidden" name="reaction" value=(reaction); button type="submit" { (label) } } } } } } } (render_square_form(&next, new_chain.depth)) p class="frame-note" { a href={"/sky/" (viewer.did)} { "view your sky" } } }) } #[derive(Deserialize)] pub struct ResonateForm { completion_uri: String, reaction: String, } pub async fn resonate( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let Some(viewer) = auth::viewer(&state, &jar).await else { return (StatusCode::UNAUTHORIZED, "log in first").into_response(); }; match resonate_inner(&state, &viewer, form).await { Ok(m) => m.into_response(), Err(e) => internal(e), } } async fn resonate_inner( state: &SharedState, viewer: &auth::Viewer, form: ResonateForm, ) -> anyhow::Result { let rival: Option<(Option,)> = sqlx::query_as("SELECT cid FROM completions WHERE uri = ?") .bind(&form.completion_uri) .fetch_optional(&state.pool) .await?; let rival_cid = rival.and_then(|(c,)| c).unwrap_or_default(); let record = Resonance { completion: Some(jacquard::api::com_atproto::repo::strong_ref::StrongRef { uri: jacquard::types::string::AtUri::new(SmolStr::new(&form.completion_uri)) .map_err(|e| anyhow::anyhow!("{e}"))?, cid: jacquard::types::string::Cid::new_owned(rival_cid.as_bytes()) .map_err(|e| anyhow::anyhow!("{e}"))?, extra_data: None, }), square: None, archetype_text: None, reaction: SmolStr::new(&form.reaction), created_at: Datetime::now(), }; let agent = viewer.agent(state).await?; let record_json = serde_json::to_value(&record)?; let out = agent .create_record(record, None) .await .map_err(|e| anyhow::anyhow!("PDS write failed: {e}"))?; crate::ingest::jetstream::index_record( state, &viewer.did, "app.ideonomics.resonance", &out.uri.to_string(), &out.cid.to_string(), &record_json, ) .await?; Ok(html! { span class="frame-note" { "☑ " (form.reaction) } }) }