use std::collections::{BTreeMap, BTreeSet};
use std::num::{NonZeroU32, NonZeroU64};
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::cli::{
DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS, WRITEBACK_CLASSIFIED_COMMANDS,
WRITEBACK_COMMAND_FLOOR_SECONDS, WRITEBACK_DELIVERS_FROM, WRITEBACK_FAILURE_EXIT,
WRITEBACK_MEMBERS_FROM, WRITEBACK_MEMBER_READ, WRITEBACK_PARTIAL_EXIT,
WRITEBACK_PROJECTIONS_FILE, WRITEBACK_PROJECTIONS_SCHEMA_VERSION, WRITEBACK_REFUSED_CLASS,
WRITEBACK_STORE_FILE,
};
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, NODE_KEY, SUPERSEDES_KEY};
const SHADOW_SOURCE: &str = "onepipeline-writeback";
const ID_KEY: &str = "onepipeline.id";
const LANDING_KEY: &str = "onepipeline.landing";
const LANDING_COMMIT_KEY: &str = "onepipeline.landing_commit";
const CHANGE_URL_KEY: &str = "onepipeline.change_url";
const LANDING_EVIDENCE_KEY: &str = "onepipeline.landing_evidence";
const DELIVERS_FIELD: &str = "delivers";
const COMMAND_FLOOR: Duration = Duration::from_secs(WRITEBACK_COMMAND_FLOOR_SECONDS);
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);
const PROJECT_SHOW: &str = WRITEBACK_CLASSIFIED_COMMANDS[0];
const TASK_LIST: &str = WRITEBACK_CLASSIFIED_COMMANDS[1];
const PROJECT_COPY: &str = WRITEBACK_CLASSIFIED_COMMANDS[2];
const TASK_SHOW: &str = WRITEBACK_MEMBER_READ;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Deadline {
Floor,
Copy { per_item: NonZeroU64, items: usize },
}
impl Deadline {
fn product(per_item: NonZeroU64, items: usize) -> Duration {
let items = u64::try_from(items).unwrap_or(u64::MAX);
Duration::from_secs(per_item.get().saturating_mul(items))
}
fn within(self) -> Duration {
match self {
Self::Floor => COMMAND_FLOOR,
Self::Copy { per_item, items } => Self::product(per_item, items).max(COMMAND_FLOOR),
}
}
fn refusal(self, name: &str) -> String {
let seconds = self.within().as_secs();
match self {
Self::Floor => format!("{name} exceeded {seconds} seconds"),
Self::Copy { per_item, items } => {
let arithmetic = format!(
"{items} {} × {} {} per item",
if items == 1 { "item" } else { "items" },
per_item,
if per_item.get() == 1 {
"second"
} else {
"seconds"
}
);
if Self::product(per_item, items) < COMMAND_FLOOR {
format!(
"{name} exceeded {seconds} seconds (the {} second floor; {arithmetic} \
is less)",
COMMAND_FLOOR.as_secs()
)
} else {
format!("{name} exceeded {seconds} seconds ({arithmetic})")
}
}
}
}
}
pub(crate) fn refused_zero_budget(spelling: &str) -> String {
format!(
"{spelling} names a write-back budget of zero seconds per item, which is no budget \
at all — give it a positive whole number of seconds, or leave it out to take \
{DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS} seconds per item"
)
}
#[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>,
stated_landings: BTreeMap<String, crate::edits::StatedLanding>,
settlements: BTreeMap<String, Value>,
superseded: BTreeMap<String, String>,
project_metadata: BTreeMap<String, Value>,
claim: Claim,
vocabulary: Vocabulary,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Claim {
Held,
Released,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Vocabulary {
WithDelivers,
BeforeDelivers,
}
impl Vocabulary {
fn of(version: &str) -> Self {
if crate::taskgraph::at_least(version, WRITEBACK_DELIVERS_FROM) {
Self::WithDelivers
} else {
Self::BeforeDelivers
}
}
fn driving_claim(self) -> Claim {
match self {
Self::WithDelivers => Claim::Held,
Self::BeforeDelivers => Claim::Released,
}
}
}
impl Snapshot {
fn lineages(&self) -> Lineages {
Lineages::of(self)
}
fn word_of(&self, node: &str) -> ProjectedStatus {
projected(
self.statuses
.get(node)
.copied()
.unwrap_or(NodeStatus::Cancelled),
self.outcomes.get(node).map(String::as_str),
self.claim,
)
}
}
struct Lineages {
roots: BTreeMap<String, String>,
chains: BTreeMap<String, Vec<String>>,
}
impl Lineages {
fn of(snapshot: &Snapshot) -> Self {
let replacements: BTreeSet<&String> = snapshot.superseded.values().collect();
let mut lineages = Self {
roots: BTreeMap::new(),
chains: BTreeMap::new(),
};
let rooted: Vec<&String> = snapshot
.nodes
.keys()
.filter(|id| !replacements.contains(id))
.collect();
for root in rooted {
lineages.walk(snapshot, root);
}
let unreached: Vec<String> = snapshot
.nodes
.keys()
.filter(|id| !lineages.roots.contains_key(*id))
.cloned()
.collect();
for root in &unreached {
if !lineages.roots.contains_key(root) {
lineages.walk(snapshot, root);
}
}
lineages
}
fn walk(&mut self, snapshot: &Snapshot, root: &str) {
let mut chain = vec![root.to_owned()];
let mut at = root;
while let Some(next) = snapshot.superseded.get(at) {
if !snapshot.nodes.contains_key(next)
|| self.roots.contains_key(next)
|| chain.contains(next)
{
break;
}
chain.push(next.clone());
at = next;
}
for id in &chain {
self.roots.insert(id.clone(), root.to_owned());
}
self.chains.insert(root.to_owned(), chain);
}
fn root_of<'a>(&'a self, id: &'a str) -> &'a str {
self.roots.get(id).map_or(id, String::as_str)
}
fn chain(&self, root: &str) -> Option<&[String]> {
self.chains.get(root).map(Vec::as_slice)
}
fn roots(&self) -> impl Iterator<Item = &String> {
self.chains.keys()
}
fn len(&self) -> usize {
self.chains.len()
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Unprojected {
pub project: QualifiedId,
pub items: Vec<String>,
pub reason: String,
pub classified: Option<Classified>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FailureClass {
Refused,
Transient,
}
impl FailureClass {
fn as_str(self) -> &'static str {
match self {
Self::Refused => WRITEBACK_REFUSED_CLASS,
Self::Transient => "transient",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Classified {
pub class: FailureClass,
pub kind: String,
}
impl Classified {
pub(crate) fn said(&self) -> String {
format!("class: {}, kind: {}", self.class.as_str(), self.kind)
}
fn refused(&self) -> bool {
self.class == FailureClass::Refused
}
}
struct Failed {
reason: String,
classified: Option<Classified>,
delivered: Vec<Map<String, Value>>,
}
impl Failed {
fn answered(output: &Output, reason: String) -> Self {
Self {
reason,
classified: classified(output.status.code(), &output.stdout),
delivered: Vec::new(),
}
}
fn refused(&self) -> bool {
self.classified.as_ref().is_some_and(Classified::refused)
}
fn said(&self) -> String {
match &self.classified {
Some(classified) => format!(
"{} ({})",
self.reason.split_whitespace().collect::<Vec<_>>().join(" "),
classified.said()
),
None => self.reason.clone(),
}
}
}
impl From<String> for Failed {
fn from(reason: String) -> Self {
Self {
reason,
classified: None,
delivered: Vec::new(),
}
}
}
#[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>,
refused: Option<Snapshot>,
attempts: u64,
queued_items: usize,
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)
|| self.refused.as_ref() == Some(&snapshot))
{
return false;
}
self.queued_items = snapshot.lineages().len();
self.latest = Some(snapshot);
true
}
}
pub struct Writeback {
pending: Arc<(Mutex<Pending>, Condvar)>,
vocabulary: Vocabulary,
per_item: NonZeroU64,
told_why_not_delivered: std::sync::atomic::AtomicBool,
}
impl Writeback {
pub fn start(
binary: PathBuf,
version: &str,
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 vocabulary = Vocabulary::of(version);
let version = version.to_owned();
let launch_dir = if launch.dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
launch.dir.clone()
};
let per_item = per_item_budget(launch);
std::thread::Builder::new()
.name(format!("writeback-{}", paths.run))
.spawn(move || {
worker(
binary,
&version,
launch_dir,
run_dir,
per_item,
worker_pending,
)
})
.ok()?;
let writer = Self {
pending,
vocabulary,
per_item,
told_why_not_delivered: std::sync::atomic::AtomicBool::new(false),
};
Some(writer)
}
pub fn publish(
&self,
paths: &RunPaths,
launch: &LaunchRecord,
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
) {
let Some(snapshot) = snapshot_of(
paths,
launch,
state,
statuses,
self.vocabulary.driving_claim(),
self.vocabulary,
) else {
return;
};
self.say_once_why_tickets_are_not_moved(&snapshot);
self.queue(snapshot);
}
pub fn publish_closeout(
&self,
paths: &RunPaths,
launch: &LaunchRecord,
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
) {
if let Some(snapshot) = snapshot_of(
paths,
launch,
state,
statuses,
Claim::Released,
self.vocabulary,
) {
self.queue(snapshot);
}
}
pub fn wait_for_first_attempt(&self) {
let (lock, ready) = &*self.pending;
let Ok(mut pending) = lock.lock() else { return };
let deadline = Instant::now() + launch_wait(self.per_item, &pending);
while pending.attempts == 0 && Instant::now() < deadline {
let wait = deadline.saturating_duration_since(Instant::now());
let Ok((next, _)) = ready.wait_timeout(pending, wait) else {
return;
};
pending = next;
}
}
fn queue(&self, snapshot: Snapshot) {
crate::loopstats::published();
let (lock, ready) = &*self.pending;
if let Ok(mut pending) = lock.lock() {
if pending.queue(snapshot) {
ready.notify_one();
}
}
}
fn say_once_why_tickets_are_not_moved(&self, snapshot: &Snapshot) {
if self.vocabulary == Vocabulary::WithDelivers
|| snapshot.nodes.values().all(|node| node.delivers.is_empty())
{
return;
}
if self
.told_why_not_delivered
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
return;
}
eprintln!(
"onetaskgraph write-back will not move the tickets this plan's tasks deliver for \
'{}': the store reports a version older than {WRITEBACK_DELIVERS_FROM}, the first \
release carrying `queued` and `delivers`, so unstarted nodes are written `todo` and \
no task carries `delivers` — install onetaskgraph {WRITEBACK_DELIVERS_FROM} or newer",
snapshot.project
);
}
}
fn snapshot_of(
paths: &RunPaths,
launch: &LaunchRecord,
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
claim: Claim,
vocabulary: Vocabulary,
) -> Option<Snapshot> {
let Ok(project) = launch.project.parse() else {
return None;
};
Some(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(),
stated_landings: state.stated_landings.clone(),
settlements: settlements(paths),
superseded: state.superseded.clone(),
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(),
claim,
vocabulary,
})
}
impl Writeback {
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;
}
}
pub fn driving_again(&self) {
let (lock, ready) = &*self.pending;
if let Ok(mut pending) = lock.lock() {
pending.phase = RunPhase::Running;
ready.notify_all();
}
}
}
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 per_item_budget(launch: &LaunchRecord) -> NonZeroU64 {
launch
.item_budget()
.unwrap_or(DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS)
}
fn launch_wait(per_item: NonZeroU64, pending: &Pending) -> Duration {
Deadline::Copy {
per_item,
items: pending.queued_items,
}
.within()
}
pub(crate) fn release_stopped(paths: &RunPaths, launch: &LaunchRecord) {
if launch.project.parse::<QualifiedId>().is_err() {
return;
}
let store = match crate::taskgraph::Store::resolve() {
Ok(store) => store,
Err(error) => {
eprintln!(
"onetaskgraph write-back could not release the nodes this stopped run never \
started: {error}"
);
return;
}
};
let state = crate::checkpoint::Projected::open(paths);
let statuses = state.statuses();
let vocabulary = Vocabulary::of(store.reported_version());
let Some(snapshot) = snapshot_of(
paths,
launch,
&state,
&statuses,
Claim::Released,
vocabulary,
) else {
return;
};
let launch_dir = if launch.dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
launch.dir.clone()
};
let carry = Carry::Whole(WholeBecause::First);
let items = carry.items(&snapshot);
let at = crate::sys::now_rfc3339();
let started = Instant::now();
let attempt = project(
&store.binary(),
&launch_dir,
&paths.dir,
per_item_budget(launch),
&snapshot,
&carry,
&BTreeMap::new(),
);
append_record(
&paths.dir,
&ProjectionRecord::of(
at,
&snapshot.project,
&carry,
items,
started.elapsed(),
&attempt,
),
);
if let Err(failed) = attempt {
eprintln!(
"onetaskgraph write-back could not release the nodes this stopped run never started \
for '{}': {}",
snapshot.project,
failed.said()
);
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Standing {
Landing,
Failing(NonZeroU32),
Refused,
}
fn worker(
binary: PathBuf,
version: &str,
launch_dir: PathBuf,
run_dir: PathBuf,
per_item: NonZeroU64,
pending: Arc<(Mutex<Pending>, Condvar)>,
) {
let members = decide_member_copy_once(&run_dir, version);
let mut standing = Standing::Landing;
let mut carried = Carried::default();
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.refused = None;
state
.latest
.take()
.expect("the worker was woken by a snapshot")
};
let carry = Carry::decide(
members,
standing != Standing::Landing,
carried.last.as_ref(),
&snapshot,
);
let items = carry.items(&snapshot);
let at = crate::sys::now_rfc3339();
let started = Instant::now();
let attempt = project(
&binary,
&launch_dir,
&run_dir,
per_item,
&snapshot,
&carry,
&carried.origins,
);
append_record(
&run_dir,
&ProjectionRecord::of(
at,
&snapshot.project,
&carry,
items.clone(),
started.elapsed(),
&attempt,
),
);
{
let (lock, ready) = &*pending;
if let Ok(mut state) = lock.lock() {
state.attempts = state.attempts.saturating_add(1);
ready.notify_all();
}
}
match attempt {
Ok(landed) => {
if standing != Standing::Landing {
eprintln!(
"onetaskgraph write-back recovered for '{}'",
snapshot.project
);
}
standing = Standing::Landing;
carried = Carried {
last: Some(snapshot.clone()),
origins: landed.origins,
};
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(failed) if failed.refused() => {
eprintln!(
"onetaskgraph write-back failed for '{}': {}; the store refused it, so it \
is not attempted again on a timer — the projection will be attempted again \
when the run's graph next changes",
snapshot.project,
failed.said()
);
standing = Standing::Refused;
let (lock, _) = &*pending;
if let Ok(mut state) = lock.lock() {
state.unprojected.push(Unprojected {
project: snapshot.project.clone(),
items,
reason: failed.reason,
classified: failed.classified,
});
if state.latest.as_ref() == Some(&snapshot) {
state.latest = None;
}
state.refused = Some(snapshot);
if state.phase == RunPhase::Stopping && state.latest.is_none() {
return;
}
}
}
Err(failed) => {
let failures = match standing {
Standing::Failing(failures) => failures.saturating_add(1),
Standing::Landing | Standing::Refused => NonZeroU32::MIN,
};
let first = failures == NonZeroU32::MIN;
standing = Standing::Failing(failures);
if first {
eprintln!(
"onetaskgraph write-back failed for '{}': {}; retrying, spacing \
further attempts out to {} seconds apart while it keeps failing",
snapshot.project,
failed.said(),
RETRY_CEILING.as_secs()
);
}
if !should_retry_after(&pending, retry_after(failures.get())) {
return;
}
let (lock, ready) = &*pending;
let Ok(mut state) = lock.lock() else { return };
if first {
state.unprojected.push(Unprojected {
project: snapshot.project.clone(),
items,
reason: failed.reason,
classified: failed.classified,
});
}
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;
}
}
struct Landed {
origins: BTreeMap<String, Origin>,
actions: Option<ProjectionActions>,
spent: Option<Map<String, Value>>,
delivered: Vec<Map<String, Value>>,
}
fn project(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
per_item: NonZeroU64,
snapshot: &Snapshot,
carry: &Carry,
known: &BTreeMap<String, Origin>,
) -> Result<Landed, Failed> {
let destination_project = destination_project(binary, launch_dir, run_dir, snapshot)?;
let mut origins = match carry {
Carry::Whole(_) => destination_origins(binary, launch_dir, run_dir, snapshot)?,
Carry::Members(named) => member_origins(binary, launch_dir, run_dir, named, known)?,
};
write_shadow(snapshot, &origins, &destination_project)?;
let root = snapshot.dir.to_string_lossy().into_owned();
let mut args = vec![
"project".to_owned(),
"copy".to_owned(),
format!("{SHADOW_SOURCE}:{}", project_file(&snapshot.project)),
"--to".to_owned(),
snapshot.project.source().to_owned(),
"--json".to_owned(),
"--set".to_owned(),
format!("sources.{SHADOW_SOURCE}.plugin=local-md"),
"--set".to_owned(),
format!("sources.{SHADOW_SOURCE}.config.root={root}"),
];
if let Carry::Members(named) = carry {
if named.is_empty() {
args.push("--no-tasks".to_owned());
}
for node in named {
args.extend(["--member".to_owned(), member_id(snapshot, node)]);
}
}
let deadline = Deadline::Copy {
per_item,
items: carry.items(snapshot).len(),
};
let output = bounded_output(binary, launch_dir, run_dir, PROJECT_COPY, &args, deadline)?;
let report: Option<CopyReport> = serde_json::from_slice(&output.stdout).ok();
if output.status.success() {
let actions = report
.as_ref()
.map(|report| report.actions(snapshot, &origins));
if let Some(report) = &report {
report.learn(&mut origins, snapshot);
}
let (spent, delivered) = report
.map(|report| (report.spent, report.delivered))
.unwrap_or_default();
Ok(Landed {
origins,
actions,
spent,
delivered,
})
} else {
let delivered = report.map(|report| report.delivered).unwrap_or_default();
let reason = match failed_deliveries(&output.stdout) {
Some(tickets) if output.status.code() == Some(WRITEBACK_PARTIAL_EXIT) => format!(
"the copy landed, but the store could not keep every delivered ticket in step: \
{tickets}"
),
_ => format!(
"copy exited {}: {}",
exit(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
),
};
let mut failed = Failed::answered(&output, reason);
failed.delivered = delivered;
Err(failed)
}
}
fn failed_deliveries(stdout: &[u8]) -> Option<String> {
let answer: DeliveredAnswer = serde_json::from_slice(stdout).ok()?;
let failed: Vec<String> = answer
.delivered
.iter()
.filter_map(|entry| match &entry.outcome {
DeliveredOutcome::Failed { failure } => Some(format!(
"ticket {} (delivered by {}): {}",
entry.ticket,
entry.deliverer,
failure
.message
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
)),
DeliveredOutcome::Written | DeliveredOutcome::Unchanged | DeliveredOutcome::Left => {
None
}
})
.collect();
(!failed.is_empty()).then(|| failed.join("; "))
}
fn destination_project(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
snapshot: &Snapshot,
) -> Result<DestinationProjectItem, Failed> {
let args = ["project", "show", snapshot.project.as_str(), "--json"];
let output = bounded_output(
binary,
launch_dir,
run_dir,
PROJECT_SHOW,
&args,
Deadline::Floor,
)?;
if !output.status.success() {
let reason = format!(
"project show exited {}: {}",
exit(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
);
return Err(Failed::answered(&output, reason));
}
let response: ProjectPage = answered(&output.stdout)?;
if !response.errors.is_empty() {
return Err("project show returned partial results".to_owned().into());
}
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
)
.into());
}
Ok(project.item)
}
#[derive(Clone)]
struct Origin {
id: QualifiedId,
labels: Vec<DestinationLabel>,
category: Option<DestinationCategory>,
}
impl Origin {
fn closed(&self) -> bool {
self.category.is_some_and(|category| {
matches!(
category,
DestinationCategory::Done | DestinationCategory::Cancelled
)
})
}
}
fn destination_origins(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
snapshot: &Snapshot,
) -> Result<BTreeMap<String, Origin>, Failed> {
let lineages = snapshot.lineages();
let mut placed: Vec<Placed> = Vec::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,
Deadline::Floor,
)?;
if !output.status.success() {
let reason = format!(
"task list exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
return Err(Failed::answered(&output, reason));
}
let response: TaskPage = answered(&output.stdout)?;
if !response.errors.is_empty() {
return Err("task list returned partial results".to_owned().into());
}
for task in response.items {
placed.push(Placed::of(&lineages, task)?);
}
let Some(next) = response.next else { break };
if next.is_empty() {
return Err("task list returned an empty next-page cursor"
.to_owned()
.into());
}
if !cursors.insert(next.clone()) {
return Err("task list repeated a next-page cursor".to_owned().into());
}
page = Some(next);
}
Ok(furthest_along(placed)?)
}
struct Placed {
root: String,
position: usize,
attempt: String,
origin: Origin,
}
impl Placed {
fn of(lineages: &Lineages, task: DestinationTask) -> Result<Self, String> {
let named = |key: &str| -> Result<Option<String>, String> {
match task.item.metadata.get(key) {
None => Ok(None),
Some(Value::String(id)) if !id.is_empty() => Ok(Some(id.clone())),
Some(other) => Err(format!(
"task '{}' carries {key} as {other}, and a node id is a non-empty string",
task.id.as_str()
)),
}
};
let node =
named(ID_KEY)?.ok_or_else(|| format!("task '{}' has no {ID_KEY}", task.id.as_str()))?;
let attempt = named(NODE_KEY)?.unwrap_or_else(|| node.clone());
let root = lineages.root_of(&node).to_owned();
let position = lineages
.chain(&root)
.and_then(|chain| chain.iter().position(|id| *id == attempt))
.unwrap_or(0);
Ok(Self {
root,
position,
attempt,
origin: Origin {
id: task.id,
labels: task.item.labels,
category: task.item.status.map(|status| status.category),
},
})
}
}
fn furthest_along(placed: Vec<Placed>) -> Result<BTreeMap<String, Origin>, String> {
let mut by_position: BTreeMap<(String, usize), Placed> = BTreeMap::new();
for item in placed {
let key = (item.root.clone(), item.position);
if by_position.contains_key(&key) {
return Err(format!(
"project has more than one task for node '{}'",
item.attempt
));
}
by_position.insert(key, item);
}
let mut by_root: BTreeMap<String, Origin> = BTreeMap::new();
for ((root, _), item) in by_position {
by_root.insert(root, item.origin);
}
Ok(by_root)
}
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],
deadline: Deadline,
) -> 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() < deadline.within() => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(deadline.refusal(name));
}
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 classified(code: Option<i32>, stdout: &[u8]) -> Option<Classified> {
match code? {
WRITEBACK_FAILURE_EXIT => {
let document: FailureDocument = serde_json::from_slice(stdout).ok()?;
Some(Classified {
class: document.failure.class,
kind: document.failure.kind,
})
}
WRITEBACK_PARTIAL_EXIT => {
let failures: Vec<(FailureClass, String)> =
match serde_json::from_slice::<PartialAnswer>(stdout) {
Ok(answer) if !answer.errors.is_empty() => answer
.errors
.into_iter()
.map(|entry| (entry.class, entry.error.kind))
.collect(),
_ => serde_json::from_slice::<DeliveredAnswer>(stdout)
.ok()?
.delivered
.into_iter()
.filter_map(|entry| match entry.outcome {
DeliveredOutcome::Failed { failure } => {
Some((failure.class, failure.kind))
}
DeliveredOutcome::Written
| DeliveredOutcome::Unchanged
| DeliveredOutcome::Left => None,
})
.collect(),
};
if failures.is_empty() {
return None;
}
let class = if failures
.iter()
.all(|(class, _)| *class == FailureClass::Refused)
{
FailureClass::Refused
} else {
FailureClass::Transient
};
let mut kinds: Vec<String> = Vec::new();
for (_, kind) in failures {
if !kinds.contains(&kind) {
kinds.push(kind);
}
}
Some(Classified {
class,
kind: kinds.join(", "),
})
}
_ => None,
}
}
fn exit(status: &ExitStatus) -> String {
status
.code()
.map_or_else(|| "on a signal".into(), |code| code.to_string())
}
#[derive(Deserialize)]
struct FailureDocument {
failure: StoreFailure,
}
#[derive(Deserialize)]
struct StoreFailure {
class: FailureClass,
kind: String,
}
#[derive(Deserialize)]
struct PartialAnswer {
errors: Vec<PartialError>,
}
#[derive(Deserialize)]
struct PartialError {
class: FailureClass,
error: PartialCause,
}
#[derive(Deserialize)]
struct DeliveredAnswer {
delivered: Vec<DeliveredEntry>,
}
#[derive(Deserialize)]
struct DeliveredEntry {
ticket: QualifiedId,
deliverer: QualifiedId,
#[serde(flatten)]
outcome: DeliveredOutcome,
}
#[derive(Deserialize)]
struct DeliveredFailure {
class: FailureClass,
kind: String,
message: String,
}
#[derive(Deserialize)]
#[serde(tag = "outcome", rename_all = "kebab-case")]
enum DeliveredOutcome {
Written,
Unchanged,
Left,
Failed { failure: DeliveredFailure },
}
#[derive(Deserialize)]
struct PartialCause {
kind: 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(default)]
status: Option<DestinationStatus>,
#[serde(rename = "location", default)]
_location: Option<Value>,
}
#[derive(Deserialize)]
struct DestinationStatus {
category: DestinationCategory,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum DestinationCategory {
Draft,
Backlog,
Todo,
Queued,
InProgress,
Done,
Cancelled,
#[serde(other)]
Unknown,
}
#[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(),
)?;
let lineages = snapshot.lineages();
let mut written: BTreeSet<PathBuf> = BTreeSet::new();
for root in lineages.roots() {
let (front, content) = task_document(snapshot, &lineages, root, origins.get(root))?;
let path = tasks.join(format!("{}.md", task_file(root)));
document(&path, &front, &content)?;
written.insert(path);
}
for entry in std::fs::read_dir(&tasks).map_err(|e| e.to_string())? {
let path = entry.map_err(|e| e.to_string())?.path();
if !written.contains(&path) {
std::fs::remove_file(&path).map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn task_document(
snapshot: &Snapshot,
lineages: &Lineages,
root: &str,
origin: Option<&Origin>,
) -> Result<(Value, String), String> {
let chain = lineages
.chain(root)
.ok_or_else(|| format!("no lineage is rooted at '{root}'"))?;
let (head, superseded) = chain
.split_last()
.ok_or_else(|| format!("the lineage rooted at '{root}' holds no node"))?;
let node = snapshot
.nodes
.get(head)
.ok_or_else(|| format!("the snapshot holds no node '{head}'"))?;
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))
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| root.to_owned());
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));
let delivers = wire
.remove(DELIVERS_FIELD)
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
wire.remove("id");
let mut metadata = Map::new();
metadata.insert(ID_KEY.into(), json!(root));
metadata.insert(NODE_KEY.into(), json!(head));
if !superseded.is_empty() {
metadata.insert(SUPERSEDES_KEY.into(), json!(superseded));
}
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);
}
let id = head.as_str();
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()));
}
match snapshot.stated_landings.get(id) {
Some(stated) => {
metadata.insert(LANDING_EVIDENCE_KEY.into(), json!(stated.tier()));
let key = match stated {
crate::edits::StatedLanding::Commit(_) => LANDING_COMMIT_KEY,
crate::edits::StatedLanding::ChangeRequest(_) => CHANGE_URL_KEY,
};
metadata.insert(key.into(), json!(stated.reference()));
}
None => {
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(lineages.root_of(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 mut front = Map::new();
front.insert("title".into(), json!(title));
front.insert("project".into(), json!(project_file(&snapshot.project)));
front.insert("status".into(), json!(snapshot.word_of(id)));
if snapshot.vocabulary == Vocabulary::WithDelivers && !delivers.is_empty() {
front.insert(DELIVERS_FIELD.into(), json!(delivers));
}
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));
}
}
Ok((Value::Object(front), content))
}
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,
#[serde(rename = "queued")]
Queued,
}
fn projected(status: NodeStatus, outcome: Option<&str>, claim: Claim) -> 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 => {
match claim {
Claim::Held => ProjectedStatus::Queued,
Claim::Released => 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()
}
fn member_id(snapshot: &Snapshot, id: &str) -> String {
format!(
"{SHADOW_SOURCE}:{}/{}",
project_file(&snapshot.project),
task_file(id)
)
}
enum Carry {
Whole(WholeBecause),
Members(BTreeSet<String>),
}
impl Carry {
fn decide(
members: bool,
after_failure: bool,
last: Option<&Snapshot>,
snapshot: &Snapshot,
) -> Self {
if !members {
return Self::Whole(WholeBecause::StoreLacksMembers);
}
if after_failure {
return Self::Whole(WholeBecause::AfterFailure);
}
let Some(last) = last else {
return Self::Whole(WholeBecause::First);
};
let (now, before) = (snapshot.lineages(), last.lineages());
Self::Members(
now.roots()
.filter(|root| {
task_document(last, &before, root, None).ok()
!= task_document(snapshot, &now, root, None).ok()
})
.cloned()
.collect(),
)
}
fn items(&self, snapshot: &Snapshot) -> Vec<String> {
match self {
Self::Whole(_) => snapshot.lineages().roots().cloned().collect(),
Self::Members(named) => named.iter().cloned().collect(),
}
}
}
#[derive(Default)]
struct Carried {
last: Option<Snapshot>,
origins: BTreeMap<String, Origin>,
}
fn member_origins(
binary: &Path,
launch_dir: &Path,
run_dir: &Path,
named: &BTreeSet<String>,
known: &BTreeMap<String, Origin>,
) -> Result<BTreeMap<String, Origin>, Failed> {
let mut origins = known.clone();
for node in named {
let Some(origin) = origins.get_mut(node) else {
continue;
};
let args = ["task", "show", origin.id.as_str(), "--json"];
let output = bounded_output(
binary,
launch_dir,
run_dir,
TASK_SHOW,
&args,
Deadline::Floor,
)?;
if !output.status.success() {
let reason = format!(
"task show exited {}: {}",
exit(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
);
return Err(Failed::answered(&output, reason));
}
let response: TaskPage = answered(&output.stdout)?;
if !response.errors.is_empty() {
return Err("task show returned partial results".to_owned().into());
}
let mut items = response.items.into_iter();
let task = items
.next()
.ok_or_else(|| format!("task '{}' was not found", origin.id))?;
if items.next().is_some() || task.id != origin.id {
return Err(format!("task show returned the wrong task for '{}'", origin.id).into());
}
origin.labels = task.item.labels;
origin.category = task.item.status.map(|status| status.category);
}
Ok(origins)
}
#[derive(Deserialize)]
struct CopyReport {
items: Vec<CopiedItem>,
#[serde(default)]
spent: Option<Map<String, Value>>,
#[serde(default)]
delivered: Vec<Map<String, Value>>,
}
#[derive(Deserialize)]
struct CopiedItem {
source: QualifiedId,
action: CopiedAction,
#[serde(default)]
destination: Option<QualifiedId>,
}
#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CopiedAction {
Created,
Updated,
Unchanged,
Orphaned,
}
impl CopyReport {
fn actions(&self, snapshot: &Snapshot, before: &BTreeMap<String, Origin>) -> ProjectionActions {
let lineages = snapshot.lineages();
let members = shadow_members(snapshot, &lineages);
let mut actions = ProjectionActions::default();
for item in &self.items {
let count = match item.action {
CopiedAction::Created => &mut actions.created,
CopiedAction::Updated => &mut actions.updated,
CopiedAction::Unchanged => &mut actions.unchanged,
CopiedAction::Orphaned => &mut actions.orphaned,
};
*count = count.saturating_add(1);
if item.action != CopiedAction::Updated {
continue;
}
let reopened = members.get(item.source.as_str()).is_some_and(|root| {
before.get(*root).is_some_and(Origin::closed)
&& lineages
.chain(root)
.and_then(<[String]>::last)
.is_some_and(|head| {
!matches!(
snapshot.word_of(head),
ProjectedStatus::Done | ProjectedStatus::Cancelled
)
})
});
if reopened {
actions.reopened = actions.reopened.saturating_add(1);
}
}
actions
}
fn learn(&self, origins: &mut BTreeMap<String, Origin>, snapshot: &Snapshot) {
let lineages = snapshot.lineages();
let members = shadow_members(snapshot, &lineages);
for item in &self.items {
let (Some(node), Some(destination)) =
(members.get(item.source.as_str()), item.destination.as_ref())
else {
continue;
};
if item.action == CopiedAction::Orphaned {
continue;
}
match origins.get_mut(*node) {
Some(origin) if origin.id == *destination => {}
Some(origin) => {
origin.id = destination.clone();
origin.labels = Vec::new();
origin.category = None;
}
None => {
origins.insert(
(*node).clone(),
Origin {
id: destination.clone(),
labels: Vec::new(),
category: None,
},
);
}
}
}
}
}
fn shadow_members<'a>(snapshot: &Snapshot, lineages: &'a Lineages) -> BTreeMap<String, &'a String> {
lineages
.roots()
.map(|root| (member_id(snapshot, root), root))
.collect()
}
fn decide_member_copy_once(run_dir: &Path, reported: &str) -> bool {
let path = run_dir.join(WRITEBACK_STORE_FILE);
if let Some(recorded) = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<StoreRecord>(&bytes).ok())
{
return recorded.members();
}
let recorded = StoreRecord {
version: reported.to_owned(),
};
let members = recorded.members();
let written = serde_json::to_vec(&recorded)
.map_err(|error| error.to_string())
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|error| error.to_string()));
if let Err(error) = written {
eprintln!(
"onetaskgraph write-back could not record whether the store offers a member copy \
at {}: {error}",
path.display()
);
}
members
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(try_from = "StoreWire", into = "StoreWire")]
struct StoreRecord {
version: String,
}
impl StoreRecord {
fn members(&self) -> bool {
crate::taskgraph::at_least(&self.version, WRITEBACK_MEMBERS_FROM)
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoreWire {
version: String,
members: bool,
}
impl TryFrom<StoreWire> for StoreRecord {
type Error = String;
fn try_from(wire: StoreWire) -> Result<Self, String> {
let record = Self {
version: wire.version,
};
if record.members() == wire.members {
Ok(record)
} else {
Err(format!(
"the store record says `members: {}` of version {:?}, which says otherwise",
wire.members, record.version
))
}
}
}
impl From<StoreRecord> for StoreWire {
fn from(record: StoreRecord) -> Self {
let members = record.members();
Self {
version: record.version,
members,
}
}
}
fn append_record(run_dir: &Path, record: &ProjectionRecord) {
let path = run_dir.join(WRITEBACK_PROJECTIONS_FILE);
let written = serde_json::to_string(record)
.map_err(|error| error.to_string())
.and_then(|line| {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, format!("{line}\n").as_bytes())
.map_err(|error| error.to_string())
});
if let Err(error) = written {
eprintln!(
"onetaskgraph write-back could not record a projection attempt at {}: {error}",
path.display()
);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "ProjectionWire", into = "ProjectionWire")]
pub struct ProjectionRecord {
pub at: String,
pub project: String,
pub scope: ProjectionScope,
pub items: Vec<String>,
pub duration_ms: u64,
pub ended: ProjectionEnded,
pub delivered: Vec<Map<String, Value>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectionScope {
Whole(WholeBecause),
Members,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WholeBecause {
First,
AfterFailure,
StoreLacksMembers,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectionEnded {
Projected {
actions: Option<ProjectionActions>,
spent: Option<Map<String, Value>>,
},
Failed {
classified: Option<ProjectionFailure>,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionFailure {
pub class: FailureClass,
pub kind: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectionActions {
pub created: u64,
pub updated: u64,
pub unchanged: u64,
pub orphaned: u64,
pub reopened: u64,
}
impl ProjectionRecord {
fn of(
at: String,
project: &QualifiedId,
carry: &Carry,
items: Vec<String>,
took: Duration,
attempt: &Result<Landed, Failed>,
) -> Self {
Self {
at,
project: project.as_str().to_owned(),
scope: match carry {
Carry::Whole(because) => ProjectionScope::Whole(*because),
Carry::Members(_) => ProjectionScope::Members,
},
items,
duration_ms: u64::try_from(took.as_millis()).unwrap_or(u64::MAX),
ended: match attempt {
Ok(landed) => ProjectionEnded::Projected {
actions: landed.actions,
spent: landed.spent.clone(),
},
Err(failed) => ProjectionEnded::Failed {
classified: failed
.classified
.as_ref()
.map(|classified| ProjectionFailure {
class: classified.class,
kind: classified.kind.clone(),
}),
reason: failed.reason.clone(),
},
},
delivered: match attempt {
Ok(landed) => landed.delivered.clone(),
Err(failed) => failed.delivered.clone(),
},
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProjectionWire {
#[serde(default = "unversioned_projection_line")]
schema_version: u32,
at: String,
project: String,
scope: ScopeWord,
whole_because: Option<WholeBecause>,
items: Vec<String>,
outcome: OutcomeWord,
class: Option<FailureClass>,
kind: Option<String>,
reason: Option<String>,
duration_ms: u64,
actions: Option<ActionsWire>,
spent: Option<Map<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
delivered: Option<Vec<Map<String, Value>>>,
}
fn unversioned_projection_line() -> u32 {
1
}
const PROJECTION_LINE_WITH_DELIVERED: u32 = 2;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ActionsWire {
created: u64,
updated: u64,
unchanged: u64,
orphaned: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
reopened: Option<u64>,
}
impl From<ProjectionActions> for ActionsWire {
fn from(actions: ProjectionActions) -> Self {
Self {
created: actions.created,
updated: actions.updated,
unchanged: actions.unchanged,
orphaned: actions.orphaned,
reopened: Some(actions.reopened),
}
}
}
impl From<ActionsWire> for ProjectionActions {
fn from(wire: ActionsWire) -> Self {
Self {
created: wire.created,
updated: wire.updated,
unchanged: wire.unchanged,
orphaned: wire.orphaned,
reopened: wire.reopened.unwrap_or_default(),
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ScopeWord {
Whole,
Members,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum OutcomeWord {
Projected,
Failed,
}
impl TryFrom<ProjectionWire> for ProjectionRecord {
type Error = String;
fn try_from(wire: ProjectionWire) -> Result<Self, String> {
let names_reopened = wire
.actions
.as_ref()
.is_some_and(|actions| actions.reopened.is_some());
match wire.schema_version {
WRITEBACK_PROJECTIONS_SCHEMA_VERSION => {
if wire.actions.is_some() && !names_reopened {
return Err(format!(
"a version {WRITEBACK_PROJECTIONS_SCHEMA_VERSION} line names `actions` \
without `actions.reopened`, which every landed attempt at that version \
names"
));
}
}
1 if wire.delivered.is_some() => {
return Err(format!(
"a version 1 line names `delivered`, which version \
{PROJECTION_LINE_WITH_DELIVERED} added"
))
}
1 | PROJECTION_LINE_WITH_DELIVERED if names_reopened => {
return Err(format!(
"a version {} line names `actions.reopened`, which version \
{WRITEBACK_PROJECTIONS_SCHEMA_VERSION} added",
wire.schema_version
))
}
1 | PROJECTION_LINE_WITH_DELIVERED => {}
other => {
return Err(format!(
"`schema_version` {other} is not one this build reads (1, \
{PROJECTION_LINE_WITH_DELIVERED}, {WRITEBACK_PROJECTIONS_SCHEMA_VERSION})"
))
}
}
if !(crate::watchers::is_rfc3339(&wire.at) && wire.at.ends_with(['Z', 'z'])) {
return Err(format!("`at` is not an RFC 3339 UTC time: {:?}", wire.at));
}
QualifiedId::try_from(wire.project.clone()).map_err(|why| format!("`project`: {why}"))?;
if wire.items.iter().any(String::is_empty) {
return Err("`items` names an empty node id".to_owned());
}
let scope = match (wire.scope, wire.whole_because) {
(ScopeWord::Whole, Some(because)) => ProjectionScope::Whole(because),
(ScopeWord::Members, None) => ProjectionScope::Members,
(ScopeWord::Whole, None) => {
return Err("a whole projection names no `whole_because`".to_owned())
}
(ScopeWord::Members, Some(_)) => {
return Err(
"a member projection names a `whole_because`, which only a whole one has"
.to_owned(),
)
}
};
let ended = match wire.outcome {
OutcomeWord::Projected => {
if wire.class.is_some() || wire.kind.is_some() || wire.reason.is_some() {
return Err(
"a projected attempt names a failure's `class`, `kind` or `reason`"
.to_owned(),
);
}
ProjectionEnded::Projected {
actions: wire.actions.map(ProjectionActions::from),
spent: wire.spent,
}
}
OutcomeWord::Failed => {
if wire.actions.is_some() || wire.spent.is_some() {
return Err(
"a failed attempt names a copy report's `actions` or `spent`".to_owned(),
);
}
let classified =
match (wire.class, wire.kind) {
(Some(class), Some(kind)) => Some(ProjectionFailure { class, kind }),
(None, None) => None,
_ => return Err(
"a failed attempt names one of `class` and `kind` without the other"
.to_owned(),
),
};
ProjectionEnded::Failed {
classified,
reason: wire
.reason
.ok_or_else(|| "a failed attempt names no `reason`".to_owned())?,
}
}
};
Ok(Self {
at: wire.at,
project: wire.project,
scope,
items: wire.items,
duration_ms: wire.duration_ms,
ended,
delivered: wire.delivered.unwrap_or_default(),
})
}
}
impl From<ProjectionRecord> for ProjectionWire {
fn from(record: ProjectionRecord) -> Self {
let (scope, whole_because) = match record.scope {
ProjectionScope::Whole(because) => (ScopeWord::Whole, Some(because)),
ProjectionScope::Members => (ScopeWord::Members, None),
};
let (outcome, class, kind, reason, actions, spent) = match record.ended {
ProjectionEnded::Projected { actions, spent } => (
OutcomeWord::Projected,
None,
None,
None,
actions.map(ActionsWire::from),
spent,
),
ProjectionEnded::Failed { classified, reason } => {
let (class, kind) = classified
.map(|failure| (Some(failure.class), Some(failure.kind)))
.unwrap_or_default();
(OutcomeWord::Failed, class, kind, Some(reason), None, None)
}
};
Self {
schema_version: WRITEBACK_PROJECTIONS_SCHEMA_VERSION,
at: record.at,
project: record.project,
scope,
whole_because,
items: record.items,
outcome,
class,
kind,
reason,
duration_ms: record.duration_ms,
actions,
spent,
delivered: (!record.delivered.is_empty()).then_some(record.delivered),
}
}
}
#[cfg(test)]
mod tests {
use super::{
classified, per_item_budget, projected, write_shadow, Claim, Classified, Deadline,
DestinationCategory, DestinationLabel, DestinationProjectItem, FailureClass, Landing,
Origin, Pending, ProjectedStatus, Snapshot, WorkerState, Writeback, CHANGE_URL_KEY,
COMMAND_FLOOR, DELIVERS_FIELD, ID_KEY, LANDING_COMMIT_KEY, LANDING_EVIDENCE_KEY,
LANDING_KEY, NODE_KEY, SUPERSEDES_KEY,
};
use crate::cli::DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS;
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::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
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 the_copy_deadline_is_the_budget_times_the_items_and_never_below_the_floor() {
let shipped = DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS;
let copy = |items: usize| Deadline::Copy {
per_item: shipped,
items,
};
assert_eq!(copy(34).within(), Duration::from_secs(340));
assert_eq!(
copy(34).refusal("project-copy"),
"project-copy exceeded 340 seconds (34 items × 10 seconds per item)"
);
assert_eq!(copy(2).within(), COMMAND_FLOOR);
assert_eq!(
copy(2).refusal("project-copy"),
"project-copy exceeded 60 seconds (the 60 second floor; 2 items × 10 seconds \
per item is less)"
);
assert_eq!(copy(6).within(), COMMAND_FLOOR);
assert_eq!(
copy(6).refusal("project-copy"),
"project-copy exceeded 60 seconds (6 items × 10 seconds per item)"
);
let one = Deadline::Copy {
per_item: NonZeroU64::MIN,
items: 1,
};
assert_eq!(
one.refusal("project-copy"),
"project-copy exceeded 60 seconds (the 60 second floor; 1 item × 1 second per \
item is less)"
);
assert_eq!(Deadline::Floor.within(), COMMAND_FLOOR);
assert_eq!(
Deadline::Floor.refusal("project-show"),
"project-show exceeded 60 seconds"
);
assert_eq!(
Deadline::Floor.refusal("task-list"),
"task-list exceeded 60 seconds"
);
let vast = Deadline::Copy {
per_item: NonZeroU64::MIN,
items: usize::MAX / 2,
};
assert_eq!(vast.within(), Duration::from_secs(usize::MAX as u64 / 2));
let saturated = Deadline::Copy {
per_item: NonZeroU64::MAX,
items: 2,
};
assert_eq!(saturated.within(), Duration::from_secs(u64::MAX));
assert_eq!(
saturated.refusal("project-copy"),
format!(
"project-copy exceeded {} seconds (2 items × {} seconds per item)",
u64::MAX,
u64::MAX
)
);
}
#[test]
fn the_divergence_records_examples_are_the_deadlines_the_copy_runs_under() {
let record = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract-divergences.md"),
)
.expect("the divergence record ships");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with("71."))
.expect("the record still carries entry 71");
let block = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.expect("entry 71 carries its json block");
let budget = serde_json::from_str::<Value>(block).expect("entry 71's block is JSON")
["budget"]
.clone();
assert_eq!(
budget["floor_seconds"].as_u64().map(Duration::from_secs),
Some(COMMAND_FLOOR),
"entry 71 states a floor other than the one the copy runs under"
);
assert_eq!(
budget["deadline"].as_str(),
Some("max(floor_seconds, budget × items)"),
"entry 71 states a deadline formula other than the one `Deadline::within` computes"
);
let examples = budget["examples"]
.as_array()
.expect("entry 71 works an example");
assert!(!examples.is_empty(), "{budget}");
for example in examples {
let items = usize::try_from(example["items"].as_u64().expect("an item count"))
.expect("a count");
let per_item = NonZeroU64::new(example["budget_seconds"].as_u64().expect("a budget"))
.expect("entry 71 works an example under a budget of zero");
let copy = Deadline::Copy { per_item, items };
assert_eq!(
copy.within(),
Duration::from_secs(example["deadline_seconds"].as_u64().expect("a deadline")),
"entry 71's example is not the deadline the copy runs under: {example}"
);
assert_eq!(
Some(copy.refusal("project-copy").as_str()),
example["refusal"].as_str(),
"entry 71's example is not the refusal the copy is killed with: {example}"
);
}
}
#[test]
fn the_per_item_budget_is_the_launch_records_or_the_shipped_default() {
let paths = RunPaths {
run: "budget".to_owned(),
dir: PathBuf::from("budget"),
};
let chosen = LaunchRecord {
writeback_item_budget: 25,
success_hook: String::new(),
failure_hook: String::new(),
hook_timeout: 0,
..a_launch(&paths)
};
assert_eq!(
per_item_budget(&chosen),
NonZeroU64::new(25).expect("a budget")
);
let older: LaunchRecord = serde_json::from_value(json!({
"run_id": "older",
"project": "plans:older",
"launcher": "test",
}))
.expect("a record predating the field reads");
assert_eq!(older.item_budget(), None);
assert_eq!(
per_item_budget(&older),
DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS
);
}
fn divergence_block(number: &str) -> Value {
let record = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract-divergences.md"),
)
.expect("the divergence record ships");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with(number))
.unwrap_or_else(|| panic!("the record still carries entry {number}"));
let block = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.unwrap_or_else(|| panic!("entry {number} carries its json block"));
serde_json::from_str(block).unwrap_or_else(|e| panic!("entry {number}'s block: {e}"))
}
#[test]
fn a_refused_snapshot_published_again_is_not_queued_and_a_different_one_is() {
let refused = snapshot(NodeStatus::Failed);
let mut pending = Pending {
refused: Some(refused.clone()),
..Pending::default()
};
assert!(
!pending.queue(refused.clone()),
"the snapshot the store refused was queued to be refused again"
);
assert!(pending.latest.is_none());
let changed = snapshot(NodeStatus::Done);
assert!(
pending.queue(changed.clone()),
"a changed graph was not queued after a refusal"
);
assert!(pending.latest.as_ref() == Some(&changed));
}
#[test]
fn the_divergence_records_refusal_rule_is_the_one_the_worker_classifies_by() {
let block = divergence_block("72.");
let rule = &block["failure"];
assert_eq!(
rule["commands"],
json!([super::PROJECT_SHOW, super::TASK_LIST, super::PROJECT_COPY]),
"entry 72 names other commands than the three an attempt runs"
);
let stops = rule["stops_the_timer"]
.as_str()
.expect("entry 72 names the class that stops the timer");
let (object, member) = rule["member"]
.as_str()
.and_then(|path| path.split_once('.'))
.expect("entry 72 names the member as a path");
let document = |class: &str| {
let mut failure = Map::new();
failure.insert(member.to_owned(), json!(class));
failure.insert("kind".to_owned(), json!("stale-origin"));
failure.insert("source".to_owned(), Value::Null);
failure.insert("message".to_owned(), json!("the store's own words"));
failure.insert("retry_after_seconds".to_owned(), Value::Null);
let mut document = Map::new();
document.insert(object.to_owned(), Value::Object(failure));
Value::Object(document).to_string()
};
let exit = rule["failure_document_exit"]
.as_i64()
.and_then(|code| i32::try_from(code).ok())
.expect("entry 72 names the exit a failure document is written under");
let refused = Some(Classified {
class: FailureClass::Refused,
kind: "stale-origin".to_owned(),
});
assert_eq!(
classified(Some(exit), document(stops).as_bytes()),
refused,
"the document entry 72 describes is not one the worker reads as refused"
);
assert_eq!(
classified(Some(exit), document("transient").as_bytes()).map(|c| c.class),
Some(FailureClass::Transient)
);
assert_eq!(classified(Some(2), document(stops).as_bytes()), None);
assert_eq!(classified(Some(exit), document("maybe").as_bytes()), None);
assert_eq!(classified(Some(exit), b"no project with that id"), None);
assert_eq!(classified(None, document(stops).as_bytes()), None);
let partial = &rule["partial_answer"];
assert_eq!(
partial["refused_when"].as_str(),
Some("every"),
"entry 72 states a partial-answer rule other than the one the worker applies"
);
let partial_exit = partial["exit"]
.as_i64()
.and_then(|code| i32::try_from(code).ok())
.expect("entry 72 names the exit a partial answer is written under");
let (list, member) = partial["member"]
.as_str()
.and_then(|path| path.split_once("[]."))
.expect("entry 72 names the partial member as a path");
let answer = |classes: &[Option<&str>]| {
let entries: Vec<Value> = classes
.iter()
.enumerate()
.map(|(n, class)| {
let mut entry = Map::new();
entry.insert("source".to_owned(), json!(format!("source{n}")));
entry.insert(
"error".to_owned(),
json!({"kind": if n == 0 { "config" } else { "unavailable" },
"message": "the store's own words"}),
);
if let Some(class) = class {
entry.insert(member.to_owned(), json!(class));
}
Value::Object(entry)
})
.collect();
let mut answer = Map::new();
answer.insert("items".to_owned(), json!([]));
answer.insert("next".to_owned(), Value::Null);
answer.insert(list.to_owned(), Value::Array(entries));
Value::Object(answer).to_string()
};
assert_eq!(
classified(Some(partial_exit), answer(&[Some(stops)]).as_bytes()),
Some(Classified {
class: FailureClass::Refused,
kind: "config".to_owned(),
})
);
assert_eq!(
classified(
Some(partial_exit),
answer(&[Some(stops), Some("transient")]).as_bytes()
),
Some(Classified {
class: FailureClass::Transient,
kind: "config, unavailable".to_owned(),
}),
"a partial answer with one entry a wait could change was read as refused"
);
assert_eq!(
classified(Some(partial_exit), answer(&[]).as_bytes()),
None,
"a partial answer naming no failure was classified"
);
assert_eq!(
classified(Some(partial_exit), answer(&[Some(stops), None]).as_bytes()),
None,
"an entry from a store that writes no class was read as a refusal"
);
}
#[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()),
refused: None,
attempts: 0,
queued_items: 0,
worker: WorkerState::Working,
phase: super::RunPhase::Running,
unprojected: Vec::new(),
};
assert!(pending.queue(first.clone()));
assert!(pending.latest.as_ref() == Some(&first));
}
#[test]
fn the_close_out_phase_is_lifted_when_the_run_is_driven_again() {
let writeback = Writeback {
pending: Arc::new((Mutex::new(Pending::default()), Condvar::new())),
vocabulary: super::Vocabulary::WithDelivers,
per_item: DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS,
told_why_not_delivered: std::sync::atomic::AtomicBool::new(false),
};
writeback.wait_briefly();
assert!(
writeback.pending.0.lock().expect("the state").phase == super::RunPhase::ClosingOut,
"the close-out did not put the run into its closing-out phase"
);
writeback.driving_again();
assert!(
writeback.pending.0.lock().expect("the state").phase == super::RunPhase::Running,
"a run driven on after a close-out is still in its closing-out phase, so a \
failing projection is retried with no interval at all"
);
}
#[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.to_path_buf(),
};
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")
}
#[test]
fn the_launch_wait_is_bounded_by_what_was_queued_after_the_worker_takes_it() {
let per_item = NonZeroU64::new(10).expect("a budget");
let nodes: BTreeMap<String, Node> = (0..12)
.map(|index| {
let id = format!("n{index}");
let node = serde_json::from_value(json!({"id": id})).expect("a node");
(id, node)
})
.collect();
let snapshot = Fixture::new("launch-wait").snapshot_with(|snapshot| snapshot.nodes = nodes);
let mut pending = Pending::default();
assert!(pending.queue(snapshot), "the first snapshot is queued");
assert!(pending.latest.take().is_some());
assert_eq!(
super::launch_wait(per_item, &pending),
Duration::from_secs(120),
"the launch wait did not follow the 12 × 10 second deadline of the queued copy"
);
}
#[test]
fn the_store_vocabulary_gate_holds_on_both_sides_of_the_first_release_carrying_delivers() {
use super::Vocabulary;
let from = crate::cli::WRITEBACK_DELIVERS_FROM;
assert_eq!(Vocabulary::of(from), Vocabulary::WithDelivers);
assert_eq!(Vocabulary::of("0.3.0"), Vocabulary::WithDelivers);
for older in ["0.2.31", "0.2.32-rc.1", "0.1.0", ""] {
assert_eq!(Vocabulary::of(older), Vocabulary::BeforeDelivers, "{older}");
}
for (vocabulary, word, carried) in [
(
Vocabulary::WithDelivers,
"queued",
Some(json!(["tickets:t-1"])),
),
(Vocabulary::BeforeDelivers, "todo", None),
] {
let snapshot = Fixture::new("vocabulary").snapshot_with(|snapshot| {
snapshot.claim = vocabulary.driving_claim();
snapshot.vocabulary = vocabulary;
snapshot
.statuses
.insert("design".to_owned(), NodeStatus::Ready);
snapshot
.nodes
.get_mut("design")
.expect("the fixture holds the node")
.delivers = vec!["tickets:t-1".to_owned()];
});
let (front, _) = super::task_document(&snapshot, &snapshot.lineages(), "design", None)
.expect("the shadow task renders");
assert_eq!(front["status"], word, "{vocabulary:?}");
assert_eq!(front.get("delivers").cloned(), carried, "{vocabulary:?}");
assert!(
front["metadata"].get("onepipeline.delivers").is_none(),
"a reserved key carries the tickets: {front}"
);
}
}
#[test]
fn a_delivered_report_is_described_only_where_every_failed_entry_reads() {
use super::failed_deliveries;
let failure = json!({"class": "transient", "kind": "unavailable", "source": "tickets",
"message": "cannot write\nnext: fix it", "retry_after_seconds": null});
let entry = |ticket: &str, outcome: &str, failure: Value| {
json!({"ticket": ticket, "deliverer": "plans:p/a", "outcome": outcome,
"from": "queued", "failure": failure})
};
let report = |entries: Vec<Value>| {
json!({"items": [], "delivered": entries})
.to_string()
.into_bytes()
};
assert_eq!(
failed_deliveries(&report(vec![entry(
"tickets:t/one",
"failed",
failure.clone()
)]))
.as_deref(),
Some("ticket tickets:t/one (delivered by plans:p/a): cannot write next: fix it")
);
assert_eq!(
failed_deliveries(&report(vec![json!({
"ticket": "tickets:t/two", "deliverer": "plans:p/a", "outcome": "written",
"from": "todo", "to": "queued"
})])),
None,
"a report failing no ticket described one"
);
for unreadable in [
entry("not qualified", "failed", failure.clone()),
entry("tickets:t/one", "exploded", failure.clone()),
entry("tickets:t/one", "failed", Value::Null),
] {
assert_eq!(
failed_deliveries(&report(vec![unreadable.clone()])),
None,
"an entry that does not validate was described: {unreadable}"
);
}
}
fn every_projected_word() -> Vec<String> {
[
ProjectedStatus::Todo,
ProjectedStatus::Queued,
ProjectedStatus::InProgress,
ProjectedStatus::Done,
ProjectedStatus::Failed,
ProjectedStatus::ProviderFailed,
ProjectedStatus::Cancelled,
ProjectedStatus::Parked,
ProjectedStatus::Skipped,
]
.into_iter()
.inspect(|status| match status {
ProjectedStatus::Todo
| ProjectedStatus::Queued
| 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,
LANDING_EVIDENCE_KEY,
];
for key in keys {
assert!(
divergence.contains(&format!("`{key}`")),
"docs/contract-divergences.md does not name the reserved key `{key}`"
);
}
let fields = [DELIVERS_FIELD];
for field in fields {
assert!(
divergence.contains(&format!("`{field}`")),
"docs/contract-divergences.md does not name the task field `{field}`"
);
}
let lineage = [NODE_KEY, SUPERSEDES_KEY];
for key in lineage {
assert!(
divergence.contains(&format!("`{key}`")),
"docs/contract-divergences.md does not name the lineage key `{key}`"
);
}
for stated in [
format!("{} words where the contract names 5", words.len()),
format!("the {} reserved keys beside the settlement", keys.len()),
format!("the {} task field the write-back owns", fields.len()),
format!("the {} lineage keys beside `{ID_KEY}`", lineage.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::Queued,
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, Claim::Held));
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}"),
}
}
static NEXT_SCRATCH: AtomicU64 = AtomicU64::new(0);
struct Scratch(PathBuf);
impl std::ops::Deref for Scratch {
type Target = Path;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn scratch(name: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!(
"onepipeline-writeback-{name}-{}-{}",
crate::sys::pid(),
NEXT_SCRATCH.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir(&dir).expect("a unique scratch directory");
Scratch(dir)
}
#[test]
fn concurrent_scratch_directories_with_the_same_name_are_independent() {
let first = std::thread::spawn(|| scratch("concurrent-same-name"));
let second = std::thread::spawn(|| scratch("concurrent-same-name"));
let first = first.join().expect("the first scratch directory");
let second = second.join().expect("the second scratch directory");
assert_ne!(&*first, &*second, "parallel invocations shared a directory");
drop(first);
assert!(
second.is_dir(),
"removing one invocation removed the other's directory"
);
}
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: Scratch,
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.to_path_buf(),
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(),
stated_landings: BTreeMap::new(),
settlements: BTreeMap::new(),
superseded: BTreeMap::new(),
project_metadata: BTreeMap::from([(
"onepipeline.concurrency".into(),
json!(4),
)]),
claim: Claim::Held,
vocabulary: super::Vocabulary::WithDelivers,
},
origins: BTreeMap::from([(
"build".to_owned(),
Origin {
id: "plans:board/002-build".parse().expect("a qualified task"),
labels: labels(&[("needs-review", Some("d73a4a"))]),
category: Some(DestinationCategory::Done),
},
)]),
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))),
)
}
}
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_node_with_no_title_is_projected_under_its_id() {
let mut fixture = Fixture::new("untitled");
for (id, title) in [("build", Value::Null), ("design", json!(" "))] {
let node = fixture
.snapshot
.nodes
.get_mut(id)
.expect("the fixture holds it");
node.title = title.as_str().map(str::to_owned);
}
fixture.project();
for id in ["build", "design"] {
let (front, _) = fixture.task_document(id);
assert_eq!(
front["title"],
json!(id),
"an untitled node reached the board with no label a destination would take"
);
assert_eq!(
front["metadata"]["onepipeline.id"],
json!(id),
"the derived title moved which node this item is"
);
}
}
#[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"
);
assert_eq!(build["metadata"].get(LANDING_EVIDENCE_KEY), None);
let (design, _) = fixture.task_document("design");
for absent in [
"onepipeline.landing",
"onepipeline.landing_commit",
"onepipeline.change_url",
LANDING_EVIDENCE_KEY,
] {
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, Claim::Held)).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");
}
use super::{
decide_member_copy_once, member_id, Carry, CopyReport, ProjectionActions, WholeBecause,
};
use crate::cli::{WRITEBACK_MEMBERS_FROM, WRITEBACK_STORE_FILE};
fn named(carry: &Carry) -> Vec<String> {
match carry {
Carry::Members(named) => named.iter().cloned().collect(),
Carry::Whole(because) => panic!("a member projection was decided whole: {because:?}"),
}
}
#[test]
fn a_member_projection_carries_exactly_the_nodes_whose_shadow_task_changed() {
let fixture = Fixture::new("carry");
let last = fixture.snapshot.clone();
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &last)),
Vec::<String>::new(),
"a snapshot the last success already projected carried a node"
);
let mut one = last.clone();
one.statuses.insert("build".to_owned(), NodeStatus::Running);
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &one)),
["build"]
);
let mut both = one.clone();
both.settlements
.insert("design".to_owned(), json!({"status": "done"}));
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &both)),
["build", "design"]
);
let mut pending = last.clone();
pending
.statuses
.insert("design".to_owned(), NodeStatus::Pending);
let mut ready = last.clone();
ready
.statuses
.insert("design".to_owned(), NodeStatus::Ready);
assert_eq!(
named(&Carry::decide(true, false, Some(&pending), &ready)),
Vec::<String>::new()
);
let mut added = last.clone();
let mut verify = added.nodes["design"].clone();
verify.id = "verify".to_owned();
added.nodes.insert("verify".to_owned(), verify);
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &added)),
["verify"]
);
let mut project_level = last.clone();
project_level
.project_metadata
.insert("onepipeline.goal".to_owned(), json!("a goal restated"));
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &project_level)),
Vec::<String>::new(),
"a change only the project item carries named a task"
);
}
#[test]
fn a_projection_is_whole_for_the_first_reason_entry_73_ranks_that_applies() {
let block = divergence_block("73.");
let precedence: Vec<String> =
serde_json::from_value(block["projection"]["whole_because_precedence"].clone())
.expect("entry 73 ranks the reasons a projection is whole");
let reasons: std::collections::BTreeSet<String> = block["projection"]["whole_because"]
.as_object()
.expect("entry 73 names each reason")
.keys()
.cloned()
.collect();
assert_eq!(
precedence
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
reasons,
"entry 73 ranks other reasons than it names"
);
let snapshot = Fixture::new("whole").snapshot.clone();
for members in [true, false] {
for after_failure in [true, false] {
for landed in [true, false] {
let applies = |reason: &str| match reason {
"store-lacks-members" => !members,
"after-failure" => after_failure,
"first" => !landed,
other => {
panic!("entry 73 names a reason the worker never decides: {other}")
}
};
let expected = precedence.iter().find(|reason| applies(reason));
let decided = Carry::decide(
members,
after_failure,
landed.then_some(&snapshot),
&snapshot,
);
match (expected, decided) {
(Some(reason), Carry::Whole(because)) => assert_eq!(
serde_json::to_value(because).expect("a reason serializes"),
json!(reason),
"members {members}, after a failure {after_failure}, landed {landed}"
),
(None, Carry::Members(_)) => {}
(expected, Carry::Whole(because)) => panic!(
"decided whole ({because:?}) where entry 73 expects {expected:?}"
),
(expected, Carry::Members(_)) => {
panic!("decided members where entry 73 expects {expected:?}")
}
}
}
}
}
assert_eq!(
serde_json::to_value(WholeBecause::StoreLacksMembers).expect("serializes"),
json!("store-lacks-members")
);
}
#[test]
fn a_copy_report_is_counted_and_teaches_the_run_where_a_created_node_landed() {
let fixture = Fixture::new("report");
let snapshot = &fixture.snapshot;
let report: CopyReport = serde_json::from_value(json!({
"items": [
{"source": format!("onepipeline-writeback:{}", super::project_file(&snapshot.project)),
"action": "unchanged", "destination": "plans:board"},
{"source": member_id(snapshot, "build"), "action": "updated",
"destination": "plans:board/002-build"},
{"source": member_id(snapshot, "design"), "action": "created",
"destination": "plans:board/003-design"},
{"source": "elsewhere:board/gone", "action": "orphaned",
"destination": "plans:board/009-gone"},
],
"spent": {"requests": 3, "budgets": []},
}))
.expect("the store's own report reads");
assert_eq!(
report.actions(snapshot, &fixture.origins),
ProjectionActions {
created: 1,
updated: 1,
unchanged: 1,
orphaned: 1,
reopened: 0,
}
);
assert_eq!(
report.spent,
Some(
json!({"requests": 3, "budgets": []})
.as_object()
.cloned()
.expect("an object")
)
);
let mut origins = fixture.origins.clone();
report.learn(&mut origins, snapshot);
assert_eq!(
origins.len(),
2,
"an item that is no node's shadow task taught a node"
);
assert_eq!(origins["design"].id.as_str(), "plans:board/003-design");
assert!(origins["design"].labels.is_empty());
assert_eq!(origins["build"].id.as_str(), "plans:board/002-build");
assert_eq!(
origins["build"].labels.len(),
1,
"an updated node the run already knew lost the labels it was read with"
);
assert!(
serde_json::from_value::<CopyReport>(json!({
"items": [{"source": "plans:board/a", "action": "moved", "destination": "plans:board/b"}]
}))
.is_err(),
"a report naming an action this build does not know was read"
);
}
#[test]
fn whether_the_store_offers_a_member_copy_is_decided_once_and_read_back_after() {
let block = divergence_block("73.");
let detection = &block["detection"];
assert_eq!(
detection["members_from"].as_str(),
Some(WRITEBACK_MEMBERS_FROM)
);
assert!(crate::taskgraph::at_least(
WRITEBACK_MEMBERS_FROM,
WRITEBACK_MEMBERS_FROM
));
assert!(crate::taskgraph::at_least("0.3.0", WRITEBACK_MEMBERS_FROM));
assert!(!crate::taskgraph::at_least(
"0.2.29",
WRITEBACK_MEMBERS_FROM
));
assert!(!crate::taskgraph::at_least(
"0.2.30-rc.1",
WRITEBACK_MEMBERS_FROM
));
assert!(!crate::taskgraph::at_least("", WRITEBACK_MEMBERS_FROM));
let dir = scratch("members-record");
assert!(!decide_member_copy_once(&dir, "0.2.29"));
let path = dir.join(WRITEBACK_STORE_FILE);
let recorded: Value =
serde_json::from_slice(&std::fs::read(&path).expect("the answer is recorded"))
.expect("the record is JSON");
assert_eq!(recorded, json!({"version": "0.2.29", "members": false}));
assert_eq!(
recorded
.as_object()
.map(|record| record.keys().collect::<Vec<_>>()),
detection["record_example"]
.as_object()
.map(|record| record.keys().collect::<Vec<_>>()),
"the record is not the shape entry 73 states"
);
assert!(
!decide_member_copy_once(&dir, "0.2.30"),
"a later decision asked the version again rather than reading the run's answer"
);
std::fs::write(&path, "not a record").expect("the record is spoiled");
assert!(
decide_member_copy_once(&dir, "0.2.30"),
"a record that does not read was kept"
);
assert_eq!(
serde_json::from_slice::<Value>(&std::fs::read(&path).expect("a record"))
.expect("JSON"),
detection["record_example"],
);
std::fs::write(&path, r#"{"version": "0.2.29", "members": true}"#)
.expect("a contradictory record is written");
assert!(
decide_member_copy_once(&dir, "0.2.30"),
"a record whose `members` its version contradicts was trusted"
);
assert_eq!(
serde_json::from_slice::<Value>(&std::fs::read(&path).expect("a record"))
.expect("JSON"),
json!({"version": "0.2.30", "members": true}),
);
}
fn retried(fixture: &mut Fixture, twice: bool) -> Vec<String> {
let mut chain = vec!["build".to_owned(), "build-2".to_owned()];
if twice {
chain.push("build-3".to_owned());
}
let head = chain.last().expect("a head").clone();
for (superseded, replacement) in chain.iter().zip(chain.iter().skip(1)) {
fixture
.snapshot
.superseded
.insert(superseded.clone(), replacement.clone());
fixture
.snapshot
.statuses
.insert(superseded.clone(), NodeStatus::Cancelled);
fixture.snapshot.settlements.insert(
superseded.clone(),
json!({"status": "cancelled", "outcome": "superseded"}),
);
let mut node: Node = serde_json::from_value(json!({
"id": replacement,
"title": format!("feat: {replacement} it again"),
"task": format!("## What\n{replacement} it, again."),
"persona": "reviewer",
"deps": ["design"],
"delivers": ["tickets:board/build"],
}))
.expect("a replacement node");
node.branch = Some(format!("topic/{replacement}"));
fixture.snapshot.nodes.insert(replacement.clone(), node);
fixture
.snapshot
.statuses
.insert(replacement.clone(), NodeStatus::Ready);
}
let ship: Node = serde_json::from_value(json!({
"id": "ship", "title": "feat: ship it", "task": "## What\nShip it.",
"persona": "engineer", "deps": [head],
}))
.expect("a dependent node");
fixture.snapshot.nodes.insert("ship".to_owned(), ship);
fixture
.snapshot
.statuses
.insert("ship".to_owned(), NodeStatus::Pending);
chain
}
#[test]
fn one_shadow_task_per_lineage_is_keyed_by_the_root_and_says_what_the_head_says() {
let mut fixture = Fixture::new("lineage");
let chain = retried(&mut fixture, true);
let stale = fixture
.dir
.join("tasks")
.join(super::project_file(&fixture.snapshot.project))
.join(format!("{}.md", super::task_file("build-2")));
std::fs::create_dir_all(stale.parent().expect("a directory")).expect("the tasks dir");
std::fs::write(&stale, "---\ntitle: left by an earlier build\n---\n")
.expect("a stale task");
fixture.project();
let (build, body) = fixture.task_document("build");
assert_eq!(build["title"], "feat: build-3 it again");
assert_eq!(body, "## What\nbuild-3 it, again.");
assert_eq!(
build["status"], "queued",
"the lineage carries a superseded attempt's word"
);
assert_eq!(build["metadata"][ID_KEY], "build");
assert_eq!(build["metadata"][NODE_KEY], "build-3");
assert_eq!(
build["metadata"][SUPERSEDES_KEY],
json!(["build", "build-2"])
);
assert_eq!(build["metadata"]["onepipeline.persona"], "reviewer");
assert_eq!(build["metadata"]["onepipeline.branch"], "topic/build-3");
assert_eq!(build["delivers"], json!(["tickets:board/build"]));
assert_eq!(
build["metadata"].get(crate::taskgraph::SETTLEMENT_KEY),
None,
"a superseded attempt's settlement reached the lineage's item"
);
assert_eq!(
build["metadata"]["onetaskgraph.origin"], "plans:board/002-build",
"the lineage was not written onto the item its root already holds"
);
assert_eq!(
build["labels"],
json!([{"id": "needs-review", "name": "needs-review", "color": "d73a4a"}]),
);
assert_eq!(
build["depends_on"],
json!([format!(
"{}/{}",
super::project_file(&fixture.snapshot.project),
super::task_file("design")
)])
);
for attempt in &chain[1..] {
let own = fixture
.dir
.join("tasks")
.join(super::project_file(&fixture.snapshot.project))
.join(format!("{}.md", super::task_file(attempt)));
assert!(
!own.exists(),
"a retry attempt was projected as a shadow task of its own: {attempt}"
);
}
assert!(
!stale.exists(),
"a shadow task this snapshot did not write was left behind"
);
let (ship, _) = fixture.task_document("ship");
assert_eq!(
ship["depends_on"],
json!([format!(
"{}/{}",
super::project_file(&fixture.snapshot.project),
super::task_file("build")
)]),
"an edge onto a retried node names something other than its lineage's root"
);
assert_eq!(ship["metadata"][NODE_KEY], "ship");
assert_eq!(
ship["metadata"].get(SUPERSEDES_KEY),
None,
"a node nothing superseded lists supersessions"
);
fixture
.snapshot
.nodes
.get_mut("build-3")
.expect("the head")
.title = None;
fixture.project();
let (untitled, _) = fixture.task_document("build");
assert_eq!(untitled["title"], "build");
assert_eq!(
Carry::Whole(WholeBecause::First).items(&fixture.snapshot),
["build", "design", "ship"]
);
}
#[test]
fn a_retry_is_carried_as_a_change_to_the_root_member() {
let mut fixture = Fixture::new("lineage-carry");
let last = fixture.snapshot.clone();
retried(&mut fixture, false);
let once = fixture.snapshot.clone();
assert_eq!(
named(&Carry::decide(true, false, Some(&last), &once)),
["build", "ship"],
"a retry named the replacement as a member of its own, or missed the root"
);
let mut fixture = Fixture::new("lineage-carry-twice");
retried(&mut fixture, true);
let twice = fixture.snapshot.clone();
assert_eq!(
named(&Carry::decide(true, false, Some(&once), &twice)),
["build"],
"a second retry of one lineage named something other than its root"
);
assert_eq!(
named(&Carry::decide(true, false, Some(&twice), &twice)),
Vec::<String>::new()
);
}
#[test]
fn several_items_under_one_root_resolve_to_the_furthest_along_attempt() {
let mut fixture = Fixture::new("furthest-along");
retried(&mut fixture, true);
let lineages = fixture.snapshot.lineages();
let item = |file: &str, id: &str, node: Option<&str>, category: &str| {
let mut metadata = json!({ID_KEY: id});
if let Some(node) = node {
metadata[NODE_KEY] = json!(node);
}
serde_json::from_value::<super::DestinationTask>(json!({
"id": format!("plans:board/{file}"),
"item": {"labels": [], "metadata": metadata,
"status": {"category": category, "name": category}},
}))
.expect("the sibling's own task response")
};
let place = |items: Vec<super::DestinationTask>| {
super::furthest_along(
items
.into_iter()
.map(|task| super::Placed::of(&lineages, task).expect("an item places"))
.collect(),
)
};
let older = || {
vec![
item("001-build", "build", None, "cancelled"),
item("004-build-2", "build-2", None, "cancelled"),
item("002-design", "design", None, "done"),
item("009-elsewhere", "elsewhere", None, "todo"),
]
};
let origins = place(older()).expect("an older board resolves");
assert_eq!(
origins.keys().cloned().collect::<Vec<_>>(),
["build", "design", "elsewhere"],
"the fold keyed something other than lineage roots and strays"
);
assert_eq!(
origins["build"].id.as_str(),
"plans:board/004-build-2",
"the lineage was not resolved to its furthest-along item"
);
assert_eq!(origins["design"].id.as_str(), "plans:board/002-design");
assert!(
!origins.contains_key("ship"),
"a lineage the board holds nothing for was given an origin"
);
let mut reversed = older();
reversed.reverse();
assert_eq!(
place(reversed).expect("resolves")["build"].id.as_str(),
"plans:board/004-build-2",
"the order the page answered in decided the fold"
);
let rewritten = vec![
item("001-build", "build", None, "cancelled"),
item("004-build-2", "build", Some("build-3"), "queued"),
];
let origins = place(rewritten).expect("a board this build wrote over resolves again");
assert_eq!(
origins["build"].id.as_str(),
"plans:board/004-build-2",
"the rewritten head lost to the older item at the root"
);
for (said, twice) in [
(
"build-2",
vec![
item("004-build-2", "build-2", None, "cancelled"),
item("005-build-2", "build", Some("build-2"), "cancelled"),
],
),
(
"build",
vec![
item("001-build", "build", None, "cancelled"),
item("003-build", "build", None, "cancelled"),
],
),
] {
let refused = match place(twice) {
Err(refused) => refused,
Ok(resolved) => panic!(
"two items at one position resolved to {:?}",
resolved.keys().collect::<Vec<_>>()
),
};
assert_eq!(
refused,
format!("project has more than one task for node '{said}'")
);
}
let stray = place(vec![
item("001-build", "build", Some("nobody"), "cancelled"),
item("004-build-2", "build-2", None, "cancelled"),
])
.expect("resolves");
assert_eq!(stray["build"].id.as_str(), "plans:board/004-build-2");
for (key, wrong) in [
(NODE_KEY, json!(7)),
(NODE_KEY, json!("")),
(ID_KEY, json!(["build"])),
] {
let mut malformed = item("001-build", "build", None, "cancelled");
malformed
.item
.metadata
.insert(key.to_owned(), wrong.clone());
let refused = super::Placed::of(&lineages, malformed)
.err()
.unwrap_or_else(|| panic!("{key} present as {wrong} was read as absent"));
assert_eq!(
refused,
format!(
"task 'plans:board/001-build' carries {key} as {wrong}, and a node id is \
a non-empty string"
)
);
}
fixture.origins = place(older()).expect("resolves");
fixture.project();
let (build, _) = fixture.task_document("build");
assert_eq!(
build["metadata"]["onetaskgraph.origin"], "plans:board/004-build-2",
"the lineage was projected onto an item other than the furthest-along one"
);
}
#[test]
fn entry_80_names_the_lineage_keys_and_the_reopen_rule_the_projection_writes() {
let block = divergence_block("80.");
let keys: Vec<String> = block["lineage"]["keys"]
.as_object()
.expect("entry 80 names the lineage keys")
.keys()
.cloned()
.collect();
assert_eq!(keys, [ID_KEY, NODE_KEY, SUPERSEDES_KEY]);
assert_eq!(block["lineage"]["keys"][ID_KEY], "root");
assert_eq!(block["lineage"]["keys"][NODE_KEY], "head");
assert_eq!(
block["lineage"]["keys"][SUPERSEDES_KEY]["written_when"],
"the head is not the root"
);
assert_eq!(block["lineage"]["shadow_tasks_per_lineage"], 1);
assert_eq!(
block["destination"]["category_read_by"],
json!({"whole": super::TASK_LIST, "members": super::TASK_SHOW})
);
assert_eq!(block["reopened"]["store_vocabulary_extended"], false);
assert_eq!(
block["reopened"]["record"]["from_schema_version"].as_u64(),
Some(u64::from(crate::cli::WRITEBACK_PROJECTIONS_SCHEMA_VERSION))
);
assert_eq!(block["reopened"]["record"]["key"], "actions.reopened");
assert!(
serde_json::to_value(ProjectionActions::default())
.expect("counts serialize")
.get("reopened")
.is_some(),
"the record has no `reopened` for entry 80 to name"
);
}
#[test]
fn reopened_is_counted_off_the_pre_copy_category_and_the_projected_word() {
let mut fixture = Fixture::new("reopened");
retried(&mut fixture, false);
let snapshot = &fixture.snapshot;
let before = BTreeMap::from([
(
"build".to_owned(),
Origin {
id: "plans:board/002-build".parse().expect("a qualified task"),
labels: Vec::new(),
category: Some(DestinationCategory::Cancelled),
},
),
(
"design".to_owned(),
Origin {
id: "plans:board/003-design".parse().expect("a qualified task"),
labels: Vec::new(),
category: Some(DestinationCategory::Done),
},
),
]);
let item = |root: &str, action: &str| {
json!({"source": member_id(snapshot, root), "action": action,
"destination": format!("plans:board/00x-{root}")})
};
let report = |items: Vec<Value>| -> CopyReport {
serde_json::from_value(json!({"items": items})).expect("the store's report reads")
};
let counted = report(vec![
item("build", "updated"),
item("design", "updated"),
item("ship", "created"),
json!({"source": "elsewhere:board/gone", "action": "updated",
"destination": "plans:board/009-gone"}),
])
.actions(snapshot, &before);
assert_eq!(
counted,
ProjectionActions {
created: 1,
updated: 3,
unchanged: 0,
orphaned: 0,
reopened: 1,
}
);
let mut unknown = before.clone();
unknown
.get_mut("build")
.expect("the lineage's origin")
.category = None;
assert_eq!(
report(vec![item("build", "updated")])
.actions(snapshot, &unknown)
.reopened,
0,
"an item whose category nobody read was counted reopened"
);
let mut dropped = fixture.snapshot.clone();
dropped
.statuses
.insert("build-2".to_owned(), NodeStatus::Cancelled);
assert_eq!(
report(vec![item("build", "updated")])
.actions(&dropped, &before)
.reopened,
0,
"a cancelled item rewritten cancelled was counted reopened"
);
let mut released = fixture.snapshot.clone();
released.claim = Claim::Released;
assert_eq!(
report(vec![item("build", "updated")])
.actions(&released, &before)
.reopened,
1
);
}
}