OXYGENHUB

Runners: Connectivity and Status apart, a heartbeat, a filterable queue e9b9619cd857

A runner whose slots are full claims nothing, so a quiet job (an image
fetch, a silent test suite) left it 'offline' while it worked (mars-1 and
earth-1 on 2026-10-16). Admin > Runners now shows Connectivity (online or
offline: when the hub last heard from it) apart from Status (disabled,
draining, idle, or running N of M, the jobs and their minutes in its
tooltip); POST /api/v1/flow/runners/heartbeat lets a busy runner say it is
there (the runner's side is OxygenFlow's). A runner's status opens the queue
filtered to it; the queue filters by repository, status and runner, kept in
the URL, and shows when each job started. Andy's design.

Refs #243
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 22:20 UTC
Signature
✔ signed by Claude Code
Hash
e9b9619cd85737c567054334f753cb0793f98f97d929b61cbd933ac9a3b4bc22
Depends on
42fa95540468 4d2e44d79b03 74be97962ed8 750579ecc721 a46d15e72101 b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368 c4c87f481d05 d23464c0ac60 efd29a0033e8
Depended on by
8501fd5815b9 03bfb945f5c1 e97500b70f9a 107eaba52c9d
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
25 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +1 −0
…
43 unchanged lines
44 44
        .fallback(no_route)
45 45
        .route("/flow/claim", post(crate::flow::claim))
46 46
        .route("/flow/runners/register", post(crate::flow::register))
47 +
        .route("/flow/runners/heartbeat", post(crate::flow::heartbeat))
47 48
        .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES)))
48 49
        .route("/flow/swarm/announce", post(crate::swarm::announce))
49 50
        .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk))
…
2358 unchanged lines
modified crates/hub/src/db/flow.rs +3 −1
…
264 unchanged lines
265 265
    pub runner: String,
266 266
    pub spec: String,
267 267
    pub sandbox: String,
268 +
    /// When it started running.
269 +
    pub started_ms: Option<i64>,
268 270
}
269 271
270 272
impl QueueEntry {
…
12 unchanged lines
283 285
/// then queued ones in the order runners claim them (hub spec §13.2).
284 286
pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> {
285 287
    sqlx::query_as(
286 -
        "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox \
288 +
        "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox, j.started_ms \
287 289
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
288 290
         WHERE j.status IN ('queued', 'running') AND r.status IN ('queued', 'running') \
289 291
         ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id",
…
678 unchanged lines
modified crates/hub/src/db/runners.rs +10 −0
…
70 unchanged lines
71 71
        self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS)
72 72
    }
73 73
74 +
    /// Whether the hub has heard from it lately (oxygenhub#243): any call it
75 +
    /// makes counts, a busy runner's heartbeat included.
76 +
    pub fn connectivity(&self) -> &'static str {
77 +
        if self.online() {
78 +
            "online"
79 +
        } else {
80 +
            "offline"
81 +
        }
82 +
    }
83 +
74 84
    /// `online`, `offline`, `draining` or `disabled`.
75 85
    pub fn status(&self) -> &'static str {
76 86
        match self.state.as_str() {
…
350 unchanged lines
modified crates/hub/src/flow.rs +9 −0
…
757 unchanged lines
758 758
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
759 759
/// different runners have declined it, it ends as unplaceable with their
760 760
/// reasons. A decline is never a failed check.
761 +
/// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose
762 +
/// job slots are all full claims nothing, so it says it is there.
763 +
pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
764 +
    let me = runner_auth(&app, &headers).await?;
765 +
    db::runners::seen(&app.db, me.id, None, None).await?;
766 +
    Ok(StatusCode::NO_CONTENT)
767 +
}
768 +
761 769
pub async fn decline(
762 770
    State(app): State<Shared>,
763 771
    headers: HeaderMap,
…
1052 unchanged lines
1816 1824
            runner: String::new(),
1817 1825
            spec: String::new(),
1818 1826
            sandbox: String::new(),
1827 +
            started_ms: None,
1819 1828
        }
1820 1829
    }
1821 1830
…
96 unchanged lines
modified crates/hub/src/web/runners.rs +102 −6
…
28 unchanged lines
29 29
    /// Where its peers reach it in the image swarm, and its site
30 30
    /// (oxygenhub#144); empty when it is not in one.
31 31
    pub swarm: String,
32 +
    /// The jobs it runs now: (repository, run, job, started).
33 +
    pub jobs: Vec<(String, i64, String, Option<i64>)>,
