~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A queued pull says what it waits for and when it should land

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/queue-stages
.changeset/queue-stages.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: patch
---
# A queued pull says what it waits for
`knotless show`, the MCP `show_item` tool and `knotless queue --json` say what a queued pull in no batch is waiting for: `Queued, 2nd of 3, running its checks`, `running its checks again on main as it is now` when one failed on an older tip, or `ready for the next batch`. Before, each just said `Queued`, except while a pre-flight was running. `--json` also gives each one's `stage`, its pre-flights' newest runs (`preflights`), and when the batch ahead of it started (`ahead`).
crates/knotless/src/queue.rs+290−26
@@ -36,6 +36,7 @@ use anyhow::{Result, bail};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::time::Duration;
/// The queued pulls among `items`, in the order they were queued: the open
/// pulls whose latest queue record is `queued`. One its author made a
@@ -376,12 +377,41 @@ pub struct Waiting {
/// one, still running.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub preflight: Vec<String>,
/// Where it stands while queued, one of [`stage`]; empty once ejected.
#[serde(skip_serializing_if = "String::is_empty")]
pub stage: String,
/// Queued and in no batch, the newest run of each of its pre-flights,
/// by workflow.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub preflights: Vec<BatchCheck>,
/// Queued in no batch, when the newest batch being checked on its
/// branch started: it lands after that one.
#[serde(skip_serializing_if = "Option::is_none")]
pub ahead: Option<String>,
}
/// Where a queued pull stands, as [`Waiting::stage`] says.
pub mod stage {
/// No run of its pre-flights seen yet.
pub const QUEUED: &str = "queued";
/// Its pre-flights running at its head.
pub const PREFLIGHT: &str = "preflight";
/// One of its pre-flights failed on an older tip of its branch, and
/// runs again at the same head on the tip as it is now.
pub const AGAIN: &str = "again";
/// Its pre-flights passed: it goes into the next batch.
pub const READY: &str = "ready";
/// In a batch being checked.
pub const CHECKING: &str = "checking";
/// In a batch that passed, landing.
pub const LANDING: &str = "landing";
}
impl Waiting {
/// Where it stands, in a few words: `Queued, 3rd of 5`, `Queued, 1st
/// of 2, waiting for its pre-flight: check`, `In a batch of 4 being
/// checked`, `Landing`, or why it was ejected and which checks failed.
/// of 2, running its checks`, `Queued, 2nd of 2, ready for the next
/// batch`, `In a batch of 4 being checked`, `Landing`, or why it was
/// ejected and which checks failed.
pub fn said(&self) -> String {
if let Some(b) = &self.batch {
return match b.state.as_str() {
@@ -394,9 +424,14 @@ impl Waiting {
}
if let Some(p) = &self.place {
let place = format!("Queued, {} of {}", ordinal(p.position), p.of);
return match self.preflight.as_slice() {
[] => place,
runs => format!("{place}, waiting for its pre-flight: {}", runs.join(", ")),
return match self.stage.as_str() {
stage::PREFLIGHT => format!("{place}, running its checks"),
stage::AGAIN => format!(
"{place}, running its checks again on {} as it is now",
p.base
),
stage::READY => format!("{place}, ready for the next batch"),
_ => place,
};
}
let q = self.item.queue.as_ref();
@@ -462,26 +497,60 @@ fn batch_of(item: &Item, batches: &[Batch]) -> Option<Batch> {
going.then(|| newest.clone())
}
/// The pre-flights of the pull `uri` still running, among `checks`,
/// newest first: of each pre-flight workflow, whether its newest run of
/// the pull is.
fn preflights(uri: &str, checks: &[Check]) -> Vec<String> {
let mut seen: Vec<&str> = Vec::new();
let mut running = Vec::new();
let runs = checks.iter().map(|c| &c.record).filter(|r| {
/// The runs of the pre-flights of the pull `uri` among `checks`, newest
/// first.
fn preflight_runs<'a>(uri: &str, checks: &'a [Check]) -> Vec<&'a Check> {
let of = |c: &&Check| {
let r = &c.record;
r.pull.as_deref() == Some(uri) && r.queue.as_deref() == Some(records::QUEUE_FIRST)
});
for r in runs {
if seen.contains(&r.workflow.as_str()) {
continue;
}
seen.push(&r.workflow);
if status::going(&r.status) {
running.push(r.workflow.clone());
};
checks.iter().filter(of).collect()
}
/// The newest of each workflow among `runs`, newest first, by workflow.
fn newest_each<'a>(runs: &[&'a Check]) -> Vec<&'a Check> {
let mut newest: Vec<&Check> = Vec::new();
for c in runs {
if newest
.iter()
.all(|n| n.record.workflow != c.record.workflow)
{
newest.push(c);
}
}
running.sort();
running
newest.sort_by(|a, b| a.record.workflow.cmp(&b.record.workflow));
newest
}
fn failed(c: &Check) -> bool {
[status::FAILURE, status::ERROR].contains(&c.record.status.as_str())
}
/// Where a pull queued in no batch stands, one of [`stage`], by `newest`,
/// its pre-flights' newest runs, among `all` its pre-flight runs. A newest
/// that failed while it is still queued failed on an older tip, as one on
/// the tip ejects it: the runner runs it again.
fn stage_of(newest: &[&Check], all: &[&Check]) -> &'static str {
let going: Vec<&&Check> = (newest.iter())
.filter(|c| status::going(&c.record.status))
.collect();
let again = |g: &&&Check| {
let same = |c: &Check| {
c.record.workflow == g.record.workflow && c.record.commit == g.record.commit
};
all.iter().any(|c| same(c) && failed(c))
};
if newest.is_empty() {
stage::QUEUED
} else if going.iter().any(again) || (going.is_empty() && newest.iter().any(|c| failed(c))) {
stage::AGAIN
} else if !going.is_empty() {
stage::PREFLIGHT
} else if newest.iter().all(|c| c.record.status == status::SUCCESS) {
stage::READY
} else {
stage::QUEUED
}
}
/// The merge queues among `items`, with the batches `checks` are runs of
@@ -495,14 +564,37 @@ pub fn waiting(items: &[Item], checks: &[Check]) -> Vec<Waiting> {
.into_iter()
.map(|i| {
let batch = batch_of(i, &batches);
let preflight = match batch {
let all = preflight_runs(&i.uri, checks);
let newest = match batch {
Some(_) => Vec::new(),
None => preflights(&i.uri, checks),
None => newest_each(&all),
};
let stage = match &batch {
Some(b) if b.state == batch::LANDING => stage::LANDING,
Some(_) => stage::CHECKING,
None => stage_of(&newest, &all),
};
let going = newest.iter().filter(|c| status::going(&c.record.status));
let refname = format!("refs/heads/{}", i.base.as_deref().unwrap_or_default());
let checking =
(batches.iter()).filter(|b| b.branch == refname && b.state == batch::CHECKING);
let ahead = match batch {
Some(_) => None,
None => checking.map(|b| b.started_at.clone()).max(),
};
Waiting {
place: place(items, &i.uri),
batch,
preflight,
preflight: going.map(|c| c.record.workflow.clone()).collect(),
stage: stage.to_string(),
preflights: (newest.iter())
.map(|c| BatchCheck {
workflow: c.record.workflow.clone(),
status: c.record.status.clone(),
uri: c.uri.clone(),
})
.collect(),
ahead,
item: i.clone(),
}
})
@@ -515,10 +607,74 @@ pub fn waiting(items: &[Item], checks: &[Check]) -> Vec<Waiting> {
place: None,
batch: None,
preflight: Vec::new(),
stage: String::new(),
preflights: Vec::new(),
ahead: None,
}));
waiting
}
/// The time between `from` and `to`, if both are times and `to` is later.
fn span(from: &str, to: &str) -> Option<Duration> {
let from = humantime::parse_rfc3339_weak(from).ok()?;
let to = humantime::parse_rfc3339_weak(to).ok()?;
to.duration_since(from).ok()
}
/// The middle of `took`, if it holds any.
fn median(mut took: Vec<Duration>) -> Option<Duration> {
took.sort();
took.get(took.len() / 2).copied()
}
/// How long a repository's merge queue typically takes, for the pages to
/// say when a queued pull should land: a pull in a batch lands a batch
/// after the batch started; one in no batch is ready once its pre-flights
/// would pass, or now if they have, and lands a batch after that or after
/// the batch being checked ahead of it ([`Waiting::ahead`]), whichever is
/// later.
#[derive(Serialize, Debug, Clone, Copy, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Typical {
/// A batch, from its first run starting to its last ending: the
/// median of those that landed.
pub batch_seconds: u64,
/// A pull's pre-flights at a head, as long as the longest of them
/// that passed: the median, or a batch's with none to go by.
pub preflight_seconds: u64,
}
/// How long the merge queue typically takes, from the runs `checks`: none
/// with no batch landed to go by.
pub fn typical(checks: &[Check]) -> Option<Typical> {
let took = |b: &Batch| span(&b.started_at, b.finished_at.as_deref()?);
let batches = batches(checks);
let landed = batches.iter().filter(|b| b.state == batch::LANDING);
let batch = median(landed.filter_map(took).collect())?;
let mut flights: BTreeMap<(&str, &str), Duration> = BTreeMap::new();
let passed = |c: &&Check| {
let r = &c.record;
r.status == status::SUCCESS && r.queue.as_deref() == Some(records::QUEUE_FIRST)
};
for c in checks.iter().filter(passed) {
let r = &c.record;
let took = r
.finished_at
.as_deref()
.and_then(|f| span(&r.started_at, f));
let (Some(pull), Some(took)) = (r.pull.as_deref(), took) else {
continue;
};
let longest = flights.entry((pull, r.commit.as_str())).or_default();
*longest = (*longest).max(took);
}
let flight = median(flights.into_values().collect()).unwrap_or(batch);
Some(Typical {
batch_seconds: batch.as_secs(),
preflight_seconds: flight.as_secs(),
})
}
/// The batches among `batches`, newest first, that landed on their
/// branch: those that passed and whose pulls `items` has merged, each as
/// its own commit in the batch.
@@ -1002,7 +1158,7 @@ mod tests {
"b: In a batch of 2 being checked",
"c: Landing",
"d: In a batch of its own being checked",
"e: Queued, 5th of 5, waiting for its pre-flight: lint",
"e: Queued, 5th of 5, running its checks",
"x: Ejected: it broke. Failed: test (failure), lint (failed)",
"y: Ejected: it broke",
]
@@ -1010,6 +1166,114 @@ mod tests {
assert_eq!(branches(&items), ["main"]);
}
/// A run of a pre-flight of the pull `p`, at its head `commit`.
fn flight(n: &str, p: &str, commit: &str, status: &str, at: &str) -> Check {
let mut c = run(n, commit, &[], "test", status, at);
c.record.batch.clear();
c.record.pull = Some(format!("at://did:a/dev.knotless.pull/{p}"));
c.record.queue = Some(records::QUEUE_FIRST.to_string());
c
}
#[test]
fn where_each_waits_and_how_long_the_queue_takes() {
let items = [
item("a", "open", queued_at("1")),
item("b", "open", queued_at("1")),
item("c", "open", queued_at("1")),
item("d", "open", queued_at("1")),
item("e", "open", queued_at("1")),
item("f", "open", queued_at("1")),
item("g", "open", queued_at("1")),
item("h", "open", queued_at("2")),
item(
"x",
"open",
json!({"state": "ejected", "by": "did:a", "at": "9",
"createdAt": "2026-01-09T00:00:00Z"}),
),
];
let mut unfinished = flight("u", "f", "h0", "success", "6");
unfinished.record.finished_at = None;
let checks = [
// An hour a batch, as z's took.
run("l", "t9", &["z"], "test", "success", "1"),
// a, being checked since 8 o'clock; h's batch passed.
run("ca", "t1", &["a"], "test", "running", "8"),
run("ch", "t3", &["h"], "test", "success", "8"),
// b's pre-flight running, at a head new since one failed.
flight("b1", "b", "h1", "running", "8"),
flight("b0", "b", "h0", "failure", "5"),
// c's failed on an older tip, and d's runs again at that head.
flight("c1", "c", "h1", "failure", "7"),
flight("d1", "d", "h1", "running", "8"),
flight("d0", "d", "h1", "failure", "6"),
// f's passed, in 20 minutes, and one never finished; g's was
// cancelled; e has none.
{
let mut f = flight("f1", "f", "h1", "success", "7");
f.record.finished_at = Some("2026-01-07T00:20:00Z".into());
f
},
unfinished,
flight("g1", "g", "h1", "cancelled", "7"),
];
let w = waiting(&items, &checks);
let stood: Vec<(&str, &str, Option<&str>)> = w
.iter()
.map(|w| (w.item.title.as_str(), w.stage.as_str(), w.ahead.as_deref()))
.collect();
let ahead = Some("2026-01-08T00:00:00Z");
assert_eq!(
stood,
[
("a", "checking", None),
("b", "preflight", ahead),
("c", "again", ahead),
("d", "again", ahead),
("e", "queued", ahead),
("f", "ready", ahead),
("g", "queued", ahead),
("h", "landing", None),
("x", "", None),
]
);
let said: Vec<String> = w.iter().take(6).map(Waiting::said).collect();
assert_eq!(
said,
[
"In a batch of its own being checked",
"Queued, 2nd of 8, running its checks",
"Queued, 3rd of 8, running its checks again on main as it is now",
"Queued, 4th of 8, running its checks again on main as it is now",
"Queued, 5th of 8",
"Queued, 6th of 8, ready for the next batch",
]
);
let runs: Vec<(&str, &str)> = (w[3].preflights.iter())
.map(|p| (p.workflow.as_str(), p.uri.as_str()))
.collect();
assert_eq!(runs, [("test", "at://did:ci/dev.knotless.check/d1")]);
// A batch takes an hour, as z's and h's did, and a pre-flight 20
// minutes, as f's that passed did; with none passed, as long as a
// batch; with no batch landed, nothing is said.
let typ = |batch_seconds, preflight_seconds| Typical {
batch_seconds,
preflight_seconds,
};
assert_eq!(typical(&checks), Some(typ(3600, 1200)));
let unpassed: Vec<Check> = (checks.iter())
.filter(|c| !c.uri.ends_with("/f1"))
.cloned()
.collect();
assert_eq!(typical(&unpassed), Some(typ(3600, 3600)));
let unlanded: Vec<Check> = (checks.iter())
.filter(|c| c.record.batch.is_empty() || c.record.status != "success")
.cloned()
.collect();
assert_eq!(typical(&unlanded), None);
}
#[test]
fn ejected_logs_name_their_runs() {
let q = |reason: &str, checks: Vec<FailedCheck>| Queued {
crates/knotless-mcp/src/lib.rs+1−1
@@ -2256,7 +2256,7 @@ impl Server {
}
#[tool(
description = "Merge an open pull request: a fast-forward when possible, else a merge commit; into a branch protected as linear, one squashed commit. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and checks with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (list_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of): the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Ejected, fix what show_item says and queue it again (one that conflicts with a pull ahead of it can merge that pull's branch in); ejected again for the same reason, stop and say so rather than queue it once more. merge_queue shows where it is, and show_item whether it merged, or where it stands (standing.said: Queued, 3rd of 5; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see protect_branch), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2 or owners of web/: @alice or @bob; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (fastForward, merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as retarget_pull changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: fastForward, merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed."
description = "Merge an open pull request: a fast-forward when possible, else a merge commit; into a branch protected as linear, one squashed commit. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and checks with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (list_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of): the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Ejected, fix what show_item says and queue it again (one that conflicts with a pull ahead of it can merge that pull's branch in); ejected again for the same reason, stop and say so rather than queue it once more. merge_queue shows where it is, and show_item whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks or ready for the next batch; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see protect_branch), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2 or owners of web/: @alice or @bob; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (fastForward, merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as retarget_pull changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: fastForward, merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed."
)]
async fn merge(
&self,
crates/knotless-run/tests/run.rs+3−3
@@ -3247,7 +3247,7 @@ fn a_merge_queue_waits_for_preflights() {
assert_eq!(ran["queue"], "first", "{ran}");
assert_eq!(ran["baseCommit"], start.as_str(), "{ran}");
assert!(q.batches().is_empty(), "no batch has run");
q.shows(&s, "Queued, 2nd of 3, waiting for its pre-flight: first");
q.shows(&s, "Queued, 2nd of 3, running its checks");
// One whose pre-flight failed is ejected without a batch, naming it;
// one still running holds up none behind it, which goes on alone.
@@ -3355,7 +3355,7 @@ fn a_preflight_only_queued_elsewhere_is_run_here_once() {
l.step(&rt);
let mine = || q.owner.record(check::NSID, &rkey);
wait_until(|| mine()["status"] == "queued");
q.shows(&d, "waiting for its pre-flight: first");
q.shows(&d, "running its checks");
// The other's starts first: given the job, the lander's is not run,
// and its check goes, so the queue waits for the other's.
@@ -3365,7 +3365,7 @@ fn a_preflight_only_queued_elsewhere_is_run_here_once() {
assert!(mine().is_null(), "{}", mine());
assert!(!flew(&head));
l.step(&rt);
q.shows(&d, "waiting for its pre-flight: first");
q.shows(&d, "running its checks");
// Once it passes, the pull goes into a batch.
theirs("success");
crates/knotless-serve/src/web/queue.rs+54−10
@@ -6,7 +6,7 @@
use super::repo::found;
use super::*;
use knotless::checks::Check as Run;
use knotless::queue::{self, Batch, Waiting};
use knotless::queue::{self, Batch, Typical, Waiting};
/// Batches landed that the page shows, newest first.
const LANDED: usize = 5;
@@ -22,6 +22,9 @@ pub struct Queue {
ejected: Vec<EjectedPull>,
/// The last few batches landed, newest first.
landed: Vec<LandedBatch>,
/// How long its merge queue typically takes.
#[serde(skip_serializing_if = "Option::is_none")]
typical: Option<Typical>,
}
/// A branch's merge queue.
@@ -41,9 +44,13 @@ pub struct QueueBranch {
pub struct QueuedPull {
pull: ItemView,
queued_at: String,
/// Where it stands, in words: `Queued, 3rd of 5`, `In a batch of 4
/// being checked` or `Landing`.
/// Where it stands, in words: `Queued, 3rd of 5, running its checks`,
/// `In a batch of 4 being checked` or `Landing`.
said: String,
/// Where it stands, as `knotless::queue::stage` says.
stage: String,
/// Until it is in a batch, the newest run of each of its pre-flights.
checks: Vec<CheckView>,
/// The batch it is in, by its place in its branch's.
#[serde(skip_serializing_if = "Option::is_none")]
batch: Option<usize>,
@@ -59,6 +66,7 @@ pub struct QueueBatch {
state: String,
/// How many pulls are in it.
size: usize,
started_at: String,
checks: Vec<CheckView>,
}
@@ -99,11 +107,25 @@ pub struct InQueue {
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
created_at: String,
/// Where it stands, in words: `Queued, 3rd of 5`, `In a batch of 4
/// being checked`, `Landing`, or why it was ejected.
/// Where it stands, in words: `Queued, 3rd of 5, running its checks`,
/// `In a batch of 4 being checked`, `Landing`, or why it was ejected.
said: String,
/// While queued, where it stands, as `knotless::queue::stage` says.
#[serde(skip_serializing_if = "String::is_empty")]
stage: String,
/// While queued, how long its merge queue typically takes.
#[serde(skip_serializing_if = "Option::is_none")]
typical: Option<Typical>,
/// While its batch is being checked or landing, when that started.
#[serde(skip_serializing_if = "Option::is_none")]
batch_started_at: Option<String>,
/// Queued in no batch, when the newest batch being checked ahead of it
/// started.
#[serde(skip_serializing_if = "Option::is_none")]
ahead_started_at: Option<String>,
/// While its batch is being checked or landing, the batch's checks;
/// once ejected by checks that failed, those.
/// queued in no batch, its pre-flights' newest runs; once ejected by
/// checks that failed, those.
checks: Vec<CheckView>,
}
@@ -165,6 +187,10 @@ fn batch_checks(repo: &RepoViewBasic, runs: &[Run], batch: &Batch) -> Vec<CheckV
shown_checks(repo, runs, batch.checks.iter().map(|c| c.uri.as_str()))
}
fn preflight_checks(repo: &RepoViewBasic, runs: &[Run], w: &Waiting) -> Vec<CheckView> {
shown_checks(repo, runs, w.preflights.iter().map(|c| c.uri.as_str()))
}
fn failed_checks(repo: &RepoViewBasic, runs: &[Run], item: &Item) -> Vec<CheckView> {
let named = item.queue.iter().flat_map(|q| &q.checks);
shown_checks(repo, runs, named.filter_map(|c| c.check.as_deref()))
@@ -175,6 +201,7 @@ struct Queues {
items: Vec<Item>,
runs: Vec<Run>,
waiting: Vec<Waiting>,
typical: Option<Typical>,
}
impl Queues {
@@ -182,10 +209,12 @@ impl Queues {
let items = assembled(app, repo).await?.items.clone();
let runs = runs(app, repo, &items);
let waiting = queue::waiting(&items, &runs);
let typical = queue::typical(&runs);
Ok(Queues {
items,
runs,
waiting,
typical,
})
}
}
@@ -205,6 +234,7 @@ pub(super) async fn get_queue(
items,
runs,
waiting,
typical,
} = Queues::of(&app, &repo).await?;
let shown = repo.basic(&app).await;
let record = repo.record(&app).unwrap_or_default();
@@ -241,6 +271,7 @@ pub(super) async fn get_queue(
commit: b.commit.clone(),
state: b.state.clone(),
size: b.pulls.len(),
started_at: b.started_at.clone(),
checks: batch_checks(&shown, &runs, b),
});
line.batches.len() - 1
@@ -250,6 +281,8 @@ pub(super) async fn get_queue(
pull,
queued_at: q.created_at,
said: w.said(),
stage: w.stage.clone(),
checks: preflight_checks(&shown, &runs, w),
batch,
});
}
@@ -274,6 +307,7 @@ pub(super) async fn get_queue(
branches,
ejected,
landed,
typical,
}))
}
@@ -285,17 +319,27 @@ pub(super) async fn in_queue(app: &App, repo: &Repo, item: &Item) -> Result<Opti
if queued.is_none() || item.state != "open" {
return Ok(None);
}
let Queues { runs, waiting, .. } = Queues::of(app, repo).await?;
let Queues {
runs,
waiting,
typical,
..
} = Queues::of(app, repo).await?;
let shown = repo.basic(app).await;
let w = waiting.into_iter().find(|w| w.item.uri == item.uri);
Ok(w.map(|w| {
let checks = match &w.batch {
Some(b) => batch_checks(&shown, &runs, b),
None => failed_checks(&shown, &runs, &w.item),
let checks = match (&w.batch, &w.place) {
(Some(b), _) => batch_checks(&shown, &runs, b),
(None, Some(_)) => preflight_checks(&shown, &runs, &w),
(None, None) => failed_checks(&shown, &runs, &w.item),
};
let q = w.item.queue.clone().expect("a pull waiting was queued");
InQueue {
said: w.said(),
typical: typical.filter(|_| w.place.is_some()),
batch_started_at: w.batch.as_ref().map(|b| b.started_at.clone()),
ahead_started_at: w.ahead.clone(),
stage: w.stage,
state: q.state,
reason: q.reason,
created_at: q.created_at,
crates/knotless-serve/tests/web.rs+34−2
@@ -2633,6 +2633,16 @@ fn the_merge_queue() {
);
let ab = serde_json::json!([batched(0, &"3".repeat(40)), batched(1, &t1)]);
run("q3", &t1, ab, "running", "3");
// c's pre-flight, running at its head.
owner.put_record(
"dev.knotless.check",
"q4",
serde_json::json!({
"$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/c",
"commit": "4".repeat(40), "pull": pulls[2].trim(), "queue": "first",
"workflow": "test", "status": "running", "startedAt": "2026-10-22T04:00:00.000Z",
}),
);
let queue = format!("{repo}/queue");
wait_until(|| {
@@ -2644,6 +2654,7 @@ fn the_merge_queue() {
&& q["ejected"][0]["checks"]
.as_array()
.is_some_and(|c| !c.is_empty())
&& q["branches"][1]["pulls"][2]["stage"] == "preflight"
});
let q = site.data(&queue);
// One line a branch, by name, though their pulls were queued between
@@ -2668,9 +2679,25 @@ fn the_merge_queue() {
[
("Pull a", "In a batch of 2 being checked", &Value::from(0)),
("Pull b", "In a batch of 2 being checked", &Value::from(0)),
("Pull c", "Queued, 3rd of 3", &Value::Null),
(
"Pull c",
"Queued, 3rd of 3, running its checks",
&Value::Null
),
]
);
// Each says where it stands, with its pre-flights' runs until it is in
// a batch; and the queue how long it takes, as the batch that landed
// did, its pre-flights too with none of them passed.
let stages: Vec<&Value> = (line["pulls"].as_array().unwrap().iter())
.map(|p| &p["stage"])
.collect();
assert_eq!(stages, ["checking", "checking", "preflight"]);
assert_eq!(line["pulls"][2]["checks"][0]["workflow"], "test");
assert_eq!(line["pulls"][0]["checks"].as_array().map(Vec::len), Some(0));
let typical = serde_json::json!({"batchSeconds": 1800, "preflightSeconds": 1800});
assert_eq!(q["typical"], typical);
assert_eq!(q["branches"][0]["pulls"][0]["stage"], "queued");
assert_eq!(line["pulls"][0]["queuedAt"], "2026-10-21T00:00:01.000Z");
assert_eq!(line["pulls"][0]["pull"]["uri"], pulls[0].trim());
let batch = &line["batches"][0];
@@ -2679,6 +2706,7 @@ fn the_merge_queue() {
(&Value::from(t1.as_str()), &Value::from("checking"))
);
assert_eq!(batch["size"], 2);
assert_eq!(batch["startedAt"], "2026-10-22T03:00:00.000Z");
assert_eq!(batch["checks"][0]["status"], "running");
let e = &q["ejected"][0];
assert_eq!(e["pull"]["title"], "Pull d");
@@ -2706,8 +2734,12 @@ fn the_merge_queue() {
let c = site.data(&page(&pulls[2]))["queue"].clone();
assert_eq!(
(c["said"].as_str(), c["checks"].as_array().map(Vec::len)),
(Some("Queued, 3rd of 3"), Some(0))
(Some("Queued, 3rd of 3, running its checks"), Some(1))
);
assert_eq!(c["stage"], "preflight");
assert_eq!(c["typical"], typical);
assert_eq!(c["aheadStartedAt"], "2026-10-22T03:00:00.000Z");
assert_eq!(a["batchStartedAt"], "2026-10-22T03:00:00.000Z");
let d = site.data(&page(&pulls[3]))["queue"].clone();
assert_eq!(d["state"], "ejected");
assert_eq!(
docs/design/merge-queue.md+25−2
@@ -466,8 +466,16 @@ into the same branch.
Readers combine queue records with batch checks to say where a pull
stands: `Queued, 2nd of 5`, `In a batch of 3 being checked`, `Landing`, or
`Ejected:` with the reason and failed workflows. A queued pull in no
batch whose newest run of a pre-flight is still running is `Queued, 2nd
of 5, waiting for its pre-flight: check`, read from its own checks. A pull is in a batch when
batch is read from its own pre-flight runs, the newest of each workflow:
| Stage | Said | When |
| --- | --- | --- |
| `queued` | `Queued, 2nd of 5` | no pre-flight run of it yet, or the newest was cancelled |
| `preflight` | `Queued, 2nd of 5, running its checks` | one is still running |
| `again` | `Queued, 2nd of 5, running its checks again on main as it is now` | one failed at that head before, which while it is still queued was on an older tip, so the runner runs it again |
| `ready` | `Queued, 2nd of 5, ready for the next batch` | all passed |
In a batch, it is `checking`, then `landing`. A pull is in a batch when
one containing it is `checking` or `landing` and started after it was
queued. The CLI, the MCP tools ([../reference/mcp.md](../reference/mcp.md))
and the web show this. A pull's page shows it in its merge box, whose Add
@@ -477,6 +485,21 @@ collaborators of a repository that names runners. The web's queue page lists eac
pulls, its batches, ejected pulls and the last five batches landed, from
the server's index of the named runners' checks; see [serve.md](serve.md).
The web also says when each queued pull should land. The server answers
how long the queue typically takes (`typical`): a batch, the median of
those that landed lately, from its first run starting to its last
ending; and a pull's pre-flights, the median of the longest passing run
at each queued pull's head, or a batch's time when there are none. The
page reckons from those against its own clock, so the time moves on
while it is open:
- A pull in a batch lands a batch's time after the batch started.
- One in no batch is ready once its pre-flights would pass, or now if
they have. It lands a batch's time after that, or after the batch being
checked ahead of it, whichever is later.
- A time already past reads as any minute now. With no batch landed to
go by, nothing is said.
## Measuring it
`just queue-stats` says how the queue did, by day (UTC), from what it
docs/reference/mcp.md+4−3
@@ -120,9 +120,10 @@ reviews](../design/collaboration.md#required-reviews)).
| `checks` | `commit` | only the runs of that commit: its hex ID, or at least its first 4 digits |
| `checks` | `wait` | seconds to wait, at most 300, for a check to show and none to be queued or running; finding none by then is an error naming what was asked for |
`standing.said` reads as `Queued, 3rd of 5`, `Queued, 1st of 2, waiting
for its pre-flight: check`, `In a batch of 4 being checked`, `Landing`,
or `Ejected: <reason>`, with the failed checks.
`standing.said` reads as `Queued, 3rd of 5`, `Queued, 1st of 2, running
its checks`, `Queued, 1st of 2, running its checks again on main as it is
now`, `Queued, 2nd of 2, ready for the next batch`, `In a batch of 4 being
checked`, `Landing`, or `Ejected: <reason>`, with the failed checks.
## Long files
harness/shots/fixtures.json+2−2
@@ -237,7 +237,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"branches\":[{\"branch\":\"main\",\"pulls\":[{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbuzv5n3\",\"kind\":\"pull\",\"title\":\"Count bytes in files that are not UTF-8\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T12:31:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-15T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"batch\":0},{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhc5eopws\",\"kind\":\"pull\",\"title\":\"Line the columns up with tabs\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T14:22:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-16T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"batch\":0},{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhcffbj7l\",\"kind\":\"pull\",\"title\":\"Count words in any script\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T16:13:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-17T09:30:00.000Z\",\"said\":\"Queued, 3rd of 3\"}],\"batches\":[{\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"state\":\"checking\",\"size\":2,\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.build\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"build\",\"status\":\"success\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"finishedAt\":\"2026-10-20T10:01:35.000Z\",\"images\":[]},{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"running\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"images\":[]}]}]}],\"ejected\":[{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhcnn4cic\",\"kind\":\"pull\",\"title\":\"Print a total for many files\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T18:04:00.000Z\",\"comments\":0},\"ejectedAt\":\"2026-10-19T15:02:00.000Z\",\"reason\":\"its batch failed its checks at 6f89484c63a3, on main at 226488be4afa: test (failure)\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.6f89484c63a36649168e3d77a1e631f695469b74.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"failure\",\"commit\":\"6f89484c63a36649168e3d77a1e631f695469b74\",\"startedAt\":\"2026-10-19T15:00:00Z\",\"finishedAt\":\"2026-10-19T15:01:35.000Z\",\"images\":[]}]}],\"landed\":[{\"branch\":\"main\",\"commit\":\"226488be4afaf037c2331c7480d4a48790f20eba\",\"landedAt\":\"2026-10-19T09:01:35.000Z\",\"pulls\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbacgmx6\",\"kind\":\"pull\",\"title\":\"Show usage on --help\",\"state\":\"merged\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T08:49:00.000Z\",\"comments\":0}]}]}"
"body": "{\"branches\":[{\"branch\":\"main\",\"pulls\":[{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbuzv5n3\",\"kind\":\"pull\",\"title\":\"Count bytes in files that are not UTF-8\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T12:31:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-15T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"stage\":\"checking\",\"checks\":[],\"batch\":0},{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhc5eopws\",\"kind\":\"pull\",\"title\":\"Line the columns up with tabs\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T14:22:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-16T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"stage\":\"checking\",\"checks\":[],\"batch\":0},{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhcffbj7l\",\"kind\":\"pull\",\"title\":\"Count words in any script\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T16:13:00.000Z\",\"comments\":0},\"queuedAt\":\"2026-10-17T09:30:00.000Z\",\"said\":\"Queued, 3rd of 3, running its checks\",\"stage\":\"preflight\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.x7xwerv7qozd33kq5cq5j54zdt.f834a66404f8eb1f5ccf94b3ef31e47246fd30ab.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"running\",\"commit\":\"f834a66404f8eb1f5ccf94b3ef31e47246fd30ab\",\"startedAt\":\"2026-10-20T13:57:00Z\",\"images\":[]}]}],\"batches\":[{\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"state\":\"checking\",\"size\":2,\"startedAt\":\"2026-10-20T10:00:00Z\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.build\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"build\",\"status\":\"success\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"finishedAt\":\"2026-10-20T10:01:35.000Z\",\"images\":[]},{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"running\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"images\":[]}]}]}],\"ejected\":[{\"pull\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhcnn4cic\",\"kind\":\"pull\",\"title\":\"Print a total for many files\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T18:04:00.000Z\",\"comments\":0},\"ejectedAt\":\"2026-10-19T15:02:00.000Z\",\"reason\":\"its batch failed its checks at 6f89484c63a3, on main at 226488be4afa: test (failure)\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.6f89484c63a36649168e3d77a1e631f695469b74.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"failure\",\"commit\":\"6f89484c63a36649168e3d77a1e631f695469b74\",\"startedAt\":\"2026-10-19T15:00:00Z\",\"finishedAt\":\"2026-10-19T15:01:35.000Z\",\"images\":[]}]}],\"landed\":[{\"branch\":\"main\",\"commit\":\"226488be4afaf037c2331c7480d4a48790f20eba\",\"landedAt\":\"2026-10-19T09:01:35.000Z\",\"pulls\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbacgmx6\",\"kind\":\"pull\",\"title\":\"Show usage on --help\",\"state\":\"merged\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T08:49:00.000Z\",\"comments\":0}]}],\"typical\":{\"batchSeconds\":95,\"preflightSeconds\":360}}"
},
"/xrpc/dev.knotless.getRepo?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Fscratch": {
"status": 200,
@@ -333,7 +333,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"item\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbuzv5n3\",\"kind\":\"pull\",\"title\":\"Count bytes in files that are not UTF-8\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T12:31:00.000Z\",\"comments\":0},\"base\":\"main\",\"head\":{\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"branch\":\"bytes\"},\"comments\":[],\"closes\":[],\"queue\":{\"state\":\"queued\",\"createdAt\":\"2026-10-15T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.build\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"build\",\"status\":\"success\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"finishedAt\":\"2026-10-20T10:01:35.000Z\",\"images\":[]},{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"running\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"images\":[]}]},\"checks\":[],\"minutes\":[{\"runner\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"minutes\":2140,\"allowance\":2000,\"freeing\":[{\"at\":\"2026-11-02T00:00:00Z\",\"minutes\":400}],\"updatedAt\":\"2026-10-25T17:52:00Z\"}],\"changes\":{\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"commits\":[{\"sha\":\"87a3fcfe0a12c9f038fa4e5a5d276a96b19e03ca\",\"parents\":[{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-11T09:00:00Z\",\"committedAt\":\"2026-10-11T09:00:00Z\",\"message\":\"Count bytes in files that are not UTF-8\"}],\"files\":[{\"old\":\"docs/bytes.md\",\"new\":\"docs/bytes.md\",\"status\":\"added\",\"added\":1,\"removed\":0,\"rows\":[],\"rowCount\":2,\"hunkCount\":1}]},\"card\":\"/bob.test/tally/pull/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwhbuzv5n3/card.png?v=4db36ffc788a66bb\"}"
"body": "{\"item\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.pull/3myvwhbuzv5n3\",\"kind\":\"pull\",\"title\":\"Count bytes in files that are not UTF-8\",\"state\":\"open\",\"author\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"createdAt\":\"2026-10-10T12:31:00.000Z\",\"comments\":0},\"base\":\"main\",\"head\":{\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"branch\":\"bytes\"},\"comments\":[],\"closes\":[],\"queue\":{\"state\":\"queued\",\"createdAt\":\"2026-10-15T09:30:00.000Z\",\"said\":\"In a batch of 2 being checked\",\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.build\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"build\",\"status\":\"success\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"finishedAt\":\"2026-10-20T10:01:35.000Z\",\"images\":[]},{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.gkn5el4746pzeyvms3btnm2gqt.ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"running\",\"commit\":\"ee347cbe693bbc8c7f70dbb7b30bc329f34dcfa6\",\"startedAt\":\"2026-10-20T10:00:00Z\",\"images\":[]}],\"stage\":\"checking\",\"typical\":{\"batchSeconds\":95,\"preflightSeconds\":360},\"batchStartedAt\":\"2026-10-20T10:00:00Z\"},\"checks\":[{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.qlmgye4xm2squgtqbxmey3k6wo.87a3fcfe0a12c9f038fa4e5a5d276a96b19e03ca.226488be4afaf037c2331c7480d4a48790f20eba.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"success\",\"commit\":\"87a3fcfe0a12c9f038fa4e5a5d276a96b19e03ca\",\"startedAt\":\"2026-10-20T09:40:00Z\",\"finishedAt\":\"2026-10-20T09:46:00.000Z\",\"images\":[]}],\"minutes\":[{\"runner\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"minutes\":2140,\"allowance\":2000,\"freeing\":[{\"at\":\"2026-11-02T00:00:00Z\",\"minutes\":400}],\"updatedAt\":\"2026-10-25T17:52:00Z\"}],\"changes\":{\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"commits\":[{\"sha\":\"87a3fcfe0a12c9f038fa4e5a5d276a96b19e03ca\",\"parents\":[{\"sha\":\"226488be4afaf037c2331c7480d4a48790f20eba\"}],\"author\":{\"name\":\"Bob Tanaka\",\"actor\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"}},\"coauthors\":[],\"date\":\"2026-10-11T09:00:00Z\",\"committedAt\":\"2026-10-11T09:00:00Z\",\"message\":\"Count bytes in files that are not UTF-8\"}],\"files\":[{\"old\":\"docs/bytes.md\",\"new\":\"docs/bytes.md\",\"status\":\"added\",\"added\":1,\"removed\":0,\"rows\":[],\"rowCount\":2,\"hunkCount\":1}]},\"card\":\"/bob.test/tally/pull/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwhbuzv5n3/card.png?v=4db36ffc788a66bb\"}"
},
"/xrpc/dev.knotless.getThread?repo=at%3A%2F%2Fbob.test%2Fdev.knotless.repo%2Ftally&uri=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.pull%2F3myvwhcnn4cic&filesOnly=true": {
"status": 200,
harness/shots/world.mjs+18−2
@@ -655,8 +655,9 @@ export async function create() {
}
// The fork's merge queue, as bob, its owner and runner, keeps it: three
// pulls queued, the first two in a batch being checked; one ejected by
// the checks of the batch it was in; and the batch that landed.
// pulls queued, the first two in a batch being checked and the third
// running its pre-flight; one ejected by the checks of the batch it was
// in; and the batch that landed.
{
const did = account.bob.did
const { key } = await fetch(`${net.pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.knotless.repo&rkey=tally`)
@@ -695,6 +696,21 @@ export async function create() {
if (name === 'ejected' && status === 'failure') failed.push({ workflow, status, check: uri })
}
}
// Their pre-flights: the first two's passed, in six minutes each, and
// the third's has run since a little before now.
const flights = [
[queued[0], 'bytes', 'success', '2026-10-20T09:40:00Z'],
[queued[1], 'tabs', 'success', '2026-10-20T09:42:00Z'],
[queued[2], 'words', 'running', '2026-10-20T13:57:00Z'],
]
for (const [pull, branch, status, startedAt] of flights) {
const commit = tip(branch)
const rkey = `${refHash(did)}.${key}.${refHash(pull)}.${commit}.${landed}.test`
const finished = status === 'running' ? {} : { finishedAt: new Date(Date.parse(startedAt) + 360_000).toISOString() }
await write('dev.knotless.check', {
repo, ref: `refs/heads/${branch}`, commit, baseCommit: landed, pull, queue: 'first', workflow: 'test', status, startedAt, ...finished, steps: [],
}, rkey)
}
for (const [i, pull] of [...queued, ejected].entries()) {
await write('dev.knotless.queue', { repo, subject: pull, state: 'queued', createdAt: `2026-10-1${5 + i}T09:30:00.000Z` })
}
lexicons/dev/knotless/defs.json+15−2
lexicons/dev/knotless/getQueue.json+31−1
web/src/lib/organisms/MergeBox.svelte+5−1
web/src/lib/organisms/QueueLine.svelte+50−15
web/src/lib/format.ts+27−0
web/src/routes/[authority]/[repo]/queue/+page.svelte+1−1
web/src/app.css+6−0