use crate::run;
use crate::tui::session::{Finished, Session};
use crate::workspace::Workspace;
use ostraka_core::record::Outcome;
use ostraka_runtime::progress::Step;
pub struct Turn {
pub prompt: String,
pub steps: Vec<Step>,
pub finished: Option<Finished>,
pub failed: Option<String>,
}
impl Turn {
pub fn approved(&self) -> bool {
self.finished
.as_ref()
.is_some_and(|f| f.outcome == Some(Outcome::Approved))
}
}
pub struct Thread {
pub turns: Vec<Turn>,
pub live: Option<Session>,
pub base_ref: String,
pub history: Vec<String>,
pub adapter: Option<String>,
pub review_adapter: Option<String>,
pub model: Option<String>,
pub author: String,
pub reviewer: String,
}
impl Default for Thread {
fn default() -> Self {
Self {
turns: Vec::new(),
live: None,
base_ref: run::BASE_REF.to_string(),
history: Vec::new(),
adapter: None,
review_adapter: None,
model: None,
author: run::AUTHOR.to_string(),
reviewer: run::REVIEWER.to_string(),
}
}
}
impl Thread {
pub fn running(&self) -> bool {
self.live.as_ref().is_some_and(|s| s.live())
}
pub fn is_empty(&self) -> bool {
self.turns.is_empty() && self.live.is_none()
}
pub fn continuing(&self) -> bool {
self.base_ref != run::BASE_REF
}
pub fn start(&mut self, workspace: Workspace, repository: Option<String>, prompt: String) {
self.history.push(prompt.clone());
let mut args = run::Args::for_task(prompt);
args.repository = repository;
args.base_ref = self.base_ref.clone();
args.adapter = self.adapter.clone();
args.review_adapter = self.review_adapter.clone();
args.model = self.model.clone();
args.author = self.author.clone();
args.reviewer = self.reviewer.clone();
self.live = Some(Session::start(workspace, args));
}
pub fn settle(&mut self) -> Option<String> {
let session = self.live.as_mut()?;
let was_live = session.live();
session.drain();
if was_live && session.live() {
return None;
}
if was_live {
return self.close_out();
}
None
}
fn close_out(&mut self) -> Option<String> {
let session = self.live.take()?;
let turn = Turn {
prompt: session.prompt.clone(),
steps: session.steps,
finished: session.finished,
failed: session.failed,
};
let ended = turn.finished.as_ref().map(|f| f.run_id.clone());
if turn.approved() {
if let Some(id) = &ended {
self.base_ref = format!("ostraka/{id}");
}
}
self.turns.push(turn);
ended
}
pub fn stop(&mut self) {
if let Some(session) = self.live.as_mut() {
session.stop();
}
}
pub fn settle_worker(&mut self) {
if let Some(session) = self.live.as_mut() {
session.stop();
session.settle();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finished(run_id: &str, outcome: Outcome) -> Finished {
Finished {
run_id: run_id.to_string(),
outcome: Some(outcome),
summary: format!("{outcome:?}"),
}
}
fn ended(thread: &mut Thread, prompt: &str, finished: Option<Finished>) {
thread.live = Some(Session::recorded(prompt, Vec::new(), finished));
thread.close_out();
}
#[test]
fn a_thread_starts_at_head_and_moves_onto_the_run_it_approved() {
let mut thread = Thread::default();
assert_eq!(thread.base_ref, "HEAD");
assert!(!thread.continuing());
ended(
&mut thread,
"write a file",
Some(finished("t1-20260908T000100Z", Outcome::Approved)),
);
assert_eq!(thread.base_ref, "ostraka/t1-20260908T000100Z");
assert!(thread.continuing());
assert_eq!(thread.turns.len(), 1);
}
#[test]
fn a_refused_run_does_not_become_the_ground_the_next_one_stands_on() {
let mut thread = Thread::default();
ended(
&mut thread,
"write a file",
Some(finished("t1-20260908T000100Z", Outcome::Approved)),
);
ended(
&mut thread,
"break the build",
Some(finished("t2-20260908T000200Z", Outcome::Rejected)),
);
assert_eq!(
thread.base_ref, "ostraka/t1-20260908T000100Z",
"the chain advanced through a refusal"
);
assert_eq!(thread.turns.len(), 2);
}
#[test]
fn a_run_that_never_started_leaves_the_chain_where_it_was() {
let mut thread = Thread::default();
let mut session = Session::recorded("a task", Vec::new(), None);
session.failed = Some("no adapter profile can run here".into());
thread.live = Some(session);
thread.close_out();
assert_eq!(thread.base_ref, "HEAD");
assert_eq!(thread.turns.len(), 1);
assert!(!thread.turns[0].approved());
}
#[test]
fn what_was_asked_is_kept_whether_or_not_it_worked() {
let mut thread = Thread::default();
thread.history.push("first".into());
thread.history.push("second".into());
assert_eq!(thread.history, ["first", "second"]);
}
}