32 34
}
33 35
36 +
impl RunnerRow {
37 +
    /// What it is doing (oxygenhub#243): `disabled`, `draining`, `idle`,
38 +
    /// or `running N of M`.
39 +
    pub fn doing(&self) -> String {
40 +
        match self.r.state.as_str() {
41 +
            "disabled" => "disabled".into(),
42 +
            "draining" if self.running > 0 => format!("draining, {} left", self.running),
43 +
            "draining" => "draining".into(),
44 +
            _ if self.running > 0 => format!("running {} of {}", self.running, self.r.max_jobs),
45 +
            _ => "idle".into(),
46 +
        }
47 +
    }
48 +
49 +
    /// The jobs it runs, for the status's tooltip.
50 +
    pub fn jobs_line(&self) -> String {
51 +
        let now = crate::db::now_ms();
52 +
        self.jobs
53 +
            .iter()
54 +
            .map(|(repo, run, job, started)| match started {
55 +
                Some(t) => format!("{repo} #{run} {job}, {} min", (now - t).max(0) / 60_000),
56 +
                None => format!("{repo} #{run} {job}"),
57 +
            })
58 +
            .collect::<Vec<_>>()
59 +
            .join("; ")
60 +
    }
61 +
}
62 +
63 +
/// The queue's filters (oxygenhub#243), kept in the URL.
64 +
#[derive(Deserialize, Default, Clone)]
65 +
pub struct QueueFilter {
66 +
    #[serde(default)]
67 +
    pub repo: String,
68 +
    #[serde(default)]
69 +
    pub status: String,
70 +
    #[serde(default)]
71 +
    pub runner: String,
72 +
}
73 +
74 +
impl QueueFilter {
75 +
    fn keeps(&self, q: &db::flow::QueueEntry) -> bool {
76 +
        (self.repo.is_empty() || format!("{}/{}", q.owner, q.name) == self.repo)
77 +
            && (self.status.is_empty() || q.status == self.status)
78 +
            && (self.runner.is_empty() || q.runner == self.runner)
79 +
    }
80 +
81 +
    pub fn any(&self) -> bool {
82 +
        !(self.repo.is_empty() && self.status.is_empty() && self.runner.is_empty())
83 +
    }
84 +
}
85 +
34 86
/// A scope in words: the whole site, an owner's repositories, or one
35 87
/// repository.
36 88
async fn scope_words(app: &Shared, s: &runners::Scope) -> String {
…
41 unchanged lines
78 130
    hub: String,
79 131
    notice: Option<String>,
80 132
    error: Option<String>,
81 -
    /// Running jobs, then queued ones in the order runners claim them.
133 +
    /// Running jobs, then queued ones in the order runners claim them,
134 +
    /// as the filter keeps them.
82 135
    queue: Vec<db::flow::QueueEntry>,
136 +
    /// How many there are, unfiltered.
137 +
    queue_total: usize,
138 +
    filter: QueueFilter,
139 +
    /// The repositories and runners in the queue, for the filter's choices.
140 +
    queue_repos: Vec<String>,
141 +
    queue_runners: Vec<String>,
83 142
}
84 143
85 144
async fn page(
…
3 unchanged lines
89 148
    notice: Option<String>,
90 149
    error: Option<String>,
91 150
) -> Result<Page<RunnersPage>, Error> {
151 +
    page_filtered(app, shell, new_token, notice, error, QueueFilter::default()).await
152 +
}
153 +
154 +
async fn page_filtered(
155 +
    app: &Shared,
156 +
    shell: Shell,
157 +
    new_token: Option<String>,
158 +
    notice: Option<String>,
159 +
    error: Option<String>,
160 +
    filter: QueueFilter,
161 +
) -> Result<Page<RunnersPage>, Error> {
92 162
    let e = |e| Error::from_db(shell.clone(), e);
93 163
    let list = runners::list(&app.db).await.map_err(e)?;
94 164
    let running: HashMap<i64, i64> = runners::running(&app.db).await.map_err(e)?.into_iter().collect();
95 165
    let pending = runners::pending_registrations(&app.db).await.map_err(e)?;
166 +
    let all = db::flow::queue(&app.db).await.map_err(e)?;
96 167
    let mut rows = Vec::with_capacity(list.len());
97 168
    for r in list {
98 169
        let scope = scope_words(app, &r.scope()).await;
…
2 unchanged lines
101 172
            .addr(r.id)
102 173
            .map(|s| if s.site.is_empty() { s.addr } else { format!("{} · site {}", s.addr, s.site) })
103 174
            .unwrap_or_default();
104 -
        rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, swarm, r });
175 +
        let jobs = all
176 +
            .iter()
177 +
            .filter(|q| q.status == "running" && q.runner == r.name)
178 +
            .map(|q| (format!("{}/{}", q.owner, q.name), q.run_number, q.job.clone(), q.started_ms))
