use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context};
use clap::Args;
use orchestral_agent_journal_fs::FileAgentJournalStore;
use orchestral_core::agent_protocol::spi::InMemoryAgentJournalStore;
use orchestral_core::agent_protocol::wire::{AgentSessionId, ProviderBindingRef};
use orchestral_core::agent_session::InMemoryAgentSessionJournalStore;
use orchestral_core::config::load_config;
use orchestral_core::session_history::{SessionHistory, SessionHistoryQuery};
use orchestral_runtime::session_history::JournalSessionHistory;
use crate::agent::AgentRunOptions;
use crate::runtime::client::resolve_runtime_config_path;
mod storage;
pub(crate) use storage::writer_root;
pub use storage::LocalSessionHistory;
pub(crate) const GENERIC_BINDING: &str = "orchestral/generic-agent";
#[derive(Debug, Args)]
pub(crate) struct ResumeCommand {
#[arg(required_unless_present = "last")]
session: Option<String>,
#[arg(long)]
last: bool,
#[arg(value_name = "INPUT", num_args = 1..)]
input: Vec<String>,
}
pub(crate) fn workspace(cwd: Option<&Path>) -> anyhow::Result<String> {
let path = cwd
.map(Path::to_path_buf)
.map_or_else(std::env::current_dir, Ok)?;
let path = std::fs::canonicalize(path).context("resolve session workspace")?;
if !path.is_dir() {
bail!("session workspace must be a directory");
}
path.to_str()
.map(str::to_owned)
.context("session workspace is not UTF-8")
}
pub(crate) fn open_history(config_path: Option<PathBuf>) -> anyhow::Result<LocalSessionHistory> {
let config = load_config(&resolve_runtime_config_path(config_path)?)?;
match config.journal.backend.as_str() {
"fs" | "filesystem" => Ok(LocalSessionHistory::Directory(
config.journal.root_dir.into(),
)),
"memory" => Ok(LocalSessionHistory::Single(JournalSessionHistory::new(
Arc::new(InMemoryAgentJournalStore::default()),
Arc::new(InMemoryAgentSessionJournalStore::default()),
ProviderBindingRef::new(GENERIC_BINDING),
))),
backend => bail!("unsupported session journal backend: {backend}"),
}
}
pub(crate) fn validate_workspace(history: &SessionHistory, workspace: &str) -> anyhow::Result<()> {
for run in &history.runs {
if let Some(origin) = orchestral_core::session_history::SessionOrigin::from_extensions(
&run.registration.run().spec.extensions,
)? {
if origin.workspace != workspace {
bail!(
"session {} belongs to workspace '{}'; reopen with -C '{}'",
history.summary.session_id,
origin.workspace,
origin.workspace
);
}
}
}
Ok(())
}
impl ResumeCommand {
pub(crate) async fn run(self, mut options: AgentRunOptions) -> anyhow::Result<()> {
if options.session_id.is_some() {
bail!("use either resume or --session-id");
}
let catalog = open_history(options.config.clone())?;
let cwd = workspace(options.cwd.as_deref())?;
let mut input = self.input;
let id = if self.last {
if let Some(first) = self.session {
input.insert(0, first);
}
catalog.list(&SessionHistoryQuery { workspace: Some(cwd.clone()), search: None }).await?
.into_iter().next().context("no built-in Agent sessions in this workspace; use sessions list --all to browse other or legacy sessions")?.session_id
} else {
AgentSessionId::new(
self.session
.context("resume requires a session ID or --last")?,
)
};
let history = catalog
.read(&id)
.await?
.with_context(|| format!("built-in Agent session not found: {id}"))?;
validate_workspace(&history, &cwd)?;
options.session_id = Some(id.as_str().to_owned());
options.input = (!input.is_empty()).then(|| input.join(" "));
crate::agent::run(options).await
}
}