use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde_json::json;
use crate::agentgraph;
use crate::channel::{ChannelState, Command, Reply, Surface, SurfaceKind};
use crate::cli::{
AttestArgs, ChannelCommand, Cli, OptionalRunArgs, ReplyArgs, RoundCommand, RunArgs, RunsArgs,
StartArgs, StopArgs, SurfaceArgs, TelemetryArgs, TranscriptArgs,
};
use crate::concurrency::{self, Liveness, State};
use crate::edits;
use crate::engine;
use crate::error::{Error, Result, EXIT_NOTHING_DRIVING, EXIT_QUEUED, EXIT_SUCCESS};
use crate::graph::{self, GraphState};
use crate::journal::{self, Journal};
use crate::ledger::{self, LaunchRecord, RunPaths};
use crate::plan::Plan;
use crate::sys;
use crate::telemetry;
use crate::views::{self, RunView};
pub const DAG_GRAPH_ENV: &str = "ONEPIPELINE_DAG_GRAPH";
pub const DEFAULT_DAG_GRAPH: &str = "graphs/dag-scope.yaml";
const ATTACH_POLL: Duration = Duration::from_millis(50);
const DRAIN_GRACE: Duration = Duration::from_secs(2);
pub fn dispatch(cli: Cli) -> Result<i32> {
use crate::cli::Command as Verb;
match cli.command {
Verb::Start(args) => start(&args),
Verb::Adopt(args) => adopt(&args),
Verb::Round(RoundCommand::Run(args)) => {
Ok(engine::round_run(&resolve(&args.run)?)?.exit_code())
}
Verb::Round(RoundCommand::Next(args)) => {
engine::round_next(&resolve(&args.run)?)?;
Ok(EXIT_SUCCESS)
}
Verb::Channel(ChannelCommand::Serve(args)) => serve(&args),
Verb::Next(args) => next(&args),
Verb::Reply(args) => reply(&args),
Verb::Surface(args) => surface(&args),
Verb::Attest(args) => attest(&args),
Verb::Stop(args) => stop(&args),
Verb::Runs(args) => runs(&args),
Verb::Status(args) => report(&args, views::status),
Verb::Host => report(&OptionalRunArgs { run: None }, views::host),
Verb::Monitor(args) => {
print!("{}", views::monitor(&RunView::open(&resolve(&args.run)?)?));
Ok(EXIT_SUCCESS)
}
Verb::Results(args) => {
print!("{}", views::results(&RunView::open(&resolve(&args.run)?)?));
Ok(EXIT_SUCCESS)
}
Verb::Goals(args) => report(&args, views::goals),
Verb::Transcript(args) => transcript(&args),
Verb::Telemetry(args) => report_telemetry(&args),
}
}
fn resolve(run: &str) -> Result<RunPaths> {
if !ledger::is_valid_run_id(run) {
return Err(Error::Invalid(format!(
"'{run}' is not a run id: a run id names one directory under the runs root, \
so it may not be a path"
)));
}
let paths = RunPaths::new(run);
if !paths.exists() {
return Err(Error::NoSuchRun {
run: run.to_string(),
root: ledger::runs_root(),
});
}
Ok(paths)
}
fn dag_graph() -> String {
std::env::var(DAG_GRAPH_ENV)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_DAG_GRAPH.to_string())
}
fn launch_dir() -> Result<PathBuf> {
std::env::current_dir()
.map_err(|error| Error::Invalid(format!("cannot read the launch directory: {error}")))
}
fn resolve_graph(reference: &str, base: &Path) -> Result<String> {
if reference.starts_with("https://") || Path::new(reference).is_absolute() {
return Ok(reference.to_string());
}
let resolved = base.join(reference);
std::fs::File::open(&resolved).map_err(|error| {
Error::Invalid(format!(
"cannot read graph '{}' resolved against launch directory '{}': {error}",
reference,
base.display()
))
})?;
Ok(resolved.to_string_lossy().into_owned())
}
fn resolve_plan_graphs(plan: &mut Plan, base: &Path) -> Result<()> {
for node in &mut plan.tasks {
if let Some(reference) = &mut node.agent_graph {
reference.0 = resolve_graph(&reference.0, base)?;
}
if let Some(steps) = &mut node.steps {
for step in steps {
if let Some(reference) = &mut step.agent_graph {
reference.0 = resolve_graph(&reference.0, base)?;
}
}
}
}
Ok(())
}
fn mint_run_id(plan: &Plan, path: &Path, root: &Path) -> String {
let base = plan
.name
.clone()
.or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.trim_end_matches(".plan").to_string())
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "run".to_string());
let base: String = base
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if !root.join(&base).exists() {
return base;
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|candidate| !root.join(candidate).exists())
.unwrap_or(base)
}
fn start(args: &StartArgs) -> Result<i32> {
let mut plan = Plan::load(&args.plan)?;
graph::validate(&plan)?;
let launch_dir = launch_dir()?;
let graph_ref = resolve_graph(&dag_graph(), &launch_dir)?;
let node_graph_ref = resolve_graph(&engine::configured_node_graph(), &launch_dir)?;
resolve_plan_graphs(&mut plan, &launch_dir)?;
let root = ledger::runs_root();
let run = mint_run_id(&plan, &args.plan, &root);
let holders = concurrency::holders(&plan)?;
for holder in holders
.iter()
.filter(|holder| holder.state == State::Open && holder.liveness == Liveness::Stale)
{
eprintln!(
"onepipeline: stale repository holder: identity '{}' session '{}' owner_pid {}; proceeding",
holder.identity, holder.token, holder.owner_pid
);
}
let live: Vec<_> = holders
.iter()
.filter(|holder| holder.state == State::Open && holder.liveness == Liveness::Live)
.collect();
if !live.is_empty() && !args.acknowledge_concurrent {
let shared = live
.iter()
.map(|holder| {
format!(
"identity '{}' held by session '{}' (owner_pid {})",
holder.identity, holder.token, holder.owner_pid
)
})
.collect::<Vec<_>>()
.join(", ");
return Err(Error::Refused(format!(
"concurrent project work refused for run '{run}': {shared}; pass --acknowledge-concurrent to proceed deliberately"
)));
}
if !live.is_empty() {
let shared = live
.iter()
.map(|holder| {
format!(
"'{}' with session '{}' (owner_pid {})",
holder.identity, holder.token, holder.owner_pid
)
})
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"onepipeline: --acknowledge-concurrent: launch '{run}' is proceeding alongside live run(s): {shared}"
);
}
let paths = RunPaths::under(&root, &run);
paths.create()?;
ledger::write_json(&paths.plan(), &plan)?;
let mut record = LaunchRecord {
run_id: run.clone(),
plan: args.plan.clone(),
graph: graph_ref.clone(),
node_graph: node_graph_ref,
launcher: sys::launcher(),
session: sys::launching_session(),
pid: sys::pid(),
host: sys::hostname(),
started_at: sys::now_rfc3339(),
round_budget: args.round_budget,
heartbeat_interval: args.heartbeat_interval,
dag_sets: args.dag_sets.clone(),
node_sets: args.node_sets.clone(),
adoptions: 0,
};
let mut open = Journal::open(&paths);
if !live.is_empty() {
open.emit(
journal::PipelineKind::ConcurrentAcknowledged,
journal::labels(&run, None, None),
journal::payload(&[
(
"shared_identities",
json!(live
.iter()
.map(|holder| holder.identity.to_string())
.collect::<Vec<_>>()),
),
(
"runs",
json!({
"launching": run,
"holding_sessions": live
.iter()
.map(|holder| holder.token.to_string())
.collect::<Vec<_>>(),
}),
),
(
"holders",
json!(live
.iter()
.map(|holder| json!({
"session": holder.token.to_string(),
"owner_pid": holder.owner_pid,
}))
.collect::<Vec<_>>()),
),
]),
)?;
}
open.emit(
journal::PipelineKind::RunStarted,
journal::labels(&run, None, None),
journal::payload(&[
("plan", json!(plan)),
("graph", json!(graph_ref)),
("round_budget", json!(args.round_budget)),
("heartbeat_interval", json!(args.heartbeat_interval)),
]),
)?;
ledger::write_json(&paths.launch(), &record)?;
if args.detach {
sys::disown_standard_handles();
}
let log = paths.driver_log();
let mut launched = launch_graph(
&paths,
&record,
if args.detach {
agentgraph::GraphOutput::Logged(&log)
} else {
agentgraph::GraphOutput::Relayed
},
)?;
record.pid = launched.pid();
ledger::write_json(&paths.launch(), &record)?;
if args.detach {
println!(
"{}",
json!({
"run_id": run,
"pid": launched.pid(),
"commands": {
"next": format!("onepipeline next {run}"),
"monitor": format!("onepipeline monitor {run}"),
},
})
);
return Ok(EXIT_SUCCESS);
}
attach(&paths, Some(&mut launched))
}
fn launch_graph(
paths: &RunPaths,
record: &LaunchRecord,
output: agentgraph::GraphOutput<'_>,
) -> Result<agentgraph::GraphRun> {
let task = format!(
"Drive run {} to settlement. Use `onepipeline round run {}` and \
`onepipeline round next {}` and nothing else to change run state.",
paths.run, paths.run, paths.run
);
let mut launched = agentgraph::GraphRun::start(
&record.graph,
&task,
None,
&journal::labels(&paths.run, None, None),
&[
(agentgraph::RUN_ID_ENV.to_string(), paths.run.clone()),
(
ledger::RUNS_DIR_ENV.to_string(),
ledger::runs_root().to_string_lossy().into_owned(),
),
],
&record.dag_sets,
output,
)?;
launched.confirm_started()?;
Ok(launched)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Settlement {
Complete,
AwaitingPlanner,
Unattended,
}
impl Settlement {
fn as_str(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::AwaitingPlanner => "awaiting-planner",
Self::Unattended => "unattended",
}
}
fn exit_code(self) -> i32 {
match self {
Self::Unattended => EXIT_NOTHING_DRIVING,
_ => EXIT_SUCCESS,
}
}
}
fn attach(paths: &RunPaths, launched: Option<&mut agentgraph::GraphRun>) -> Result<i32> {
let (tx, rx) = std::sync::mpsc::channel();
let mut driver = launched;
if let Some(run) = driver.as_deref_mut() {
let events = run.events();
std::thread::Builder::new()
.name(format!("attach-{}", paths.run))
.spawn(move || {
for envelope in events.flatten() {
if tx.send(envelope).is_err() {
return;
}
}
})
.map_err(|e| Error::Invalid(format!("cannot start the attach relay: {e}")))?;
}
let mut reported = 0usize;
let mut journal = Journal::open(paths);
loop {
while let Ok(envelope) = rx.try_recv() {
journal.relay(&envelope)?;
}
let driver_gone = driver
.as_deref_mut()
.is_some_and(agentgraph::GraphRun::has_exited);
if driver_gone {
let deadline = std::time::Instant::now() + DRAIN_GRACE;
while std::time::Instant::now() < deadline {
match rx.recv_timeout(ATTACH_POLL) {
Ok(envelope) => journal.relay(&envelope)?,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
let view = RunView::open(paths)?;
let lines: Vec<String> = views::monitor(&view).lines().map(str::to_string).collect();
for line in lines.iter().skip(reported) {
eprintln!("{line}");
}
reported = lines.len();
if let Some(settlement) = settlement_of(&view, driver_gone) {
println!(
"{}",
json!({"run_id": paths.run, "settlement": settlement.as_str()})
);
return Ok(settlement.exit_code());
}
std::thread::sleep(ATTACH_POLL);
}
}
fn settlement_of(view: &RunView, driver_gone: bool) -> Option<Settlement> {
let statuses = view.state.statuses();
if !view.state.round_open
&& !statuses.is_empty()
&& graph::state_of(&statuses) == GraphState::Complete
{
return Some(Settlement::Complete);
}
if let Some(pending) = ChannelState::new(&view.paths).pending() {
if pending.blocking {
return Some(Settlement::AwaitingPlanner);
}
}
if driver_gone || view.liveness().is_undriven() {
return Some(Settlement::Unattended);
}
None
}
fn adopt(args: &RunArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let session = sys::launching_session();
let mut record: LaunchRecord = ledger::read_json(&paths.launch())?;
if !record.owned_by(&session) {
return Err(Error::NotOwned {
run: paths.run.clone(),
owner: record.owner_label(&session),
});
}
let view = RunView::open(&paths)?;
if !view.liveness().is_undriven() {
return Err(Error::Refused(format!(
"run '{}' is still being driven; end it with `onepipeline stop {}` first",
paths.run, paths.run
)));
}
record.adoptions += 1;
record.pid = sys::pid();
record.host = sys::hostname();
let previous = paths
.dir
.join(format!("launch.pre-adopt-{}.json", record.adoptions));
let _ = std::fs::copy(paths.launch(), previous);
ledger::write_json(&paths.launch(), &record)?;
let mut journal = Journal::open(&paths);
journal.emit(
journal::PipelineKind::DriverAdopted,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[
("adoption", json!(record.adoptions)),
("pid", json!(record.pid)),
]),
)?;
let mut launched = launch_graph(&paths, &record, agentgraph::GraphOutput::Relayed)?;
attach(&paths, Some(&mut launched))
}
fn stop(args: &StopArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let session = sys::launching_session();
let record: LaunchRecord = ledger::read_json(&paths.launch())?;
let owner = record.owner_label(&session);
if !record.owned_by(&session) {
if !args.force {
return Err(Error::NotOwned {
run: paths.run.clone(),
owner,
});
}
eprintln!(
"onepipeline: run '{}' belongs to {owner}; stopping it anyway",
paths.run
);
}
let view = RunView::open(&paths)?;
let mut journal = Journal::open(&paths);
journal.emit(
journal::PipelineKind::RunStopped,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[("owner", json!(owner)), ("forced", json!(args.force))]),
)?;
terminate(record.pid, &record.host);
println!(
"{}",
json!({"run_id": paths.run, "stopped": true, "owner": owner})
);
Ok(EXIT_SUCCESS)
}
fn terminate(pid: u32, host: &str) {
if host != sys::hostname() || pid == 0 || pid == sys::pid() {
return;
}
#[cfg(unix)]
if let Ok(raw) = i32::try_from(pid) {
unsafe { libc::kill(raw, libc::SIGTERM) };
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
fn next(args: &RunArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let view = RunView::open(&paths)?;
let channel = ChannelState::new(&paths);
let Some(surface) = channel.claim(view.state.round)? else {
let settled = !view.state.round_open && view.liveness().is_undriven();
println!(
"{}",
if settled {
json!({"status": "finished", "surface": null})
} else {
json!({"status": "running", "surface": null})
}
);
return Ok(EXIT_SUCCESS);
};
let mut journal = Journal::open(&paths);
journal.emit(
journal::PipelineKind::PlannerSurfaced,
journal::labels(
&paths.run,
Some(view.state.round),
surface.workstream.as_deref(),
),
journal::payload(&[
("kind", json!(surface.kind)),
("message", json!(surface.message)),
("source", json!(surface.source)),
("blocking", json!(surface.blocking)),
]),
)?;
if let Err(error) = agentgraph::reset_timer(&paths.run, agentgraph::CHECK_IN_MEMBER) {
eprintln!("onepipeline: could not reset the check-in pacemaker: {error}");
}
println!("{}", json!({"status": "surface", "surface": surface}));
Ok(EXIT_SUCCESS)
}
fn surface(args: &SurfaceArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let view = RunView::open(&paths)?;
let kind = match args.kind {
SurfaceKind::CheckIn => crate::channel::source::CHECK_IN,
};
let queued = ChannelState::new(&paths).push(Surface {
id: 0,
kind: kind.to_string(),
message: args.message.clone(),
source: kind.to_string(),
blocking: false,
round: view.state.round,
queued_at: sys::now_millis(),
workstream: None,
})?;
let mut journal = Journal::open(&paths);
journal.emit(
journal::PipelineKind::PlannerSurfaceQueued,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[
("kind", json!(queued.kind)),
("message", json!(queued.message)),
("source", json!(queued.source)),
("blocking", json!(false)),
]),
)?;
println!("{}", json!({"surface": queued.id, "state": "queued"}));
Ok(EXIT_SUCCESS)
}
fn attest(args: &AttestArgs) -> Result<i32> {
submit(
&resolve(&args.run)?,
&Reply {
version: Some(crate::channel::REPLY_ENVELOPE_VERSION),
commands: vec![Command::Attest {
reference: args.reference.clone(),
}],
..Reply::default()
},
)
}
fn reply(args: &ReplyArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let text = match &args.file {
Some(path) => std::fs::read_to_string(path).map_err(|e| Error::Ledger {
path: path.clone(),
source: e,
})?,
None => {
let mut buffer = String::new();
std::io::stdin()
.read_to_string_compat(&mut buffer)
.map_err(|e| Error::Refused(format!("cannot read the reply from stdin: {e}")))?;
buffer
}
};
let envelope: Reply = serde_json::from_str(text.trim())
.map_err(|e| Error::Refused(format!("the reply is malformed: {e}")))?;
submit(&paths, &envelope)
}
fn submit(paths: &RunPaths, envelope: &Reply) -> Result<i32> {
let view = RunView::open(paths)?;
let channel = ChannelState::new(paths);
if envelope.commands.is_empty() {
if channel.pending().is_none() && view.liveness().is_undriven() {
return Err(Error::Refused(format!(
"run '{}' has settled, so nothing will ever read a reply to it; \
no reply was queued",
paths.run
)));
}
let id = channel.answer(envelope)?;
let mut journal = Journal::open(paths);
journal.emit(
journal::PipelineKind::PlannerReplied,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[
("completion", json!(envelope.completion)),
("reason", json!(envelope.reason)),
]),
)?;
if let Some(reason) = &envelope.reason {
if envelope.completion == Some(true) {
journal.emit(
journal::PipelineKind::CompletionRequested,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[("reason", json!(reason))]),
)?;
}
}
println!("{}", json!({"reply": id, "state": "delivered"}));
return Ok(EXIT_SUCCESS);
}
if envelope.version != Some(crate::channel::REPLY_ENVELOPE_VERSION) {
return Err(Error::Refused(format!(
"an edit envelope requires version {}",
crate::channel::REPLY_ENVELOPE_VERSION
)));
}
let structural = envelope
.commands
.iter()
.any(|command| !matches!(command, Command::Complete { .. } | Command::Attest { .. }));
if structural && !view.state.round_open {
return Err(Error::Refused(format!(
"run '{}' has no round executing, and an edit needs a live round to apply to",
paths.run
)));
}
let mut projected = view.state.graph.clone();
let frontier = view.state.frontier();
for command in &envelope.commands {
edits::compile(&mut projected, &frontier, command)?;
}
if !view.state.round_open {
let lock = ledger::OwnershipLock::acquire(paths, "reply")?;
let mut journal = Journal::open(paths);
for command in &envelope.commands {
match command {
Command::Complete { reason } => journal.emit(
journal::PipelineKind::CompletionRequested,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[("reason", json!(reason))]),
)?,
Command::Attest { reference } => journal.emit(
journal::PipelineKind::HumanAttested,
journal::labels(&paths.run, Some(view.state.round), Some(reference)),
journal::payload(&[("ref", json!(reference))]),
)?,
_ => {}
}
}
lock.release();
channel.answer(envelope)?;
println!("{}", json!({"reply": 0, "state": "applied"}));
return Ok(EXIT_SUCCESS);
}
let id = channel.submit(&envelope.commands)?;
let deadline = Instant::now() + Duration::from_secs(reply_timeout_seconds());
while Instant::now() < deadline {
if let Some(outcome) = channel.outcome_of(id) {
channel.answer(envelope)?;
if outcome.applied {
println!("{}", json!({"reply": id, "state": "applied"}));
return Ok(EXIT_SUCCESS);
}
return Err(Error::Refused(
outcome
.reason
.unwrap_or_else(|| "the reconciler rejected the edit".into()),
));
}
std::thread::sleep(ATTACH_POLL);
}
println!("{}", json!({"reply": id, "state": "queued"}));
Ok(EXIT_QUEUED)
}
fn reply_timeout_seconds() -> u64 {
std::env::var(crate::channel::REPLY_TIMEOUT_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(crate::channel::DEFAULT_REPLY_TIMEOUT_SECONDS)
}
fn serve(args: &RunArgs) -> Result<i32> {
let paths = resolve(&args.run)?;
let channel = ChannelState::new(&paths);
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = line.map_err(|e| Error::Sibling {
tool: "oneagentgraph",
message: format!("the orchestrator's frame stream could not be read: {e}"),
})?;
if line.trim().is_empty() {
continue;
}
let frame: BoundaryFrame = serde_json::from_str(line.trim())
.map_err(|e| Error::Refused(format!("the orchestrator emitted a bad frame: {e}")))?;
let view = RunView::open(&paths)?;
let queued = channel.push(Surface {
id: 0,
kind: frame.kind,
message: frame.message,
source: crate::channel::source::PROPOSAL.to_string(),
blocking: frame.blocking,
round: view.state.round,
queued_at: sys::now_millis(),
workstream: frame.node,
})?;
let mut journal = Journal::open(&paths);
journal.emit(
journal::PipelineKind::PlannerSurfaceQueued,
journal::labels(&paths.run, Some(view.state.round), None),
journal::payload(&[
("kind", json!(queued.kind)),
("message", json!(queued.message)),
("source", json!(queued.source)),
("blocking", json!(queued.blocking)),
]),
)?;
let answer = wait_for_reply(&channel)?;
println!(
"{}",
serde_json::to_string(&answer).map_err(|e| Error::Invalid(format!("verdict: {e}")))?
);
std::io::stdout()
.flush()
.map_err(|e| Error::Refused(format!("cannot write the verdict: {e}")))?;
if answer.completion == Some(true) {
break;
}
}
Ok(EXIT_SUCCESS)
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BoundaryFrame {
kind: String,
message: String,
#[serde(default = "blocking_by_default")]
blocking: bool,
#[serde(default)]
node: Option<String>,
}
fn blocking_by_default() -> bool {
true
}
fn wait_for_reply(channel: &ChannelState) -> Result<Reply> {
let deadline = Instant::now() + Duration::from_secs(reply_timeout_seconds());
while Instant::now() < deadline {
if let Some(claimed) = channel.claim_replies()?.into_iter().next_back() {
return Ok(claimed.reply);
}
std::thread::sleep(ATTACH_POLL);
}
Ok(Reply {
completion: Some(false),
message: Some("no planner reply within the timeout; continue".into()),
reason: Some("the channel timed out waiting for a verdict".into()),
..Reply::default()
})
}
fn runs(args: &RunsArgs) -> Result<i32> {
print!(
"{}",
views::runs(&ledger::runs_root(), args.mine, &sys::launching_session())
);
Ok(EXIT_SUCCESS)
}
fn report(args: &OptionalRunArgs, render: fn(&[RunView]) -> String) -> Result<i32> {
let views = match &args.run {
Some(run) => vec![RunView::open(&resolve(run)?)?],
None => RunView::all(&ledger::runs_root()),
};
print!("{}", render(&views));
Ok(EXIT_SUCCESS)
}
fn transcript(args: &TranscriptArgs) -> Result<i32> {
let view = RunView::open(&resolve(&args.run)?)?;
if let Some(node) = &args.node {
if views::nodes_with_agent_records(&view, Some(node)).is_empty() {
let recorded = views::nodes_with_agent_records(&view, None);
return Err(Error::Refused(format!(
"run '{}' has recorded nothing for node '{node}'; it has records for: {}",
args.run,
if recorded.is_empty() {
"nothing yet".to_string()
} else {
recorded.join(", ")
}
)));
}
}
print!("{}", views::transcript(&view, args.node.as_deref()));
Ok(EXIT_SUCCESS)
}
fn report_telemetry(args: &TelemetryArgs) -> Result<i32> {
let views = match &args.run {
Some(run) => vec![RunView::open(&resolve(run)?)?],
None => RunView::all(&ledger::runs_root()),
};
for view in &views {
let aggregated = telemetry::of_run(&view.paths, &view.events);
if args.breakdown {
print!("{}", telemetry::render_breakdown(&aggregated));
} else {
println!(
"{}",
serde_json::to_string(&aggregated)
.map_err(|e| Error::Invalid(format!("telemetry: {e}")))?
);
}
}
Ok(EXIT_SUCCESS)
}
trait ReadToStringCompat {
fn read_to_string_compat(&self, buffer: &mut String) -> std::io::Result<usize>;
}
impl ReadToStringCompat for std::io::Stdin {
fn read_to_string_compat(&self, buffer: &mut String) -> std::io::Result<usize> {
use std::io::Read;
self.lock().read_to_string(buffer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{Node, PLAN_SCHEMA_VERSION};
use crate::views::DriverLiveness;
use std::path::PathBuf;
fn plan(name: Option<&str>) -> Plan {
Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: None,
name: name.map(str::to_string),
concurrency: 4,
tasks: vec![Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
..Node::default()
}],
}
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("onepipeline-driver-{name}-{}", sys::pid()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch root");
dir
}
#[test]
fn a_run_id_comes_from_the_plans_name_and_is_made_unique() {
let root = scratch("mint");
let path = Path::new("plans/release.plan.json");
assert_eq!(
mint_run_id(&plan(Some("tracked-release")), path, &root),
"tracked-release"
);
std::fs::create_dir_all(root.join("tracked-release")).expect("an existing run");
assert_eq!(
mint_run_id(&plan(Some("tracked-release")), path, &root),
"tracked-release-2"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_nameless_plan_takes_its_run_id_from_the_file() {
let root = scratch("mint-file");
assert_eq!(
mint_run_id(&plan(None), Path::new("plans/release.plan.json"), &root),
"release"
);
assert_eq!(
mint_run_id(&plan(None), Path::new("plans/odd name!.json"), &root),
"odd-name-"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn every_settlement_carries_the_exit_code_the_contract_assigns() {
assert_eq!(Settlement::Complete.exit_code(), EXIT_SUCCESS);
assert_eq!(Settlement::AwaitingPlanner.exit_code(), EXIT_SUCCESS);
assert_eq!(Settlement::Unattended.exit_code(), EXIT_NOTHING_DRIVING);
assert_eq!(Settlement::Complete.as_str(), "complete");
assert_eq!(Settlement::AwaitingPlanner.as_str(), "awaiting-planner");
assert_eq!(Settlement::Unattended.as_str(), "unattended");
}
#[test]
fn the_dag_graph_comes_from_the_environment_or_falls_back_to_the_shipped_one() {
assert!(!dag_graph().is_empty());
assert!(dag_graph().contains("dag-scope") || std::env::var(DAG_GRAPH_ENV).is_ok());
}
#[test]
fn an_undriven_run_is_the_settlement_a_planner_must_intervene_in() {
assert!(DriverLiveness::DriverDead.is_undriven());
assert!(DriverLiveness::Parked.is_undriven());
assert!(!DriverLiveness::Driving.is_undriven());
}
#[test]
fn the_reply_timeout_falls_back_when_the_environment_is_unusable() {
assert!(reply_timeout_seconds() > 0);
}
}