179 +
            .collect();
180 +
        rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, swarm, jobs, r });
105 181
    }
106 -
    let queue = db::flow::queue(&app.db).await.map_err(e)?;
107 -
    Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error, queue }))
182 +
    let mut queue_repos: Vec<String> = all.iter().map(|q| format!("{}/{}", q.owner, q.name)).collect();
183 +
    queue_repos.sort();
184 +
    queue_repos.dedup();
185 +
    let mut queue_runners: Vec<String> = all.iter().filter(|q| !q.runner.is_empty()).map(|q| q.runner.clone()).collect();
186 +
    queue_runners.sort();
187 +
    queue_runners.dedup();
188 +
    let queue_total = all.len();
189 +
    let queue = all.into_iter().filter(|q| filter.keeps(q)).collect();
190 +
    Ok(Page(RunnersPage {
191 +
        hub: app.config.base_url().to_string(),
192 +
        shell,
193 +
        runners: rows,
194 +
        pending,
195 +
        new_token,
196 +
        notice,
197 +
        error,
198 +
        queue,
199 +
        queue_total,
200 +
        filter,
201 +
        queue_repos,
202 +
        queue_runners,
203 +
    }))
108 204
}
109 205
110 206
/// `GET /admin/runners`.
111 -
pub async fn list(State(app): State<Shared>, viewer: Viewer) -> Result<Page<RunnersPage>, Error> {
207 +
pub async fn list(State(app): State<Shared>, viewer: Viewer, Query(filter): Query<QueueFilter>) -> Result<Page<RunnersPage>, Error> {
112 208
    let shell = Shell::new(&app, "/admin/runners", &viewer);
113 209
    require_admin(&viewer, &shell)?;
114 -
    page(&app, shell, None, None, None).await
210 +
    page_filtered(&app, shell, None, None, None, filter).await
115 211
}
116 212
117 213
#[derive(Deserialize)]
…
299 unchanged lines
modified crates/hub/tests/flow.rs +16 −1
…
1364 unchanged lines
1365 1365
    );
1366 1366
    // The queue on Admin → Runners and through the API (#41, part 4).
1367 1367
    let (_, page) = web("GET", "/admin/runners".into(), None).await;
1368 -
    assert!(page.contains("<h2>Queue</h2>") && page.contains("<td>plex</td>"), "{page}");
1368 +
    assert!(page.contains("id=\"queue\"") && page.contains(">plex</a></td>"), "{page}");
1369 +
    // Connectivity and what it is doing, kept apart (#243); its status
1370 +
    // opens the queue filtered to it, and the filters keep and drop jobs.
1371 +
    assert!(page.contains("running 1 of ") && page.contains("/admin/runners?runner=plex#queue"), "{page}");
1372 +
    let (_, only) = web("GET", "/admin/runners?runner=plex".into(), None).await;
1373 +
    assert!(only.contains(">plex</a></td>") && only.contains(" of ") && only.contains(">all</a>"), "{only}");
1374 +
    let (_, none) = web("GET", "/admin/runners?runner=nobody&status=running".into(), None).await;
1375 +
    assert!(none.contains("Nothing matches."), "{none}");
1376 +
    // A busy runner claims nothing; its heartbeat keeps it online.
1377 +
    sqlx::query("UPDATE flow_runners SET last_seen_ms = 0 WHERE id = $1").bind(plex.id).execute(&h.app.db).await.unwrap();
1378 +
    let (_, page) = web("GET", "/admin/runners".into(), None).await;
1379 +
    assert!(page.contains("st-offline"), "quiet for long: offline");
1380 +
    assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/heartbeat", Some(&credential), None).await.0, StatusCode::NO_CONTENT);
1381 +
    assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/heartbeat", Some("flowrunner_nope"), None).await.0, StatusCode::UNAUTHORIZED);
1382 +
    let plex_now = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex").unwrap();
1383 +
    assert_eq!(plex_now.connectivity(), "online");
1369 1384
    let (_, tokens) = web("GET", "/settings/tokens".into(), None).await;
1370 1385
    let (_, made) = web("POST", "/settings/tokens".into(), Some(format!("_csrf={}&name=admin&scopes=user%3Aread", csrf_of(&tokens)))).await;
1371 1386
    let i = made.find("oxyhub_").unwrap();
…
1660 unchanged lines
modified templates/admin_runners.html +14 −8
…
17 unchanged lines
18 18
<section class="panel">
19 19
  {% if runners.is_empty() %}<p class="mute">No runners yet. Create a registration token below and run <code>flow runner register</code> on the runner.</p>{% else %}
20 20
  <div class="table"><table>
