719 lines
26 KiB
Rust
719 lines
26 KiB
Rust
//! Test support: a real mind over a temporary directory behind `MindPort`, a
|
|
//! recording launcher and a settable clock. Every test document is built here,
|
|
//! so a field the leaf adds to a kind is one edit.
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
use std::collections::VecDeque;
|
|
use std::rc::Rc;
|
|
use std::time::Duration as StdDuration;
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use chrono::Duration as ChronoDuration;
|
|
use model_connector::PlanQuota;
|
|
use chrono::{DateTime, Duration, TimeZone, Utc};
|
|
use cultnet_rs::Selection;
|
|
use eureka_pipeline::{
|
|
CommitRange, CutVerification, Date, DocRef, MemoryOrigin, MemorySort, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFollowUp,
|
|
PipelineInstance, PipelineKind, PipelineMemory, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, PipelineRun, PipelineStewardship,
|
|
PipelineTarget, QuestionOption, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Slug, StructuralDelta, TargetInvariant,
|
|
};
|
|
use huginn_mind::{
|
|
Faculty, HuginnMindRequest, HuginnMindResponse, Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome,
|
|
PipelineDocumentView, PipelineProvenance, PipelineSelectionPage,
|
|
};
|
|
use tempfile::TempDir;
|
|
|
|
use crate::launch::{Clock, Launcher};
|
|
use crate::lease_client::{Lease, LeaseRefusalKind, LeaseRefused, LeaseRequest, Lender, Lent, TokenFd, token_holding};
|
|
use crate::queue::MindPort;
|
|
use crate::self_run::{Child, Read, Spawner, Stop};
|
|
|
|
pub(crate) const INSTANCE: &str = "yggdrasil";
|
|
pub(crate) const CAMPAIGN: &str = "eureka-body";
|
|
pub(crate) const REPO: &str = "GameCult/Huginn";
|
|
|
|
pub(crate) fn at(milliseconds: i64) -> DateTime<Utc> {
|
|
Utc.with_ymd_and_hms(2026, 10, 9, 13, 15, 0).unwrap() + Duration::milliseconds(milliseconds)
|
|
}
|
|
|
|
pub(crate) struct Fixed(pub(crate) Cell<DateTime<Utc>>);
|
|
|
|
impl Clock for Fixed {
|
|
fn now(&self) -> DateTime<Utc> {
|
|
self.0.get()
|
|
}
|
|
}
|
|
|
|
/// A launcher that records every start as (instance, turn), fails them on
|
|
/// command, and answers `alive` per unit, which is what instance and turn name:
|
|
/// `start` fills it, `kill` empties a turn's unit and `revive` fills it for a
|
|
/// seeded run.
|
|
#[derive(Default)]
|
|
pub(crate) struct Recording {
|
|
pub(crate) started: RefCell<Vec<(Slug, RunTurn)>>,
|
|
pub(crate) fail: Cell<bool>,
|
|
/// One named instance whose unit never starts.
|
|
pub(crate) fail_for: RefCell<Option<Slug>>,
|
|
running: RefCell<Vec<(Slug, RunTurn)>>,
|
|
}
|
|
|
|
impl Recording {
|
|
pub(crate) fn kill(&self, turn: RunTurn) {
|
|
self.running.borrow_mut().retain(|unit| *unit != (Slug(INSTANCE.into()), turn));
|
|
}
|
|
|
|
pub(crate) fn revive(&self, turn: RunTurn) {
|
|
self.revive_as(&Slug(INSTANCE.into()), turn);
|
|
}
|
|
|
|
/// `revive` for a named instance.
|
|
pub(crate) fn revive_as(&self, instance: &Slug, turn: RunTurn) {
|
|
self.running.borrow_mut().push((instance.clone(), turn));
|
|
}
|
|
}
|
|
|
|
impl Launcher for Recording {
|
|
fn start(&self, instance: &Slug, turn: RunTurn) -> Result<()> {
|
|
if self.fail.get() || self.fail_for.borrow().as_ref() == Some(instance) {
|
|
return Err(anyhow!("refused"));
|
|
}
|
|
self.started.borrow_mut().push((instance.clone(), turn));
|
|
self.running.borrow_mut().push((instance.clone(), turn));
|
|
Ok(())
|
|
}
|
|
|
|
fn alive(&self, instance: &Slug, turn: RunTurn) -> bool {
|
|
self.running.borrow().contains(&(instance.clone(), turn))
|
|
}
|
|
}
|
|
|
|
pub(crate) struct TestMind {
|
|
_dir: TempDir,
|
|
instance: Slug,
|
|
mind: RefCell<Mind<OwnedRedbMessagePackBackingStore>>,
|
|
now: Cell<DateTime<Utc>>,
|
|
}
|
|
|
|
impl TestMind {
|
|
/// A mind holding its identity, the repo's stewardship, the campaign and a
|
|
/// first target.
|
|
pub(crate) fn seeded() -> Self {
|
|
Self::seeded_as(INSTANCE)
|
|
}
|
|
|
|
/// A mind holding only its identity and the repo's stewardship: no campaign.
|
|
pub(crate) fn without_campaigns() -> Self {
|
|
let dir = TempDir::new().unwrap();
|
|
let mind = Mind::open(dir.path(), &Slug(INSTANCE.into())).unwrap();
|
|
let this = Self { _dir: dir, instance: Slug(INSTANCE.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
|
|
this.put(vec![instance(INSTANCE), stewardship(INSTANCE)]);
|
|
this
|
|
}
|
|
|
|
/// A mind holding only its identity: no stewardship, no campaign.
|
|
pub(crate) fn bare() -> Self {
|
|
let dir = TempDir::new().unwrap();
|
|
let mind = Mind::open(dir.path(), &Slug(INSTANCE.into())).unwrap();
|
|
let this = Self { _dir: dir, instance: Slug(INSTANCE.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
|
|
this.put(vec![instance(INSTANCE)]);
|
|
this
|
|
}
|
|
|
|
/// The same, for another instance name.
|
|
pub(crate) fn seeded_as(name: &str) -> Self {
|
|
let dir = TempDir::new().unwrap();
|
|
let mind = Mind::open(dir.path(), &Slug(name.into())).unwrap();
|
|
let this = Self { _dir: dir, instance: Slug(name.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
|
|
this.put(vec![instance(name), stewardship(name), campaign(), target()]);
|
|
this
|
|
}
|
|
|
|
/// The next admission lands exactly at `when`.
|
|
pub(crate) fn admit_at(&self, when: DateTime<Utc>) {
|
|
self.now.set(when - Duration::seconds(1));
|
|
}
|
|
|
|
/// Admits as Hands, one second after the last admission.
|
|
pub(crate) fn put(&self, documents: Vec<PipelineDocument>) -> PipelineAdmissionOutcome {
|
|
self.put_as(Faculty::Hands, documents)
|
|
}
|
|
|
|
/// Admits as `faculty`, one second after the last admission.
|
|
pub(crate) fn put_as(&self, faculty: Faculty, documents: Vec<PipelineDocument>) -> PipelineAdmissionOutcome {
|
|
self.now.set(self.now.get() + Duration::seconds(1));
|
|
let batch = PipelineAdmissionBatch {
|
|
instance: self.instance.clone(),
|
|
provenance: PipelineProvenance { faculty, agent: "test".into(), session: "test".into(), tool: "test".into() },
|
|
documents,
|
|
};
|
|
self.mind.borrow_mut().admit(batch, self.now.get())
|
|
}
|
|
|
|
pub(crate) fn committed(&self, documents: Vec<PipelineDocument>) {
|
|
let outcome = self.put(documents);
|
|
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
|
}
|
|
|
|
/// What a daemon over this mind answers a request with.
|
|
pub(crate) fn answer(&self, request: HuginnMindRequest) -> HuginnMindResponse {
|
|
match request {
|
|
HuginnMindRequest::Query { selection, .. } => match self.mind.borrow().query(&selection) {
|
|
Ok(page) => HuginnMindResponse::Query(page),
|
|
Err(refusal) => HuginnMindResponse::Refused(refusal),
|
|
},
|
|
HuginnMindRequest::Admit(batch) => HuginnMindResponse::Admit(self.mind.borrow_mut().admit(batch, self.now.get())),
|
|
HuginnMindRequest::RootsTouched { .. } => match self.mind.borrow().roots_touched() {
|
|
Ok(roots) => HuginnMindResponse::RootsTouched(roots),
|
|
Err(refusal) => HuginnMindResponse::Refused(refusal),
|
|
},
|
|
other => panic!("the tests ask nothing else: {other:?}"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn view(&self, kind: PipelineKind, id: &str) -> Option<PipelineDocumentView> {
|
|
self.mind.borrow().view(&PipelineRef { kind, id: id.into() }).unwrap()
|
|
}
|
|
|
|
/// Every document of a kind, as ids.
|
|
pub(crate) fn ids(&self, kind: PipelineKind) -> Vec<String> {
|
|
let selection = Selection { schemas: Some(vec![kind.type_id().to_string()]), ..Selection::default() };
|
|
let page = self.query(&selection).unwrap();
|
|
match page.items {
|
|
huginn_mind::PipelinePageItems::Headers(headers) => headers.into_iter().map(|header| header.id.id.0).collect(),
|
|
huginn_mind::PipelinePageItems::Documents(views) => views.into_iter().map(|view| view.id.id.0).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MindPort for TestMind {
|
|
fn instance(&self) -> &Slug {
|
|
&self.instance
|
|
}
|
|
|
|
fn query(&self, selection: &Selection) -> Result<PipelineSelectionPage> {
|
|
self.mind.borrow().query(selection).map_err(|_| anyhow!("refused"))
|
|
}
|
|
|
|
/// A stand-in for the daemon's index, which this crate does not own: the selection's members
|
|
/// ranked by the words they share with `text`, ties in the mind's order. The tests pin what is
|
|
/// asked (`Seen`-style ports record it); what the index would rank is the daemon's.
|
|
fn query_near(&self, selection: &Selection, text: &str, top_k: u32) -> Result<PipelineSelectionPage> {
|
|
let mut page = self.query(selection)?;
|
|
let words: std::collections::BTreeSet<String> = text.split_whitespace().map(str::to_lowercase).collect();
|
|
if let huginn_mind::PipelinePageItems::Documents(views) = &mut page.items {
|
|
let shared = |view: &PipelineDocumentView| {
|
|
let own = serde_json::to_string(&view.document).unwrap_or_default().to_lowercase();
|
|
words.iter().filter(|word| own.contains(word.as_str())).count()
|
|
};
|
|
views.sort_by_key(|view| std::cmp::Reverse(shared(view)));
|
|
views.truncate(top_k as usize);
|
|
}
|
|
Ok(page)
|
|
}
|
|
|
|
/// A forge with nothing new in it: the daemon with a healthy forge and a quiet org.
|
|
fn stewarded_activity(&self, _repos: &[huginn_mind::eureka_pipeline::OrgRepo], after: Option<u64>) -> Result<huginn_mind::RepoActivity> {
|
|
Ok(huginn_mind::RepoActivity { head: after.unwrap_or(0), events: vec![], capped: vec![], open_prs: vec![], unseen: vec![], moved: vec![] })
|
|
}
|
|
|
|
/// The roots of this mind as it holds them: the daemon's answer from the same mind, never a stub.
|
|
fn roots_touched(&self) -> Result<Vec<huginn_mind::RootTouched>> {
|
|
self.mind.borrow().roots_touched().map_err(|_| anyhow!("refused"))
|
|
}
|
|
|
|
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
|
self.now.set(self.now.get() + Duration::seconds(1));
|
|
let batch = PipelineAdmissionBatch {
|
|
instance: self.instance.clone(),
|
|
provenance: PipelineProvenance { faculty: Faculty::SelfFaculty, agent: agent.into(), session: session.into(), tool: "mind-body".into() },
|
|
documents,
|
|
};
|
|
Ok(self.mind.borrow_mut().admit(batch, self.now.get()))
|
|
}
|
|
}
|
|
|
|
/// A mind whose admissions answer as told, over a real mind's reads.
|
|
pub(crate) struct Answering<'a> {
|
|
pub(crate) inner: &'a TestMind,
|
|
pub(crate) outcome: PipelineAdmissionOutcome,
|
|
}
|
|
|
|
impl MindPort for Answering<'_> {
|
|
fn instance(&self) -> &Slug {
|
|
self.inner.instance()
|
|
}
|
|
|
|
fn query(&self, selection: &Selection) -> Result<PipelineSelectionPage> {
|
|
self.inner.query(selection)
|
|
}
|
|
|
|
fn admit(&self, _agent: &str, _session: &str, _documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
|
Ok(self.outcome.clone())
|
|
}
|
|
}
|
|
|
|
/// One of her memories: `sort` and `text`, about `about`, learned in #swarm, salience 500.
|
|
pub(crate) fn memory(label: &str, sort: MemorySort, text: &str, about: &[&str]) -> PipelineDocument {
|
|
memory_from(label, sort, text, about, MemoryOrigin::Room { rooms: "swarm".into(), heard_from: vec![] }, 500)
|
|
}
|
|
|
|
/// One of her memories with the origin and salience the ranking tests need.
|
|
pub(crate) fn memory_from(label: &str, sort: MemorySort, text: &str, about: &[&str], origin: MemoryOrigin, salience_permille: u16) -> PipelineDocument {
|
|
PipelineDocument::Memory(PipelineMemory {
|
|
instance: Slug(INSTANCE.into()),
|
|
label: eureka_pipeline::Label(label.into()),
|
|
sort,
|
|
text: text.into(),
|
|
about: about.iter().map(|who| (*who).into()).collect(),
|
|
origin,
|
|
sources: vec![],
|
|
salience_permille,
|
|
consolidates: vec![],
|
|
})
|
|
}
|
|
|
|
fn doc_ref() -> DocRef {
|
|
DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) }
|
|
}
|
|
|
|
fn instance(name: &str) -> PipelineDocument {
|
|
PipelineDocument::Instance(PipelineInstance {
|
|
instance: Slug(name.into()),
|
|
display_name: "test mind".into(),
|
|
created_at: Date("2026-10-09".into()),
|
|
host: "test".into(),
|
|
})
|
|
}
|
|
|
|
fn stewardship(name: &str) -> PipelineDocument {
|
|
PipelineDocument::Stewardship(PipelineStewardship {
|
|
instance: Slug(name.into()),
|
|
repo: REPO.into(),
|
|
sequence: 1,
|
|
assigned_on: Date("2026-10-09".into()),
|
|
note: "assigned".into(),
|
|
})
|
|
}
|
|
|
|
fn campaign() -> PipelineDocument {
|
|
campaign_named(CAMPAIGN, "Eureka body")
|
|
}
|
|
|
|
pub(crate) fn campaign_named(slug: &str, title: &str) -> PipelineDocument {
|
|
PipelineDocument::Campaign(PipelineCampaign {
|
|
slug: Slug(slug.into()),
|
|
title: title.into(),
|
|
repos: vec![REPO.into()],
|
|
working_branch: "main".into(),
|
|
target_doc: doc_ref(),
|
|
})
|
|
}
|
|
|
|
/// A campaign opened on `repos`, with its first target and the one invariant.
|
|
pub(crate) fn campaign_on(slug: &str, repos: &[&str]) -> Vec<PipelineDocument> {
|
|
let PipelineDocument::Campaign(mut opened) = campaign_named(slug, slug) else { unreachable!("a campaign") };
|
|
opened.repos = repos.iter().map(|repo| (*repo).into()).collect();
|
|
let PipelineDocument::Target(mut first) = target() else { unreachable!("a target") };
|
|
first.campaign = Slug(slug.into());
|
|
vec![PipelineDocument::Campaign(opened), PipelineDocument::Target(first)]
|
|
}
|
|
|
|
fn target() -> PipelineDocument {
|
|
PipelineDocument::Target(PipelineTarget {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
revision: 1,
|
|
invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }],
|
|
not_in_scope: vec![],
|
|
canonical_implementations: vec![],
|
|
repos: vec![],
|
|
doc: doc_ref(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn id(kind: &str, local: &str) -> String {
|
|
format!("{CAMPAIGN}:{kind}:{local}")
|
|
}
|
|
|
|
pub(crate) fn reference(kind: PipelineKind, id: &str) -> PipelineRef {
|
|
PipelineRef { kind, id: id.into() }
|
|
}
|
|
|
|
pub(crate) fn spec_ref(cut: &str) -> PipelineRef {
|
|
reference(PipelineKind::CutSpec, &id("cut_spec", &format!("cut-{cut}.r1")))
|
|
}
|
|
|
|
pub(crate) fn spec(cut: &str) -> PipelineDocument {
|
|
PipelineDocument::CutSpec(PipelineCutSpec {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
cut: cut.into(),
|
|
revision: 1,
|
|
title: "A cut".into(),
|
|
repo: REPO.into(),
|
|
branch: "eureka-body/x".into(),
|
|
base: Sha("df7507b".into()),
|
|
depends_on: vec![],
|
|
first: vec![],
|
|
deletes: vec![],
|
|
keeps_moves: vec![],
|
|
adds: vec![],
|
|
file_changes: vec![],
|
|
reads: vec![],
|
|
authority_map: None,
|
|
verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] },
|
|
estimate: delta(),
|
|
rulings: vec![],
|
|
questions: vec![],
|
|
})
|
|
}
|
|
|
|
fn delta() -> StructuralDelta {
|
|
StructuralDelta {
|
|
lines_added: 0,
|
|
lines_removed: 0,
|
|
dependencies_added: vec![],
|
|
dependencies_removed: vec![],
|
|
formats_added: vec![],
|
|
formats_removed: vec![],
|
|
targets_added: vec![],
|
|
targets_removed: vec![],
|
|
}
|
|
}
|
|
|
|
pub(crate) fn report(cut: &str) -> PipelineDocument {
|
|
PipelineDocument::CutReport(PipelineCutReport {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
cut_spec: id("cut_spec", &format!("cut-{cut}.r1")).as_str().into(),
|
|
attempt: 1,
|
|
repo: REPO.into(),
|
|
branch: "eureka-body/x".into(),
|
|
commits: vec![eureka_pipeline::ReportCommit { sha: Sha("df7507b".into()), subject: "Land the cut".into(), builds: true }],
|
|
range: CommitRange { base: Sha("df7507b".into()), head: Sha("df7507b".into()) },
|
|
verification: vec![],
|
|
mutations: vec![],
|
|
deviations: vec![],
|
|
forks: vec![],
|
|
structural_delta: delta(),
|
|
landed_names: vec![],
|
|
undone: vec![],
|
|
promises: vec![],
|
|
})
|
|
}
|
|
|
|
/// A question raised in `item`, which blocks it.
|
|
pub(crate) fn question_in(label: &str, item: &PipelineRef) -> PipelineDocument {
|
|
PipelineDocument::Question(PipelineQuestion {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
label: label.into(),
|
|
title: "A fork".into(),
|
|
question: "Which?".into(),
|
|
options: ["A", "B", "reopen", "retire"].iter().map(|name| QuestionOption { label: (*name).into(), text: "an option".into() }).collect(),
|
|
recommended: "A".into(),
|
|
depends: vec![],
|
|
raised_in: Some(item.clone()),
|
|
asked_on: Date("2026-10-09".into()),
|
|
})
|
|
}
|
|
|
|
/// A standing ruling: the plain "something else was admitted" document. With
|
|
/// `answers` (a question's id and one of its option labels), the ruling that
|
|
/// answers it.
|
|
pub(crate) fn ruling(label: &str, answers: Option<(&str, &str)>) -> PipelineDocument {
|
|
PipelineDocument::Ruling(PipelineRuling {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
label: label.into(),
|
|
title: "A ruling".into(),
|
|
answers: answers.map(|(question, _)| question.into()),
|
|
choice: answers.map(|(_, choice)| choice.into()),
|
|
ruling: "So.".into(),
|
|
operator_quote: None,
|
|
ruled_on: Date("2026-10-09".into()),
|
|
precedents: vec![],
|
|
authority: RulingAuthority::Standing,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn run_ref(label: &str) -> PipelineRef {
|
|
reference(PipelineKind::Run, &format!("{INSTANCE}:run:{label}"))
|
|
}
|
|
|
|
pub(crate) fn run(label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
|
|
run_in(INSTANCE, label, turn, operated_by, claims)
|
|
}
|
|
|
|
pub(crate) fn run_in(instance: &str, label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
|
|
PipelineDocument::Run(PipelineRun {
|
|
instance: Slug(instance.into()),
|
|
label: label.into(),
|
|
operated_by,
|
|
turn,
|
|
host: "test".into(),
|
|
started_on: Date("2026-10-09".into()),
|
|
budget_usd: "0".into(),
|
|
claims: claims.to_vec(),
|
|
campaigns: vec![Slug(CAMPAIGN.into())],
|
|
})
|
|
}
|
|
|
|
pub(crate) fn close(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument {
|
|
PipelineDocument::Resolution(PipelineResolution {
|
|
subject,
|
|
sequence: 1,
|
|
outcome,
|
|
rationale: "Closed.".into(),
|
|
resolved_on: Date("2026-10-09".into()),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn recorded() -> ResolutionOutcome {
|
|
ResolutionOutcome::Recorded { reason: "finished".into() }
|
|
}
|
|
|
|
pub(crate) fn withdrawn() -> ResolutionOutcome {
|
|
ResolutionOutcome::Withdrawn { reason: "never ran".into() }
|
|
}
|
|
|
|
/// Her Self run on `item`, ended, as `n` in a history: opened, closed, and
|
|
/// followed by a follow-up sourced from `item` when `leaves_something` (the
|
|
/// document that cites the item, which makes the run non-empty).
|
|
pub(crate) fn ended_run(mind: &TestMind, item: &PipelineRef, prefix: &str, n: usize, leaves_something: bool) {
|
|
let label = format!("{prefix}{n}");
|
|
mind.committed(vec![run(&label, RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(item))]);
|
|
let mut closing = vec![close(run_ref(&label), recorded())];
|
|
if leaves_something {
|
|
closing.push(follow_up(&format!("{prefix}left{n}"), item));
|
|
}
|
|
mind.committed(closing);
|
|
}
|
|
|
|
/// A history of ended runs on `item`, oldest first; `true` leaves a document.
|
|
pub(crate) fn history(mind: &TestMind, item: &PipelineRef, runs: &[bool]) {
|
|
history_as(mind, item, "h", runs);
|
|
}
|
|
|
|
/// The same, with run labels `<prefix>0`, `<prefix>1`, and so on, for a mind
|
|
/// holding the histories of several items.
|
|
pub(crate) fn history_as(mind: &TestMind, item: &PipelineRef, prefix: &str, runs: &[bool]) {
|
|
for (n, leaves_something) in runs.iter().enumerate() {
|
|
ended_run(mind, item, prefix, n, *leaves_something);
|
|
}
|
|
}
|
|
|
|
/// A follow-up sourced from `source`: work a run may claim that the queue does
|
|
/// not list.
|
|
pub(crate) fn follow_up(label: &str, source: &PipelineRef) -> PipelineDocument {
|
|
PipelineDocument::FollowUp(PipelineFollowUp {
|
|
campaign: Slug(CAMPAIGN.into()),
|
|
label: label.into(),
|
|
source: source.clone(),
|
|
repo: REPO.into(),
|
|
locations: vec![],
|
|
item: "Later.".into(),
|
|
why_it_can_wait: "It can.".into(),
|
|
owner: "Hands".into(),
|
|
})
|
|
}
|
|
|
|
// The scripted child, the fake spawner and the fake lease: the stream and the lease the Self and the Persona organs are tested against.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) enum Event {
|
|
Wrote(String),
|
|
InputClosed,
|
|
/// A signal was attempted, delivered or not.
|
|
Stopped(Stop),
|
|
Asked(StdDuration),
|
|
/// The connector was asked for a lease and answered one.
|
|
Lent,
|
|
/// A child was started.
|
|
Spawned,
|
|
/// The lease was dropped, which ends it: nothing is settled.
|
|
Abandoned,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) enum Step {
|
|
Line(String),
|
|
/// Time passes with no output: the clock moves and the read times out.
|
|
Idle(i64),
|
|
/// The clock moves, then this line arrives.
|
|
After(i64, String),
|
|
/// The child is reaped with this code; its stream stays open.
|
|
Exit(Option<i32>),
|
|
/// Its stdout ends; the child is not reaped yet.
|
|
Eof,
|
|
/// The clock moves, this runs (the mind changes, a tool call's hook fires), and the read times out.
|
|
Do(i64, Rc<dyn Fn()>),
|
|
}
|
|
|
|
pub(crate) struct Scripted {
|
|
pub(crate) steps: VecDeque<Step>,
|
|
pub(crate) log: Rc<RefCell<Vec<Event>>>,
|
|
pub(crate) clock: Rc<Fixed>,
|
|
pub(crate) code: Option<i32>,
|
|
/// The weakest signal that ends it; weaker ones are ignored.
|
|
pub(crate) dies_on: Option<Stop>,
|
|
/// Signals that cannot be delivered (no `kill` program, say).
|
|
pub(crate) refuses: Vec<Stop>,
|
|
/// When the steps run out it neither exits nor closes its stream: time just passes.
|
|
pub(crate) hangs: bool,
|
|
pub(crate) exited: bool,
|
|
pub(crate) eof: bool,
|
|
/// How many times it was asked: a loop that never ends is a failure, not a hang.
|
|
pub(crate) asked: u32,
|
|
}
|
|
|
|
impl Child for Scripted {
|
|
fn write_line(&mut self, line: &str) {
|
|
self.log.borrow_mut().push(Event::Wrote(line.to_string()));
|
|
}
|
|
|
|
fn close_input(&mut self) {
|
|
self.log.borrow_mut().push(Event::InputClosed);
|
|
}
|
|
|
|
fn next(&mut self, timeout: StdDuration) -> Read {
|
|
self.log.borrow_mut().push(Event::Asked(timeout));
|
|
self.asked += 1;
|
|
assert!(self.asked < 500, "the loop never ended");
|
|
match self.steps.pop_front() {
|
|
Some(Step::Line(line)) => Read::Line(line),
|
|
Some(Step::Idle(seconds)) => {
|
|
self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(seconds));
|
|
Read::Timeout
|
|
}
|
|
Some(Step::After(seconds, line)) => {
|
|
self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(seconds));
|
|
Read::Line(line)
|
|
}
|
|
Some(Step::Exit(code)) => {
|
|
self.exited = true;
|
|
Read::Exited(code)
|
|
}
|
|
Some(Step::Do(seconds, act)) => {
|
|
self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(seconds));
|
|
act();
|
|
Read::Timeout
|
|
}
|
|
Some(Step::Eof) => {
|
|
self.eof = true;
|
|
Read::Eof
|
|
}
|
|
// The stream is over: the child is reaped, then its stdout ends.
|
|
None if self.hangs => {
|
|
self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(timeout.as_secs() as i64));
|
|
Read::Timeout
|
|
}
|
|
None if !self.exited => {
|
|
self.exited = true;
|
|
Read::Exited(self.code)
|
|
}
|
|
None if !self.eof => {
|
|
self.eof = true;
|
|
Read::Eof
|
|
}
|
|
None => panic!("the loop kept waiting on a child that ended"),
|
|
}
|
|
}
|
|
|
|
fn stop(&mut self, how: Stop) -> bool {
|
|
self.log.borrow_mut().push(Event::Stopped(how));
|
|
if self.refuses.contains(&how) {
|
|
return false;
|
|
}
|
|
if self.dies_on.is_some_and(|weakest| how >= weakest) {
|
|
self.steps.clear();
|
|
self.hangs = false;
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
pub(crate) struct Fake {
|
|
pub(crate) child: RefCell<Option<Scripted>>,
|
|
pub(crate) spawns: RefCell<Vec<Vec<String>>>,
|
|
pub(crate) refuse: bool,
|
|
}
|
|
|
|
impl Fake {
|
|
pub(crate) fn with(child: Option<Scripted>, refuse: bool) -> Self {
|
|
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), refuse }
|
|
}
|
|
}
|
|
|
|
impl Spawner for Fake {
|
|
fn spawn(&self, args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
|
|
self.spawns.borrow_mut().push(args.to_vec());
|
|
if self.refuse {
|
|
return Err(anyhow!("refused"));
|
|
}
|
|
let child = self.child.borrow_mut().take().expect("spawned twice");
|
|
child.log.borrow_mut().push(Event::Spawned);
|
|
Ok(Box::new(child))
|
|
}
|
|
}
|
|
|
|
/// What the connector answers.
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) enum Answer {
|
|
Grant,
|
|
Refuse(LeaseRefusalKind),
|
|
Unreachable,
|
|
}
|
|
|
|
/// The connector, as the actuator asks it: it logs into the child's log, so the
|
|
/// order of lend, spawn, the child's end and settle is one sequence.
|
|
pub(crate) struct FakeLender {
|
|
pub(crate) log: Rc<RefCell<Vec<Event>>>,
|
|
pub(crate) asked: RefCell<Vec<LeaseRequest>>,
|
|
pub(crate) answer: Answer,
|
|
}
|
|
|
|
impl FakeLender {
|
|
pub(crate) fn new(answer: Answer) -> Self {
|
|
Self::logging(Rc::new(RefCell::new(Vec::new())), answer)
|
|
}
|
|
|
|
pub(crate) fn logging(log: Rc<RefCell<Vec<Event>>>, answer: Answer) -> Self {
|
|
Self { log, asked: RefCell::new(Vec::new()), answer }
|
|
}
|
|
}
|
|
|
|
impl Lender for FakeLender {
|
|
fn lend(&self, request: &LeaseRequest) -> Result<Lent> {
|
|
self.asked.borrow_mut().push(request.clone());
|
|
match self.answer {
|
|
Answer::Grant => {
|
|
self.log.borrow_mut().push(Event::Lent);
|
|
Ok(Lent::Granted(Box::new(FakeLease { log: self.log.clone(), token: token_holding("tok-canary"), plan: vec![plan_window()] })))
|
|
}
|
|
Answer::Refuse(kind) => Ok(Lent::Refused(LeaseRefused { kind, detail: "detail-canary".into() })),
|
|
Answer::Unreachable => Err(anyhow!("down")),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn plan_window() -> PlanQuota {
|
|
PlanQuota { window: "five_hour".into(), utilization_permille: Some(423), resets_at_unix_ms: 1_793_307_000_000, status: "allowed".into(), observed_at_unix_ms: 1_793_306_000_000 }
|
|
}
|
|
|
|
pub(crate) struct FakeLease {
|
|
pub(crate) log: Rc<RefCell<Vec<Event>>>,
|
|
pub(crate) token: TokenFd,
|
|
pub(crate) plan: Vec<PlanQuota>,
|
|
}
|
|
|
|
impl Lease for FakeLease {
|
|
fn token(&self) -> &TokenFd {
|
|
&self.token
|
|
}
|
|
|
|
fn plan_quota(&self) -> &[PlanQuota] {
|
|
&self.plan
|
|
}
|
|
}
|
|
|
|
impl Drop for FakeLease {
|
|
fn drop(&mut self) {
|
|
self.log.borrow_mut().push(Event::Abandoned);
|
|
}
|
|
}
|
|
|