use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::edits::Operation;
use crate::event::Source;
use crate::graph::{Landing, NodeStatus};
use crate::ledger::{LaunchRecord, RunPaths};
use crate::plan::Node;
use crate::projection::RunState;
use crate::taskgraph::{QualifiedId, BINARY_ENV};
const SHADOW_SOURCE: &str = "onepipeline-writeback";
const LANDING_KEY: &str = "onepipeline.landing";
const LANDING_COMMIT_KEY: &str = "onepipeline.landing_commit";
const CHANGE_URL_KEY: &str = "onepipeline.change_url";
const COMMAND_LIMIT: Duration = Duration::from_secs(60);
const FIRST_RETRY_AFTER: Duration = Duration::from_millis(250);
const RETRY_GROWTH: u32 = 4;
const RETRY_CEILING: Duration = Duration::from_secs(60);
const CLOSEOUT_WAIT: Duration = Duration::from_millis(2_250);
#[derive(Clone, PartialEq)]
struct Snapshot {
project: QualifiedId,
dir: PathBuf,
nodes: BTreeMap<String, Node>,
statuses: BTreeMap<String, NodeStatus>,
outcomes: BTreeMap<String, String>,
landings: BTreeMap<String, Landing>,
landing_commits: BTreeMap<String, String>,
change_urls: BTreeMap<String, String>,
settlements: BTreeMap<String, Value>,
project_metadata: BTreeMap<String, Value>,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Unprojected {
pub project: QualifiedId,
pub items: Vec<String>,
pub reason: String,
}
#[derive(Default, PartialEq, Eq)]
enum WorkerState {
#[default]
Idle,
Working,
}
#[derive(Default, PartialEq, Eq)]
enum RunPhase {
#[default]
Running,
ClosingOut,
Stopping,
}
#[derive(Default)]
struct Pending {
latest: Option<Snapshot>,
last_success: Option<Snapshot>,
worker: WorkerState,
phase: RunPhase,
unprojected: Vec<Unprojected>,
}
impl Pending {
fn queue(&mut self, snapshot: Snapshot) -> bool {
if self.latest.as_ref() == Some(&snapshot) {
return false;
}
if self.worker == WorkerState::Idle
&& self.latest.is_none()
&& self.last_success.as_ref() == Some(&snapshot)
{
return false;
}
self.latest = Some(snapshot);
true
}
}
pub struct Writeback {
pending: Arc<(Mutex<Pending>, Condvar)>,
}
impl Writeback {
pub fn start(binary: PathBuf, paths: &RunPaths, launch: &LaunchRecord) -> Option<Self> {
let pending = Arc::new((Mutex::new(Pending::default()), Condvar::new()));
let worker_pending = Arc::clone(&pending);
let run_dir = paths.dir.clone();
let launch_dir = if launch.dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
launch.dir.clone()
};
std::thread::Builder::new()
.name(format!("writeback-{}", paths.run))
.spawn(move || worker(binary, launch_dir, run_dir, worker_pending))
.ok()?;
let writer = Self { pending };
Some(writer)
}
pub fn publish(
&self,
paths: &RunPaths,
launch: &LaunchRecord,
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
) {
let Ok(project) = launch.project.parse() else {
return;
};
let snapshot = Snapshot {
project,
dir: paths.dir.join("writeback"),
nodes: all_nodes(paths, state),
statuses: statuses.clone(),
outcomes: state.outcomes.clone(),
landings: state.landings.clone(),
landing_commits: state.landing_commits.clone(),
change_urls: state.change_urls.clone(),
settlements: settlements(paths),
project_metadata: state
.plan
.as_ref()
.map(|plan| {
let mut metadata = BTreeMap::from([
(
"onepipeline.schema_version".into(),
json!(plan.schema_version),
),
("onepipeline.concurrency".into(), json!(plan.concurrency)),
]);
if let Some(goal) = &plan.goal {
metadata.insert("onepipeline.goal".into(), json!(goal));
}
if let Some(name) = &plan.name {
metadata.insert("onepipeline.name".into(), json!(name));
}
metadata
})
.unwrap_or_default(),
};
crate::loopstats::published();
let (lock, ready) = &*self.pending;
if let Ok(mut pending) = lock.lock() {
if pending.queue(snapshot) {
ready.notify_one();
}
}
}
pub fn has_unprojected(&self) -> bool {
let (lock, _) = &*self.pending;
lock.lock()
.is_ok_and(|pending| !pending.unprojected.is_empty())
}
pub fn take_unprojected(&self) -> Vec<Unprojected> {
let (lock, _) = &*self.pending;
lock.lock()
.map(|mut pending| std::mem::take(&mut pending.unprojected))
.unwrap_or_default()
}
pub fn wait_briefly(&self) {
let deadline = Instant::now() + CLOSEOUT_WAIT;
let (lock, ready) = &*self.pending;
let Ok(mut pending) = lock.lock() else { return };
pending.phase = RunPhase::ClosingOut;
ready.notify_all();
while (pending.latest.is_some() || pending.worker == WorkerState::Working)
&& Instant::now() < deadline
{
let wait = deadline.saturating_duration_since(Instant::now());
let Ok((next, _)) = ready.wait_timeout(pending, wait) else {
return;
};
pending = next;
}
}
}
impl Drop for Writeback {
fn drop(&mut self) {
let (lock, ready) = &*self.pending;
if let Ok(mut pending) = lock.lock() {
pending.phase = RunPhase::Stopping;
ready.notify_all();
}
}
}
fn worker(
binary: PathBuf,
launch_dir: PathBuf,
run_dir: PathBuf,
pending: Arc<(Mutex<Pending>, Condvar)>,
) {
let mut failures: u32 = 0;
loop {
let snapshot = {
let (lock, ready) = &*pending;
let mut state = match lock.lock() {
Ok(state) => state,
Err(_) => return,
};
while state.latest.is_none() && state.phase != RunPhase::Stopping {
state = match ready.wait(state) {
Ok(state) => state,
Err(_) => return,
};
}
if state.phase == RunPhase::Stopping && state.latest.is_none() {
return;
}
state.worker = WorkerState::Working;
state
.latest
.take()
.expect("the worker was woken by a snapshot")
};
match project(&binary, &launch_dir, &run_dir, &snapshot) {
Ok(()) => {
if failures > 0 {
eprintln!(
"onetaskgraph write-back recovered for '{}'",
snapshot.project
);
}
failures = 0;
let (lock, _) = &*pending;
if let Ok(mut state) = lock.lock() {
state.last_success = Some(snapshot.clone());
if state.phase == RunPhase::Stopping && state.latest.is_none() {
return;
}
}
}
Err(error) => {
let first = failures == 0;
failures = failures.saturating_add(1);
if first {
eprintln!(
"onetaskgraph write-back failed for '{}': {error}; retrying, spacing \
further attempts out to {} seconds apart while it keeps failing",
snapshot.project,
RETRY_CEILING.as_secs()
);
}
if !should_retry_after(&pending, retry_after(failures)) {
return;
}
let (lock, ready) = &*pending;
let Ok(mut state) = lock.lock() else { return };
if first {
state.unprojected.push(Unprojected {
project: snapshot.project.clone(),
items: snapshot.nodes.keys().cloned().collect(),
reason: error,
});
}
if state.phase == RunPhase::Stopping {
return;
}
if state.latest.is_none() {
state.latest = Some(snapshot);
ready.notify_one();
}
}
}
let (lock, ready) = &*pending;
if let Ok(mut state) = lock.lock() {
state.worker = WorkerState::Idle;
ready.notify_all();
}
}
}
fn retry_after(failures: u32) -> Duration {
FIRST_RETRY_AFTER
.saturating_mul(RETRY_GROWTH.saturating_pow(failures.saturating_sub(1)))
.min(RETRY_CEILING)
}
fn should_retry_after(pending: &(Mutex<Pending>, Condvar), interval: Duration) -> bool {
let (lock, ready) = pending;
let Ok(mut state) = lock.lock() else {
return false;
};
let due = Instant::now() + interval;
loop {
match state.phase {
RunPhase::Stopping => return false,
RunPhase::ClosingOut => return true,
RunPhase::Running => {}
}
let left = due.saturating_duration_since(Instant::now());
if left.is_zero() {
return true;
}
let Ok((next, _)) = ready.wait_timeout(state, left) else {
return false;
};
state = next;
}
}
fn project(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
snapshot: &Snapshot,
) -> Result<(), String> {
let destination_project = destination_project(binary, launch_dir, run_dir, snapshot)?;
let origins = destination_origins(binary, launch_dir, run_dir, snapshot)?;
write_shadow(snapshot, &origins, &destination_project)?;
let root = snapshot.dir.to_string_lossy().into_owned();
let shadow_project = format!("{SHADOW_SOURCE}:{}", project_file(&snapshot.project));
let args = [
"project",
"copy",
&shadow_project,
"--to",
snapshot.project.source(),
"--json",
"--set",
&format!("sources.{SHADOW_SOURCE}.plugin=local-md"),
"--set",
&format!("sources.{SHADOW_SOURCE}.config.root={root}"),
];
let output = bounded_output(binary, launch_dir, run_dir, "project-copy", &args)?;
if output.status.success() {
Ok(())
} else {
Err(format!(
"copy exited {}: {}",
exit(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn destination_project(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
snapshot: &Snapshot,
) -> Result<DestinationProjectItem, String> {
let args = ["project", "show", snapshot.project.as_str(), "--json"];
let output = bounded_output(binary, launch_dir, run_dir, "project-show", &args)?;
if !output.status.success() {
return Err(format!(
"project show exited {}: {}",
exit(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let response: ProjectPage = answered(&output.stdout)?;
if !response.errors.is_empty() {
return Err("project show returned partial results".to_owned());
}
let mut items = response.items.into_iter();
let project = items
.next()
.ok_or_else(|| format!("project '{}' was not found", snapshot.project))?;
if items.next().is_some() || project.id != snapshot.project {
return Err(format!(
"project show returned the wrong project for '{}'",
snapshot.project
));
}
Ok(project.item)
}
struct Origin {
id: QualifiedId,
labels: Vec<DestinationLabel>,
}
fn destination_origins(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
snapshot: &Snapshot,
) -> Result<BTreeMap<String, Origin>, String> {
let mut origins = BTreeMap::new();
let mut page: Option<String> = None;
let mut cursors = BTreeSet::new();
loop {
let mut args = vec![
"task".to_owned(),
"list".to_owned(),
"--project".to_owned(),
snapshot.project.as_str().to_owned(),
"--limit".to_owned(),
"10000".to_owned(),
"--json".to_owned(),
];
if let Some(token) = &page {
args.extend(["--page".to_owned(), token.clone()]);
}
let output = bounded_output(binary, launch_dir, run_dir, "task-list", &args)?;
if !output.status.success() {
return Err(format!(
"task list exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
let response: TaskPage = answered(&output.stdout)?;
if !response.errors.is_empty() {
return Err("task list returned partial results".to_owned());
}
for task in response.items {
let node = task
.item
.metadata
.get("onepipeline.id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| format!("task '{}' has no onepipeline.id", task.id.as_str()))?;
let node = node.to_owned();
if origins
.insert(
node.clone(),
Origin {
id: task.id,
labels: task.item.labels,
},
)
.is_some()
{
return Err(format!("project has more than one task for node '{node}'"));
}
}
let Some(next) = response.next else { break };
if next.is_empty() {
return Err("task list returned an empty next-page cursor".to_owned());
}
if !cursors.insert(next.clone()) {
return Err("task list repeated a next-page cursor".to_owned());
}
page = Some(next);
}
Ok(origins)
}
struct Output {
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn bounded_output<S: AsRef<std::ffi::OsStr>>(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
name: &str,
args: &[S],
) -> Result<Output, String> {
let stdout = run_dir.join(format!("writeback-{name}.stdout"));
let stderr = run_dir.join(format!("writeback-{name}.stderr"));
let stdout_file = std::fs::File::create(&stdout).map_err(|error| error.to_string())?;
let stderr_file = std::fs::File::create(&stderr).map_err(|error| error.to_string())?;
let mut child = Command::new(binary)
.current_dir(launch_dir)
.args(args)
.env_remove(BINARY_ENV)
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|error| format!("cannot run {}: {error}", binary.display()))?;
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < COMMAND_LIMIT => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"{name} exceeded {} seconds",
COMMAND_LIMIT.as_secs()
));
}
Err(error) => return Err(format!("cannot wait for {name}: {error}")),
}
};
Ok(Output {
status,
stdout: std::fs::read(stdout).map_err(|error| error.to_string())?,
stderr: std::fs::read(stderr).map_err(|error| error.to_string())?,
})
}
fn answered<T: serde::de::DeserializeOwned>(stdout: &[u8]) -> Result<T, String> {
let mut reading = serde_json::Deserializer::from_slice(stdout);
let response: T =
serde_path_to_error::deserialize(&mut reading).map_err(|error| {
match error.path().to_string() {
path if path == "." => error.into_inner().to_string(),
path => format!("{path}: {}", error.into_inner()),
}
})?;
reading.end().map_err(|error| error.to_string())?;
Ok(response)
}
fn exit(status: &ExitStatus) -> String {
status
.code()
.map_or_else(|| "on a signal".into(), |code| code.to_string())
}
#[derive(Deserialize)]
struct TaskPage {
items: Vec<DestinationTask>,
next: Option<String>,
errors: Vec<Value>,
}
#[derive(Deserialize)]
struct ProjectPage {
items: Vec<DestinationProject>,
errors: Vec<Value>,
}
#[derive(Deserialize)]
struct DestinationProject {
id: QualifiedId,
item: DestinationProjectItem,
}
#[derive(Deserialize)]
struct DestinationProjectItem {
title: String,
content: Option<String>,
labels: Vec<DestinationLabel>,
metadata: BTreeMap<String, Value>,
#[serde(rename = "location", default)]
_location: Option<Value>,
}
#[derive(Deserialize)]
struct DestinationTask {
id: QualifiedId,
item: DestinationTaskItem,
}
#[derive(Deserialize)]
struct DestinationTaskItem {
labels: Vec<DestinationLabel>,
metadata: BTreeMap<String, Value>,
#[serde(rename = "location", default)]
_location: Option<Value>,
}
#[derive(Clone, Deserialize, Serialize)]
struct DestinationLabel {
id: String,
name: String,
color: Option<String>,
}
fn write_shadow(
snapshot: &Snapshot,
origins: &BTreeMap<String, Origin>,
destination_project: &DestinationProjectItem,
) -> Result<(), String> {
let projects = snapshot.dir.join("projects");
let tasks = snapshot
.dir
.join("tasks")
.join(project_file(&snapshot.project));
std::fs::create_dir_all(&projects).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&tasks).map_err(|e| e.to_string())?;
let mut project_metadata = destination_project.metadata.clone();
for (key, value) in &snapshot.project_metadata {
if project_metadata.contains_key(key) {
project_metadata.insert(key.clone(), value.clone());
}
}
project_metadata.insert(
"onetaskgraph.origin".into(),
json!(snapshot.project.as_str()),
);
document(
&projects.join(format!("{}.md", project_file(&snapshot.project))),
&json!({
"title": destination_project.title,
"labels": destination_project.labels,
"metadata": project_metadata
}),
destination_project.content.as_deref().unwrap_or_default(),
)?;
for (id, node) in &snapshot.nodes {
let mut wire = serde_json::to_value(node)
.map_err(|e| e.to_string())?
.as_object()
.cloned()
.ok_or_else(|| "node did not serialize as a mapping".to_owned())?;
let title = wire
.remove("title")
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default();
let content = wire
.remove("task")
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default();
let deps = wire
.remove("deps")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
let repo = wire
.remove("repo")
.and_then(|v| v.as_str().map(str::to_owned));
wire.remove("id");
let mut metadata = Map::new();
metadata.insert("onepipeline.id".into(), json!(id));
let origin = origins.get(id);
if let Some(origin) = origin {
metadata.insert("onetaskgraph.origin".into(), json!(origin.id.as_str()));
}
for (key, value) in wire {
metadata.insert(format!("onepipeline.{key}"), value);
}
if let Some(settlement) = snapshot.settlements.get(id) {
metadata.insert(crate::taskgraph::SETTLEMENT_KEY.into(), settlement.clone());
}
if let Some(landing) = snapshot.landings.get(id) {
metadata.insert(LANDING_KEY.into(), json!(landing.as_str()));
}
if let Some(commit) = snapshot.landing_commits.get(id) {
metadata.insert(LANDING_COMMIT_KEY.into(), json!(commit));
}
if let Some(url) = snapshot.change_urls.get(id) {
metadata.insert(CHANGE_URL_KEY.into(), json!(url));
}
let local_deps: Vec<String> = deps
.iter()
.filter_map(Value::as_str)
.filter(|dep| !crate::graph::is_cross_dag(dep))
.map(|dep| format!("{}/{}", project_file(&snapshot.project), task_file(dep)))
.collect();
let cross: Vec<String> = deps
.iter()
.filter_map(Value::as_str)
.filter(|dep| crate::graph::is_cross_dag(dep))
.map(str::to_owned)
.collect();
if !cross.is_empty() {
metadata.insert("onepipeline.deps".into(), json!(cross));
}
let status = snapshot
.statuses
.get(id)
.copied()
.unwrap_or(NodeStatus::Cancelled);
let mut front = Map::new();
front.insert("title".into(), json!(title));
front.insert("project".into(), json!(project_file(&snapshot.project)));
front.insert(
"status".into(),
json!(projected(
status,
snapshot.outcomes.get(id).map(String::as_str)
)),
);
front.insert(
"labels".into(),
json!(origin.map(|origin| origin.labels.as_slice()).unwrap_or(&[])),
);
front.insert("depends_on".into(), json!(local_deps));
front.insert("metadata".into(), Value::Object(metadata));
if let Some(repo) = repo {
if repo.starts_with("github.com/") {
front.insert("repositories".into(), json!([repo]));
} else if let Some(Value::Object(metadata)) = front.get_mut("metadata") {
metadata.insert("onepipeline.repo".into(), json!(repo));
}
}
document(
&tasks.join(format!("{}.md", task_file(id))),
&Value::Object(front),
&content,
)?;
}
Ok(())
}
fn document(path: &Path, front: &Value, body: &str) -> Result<(), String> {
let yaml = serde_norway::to_string(front).map_err(|e| e.to_string())?;
std::fs::write(path, format!("---\n{yaml}---\n{body}")).map_err(|e| e.to_string())
}
#[derive(Clone, Copy, PartialEq, Eq, Serialize)]
enum ProjectedStatus {
#[serde(rename = "in progress")]
InProgress,
#[serde(rename = "done")]
Done,
#[serde(rename = "failed")]
Failed,
#[serde(rename = "provider-failed")]
ProviderFailed,
#[serde(rename = "cancelled")]
Cancelled,
#[serde(rename = "parked")]
Parked,
#[serde(rename = "skipped")]
Skipped,
#[serde(rename = "todo")]
Todo,
}
fn projected(status: NodeStatus, outcome: Option<&str>) -> ProjectedStatus {
match status {
NodeStatus::Running | NodeStatus::CompleteDraft => ProjectedStatus::InProgress,
NodeStatus::Done => ProjectedStatus::Done,
NodeStatus::Failed if outcome == Some(crate::engine::PROVIDER_FAILED) => {
ProjectedStatus::ProviderFailed
}
NodeStatus::Failed => ProjectedStatus::Failed,
NodeStatus::Cancelled => ProjectedStatus::Cancelled,
NodeStatus::Parked => ProjectedStatus::Parked,
NodeStatus::Skipped => ProjectedStatus::Skipped,
NodeStatus::Pending | NodeStatus::Ready | NodeStatus::Waiting | NodeStatus::Blocked => {
ProjectedStatus::Todo
}
}
}
fn all_nodes(paths: &RunPaths, state: &RunState) -> BTreeMap<String, Node> {
let mut nodes: BTreeMap<String, Node> = state
.plan
.as_ref()
.into_iter()
.flat_map(|plan| plan.tasks.iter())
.map(|node| (node.id.clone(), node.clone()))
.collect();
for event in crate::journal::read(&paths.journal()) {
if event.source != Source::Pipeline
|| event.kind.0 != crate::journal::PipelineKind::EditCommitted.as_str()
{
continue;
}
let Some(operations) = event
.payload
.get("operations")
.and_then(|v| serde_json::from_value::<Vec<Operation>>(v.clone()).ok())
else {
continue;
};
for operation in operations {
if let Operation::NodeAdded { node, .. } = operation {
nodes.insert(node.id.clone(), *node);
}
}
}
for node in state.graph.iter() {
nodes.insert(node.id.clone(), node.clone());
}
nodes
}
fn settlements(paths: &RunPaths) -> BTreeMap<String, Value> {
let mut found = BTreeMap::new();
for event in crate::journal::read(&paths.journal()) {
if event.source == Source::Pipeline
&& event.kind.0 == crate::journal::PipelineKind::NodeSettled.as_str()
{
if let Some(node) = event.labels.node.as_ref() {
found.insert(node.clone(), Value::Object(event.payload));
}
}
}
found
}
fn project_file(project: &QualifiedId) -> String {
encoded(project.as_str())
}
fn task_file(id: &str) -> String {
encoded(id)
}
fn encoded(value: &str) -> String {
value
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::{
projected, write_shadow, DestinationLabel, DestinationProjectItem, Landing, Origin,
Pending, ProjectedStatus, Snapshot, WorkerState, Writeback, CHANGE_URL_KEY,
LANDING_COMMIT_KEY, LANDING_KEY,
};
use crate::graph::NodeStatus;
use crate::ledger::{LaunchRecord, RunPaths};
use crate::plan::Node;
use crate::projection::RunState;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn snapshot(status: NodeStatus) -> Snapshot {
Fixture::new("queue").snapshot_with(|snapshot| {
snapshot.project = "plans:deduplication".parse().expect("a qualified project");
snapshot.dir = PathBuf::from("writeback");
snapshot.statuses = BTreeMap::from([("node".to_owned(), status)]);
})
}
#[test]
fn returning_to_the_last_success_supersedes_a_different_pending_snapshot() {
let first = snapshot(NodeStatus::Pending);
let superseded = snapshot(NodeStatus::Running);
let mut pending = Pending {
latest: Some(superseded),
last_success: Some(first.clone()),
worker: WorkerState::Working,
phase: super::RunPhase::Running,
unprojected: Vec::new(),
};
assert!(pending.queue(first.clone()));
assert!(pending.latest.as_ref() == Some(&first));
}
#[test]
fn a_stop_reaches_a_worker_that_is_waiting_out_a_retry_interval() {
let dir = scratch("stop-mid-wait");
let paths = RunPaths {
run: "stopmidwait".to_owned(),
dir: dir.clone(),
};
let launch = a_launch(&paths);
let writeback =
Writeback::start(dir.join("onetaskgraph-nobody-installed"), &paths, &launch)
.expect("a write-back worker");
writeback.publish(&paths, &launch, &RunState::default(), &BTreeMap::new());
let waited = intervals_between_attempts(&paths, 4);
let outstanding = *waited.last().expect("an interval between attempts");
assert!(
outstanding >= Duration::from_secs(2),
"the streak is not deep enough for a stop to have anything to wait out: {waited:?}"
);
let shared = Arc::clone(&writeback.pending);
let asked = Instant::now();
drop(writeback);
while Arc::strong_count(&shared) > 1 {
assert!(
asked.elapsed() < outstanding,
"the worker was still running {:?} after its stop was requested, with an \
interval of at least {outstanding:?} outstanding",
asked.elapsed()
);
std::thread::sleep(Duration::from_millis(5));
}
assert!(
!attempt_capture(&paths).exists(),
"the worker asked the destination again on its way out"
);
}
fn intervals_between_attempts(paths: &RunPaths, count: usize) -> Vec<Duration> {
let capture = attempt_capture(paths);
let deadline = Instant::now() + Duration::from_secs(60);
let mut at: Vec<Instant> = Vec::new();
while at.len() < count {
assert!(
Instant::now() < deadline,
"the worker made {} attempts, not the {count} this test reads its intervals \
off",
at.len()
);
if capture.exists() {
at.push(Instant::now());
std::fs::remove_file(&capture).expect("the capture file is taken away");
} else {
std::thread::sleep(Duration::from_millis(5));
}
}
at.windows(2).map(|pair| pair[1] - pair[0]).collect()
}
fn attempt_capture(paths: &RunPaths) -> PathBuf {
paths.dir.join("writeback-project-show.stderr")
}
fn a_launch(paths: &RunPaths) -> LaunchRecord {
serde_json::from_value(json!({
"run_id": paths.run,
"project": "plans:stop-mid-wait",
"dir": paths.dir,
"launcher": "test",
"session": "test",
"pid": crate::sys::pid(),
"host": "test",
"started": "linux-proc-stat:1",
"started_at": "2026-09-01T00:00:00Z",
"heartbeat_interval": 1_800,
}))
.expect("a launch record")
}
fn every_projected_word() -> Vec<String> {
[
ProjectedStatus::Todo,
ProjectedStatus::InProgress,
ProjectedStatus::Done,
ProjectedStatus::Failed,
ProjectedStatus::ProviderFailed,
ProjectedStatus::Cancelled,
ProjectedStatus::Parked,
ProjectedStatus::Skipped,
]
.into_iter()
.inspect(|status| match status {
ProjectedStatus::Todo
| ProjectedStatus::InProgress
| ProjectedStatus::Done
| ProjectedStatus::Failed
| ProjectedStatus::ProviderFailed
| ProjectedStatus::Cancelled
| ProjectedStatus::Parked
| ProjectedStatus::Skipped => {}
})
.map(word)
.collect()
}
#[test]
fn every_word_and_key_this_projection_writes_is_named_by_the_divergence() {
let divergence = include_str!("../docs/contract-divergences.md");
let words = every_projected_word();
for word in &words {
assert!(
divergence.contains(&format!("`{word}`")),
"docs/contract-divergences.md does not name the projected status `{word}`"
);
}
let keys = [LANDING_KEY, LANDING_COMMIT_KEY, CHANGE_URL_KEY];
for key in keys {
assert!(
divergence.contains(&format!("`{key}`")),
"docs/contract-divergences.md does not name the reserved key `{key}`"
);
}
for stated in [
format!("{} words where the contract names 4", words.len()),
format!("the {} reserved keys beside the settlement", keys.len()),
] {
assert!(
divergence.contains(&stated),
"docs/contract-divergences.md does not say \"{stated}\""
);
}
}
#[test]
fn the_projected_categories_remain_named_by_the_approved_contract() {
let contract = include_str!("../docs/contract.md");
for status in [
ProjectedStatus::Todo,
ProjectedStatus::InProgress,
ProjectedStatus::Done,
ProjectedStatus::Cancelled,
] {
let native = word(status).replace(' ', "-");
assert!(
contract.contains(&format!("`{native}`")),
"docs/contract.md no longer names the projected status category `{native}`"
);
}
}
#[test]
fn every_settlement_is_projected_under_a_word_of_its_own() {
let settlements = [
("done", NodeStatus::Done, None),
("a task the agent failed", NodeStatus::Failed, None),
(
"a dispatch its provider killed",
NodeStatus::Failed,
Some(crate::engine::PROVIDER_FAILED),
),
("cancelled", NodeStatus::Cancelled, None),
("parked", NodeStatus::Parked, None),
];
let mut seen: BTreeMap<String, &str> = BTreeMap::new();
for (settlement, status, outcome) in settlements {
let projected = word(projected(status, outcome));
if let Some(shared) = seen.insert(projected.clone(), settlement) {
panic!(
"'{settlement}' and '{shared}' are both projected as `{projected}`, so a \
board cannot tell them apart"
);
}
}
assert_eq!(
seen.keys().cloned().collect::<Vec<_>>(),
["cancelled", "done", "failed", "parked", "provider-failed"],
);
}
fn word(status: ProjectedStatus) -> String {
match serde_json::to_value(status).expect("a projected status serializes") {
Value::String(word) => word,
other => panic!("a projected status is a string, not {other}"),
}
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"onepipeline-writeback-{name}-{}",
crate::sys::pid()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch directory");
dir
}
fn owned(key: &str) -> bool {
key.starts_with("onepipeline.") || key.starts_with("onetaskgraph.")
}
fn preserved(front: &Value, body: &str) -> Value {
let metadata: Map<String, Value> = front["metadata"]
.as_object()
.expect("a projected document carries metadata")
.iter()
.filter(|(key, _)| !owned(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
json!({
"title": front["title"],
"labels": front["labels"],
"metadata": metadata,
"body": body,
})
}
fn preserved_of(destination: &DestinationProjectItem) -> Value {
let metadata: Map<String, Value> = destination
.metadata
.iter()
.filter(|(key, _)| !owned(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
json!({
"title": destination.title,
"labels": destination.labels,
"metadata": metadata,
"body": destination.content.clone().unwrap_or_default(),
})
}
fn written(path: &Path) -> (Value, String) {
let document = std::fs::read_to_string(path)
.unwrap_or_else(|error| panic!("{} was not written: {error}", path.display()));
let (front, body) = document
.strip_prefix("---\n")
.expect("a projected document opens its front matter")
.split_once("---\n")
.expect("a projected document closes its front matter");
(
serde_norway::from_str(front).expect("the front matter is YAML"),
body.to_owned(),
)
}
struct Fixture {
name: &'static str,
dir: PathBuf,
snapshot: Snapshot,
origins: BTreeMap<String, Origin>,
destination: DestinationProjectItem,
}
impl Fixture {
fn new(name: &'static str) -> Self {
let dir = scratch(name);
let node = |id: &str, deps: &[&str]| -> Node {
serde_json::from_value(json!({
"id": id,
"title": format!("feat: {id} it"),
"task": format!("## What\n{id} it."),
"persona": "engineer",
"deps": deps,
}))
.expect("a plan node")
};
Self {
name,
snapshot: Snapshot {
project: "plans:board".parse().expect("a qualified project"),
dir: dir.clone(),
nodes: BTreeMap::from([
("build".to_owned(), node("build", &["design"])),
("design".to_owned(), node("design", &[])),
]),
statuses: BTreeMap::from([
("build".to_owned(), NodeStatus::Done),
("design".to_owned(), NodeStatus::Done),
]),
outcomes: BTreeMap::new(),
landings: BTreeMap::new(),
landing_commits: BTreeMap::new(),
change_urls: BTreeMap::new(),
settlements: BTreeMap::new(),
project_metadata: BTreeMap::from([(
"onepipeline.concurrency".into(),
json!(4),
)]),
},
origins: BTreeMap::from([(
"build".to_owned(),
Origin {
id: "plans:board/002-build".parse().expect("a qualified task"),
labels: labels(&[("needs-review", Some("d73a4a"))]),
},
)]),
destination: destination("A person's own board", &["planning", "q3"]),
dir,
}
}
fn snapshot_with(mut self, edit: impl FnOnce(&mut Snapshot)) -> Snapshot {
edit(&mut self.snapshot);
self.snapshot.clone()
}
fn project(&self) {
if let Some(missing) =
undiscriminating(self.name, &self.destination, &self.snapshot, &self.origins)
{
panic!("{missing}");
}
write_shadow(&self.snapshot, &self.origins, &self.destination)
.expect("the shadow project is written");
}
fn project_document(&self) -> (Value, String) {
written(&self.dir.join("projects").join(format!(
"{}.md",
super::project_file(&self.snapshot.project)
)))
}
fn task_document(&self, node: &str) -> (Value, String) {
written(
&self
.dir
.join("tasks")
.join(super::project_file(&self.snapshot.project))
.join(format!("{}.md", super::task_file(node))),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn undiscriminating(
fixture: &str,
destination: &DestinationProjectItem,
snapshot: &Snapshot,
origins: &BTreeMap<String, Origin>,
) -> Option<String> {
let missing = if snapshot.project.native() == destination.title {
"its destination project's identifier is its own title, so writing the \
identifier and preserving the title are the same bytes"
} else if snapshot.nodes.len() < 2 {
"it holds one node, so a projection that wrote the wrong one is \
indistinguishable from one that wrote the right one"
} else if !snapshot.nodes.keys().any(|id| origins.contains_key(id))
|| !snapshot.nodes.keys().any(|id| !origins.contains_key(id))
{
"every node has a destination task or none does, so a projection that \
ignored what the destination already holds cannot be told from one that \
read it"
} else {
return None;
};
Some(format!("fixture '{fixture}': {missing}"))
}
#[test]
fn a_fixture_that_could_not_tell_a_right_answer_from_a_wrong_one_is_refused() {
let sound = Fixture::new("discrimination");
assert_eq!(
undiscriminating(
sound.name,
&sound.destination,
&sound.snapshot,
&sound.origins
),
None,
"the fixture every test here is built from does not meet its own rule"
);
let named = |missing: Option<String>, property: &str| {
let said = missing.unwrap_or_else(|| {
panic!("a fixture whose {property} could prove nothing was accepted")
});
assert!(
said.contains("fixture 'degenerate'"),
"the refusal does not name the fixture: {said}"
);
assert!(
said.contains(property),
"the refusal does not name the missing property: {said}"
);
};
named(
undiscriminating(
"degenerate",
&destination("board", &[]),
&sound.snapshot,
&sound.origins,
),
"identifier is its own title",
);
let one = Fixture::new("one-node").snapshot_with(|snapshot| {
snapshot.nodes.retain(|id, _| id == "build");
});
named(
undiscriminating("degenerate", &sound.destination, &one, &sound.origins),
"holds one node",
);
named(
undiscriminating(
"degenerate",
&sound.destination,
&sound.snapshot,
&BTreeMap::new(),
),
"every node has a destination task or none does",
);
}
fn destination(title: &str, labels: &[&str]) -> DestinationProjectItem {
serde_json::from_value(json!({
"id": "board",
"title": title,
"content": "A person's own description.",
"status": {"category": "backlog", "name": "backlog"},
"labels": labels
.iter()
.map(|name| json!({"id": name, "name": name, "color": null}))
.collect::<Vec<_>>(),
"url": null,
"created_at": null,
"updated_at": null,
"metadata": {"authored.note": "keep this value", "authored.owner": "a person"},
"repositories": [],
}))
.expect("the sibling's own project response")
}
fn labels(named: &[(&str, Option<&str>)]) -> Vec<DestinationLabel> {
serde_json::from_value(json!(named
.iter()
.map(|(name, color)| json!({"id": name, "name": name, "color": color}))
.collect::<Vec<_>>()))
.expect("the sibling's own labels")
}
#[test]
fn a_projection_replaces_every_field_the_plan_declares() {
let mut fixture = Fixture::new("declared");
fixture
.snapshot
.statuses
.insert("build".to_owned(), NodeStatus::Running);
fixture.project();
let (front, body) = fixture.task_document("build");
assert_eq!(front["title"], "feat: build it");
assert_eq!(body, "## What\nbuild it.");
assert_eq!(front["status"], "in progress");
assert_eq!(
front["depends_on"],
json!([format!(
"{}/{}",
super::project_file(&fixture.snapshot.project),
super::task_file("design")
)]),
"the projection lost the plan's dependency edge, or named its far end \
the way no store resolves to a task of this project"
);
assert_eq!(front["metadata"]["onepipeline.id"], "build");
assert_eq!(front["metadata"]["onepipeline.persona"], "engineer");
let (design, _) = fixture.task_document("design");
assert_eq!(design["title"], "feat: design it");
assert_eq!(design["metadata"]["onepipeline.id"], "design");
}
#[test]
fn a_projection_carries_through_everything_the_plan_does_not_declare() {
let fixture = Fixture::new("preserved");
fixture.project();
let (front, body) = fixture.project_document();
assert_eq!(
preserved(&front, &body),
preserved_of(&fixture.destination),
"the projection changed something the plan does not declare"
);
assert_ne!(
front["title"],
json!(fixture.snapshot.project.native()),
"the projection wrote the project's native identifier as its title"
);
assert_eq!(
front["metadata"]["onetaskgraph.origin"],
json!(fixture.snapshot.project.as_str()),
"the projection lost the destination project it writes onto"
);
let mut deleted = destination("A person's own board", &["planning", "q3"]);
deleted.metadata.remove("authored.note");
assert_ne!(
preserved(&front, &body),
preserved_of(&deleted),
"the complement assertion cannot fail on a deleted field, so it proves nothing"
);
let mut unlabelled = destination("A person's own board", &["planning"]);
unlabelled.metadata = fixture.destination.metadata.clone();
assert_ne!(
preserved(&front, &body),
preserved_of(&unlabelled),
"the complement assertion cannot fail on a dropped label, so it proves nothing"
);
}
#[test]
fn a_projection_carries_a_destination_tasks_labels_through_and_invents_none() {
let fixture = Fixture::new("task-labels");
fixture.project();
let (build, _) = fixture.task_document("build");
assert_eq!(
build["labels"],
json!([{"id": "needs-review", "name": "needs-review", "color": "d73a4a"}]),
"the projection dropped the destination task's labels"
);
assert_eq!(
build["metadata"]["onetaskgraph.origin"], "plans:board/002-build",
"the projection lost the destination task it writes onto"
);
let (design, _) = fixture.task_document("design");
assert_eq!(
design["labels"],
json!([]),
"the projection invented labels for a task the destination does not hold"
);
assert_eq!(
design["metadata"].get("onetaskgraph.origin"),
None,
"the projection claimed a destination task for a node the destination has none for"
);
}
#[test]
fn a_settled_node_carries_the_change_that_closed_it_and_a_node_without_one_carries_none() {
let mut fixture = Fixture::new("landing");
fixture
.snapshot
.landings
.insert("build".to_owned(), Landing::Landed);
fixture
.snapshot
.landing_commits
.insert("build".to_owned(), "d3adb33f".to_owned());
fixture.snapshot.change_urls.insert(
"build".to_owned(),
"https://example.invalid/pull/7".to_owned(),
);
fixture.project();
let (build, _) = fixture.task_document("build");
assert_eq!(build["status"], "done", "a node that landed is not closed");
assert_eq!(build["metadata"]["onepipeline.landing"], "landed");
assert_eq!(build["metadata"]["onepipeline.landing_commit"], "d3adb33f");
assert_eq!(
build["metadata"]["onepipeline.change_url"],
"https://example.invalid/pull/7"
);
let (design, _) = fixture.task_document("design");
for absent in [
"onepipeline.landing",
"onepipeline.landing_commit",
"onepipeline.change_url",
] {
assert_eq!(
design["metadata"].get(absent),
None,
"a node with no change of its own was recorded as having {absent}"
);
}
}
#[test]
fn each_settlement_reaches_the_document_under_its_own_word() {
let mut seen: BTreeMap<String, &str> = BTreeMap::new();
for (settlement, status, outcome) in [
("done", NodeStatus::Done, None),
("failed", NodeStatus::Failed, None),
(
"provider-failed",
NodeStatus::Failed,
Some(crate::engine::PROVIDER_FAILED),
),
("cancelled", NodeStatus::Cancelled, None),
("parked", NodeStatus::Parked, None),
] {
let mut fixture = Fixture::new("settlement-words");
fixture.snapshot.statuses.insert("build".to_owned(), status);
if let Some(outcome) = outcome {
fixture
.snapshot
.outcomes
.insert("build".to_owned(), outcome.to_owned());
}
fixture.snapshot.settlements.insert(
"build".to_owned(),
json!({"status": status.as_str(), "outcome": outcome}),
);
fixture.project();
let (build, _) = fixture.task_document("build");
let word = build["status"]
.as_str()
.expect("a projected status is a string")
.to_owned();
assert_eq!(
build["metadata"][crate::taskgraph::SETTLEMENT_KEY]["status"],
json!(status.as_str()),
"the settlement did not reach the item beside its word"
);
if let Some(shared) = seen.insert(word.clone(), settlement) {
panic!("'{settlement}' and '{shared}' both reached the board as `{word}`");
}
}
assert_eq!(seen.len(), 5, "two settlements shared a word: {seen:?}");
}
#[test]
fn a_draft_complete_node_reaches_the_board_as_in_progress_and_says_why() {
let mut fixture = Fixture::new("draft-complete");
fixture
.snapshot
.statuses
.insert("build".to_owned(), NodeStatus::CompleteDraft);
fixture
.snapshot
.outcomes
.insert("build".to_owned(), crate::vcs::DRAFTED.to_owned());
fixture
.snapshot
.landings
.insert("build".to_owned(), Landing::Unlanded);
fixture.snapshot.change_urls.insert(
"build".to_owned(),
"https://example.invalid/pull/9".to_owned(),
);
fixture.snapshot.settlements.insert(
"build".to_owned(),
json!({
"status": NodeStatus::CompleteDraft.as_str(),
"outcome": crate::vcs::DRAFTED,
"detail": "awaiting the crate release of github.com/owner/engine",
}),
);
fixture.project();
let (build, _) = fixture.task_document("build");
assert_eq!(
build["status"], "in progress",
"a node whose change cannot land yet did not reach the board as work still in hand"
);
for settled in [NodeStatus::Done, NodeStatus::Cancelled] {
assert_ne!(
build["status"].as_str(),
Some(word(projected(settled, None)).as_str()),
"a draft-complete node took the board word `{}` settles under",
settled.as_str()
);
}
assert_eq!(
build["metadata"][crate::taskgraph::SETTLEMENT_KEY]["detail"],
json!("awaiting the crate release of github.com/owner/engine"),
"the draft reached the board with nothing on it saying why it is still open"
);
assert_eq!(
build["metadata"]["onepipeline.change_url"],
"https://example.invalid/pull/9"
);
assert_eq!(build["metadata"]["onepipeline.landing"], "unlanded");
}
}