web: new-story form gets 'fire now' too — same pattern as continue form

Cobb tried creating a story via /stories/new tonight, ticked through
the form, hit Create, then expected gen to fire. It didn't, because
the fire-now checkbox only existed on the continue form. Story sat
in 'seed' for 30 min before he asked.

Fix: same path as continue. NewStoryForm picks up an optional 'fire'
field; new_story_create spawns a tokio::spawn task that calls
continue_story::run() with parent_story_id=None semantics:
- Context is the story's own prompt (not parent's bible)
- Audit pass is skipped (no parent to compare against)
- Status flow: seed → generating → cleaning → complete

The form copy explains the audit-skip for first chapters so the
user isn't surprised to see no findings.

Also fired Cobb's pending story manually via 'skald continue
--story bd73dd19...' so it actually generates this round.
This commit is contained in:
Kayos 2026-05-13 13:44:41 -07:00
parent 1e19305432
commit 20e262c85d

View file

@ -128,6 +128,8 @@ pub struct NewStoryForm {
word_count_target: String,
#[serde(default)]
author_slug: String,
#[serde(default)]
fire: String, // "now" = spawn background gen; empty = just queue the seed row
}
async fn new_story_form(State(state): State<Arc<WebState>>) -> Html<String> {
@ -177,6 +179,39 @@ async fn new_story_create(
}
}
// Fire generation immediately if requested. No-parent gen path:
// continue_story::run handles parent_story_id=None by using the
// story's own prompt as the LLM context (instead of the parent
// chain's bible/chapters). Audit pass is skipped — there's no
// parent to compare against. So you get a single first-chapter
// gen + cleanup pass and status flows to 'complete'.
if form.fire == "now" {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
let author_owned = if author_slug.is_empty() {
None
} else {
Some(author_slug.to_string())
};
tokio::spawn(async move {
if let Err(e) = crate::continue_story::run(
&database_url,
id,
author_owned.as_deref(),
None, // direction (no parent — uses story.prompt instead)
target,
2, // recent_n
true, // skip_audit (no parent → can't audit)
)
.await
{
tracing::error!(story_id = %id, error = %e, "background new-story task failed");
} else {
tracing::info!(story_id = %id, "background new-story task succeeded");
}
});
}
Ok(Redirect::to(&format!("/stories/{}", id)))
}
@ -457,7 +492,11 @@ fn new_story_panel(err: Option<&str>, authors: &[AuthorOpt]) -> Markup {
article.form-panel {
h1 { "Begin a new saga" }
(ornament())
p.muted { "Create a story row + pick its author. The gen pipeline fires on " code { "skald continue --story <id>" } " or via the continue form once you've drafted the first chapter." }
p.muted {
"Create a story row, pick its author, and optionally fire the gen pipeline "
"immediately. Without " strong { "fire now" } ", the seed row waits for "
code { "skald continue --story <id>" } "."
}
@if let Some(e) = err { p.error { (e) } }
form method="post" action="/stories/new" {
label { "Title"
@ -474,12 +513,16 @@ fn new_story_panel(err: Option<&str>, authors: &[AuthorOpt]) -> Markup {
}
}
}
label { "Seed prompt (optional)"
textarea name="prompt" rows="4" placeholder="What is this saga about? Setting, frame, conflict." {}
label { "Seed prompt"
textarea name="prompt" rows="5" placeholder="What is this saga about? Setting, frame, conflict, vibe. The richer this is, the better the first chapter." {}
}
label { "Target word count per chapter (optional)"
label { "Target word count for the first chapter"
input type="number" name="word_count_target" min="500" step="500" placeholder="3000";
}
label.checkbox-label {
input type="checkbox" name="fire" value="now";
span { " fire generation now (background task, ~7 min — no audit pass on the first chapter since there's no parent yet)" }
}
button type="submit" { "Create" }
}
}