21 -
  <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Running</th><th>Last seen</th><th></th></tr>
21 +
  <tr><th>Runner</th><th>Connectivity</th><th>Status</th><th>Serves</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Last seen</th><th></th></tr>
22 22
  {% for row in runners %}{% let r = row.r %}
23 23
  <tr>
24 24
    <td><a href="/admin/runners/{{ r.id }}"><strong>{{ r.name }}</strong></a>{% if r.source == "config" %} <span class="tag" title="Configured with a hub.toml token">hub.toml</span>{% endif %}</td>
25 -
    <td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td>
25 +
    <td><span class="runner-status st-{{ r.connectivity() }}">{{ r.connectivity() }}</span></td>
26 +
    <td class="small"><a href="/admin/runners?runner={{ r.name|urlencode }}#queue" title="{% if row.jobs.is_empty() %}Its jobs in the queue{% else %}{{ row.jobs_line() }}{% endif %}">{{ row.doing() }}</a></td>
26 27
    <td class="small">{{ row.scope }}
27 28
      <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/scope" title="Empty for the whole site, an owner, or owner/repository"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="scope" size="14" placeholder="owner or owner/repo"> <button>set</button></form></td>
28 29
    <td>{% if r.key.is_empty() %}<span class="mute" title="Bound on its first signed receipt">not yet</span>{% else %}<code title="{{ r.key }} · {{ r.key_name }}">{{ r.key|short }}</code>{% endif %}</td>
29 30
    <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}{% let f = r.facts_line() %}{% if !f.is_empty() %}<br><span class="mute" title="Measured by the runner at its last claim{% if !r.kernel.is_empty() %}; kernel {{ r.kernel }}{% endif %}">{{ f }}</span>{% endif %}{% if !row.swarm.is_empty() %}<br><span class="mute" title="Where other runners fetch image chunks from it">swarm {{ row.swarm }}</span>{% endif %}</td>
30 31
    <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td>
31 32
    <td class="small">{{ r.max_jobs|plural("job", "jobs") }}{% if let Some(m) = r.memory_mib %} · {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %} · {{ c }} vCPU{% endif %}</td>
32 -
    <td>{{ row.running }}</td>
33 33
    <td class="small">{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
34 34
    <td><form class="tiny" method="post" action="/admin/runners/{{ r.id }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
35 35
      {% if r.state == "active" %}<button name="state" value="draining" title="Finish current jobs, take no new ones">drain</button> <button name="state" value="disabled" title="Refuse its requests at once">disable</button>{% else %}<button name="state" value="active">resume</button>{% endif %}</form>
…
3 unchanged lines
39 39
  </table></div>
40 40
  {% endif %}
41 41
</section>
42 -
<section class="panel">
43 -
  <h2>Queue</h2>
44 -
  {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %}
42 +
<section class="panel" id="queue">
43 +
  <h2>Queue <small>{% if filter.any() %}{{ queue.len() }} of {{ queue_total }} · <a href="/admin/runners#queue">all</a>{% else %}{{ queue_total|plural("job", "jobs") }}{% endif %}</small></h2>
44 +
  <form class="filters" method="get" action="/admin/runners#queue">
45 +
    <select name="repo" aria-label="Repository"><option value="">every repository</option>{% for r in queue_repos %}<option{% if *r == filter.repo %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
46 +
    <select name="status" aria-label="Status"><option value="">running and queued</option><option value="running"{% if filter.status == "running" %} selected{% endif %}>running</option><option value="queued"{% if filter.status == "queued" %} selected{% endif %}>queued</option></select>
47 +
    <select name="runner" aria-label="Runner"><option value="">every runner</option>{% for r in queue_runners %}<option{% if *r == filter.runner %} selected{% endif %}>{{ r }}</option>{% endfor %}{% if !filter.runner.is_empty() && !queue_runners.contains(filter.runner) %}<option selected>{{ filter.runner }}</option>{% endif %}</select>
48 +
    <button>filter</button>
49 +
  </form>
50 +
  {% if queue.is_empty() %}<p class="mute small">{% if filter.any() %}Nothing matches.{% else %}Nothing running or waiting.{% endif %}</p>{% else %}
45 51
  <div class="table"><table>
46 -
  <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr>
47 -
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %}
52 +
  <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Started</th><th>Runner</th></tr>
53 +
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td class="small">{% if let Some(t) = q.started_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}<a href="/admin/runners?runner={{ q.runner|urlencode }}#queue">{{ q.runner }}</a>{% endif %}</td></tr>{% endfor %}
48 54
  </table></div>
49 55
  <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p>
50 56
  {% endif %}
…
12 unchanged lines