OxygenFlow runs: pushes start pipeline runs, a runner API with job tokens, jobs as checks, and a Flow tab with the job graph and live logs 42fa95540468
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 11:48 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
42fa95540468b40f1710f40f1a79b1bea010c68de2bf9551876f2134644f8e09- Depends on
- 07b1a5bbf481 145306044896 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 25ab8c73514a 3f4b0ea6eabf 6ae41081ccd3 93be9ce4653e b64e2537d702 d23464c0ac60 f160cbc57756
- Depended on by
- 50928fab5ffc f87a74fffc87 1c52d940b4a0 b7e1be264c0a 42c77f6515c2 2f79b17e089a c26267b3aba6 fac6853c4e46 3d086bc3d69b 1ec29691b67a 06b756358f7e 91c306aa4706 b1860d6090ec aeed63c00ad7 42cd46c9b09e 12325e1b1020 5d66f27cebe2 0468394734e9 951aed50c2f5 d9d0d314fdcb 84e3c09656b2 27babfa16580 dd0788b756f2 9bbca80f777a faf5246c900e dc5c64296b0a d7050703d2f4 8501fd5815b9 5f3fa151688e 17dc8de4fca8 e4203e141e90 3b65065298b7 56b8dbe66974 ab4df6aae179 5714347a6233 69e4f27c5633 794ff94f194a a66c05a6a881 ab2d8b40f9f4 5588bb3631d2 3b3a51afde59 737de0fac722 367f34b8e590 858eca0d03c4 a0a1601eb5a5 166f6e9bf4f1 c7067c7bda56 b5c199bd6b1a 9c04c779f032 33d98147511b a9f1c0bf403a 2abea30809dd 13b27cb2200c 58cbf42447bc 4a68f1064b08 2a8b621676b4 7c06f5b28ade e97500b70f9a 67523e06ed86 bb018142cca7 16ee5d19d220 85ec74fd82d8 d4a0043fded4 2c47eecfeab6 205d8f863e5c a6d824c5ebf5 b9b74d997cec a10446c68043 8a48dd830178 8dcaeeb848c6 a7aa76ac52d7 317d776c725b 05f1a85a2842 4f40eed0588f 7dbb41b09a99 e5b97ac399e7 965b950f9b96 559c39204234 244724133fed 8aa5ef384ef1 3eb491e9489b 2c31915f5c89 026a76d251ce 53a0fb2e4ac6 97fc47fee9cf 681db69eba17 76f93eaefe45 076c7a7c86b1 107eaba52c9d 55673c650986 e9b9619cd857 c8684d44d549 f0f6c2d96b1c 4e2cad00a484 3b1bc27fe805 ea14c4ec5c3f d1bf2d698777 5d90e0e045e2 47e374020b59 a2ef53c5db26 67be8ac00c24 7e01a7665aa5 b644e1757711 dc59d1b56c3d 417184c73f4e cd437c4f5f8d 62cdf34daec1 0e04dfc20211 5d54cf746e00 d74ce22fa80e 9a777fec4ed9 1870e4fa9808 f7bcee49bf24 bff9106edcba d9b3b84605a4 4a4400c5812d e8fa53694f68 c2be148cfe83 f4d6ef418a96 93501da2a162 d7d182467382 6246d1e51f4f 4fa0da126b70 eb2a3502601d c9aacc0e7dc1 d943160dafeb 180dc43ad0e0 757195ad7a5d c4e432da4bc9 9aaa88b2d9c8 f01cb865fc60 7a14bdaeb857 3776b70d9b17 a7e6e8e6094c 1d634f698ecd e71583501b1d f677d6270298 750579ecc721 6ca0b971ff07 39442b000e6b 6adcd68b90b1 a46d15e72101 ac713df7b38b c95446a17fdd 1c8276b90314 7829b7c7bb59 20b8fe85888b 95b525b0b977 501d8b268dd4 44a3d2ebaddd 2fb70d019851 40dd3f86379a 92a797b20912 f3e5e2bc78e9 c8df33a6829f 67040b158ae2 0650d94ede82 a36bab6b9ffc 4597750166e7 336c953a020f 3d5c825aebba 617c2840446c 7fff30c4f5e2 36e5a9c15a84 0b203bbf7cc3 d29070faef66 6f52a0419331 7edaff01d506 c987c86513df 554ccacfc123 4d2e44d79b03 efd29a0033e8 d1727c573f30 4ac01a732552 2a83f5303458 7f5f6c056d8f 8933faff2043 d94449e42718 edcef27575a7 8a3334b7a364 745d225de344 967ef74f6fd9 348f8c4baccf c889911bec12 5dead1f4f26d d7ce41acf22c ea85398a51bc bf5101d394c0 eef677c48cd1 b0612e7dce30 070a7b64b368 2bbe88a85860 c4c87f481d05 7974adb0be66 b66e70b7b6c7 74be97962ed8 afd0be382257 a4f01d51c2a7 df15b74774ca e9bfea8e26db b6b7e4cf6b24 df940d75cd3d 4f6925337848 10708ab8f54d a3c8288b0141 cc19697f8871 c5f3bde466d1 bf3d07541368 90326c9601f9 9a065bc0ce33 1edac4b68c6d a0d4bbfbedd1 9008bf6c2b8c 941abdd6bb4c 76bc5a058351 25091e5c3bba 380996db84e5 370035a46397 fd3db644df22 ef20fdc14e8b 756fe44a0f4d 3d1e74cdac76 9991530b4bee 736f6813543d af9c6ca11c49
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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
- 43 atoms, 7 tree operations, 23 files
Browse the tree at this change
added crates/hub/migrations/0011_flow.sql +52 −0
| 1 | + | -- OxygenFlow runs, jobs and their logs (hub spec §13.2). |
|
| 2 | + | ||
| 3 | + | CREATE TABLE flow_runs ( |
|
| 4 | + | id INTEGER PRIMARY KEY, |
|
| 5 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 6 | + | bookmark TEXT NOT NULL, |
|
| 7 | + | -- The bookmark's state hash (checks are keyed by it) and its heads, joined with '+'. |
|
| 8 | + | state TEXT NOT NULL, |
|
| 9 | + | heads TEXT NOT NULL, |
|
| 10 | + | pipeline_hash TEXT NOT NULL DEFAULT '', |
|
| 11 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'error')), |
|
| 12 | + | -- Pipeline problems, one per line, for a run in error. |
|
| 13 | + | error TEXT NOT NULL DEFAULT '', |
|
| 14 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 15 | + | created_ms INTEGER NOT NULL, |
|
| 16 | + | finished_ms INTEGER |
|
| 17 | + | ); |
|
| 18 | + | CREATE INDEX flow_runs_by_repo ON flow_runs (repo_id, id DESC); |
|
| 19 | + | ||
| 20 | + | CREATE TABLE flow_jobs ( |
|
| 21 | + | id INTEGER PRIMARY KEY, |
|
| 22 | + | run_id INTEGER NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 23 | + | name TEXT NOT NULL, |
|
| 24 | + | depth INTEGER NOT NULL, |
|
| 25 | + | -- JSON: the job's dependencies, compiled form and sandbox (flow-core types). |
|
| 26 | + | depends TEXT NOT NULL, |
|
| 27 | + | spec TEXT NOT NULL, |
|
| 28 | + | sandbox TEXT NOT NULL, |
|
| 29 | + | runtime TEXT NOT NULL, |
|
| 30 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'skipped', 'error')), |
|
| 31 | + | runner TEXT NOT NULL DEFAULT '', |
|
| 32 | + | token_hash TEXT NOT NULL DEFAULT '', |
|
| 33 | + | started_ms INTEGER, |
|
| 34 | + | finished_ms INTEGER, |
|
| 35 | + | exit_code INTEGER, |
|
| 36 | + | error TEXT NOT NULL DEFAULT '', |
|
| 37 | + | receipt TEXT NOT NULL DEFAULT '', |
|
| 38 | + | receipt_hash TEXT NOT NULL DEFAULT '', |
|
| 39 | + | log_count INTEGER NOT NULL DEFAULT 0, |
|
| 40 | + | UNIQUE (run_id, name) |
|
| 41 | + | ); |
|
| 42 | + | CREATE INDEX flow_jobs_queued ON flow_jobs (status, id); |
|
| 43 | + | CREATE INDEX flow_jobs_token ON flow_jobs (token_hash) WHERE token_hash <> ''; |
|
| 44 | + | ||
| 45 | + | CREATE TABLE flow_logs ( |
|
| 46 | + | job_id INTEGER NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 47 | + | seq INTEGER NOT NULL, |
|
| 48 | + | t INTEGER NOT NULL, |
|
| 49 | + | stream TEXT NOT NULL, |
|
| 50 | + | text TEXT NOT NULL, |
|
| 51 | + | PRIMARY KEY (job_id, seq) |
|
| 52 | + | ); |
added crates/hub/src/db/flow.rs +318 −0
| 1 | + | //! OxygenFlow runs, jobs and logs (hub spec §13.2). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct Run { |
|
| 8 | + | pub id: i64, |
|
| 9 | + | pub repo_id: i64, |
|
| 10 | + | pub bookmark: String, |
|
| 11 | + | pub state: String, |
|
| 12 | + | pub heads: String, |
|
| 13 | + | pub pipeline_hash: String, |
|
| 14 | + | pub status: String, |
|
| 15 | + | pub error: String, |
|
| 16 | + | pub actor: String, |
|
| 17 | + | pub created_ms: i64, |
|
| 18 | + | pub finished_ms: Option<i64>, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | impl Run { |
|
| 22 | + | pub fn head_list(&self) -> Vec<&str> { |
|
| 23 | + | self.heads.split('+').filter(|h| !h.is_empty()).collect() |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | pub fn live(&self) -> bool { |
|
| 27 | + | matches!(self.status.as_str(), "queued" | "running") |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | #[derive(Clone, Debug, FromRow)] |
|
| 32 | + | pub struct Job { |
|
| 33 | + | pub id: i64, |
|
| 34 | + | pub run_id: i64, |
|
| 35 | + | pub name: String, |
|
| 36 | + | pub depth: i64, |
|
| 37 | + | pub depends: String, |
|
| 38 | + | pub spec: String, |
|
| 39 | + | pub sandbox: String, |
|
| 40 | + | pub runtime: String, |
|
| 41 | + | pub status: String, |
|
| 42 | + | pub runner: String, |
|
| 43 | + | pub token_hash: String, |
|
| 44 | + | pub started_ms: Option<i64>, |
|
| 45 | + | pub finished_ms: Option<i64>, |
|
| 46 | + | pub exit_code: Option<i64>, |
|
| 47 | + | pub error: String, |
|
| 48 | + | pub receipt: String, |
|
| 49 | + | pub receipt_hash: String, |
|
| 50 | + | pub log_count: i64, |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | impl Job { |
|
| 54 | + | pub fn depends_on(&self) -> Vec<String> { |
|
| 55 | + | serde_json::from_str(&self.depends).unwrap_or_default() |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | pub fn done(&self) -> bool { |
|
| 59 | + | matches!(self.status.as_str(), "passed" | "failed" | "skipped" | "error") |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | #[derive(Clone, Debug, FromRow)] |
|
| 64 | + | pub struct LogLine { |
|
| 65 | + | pub seq: i64, |
|
| 66 | + | pub t: i64, |
|
| 67 | + | pub stream: String, |
|
| 68 | + | pub text: String, |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /// A job as the trigger creates it. |
|
| 72 | + | pub struct NewJob { |
|
| 73 | + | pub name: String, |
|
| 74 | + | pub depth: i64, |
|
| 75 | + | pub depends: Vec<String>, |
|
| 76 | + | pub spec: String, |
|
| 77 | + | pub sandbox: String, |
|
| 78 | + | pub runtime: String, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub struct NewRun<'a> { |
|
| 82 | + | pub repo_id: i64, |
|
| 83 | + | pub bookmark: &'a str, |
|
| 84 | + | pub state: &'a str, |
|
| 85 | + | pub heads: &'a str, |
|
| 86 | + | pub pipeline_hash: &'a str, |
|
| 87 | + | pub actor: &'a str, |
|
| 88 | + | /// Problems when the pipeline is invalid: the run is recorded in error. |
|
| 89 | + | pub error: Option<&'a str>, |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | pub async fn create_run(pool: &Pool, r: NewRun<'_>, jobs: &[NewJob]) -> sqlx::Result<i64> { |
|
| 93 | + | let mut tx = pool.begin().await?; |
|
| 94 | + | let now = now_ms(); |
|
| 95 | + | let status = if r.error.is_some() { "error" } else { "queued" }; |
|
| 96 | + | let id: i64 = sqlx::query_scalar( |
|
| 97 | + | "INSERT INTO flow_runs (repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 98 | + | ) |
|
| 99 | + | .bind(r.repo_id) |
|
| 100 | + | .bind(r.bookmark) |
|
| 101 | + | .bind(r.state) |
|
| 102 | + | .bind(r.heads) |
|
| 103 | + | .bind(r.pipeline_hash) |
|
| 104 | + | .bind(status) |
|
| 105 | + | .bind(r.error.unwrap_or("")) |
|
| 106 | + | .bind(r.actor) |
|
| 107 | + | .bind(now) |
|
| 108 | + | .bind(r.error.map(|_| now)) |
|
| 109 | + | .fetch_one(&mut *tx) |
|
| 110 | + | .await?; |
|
| 111 | + | for j in jobs { |
|
| 112 | + | sqlx::query( |
|
| 113 | + | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')", |
|
| 114 | + | ) |
|
| 115 | + | .bind(id) |
|
| 116 | + | .bind(&j.name) |
|
| 117 | + | .bind(j.depth) |
|
| 118 | + | .bind(serde_json::to_string(&j.depends).unwrap_or_else(|_| "[]".into())) |
|
| 119 | + | .bind(&j.spec) |
|
| 120 | + | .bind(&j.sandbox) |
|
| 121 | + | .bind(&j.runtime) |
|
| 122 | + | .execute(&mut *tx) |
|
| 123 | + | .await?; |
|
| 124 | + | } |
|
| 125 | + | tx.commit().await?; |
|
| 126 | + | Ok(id) |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> { |
|
| 130 | + | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id < ? ORDER BY id DESC LIMIT ?") |
|
| 131 | + | .bind(repo_id) |
|
| 132 | + | .bind(before.unwrap_or(i64::MAX)) |
|
| 133 | + | .bind(limit) |
|
| 134 | + | .fetch_all(pool) |
|
| 135 | + | .await |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | pub async fn run(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Run>> { |
|
| 139 | + | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | pub async fn run_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Run>> { |
|
| 143 | + | sqlx::query_as("SELECT * FROM flow_runs WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | pub async fn jobs(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Job>> { |
|
| 147 | + | sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ? ORDER BY depth, name").bind(run_id).fetch_all(pool).await |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | pub async fn job(pool: &Pool, id: i64) -> sqlx::Result<Option<Job>> { |
|
| 151 | + | sqlx::query_as("SELECT * FROM flow_jobs WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | /// The running job a job token belongs to, while it may still be used. |
|
| 155 | + | pub async fn job_by_token(pool: &Pool, token_hash: &str, max_age_ms: i64) -> sqlx::Result<Option<Job>> { |
|
| 156 | + | sqlx::query_as("SELECT * FROM flow_jobs WHERE token_hash = ? AND status = 'running' AND started_ms >= ?") |
|
| 157 | + | .bind(token_hash) |
|
| 158 | + | .bind(now_ms() - max_age_ms) |
|
| 159 | + | .fetch_optional(pool) |
|
| 160 | + | .await |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | /// Take the oldest queued job whose dependencies all passed and whose tier |
|
| 164 | + | /// the runner offers, and mark it running for `runner` with a token. |
|
| 165 | + | pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> { |
|
| 166 | + | let mut tx = pool.begin().await?; |
|
| 167 | + | let queued: Vec<Job> = sqlx::query_as( |
|
| 168 | + | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id WHERE j.status = 'queued' AND r.status IN ('queued', 'running') ORDER BY j.run_id, j.depth, j.id LIMIT 200", |
|
| 169 | + | ) |
|
| 170 | + | .fetch_all(&mut *tx) |
|
| 171 | + | .await?; |
|
| 172 | + | for j in queued { |
|
| 173 | + | if !runtimes.iter().any(|r| r == &j.runtime) { |
|
| 174 | + | continue; |
|
| 175 | + | } |
|
| 176 | + | let deps = j.depends_on(); |
|
| 177 | + | if !deps.is_empty() { |
|
| 178 | + | let mut passed = 0i64; |
|
| 179 | + | for d in &deps { |
|
| 180 | + | let st: Option<String> = sqlx::query_scalar("SELECT status FROM flow_jobs WHERE run_id = ? AND name = ?") |
|
| 181 | + | .bind(j.run_id) |
|
| 182 | + | .bind(d) |
|
| 183 | + | .fetch_optional(&mut *tx) |
|
| 184 | + | .await?; |
|
| 185 | + | if st.as_deref() == Some("passed") { |
|
| 186 | + | passed += 1; |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | if passed != deps.len() as i64 { |
|
| 190 | + | continue; |
|
| 191 | + | } |
|
| 192 | + | } |
|
| 193 | + | let now = now_ms(); |
|
| 194 | + | let done = sqlx::query( |
|
| 195 | + | "UPDATE flow_jobs SET status = 'running', runner = ?, token_hash = ?, started_ms = ? WHERE id = ? AND status = 'queued'", |
|
| 196 | + | ) |
|
| 197 | + | .bind(runner) |
|
| 198 | + | .bind(token_hash) |
|
| 199 | + | .bind(now) |
|
| 200 | + | .bind(j.id) |
|
| 201 | + | .execute(&mut *tx) |
|
| 202 | + | .await?; |
|
| 203 | + | if done.rows_affected() == 1 { |
|
| 204 | + | sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = ? AND status = 'queued'") |
|
| 205 | + | .bind(j.run_id) |
|
| 206 | + | .execute(&mut *tx) |
|
| 207 | + | .await?; |
|
| 208 | + | tx.commit().await?; |
|
| 209 | + | return job(pool, j.id).await; |
|
| 210 | + | } |
|
| 211 | + | } |
|
| 212 | + | tx.commit().await?; |
|
| 213 | + | Ok(None) |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | /// Append records; returns how many were kept (the rest exceed the cap). |
|
| 217 | + | pub async fn append_log(pool: &Pool, job_id: i64, records: &[flow_core::api::Record], cap: i64) -> sqlx::Result<usize> { |
|
| 218 | + | let mut tx = pool.begin().await?; |
|
| 219 | + | let count: i64 = sqlx::query_scalar("SELECT log_count FROM flow_jobs WHERE id = ?").bind(job_id).fetch_one(&mut *tx).await?; |
|
| 220 | + | let room = (cap - count).max(0) as usize; |
|
| 221 | + | let keep = &records[..records.len().min(room)]; |
|
| 222 | + | for (i, r) in keep.iter().enumerate() { |
|
| 223 | + | sqlx::query("INSERT INTO flow_logs (job_id, seq, t, stream, text) VALUES (?, ?, ?, ?, ?)") |
|
| 224 | + | .bind(job_id) |
|
| 225 | + | .bind(count + i as i64 + 1) |
|
| 226 | + | .bind(r.t as i64) |
|
| 227 | + | .bind(&r.stream) |
|
| 228 | + | .bind(&r.text) |
|
| 229 | + | .execute(&mut *tx) |
|
| 230 | + | .await?; |
|
| 231 | + | } |
|
| 232 | + | sqlx::query("UPDATE flow_jobs SET log_count = log_count + ? WHERE id = ?") |
|
| 233 | + | .bind(keep.len() as i64) |
|
| 234 | + | .bind(job_id) |
|
| 235 | + | .execute(&mut *tx) |
|
| 236 | + | .await?; |
|
| 237 | + | tx.commit().await?; |
|
| 238 | + | Ok(keep.len()) |
|
| 239 | + | } |
|
| 240 | + | ||
| 241 | + | pub async fn log(pool: &Pool, job_id: i64, after: i64, limit: i64) -> sqlx::Result<Vec<LogLine>> { |
|
| 242 | + | sqlx::query_as("SELECT seq, t, stream, text FROM flow_logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT ?") |
|
| 243 | + | .bind(job_id) |
|
| 244 | + | .bind(after) |
|
| 245 | + | .bind(limit) |
|
| 246 | + | .fetch_all(pool) |
|
| 247 | + | .await |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | pub struct Outcome<'a> { |
|
| 251 | + | pub status: &'a str, |
|
| 252 | + | pub exit_code: Option<i64>, |
|
| 253 | + | pub error: &'a str, |
|
| 254 | + | pub receipt: &'a str, |
|
| 255 | + | pub receipt_hash: &'a str, |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | /// Record a job's end, skip the jobs that can no longer run, and settle the |
|
| 259 | + | /// run. Returns the jobs whose status changed (for their checks). |
|
| 260 | + | pub async fn finish(pool: &Pool, job_id: i64, o: Outcome<'_>) -> sqlx::Result<Vec<Job>> { |
|
| 261 | + | let mut tx = pool.begin().await?; |
|
| 262 | + | let now = now_ms(); |
|
| 263 | + | let j: Job = sqlx::query_as("SELECT * FROM flow_jobs WHERE id = ?").bind(job_id).fetch_one(&mut *tx).await?; |
|
| 264 | + | sqlx::query("UPDATE flow_jobs SET status = ?, exit_code = ?, error = ?, receipt = ?, receipt_hash = ?, finished_ms = ?, token_hash = '' WHERE id = ?") |
|
| 265 | + | .bind(o.status) |
|
| 266 | + | .bind(o.exit_code) |
|
| 267 | + | .bind(o.error) |
|
| 268 | + | .bind(o.receipt) |
|
| 269 | + | .bind(o.receipt_hash) |
|
| 270 | + | .bind(now) |
|
| 271 | + | .bind(job_id) |
|
| 272 | + | .execute(&mut *tx) |
|
| 273 | + | .await?; |
|
| 274 | + | let mut changed = vec![job_id]; |
|
| 275 | + | // Dependents of anything that did not pass are skipped, transitively. |
|
| 276 | + | loop { |
|
| 277 | + | let all: Vec<Job> = sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ?").bind(j.run_id).fetch_all(&mut *tx).await?; |
|
| 278 | + | let blocked: Vec<i64> = all |
|
| 279 | + | .iter() |
|
| 280 | + | .filter(|x| x.status == "queued") |
|
| 281 | + | .filter(|x| { |
|
| 282 | + | x.depends_on() |
|
| 283 | + | .iter() |
|
| 284 | + | .any(|d| all.iter().any(|y| &y.name == d && matches!(y.status.as_str(), "failed" | "skipped" | "error"))) |
|
| 285 | + | }) |
|
| 286 | + | .map(|x| x.id) |
|
| 287 | + | .collect(); |
|
| 288 | + | if blocked.is_empty() { |
|
| 289 | + | break; |
|
| 290 | + | } |
|
| 291 | + | for id in blocked { |
|
| 292 | + | sqlx::query("UPDATE flow_jobs SET status = 'skipped', finished_ms = ? WHERE id = ?") |
|
| 293 | + | .bind(now) |
|
| 294 | + | .bind(id) |
|
| 295 | + | .execute(&mut *tx) |
|
| 296 | + | .await?; |
|
| 297 | + | changed.push(id); |
|
| 298 | + | } |
|
| 299 | + | } |
|
| 300 | + | let all: Vec<Job> = sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ?").bind(j.run_id).fetch_all(&mut *tx).await?; |
|
| 301 | + | if all.iter().all(Job::done) { |
|
| 302 | + | let status = if all.iter().all(|x| x.status == "passed") { "passed" } else { "failed" }; |
|
| 303 | + | sqlx::query("UPDATE flow_runs SET status = ?, finished_ms = ? WHERE id = ?") |
|
| 304 | + | .bind(status) |
|
| 305 | + | .bind(now) |
|
| 306 | + | .bind(j.run_id) |
|
| 307 | + | .execute(&mut *tx) |
|
| 308 | + | .await?; |
|
| 309 | + | } |
|
| 310 | + | tx.commit().await?; |
|
| 311 | + | let mut out = Vec::new(); |
|
| 312 | + | for id in changed { |
|
| 313 | + | if let Some(x) = job(pool, id).await? { |
|
| 314 | + | out.push(x); |
|
| 315 | + | } |
|
| 316 | + | } |
|
| 317 | + | Ok(out) |
|
| 318 | + | } |
added crates/hub/src/flow.rs +253 −0
| 1 | + | //! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the |
|
| 2 | + | //! runner API, job tokens and the checks that mirror jobs. Pipelines are |
|
| 3 | + | //! parsed by `flow-core`, the same code `flow check` runs. |
|
| 4 | + | ||
| 5 | + | use crate::api::ApiError; |
|
| 6 | + | use crate::db::{self, flow as fdb}; |
|
| 7 | + | use crate::repos::RepoName; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use axum::extract::{Path, State}; |
|
| 10 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 11 | + | use axum::response::{IntoResponse, Response}; |
|
| 12 | + | use axum::Json; |
|
| 13 | + | use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT}; |
|
| 14 | + | use flow_core::{Pipeline, PIPELINE_PATH}; |
|
| 15 | + | use oxy_core::hash::ChangeHash; |
|
| 16 | + | ||
| 17 | + | /// Prefix of job tokens, so the wire protocol can tell them from personal tokens. |
|
| 18 | + | pub const JOB_TOKEN_PREFIX: &str = "flowjob_"; |
|
| 19 | + | /// A job token is refused this long after its job started, whatever its state. |
|
| 20 | + | pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000; |
|
| 21 | + | ||
| 22 | + | /// A bookmark moved by a push: start a run when the repository is allowed |
|
| 23 | + | /// and the new state has a pipeline. |
|
| 24 | + | pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) { |
|
| 25 | + | if bookmark.starts_with("tags/") || heads.is_empty() || !app.config.flow.allows(&row.owner, &row.name) { |
|
| 26 | + | return; |
|
| 27 | + | } |
|
| 28 | + | let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 29 | + | let h = heads.to_vec(); |
|
| 30 | + | let text = app |
|
| 31 | + | .repos |
|
| 32 | + | .read(&repo, move |r, cache| { |
|
| 33 | + | let loaded = cache.load(r, &h)?; |
|
| 34 | + | Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) { |
|
| 35 | + | Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()), |
|
| 36 | + | _ => None, |
|
| 37 | + | }) |
|
| 38 | + | }) |
|
| 39 | + | .await; |
|
| 40 | + | let text = match text { |
|
| 41 | + | Ok(Some(t)) => t, |
|
| 42 | + | Ok(None) => return, |
|
| 43 | + | Err(e) => { |
|
| 44 | + | tracing::warn!("flow: reading the pipeline of {repo}: {e}"); |
|
| 45 | + | return; |
|
| 46 | + | } |
|
| 47 | + | }; |
|
| 48 | + | let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
|
| 49 | + | hex.sort(); |
|
| 50 | + | let heads_s = hex.join("+"); |
|
| 51 | + | let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, error: None }; |
|
| 52 | + | let result = match Pipeline::parse(&text) { |
|
| 53 | + | Err(problems) => { |
|
| 54 | + | let msg = problems.to_string(); |
|
| 55 | + | db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await |
|
| 56 | + | } |
|
| 57 | + | Ok(p) => { |
|
| 58 | + | let depths = p.depths(); |
|
| 59 | + | let jobs: Vec<fdb::NewJob> = p |
|
| 60 | + | .order() |
|
| 61 | + | .into_iter() |
|
| 62 | + | .map(|j| { |
|
| 63 | + | let sb = &p.sandboxes[&j.sandbox]; |
|
| 64 | + | fdb::NewJob { |
|
| 65 | + | name: j.name.clone(), |
|
| 66 | + | depth: depths[j.name.as_str()] as i64, |
|
| 67 | + | depends: j.depends_on.clone(), |
|
| 68 | + | spec: serde_json::to_string(j).unwrap_or_default(), |
|
| 69 | + | sandbox: serde_json::to_string(sb).unwrap_or_default(), |
|
| 70 | + | runtime: sb.runtime.as_str().into(), |
|
| 71 | + | } |
|
| 72 | + | }) |
|
| 73 | + | .collect(); |
|
| 74 | + | let hash = hex::encode(p.hash()); |
|
| 75 | + | let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await; |
|
| 76 | + | if let Ok(id) = id { |
|
| 77 | + | for j in &jobs { |
|
| 78 | + | report_check(app, row, state, id, &j.name, "queued", "waiting for a runner").await; |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | id |
|
| 82 | + | } |
|
| 83 | + | }; |
|
| 84 | + | if let Err(e) = result { |
|
| 85 | + | tracing::warn!("flow: recording a run of {repo}: {e}"); |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_id: i64, job: &str, status: &str, summary: &str) { |
|
| 90 | + | let url = format!("{}/{}/{}/flow/{run_id}", app.config.base_url(), row.owner, row.name); |
|
| 91 | + | let name = format!("flow/{job}"); |
|
| 92 | + | let c = db::hooks::upsert_check( |
|
| 93 | + | &app.db, |
|
| 94 | + | row.id, |
|
| 95 | + | db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" }, |
|
| 96 | + | ) |
|
| 97 | + | .await; |
|
| 98 | + | if let Ok(c) = c { |
|
| 99 | + | let api = crate::api::check_of(&c); |
|
| 100 | + | app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
|
| 101 | + | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// The check status a job's status shows as. |
|
| 106 | + | fn check_status(job_status: &str) -> &'static str { |
|
| 107 | + | match job_status { |
|
| 108 | + | "queued" => "queued", |
|
| 109 | + | "running" => "running", |
|
| 110 | + | "passed" => "passed", |
|
| 111 | + | "skipped" => "skipped", |
|
| 112 | + | _ => "failed", |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> { |
|
| 117 | + | let Some(t) = oxy_server::bearer_token(headers) else { |
|
| 118 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into())); |
|
| 119 | + | }; |
|
| 120 | + | if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) { |
|
| 121 | + | Ok(()) |
|
| 122 | + | } else { |
|
| 123 | + | Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())) |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | /// `POST /api/v1/flow/claim`. |
|
| 128 | + | pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> { |
|
| 129 | + | runner_auth(&app, &headers)?; |
|
| 130 | + | let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect(); |
|
| 131 | + | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
|
| 132 | + | let Some(job) = fdb::claim(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret)).await? else { |
|
| 133 | + | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 134 | + | }; |
|
| 135 | + | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 136 | + | let row = |
|
| 137 | + | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
|
| 138 | + | report_check(&app, &row, &run.state, run.id, &job.name, "running", &format!("running on {runner}")).await; |
|
| 139 | + | let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")); |
|
| 140 | + | let claim = Claim { |
|
| 141 | + | job_id: job.id, |
|
| 142 | + | run_id: run.id, |
|
| 143 | + | repo: format!("{}/{}", row.owner, row.name), |
|
| 144 | + | clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name), |
|
| 145 | + | token: secret, |
|
| 146 | + | heads: run.head_list().into_iter().map(String::from).collect(), |
|
| 147 | + | pipeline_hash: run.pipeline_hash.clone(), |
|
| 148 | + | job: serde_json::from_str(&job.spec).map_err(bad)?, |
|
| 149 | + | sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?, |
|
| 150 | + | timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60, |
|
| 151 | + | }; |
|
| 152 | + | Ok(Json(claim).into_response()) |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> { |
|
| 156 | + | match fdb::job(&app.db, id).await? { |
|
| 157 | + | Some(j) if j.status == "running" => Ok(j), |
|
| 158 | + | Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())), |
|
| 159 | + | None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())), |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | /// `POST /api/v1/flow/jobs/{id}/log`. |
|
| 164 | + | pub async fn log( |
|
| 165 | + | State(app): State<Shared>, |
|
| 166 | + | headers: HeaderMap, |
|
| 167 | + | Path(id): Path<i64>, |
|
| 168 | + | Json(mut batch): Json<LogBatch>, |
|
| 169 | + | ) -> Result<StatusCode, ApiError> { |
|
| 170 | + | runner_auth(&app, &headers)?; |
|
| 171 | + | let job = running_job(&app, id).await?; |
|
| 172 | + | for r in &mut batch.records { |
|
| 173 | + | if r.text.len() > MAX_RECORD_TEXT { |
|
| 174 | + | let mut cut = MAX_RECORD_TEXT; |
|
| 175 | + | while !r.text.is_char_boundary(cut) { |
|
| 176 | + | cut -= 1; |
|
| 177 | + | } |
|
| 178 | + | r.text.truncate(cut); |
|
| 179 | + | } |
|
| 180 | + | if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") { |
|
| 181 | + | r.stream = "console".into(); |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?; |
|
| 185 | + | if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 { |
|
| 186 | + | tracing::info!("flow: job {} reached the log limit", job.id); |
|
| 187 | + | } |
|
| 188 | + | Ok(StatusCode::NO_CONTENT) |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | /// `POST /api/v1/flow/jobs/{id}/finish`. |
|
| 192 | + | pub async fn finish( |
|
| 193 | + | State(app): State<Shared>, |
|
| 194 | + | headers: HeaderMap, |
|
| 195 | + | Path(id): Path<i64>, |
|
| 196 | + | Json(f): Json<Finish>, |
|
| 197 | + | ) -> Result<StatusCode, ApiError> { |
|
| 198 | + | runner_auth(&app, &headers)?; |
|
| 199 | + | let job = running_job(&app, id).await?; |
|
| 200 | + | let status = match (f.exit_code, &f.error) { |
|
| 201 | + | (Some(0), None) => "passed", |
|
| 202 | + | (Some(_), _) => "failed", |
|
| 203 | + | (None, _) => "error", |
|
| 204 | + | }; |
|
| 205 | + | let (receipt, receipt_hash) = match &f.receipt { |
|
| 206 | + | Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => { |
|
| 207 | + | (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash())) |
|
| 208 | + | } |
|
| 209 | + | Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())), |
|
| 210 | + | None => (String::new(), String::new()), |
|
| 211 | + | }; |
|
| 212 | + | let error = f.error.clone().unwrap_or_default(); |
|
| 213 | + | let changed = fdb::finish( |
|
| 214 | + | &app.db, |
|
| 215 | + | job.id, |
|
| 216 | + | fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash }, |
|
| 217 | + | ) |
|
| 218 | + | .await?; |
|
| 219 | + | if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? { |
|
| 220 | + | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
|
| 221 | + | for j in changed { |
|
| 222 | + | let summary = match j.status.as_str() { |
|
| 223 | + | "passed" => format!("passed in {}", duration(&j)), |
|
| 224 | + | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 225 | + | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 226 | + | _ => format!("could not run: {}", j.error), |
|
| 227 | + | }; |
|
| 228 | + | report_check(&app, &row, &run.state, run.id, &j.name, check_status(&j.status), &summary).await; |
|
| 229 | + | } |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | Ok(StatusCode::NO_CONTENT) |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | /// A job's wall time, as the pages show it. |
|
| 236 | + | pub fn duration(j: &fdb::Job) -> String { |
|
| 237 | + | match (j.started_ms, j.finished_ms) { |
|
| 238 | + | (Some(a), Some(b)) => human_ms(b - a), |
|
| 239 | + | (Some(a), None) => human_ms(db::now_ms() - a), |
|
| 240 | + | _ => "–".into(), |
|
| 241 | + | } |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | pub fn human_ms(ms: i64) -> String { |
|
| 245 | + | let ms = ms.max(0); |
|
| 246 | + | if ms < 1000 { |
|
| 247 | + | format!("{ms} ms") |
|
| 248 | + | } else if ms < 60_000 { |
|
| 249 | + | format!("{:.1} s", ms as f64 / 1000.0) |
|
| 250 | + | } else { |
|
| 251 | + | format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000) |
|
| 252 | + | } |
|
| 253 | + | } |
added crates/hub/src/web/flow.rs +225 −0
| 1 | + | //! The Flow tab (hub spec §13.2): runs of a repository, and a run's job |
|
| 2 | + | //! graph with live status and logs. |
|
| 3 | + | ||
| 4 | + | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::db::{self, flow as fdb}; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use askama::Template; |
|
| 9 | + | use axum::extract::{Path, Query, State}; |
|
| 10 | + | use axum::response::{IntoResponse, Response}; |
|
| 11 | + | use axum::Json; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | ||
| 14 | + | pub struct RunRow { |
|
| 15 | + | pub r: fdb::Run, |
|
| 16 | + | pub duration: String, |
|
| 17 | + | pub jobs: Vec<(String, String)>, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | #[derive(Template)] |
|
| 21 | + | #[template(path = "flow_runs.html")] |
|
| 22 | + | pub struct RunsPage { |
|
| 23 | + | shell: Shell, |
|
| 24 | + | repo: RepoShell, |
|
| 25 | + | runs: Vec<RunRow>, |
|
| 26 | + | enabled: bool, |
|
| 27 | + | older: Option<i64>, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | #[derive(Deserialize)] |
|
| 31 | + | pub struct RunsQuery { |
|
| 32 | + | before: Option<i64>, |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | const PER_PAGE: i64 = 30; |
|
| 36 | + | ||
| 37 | + | pub async fn runs( |
|
| 38 | + | State(app): State<Shared>, |
|
| 39 | + | viewer: Viewer, |
|
| 40 | + | Path((owner, name)): Path<(String, String)>, |
|
| 41 | + | Query(q): Query<RunsQuery>, |
|
| 42 | + | ) -> Result<Page<RunsPage>, Error> { |
|
| 43 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer); |
|
| 44 | + | let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 45 | + | let list = fdb::runs(&app.db, row.id, PER_PAGE + 1, q.before).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 46 | + | let older = (list.len() as i64 > PER_PAGE).then(|| list[PER_PAGE as usize - 1].id); |
|
| 47 | + | let mut runs = Vec::new(); |
|
| 48 | + | for r in list.into_iter().take(PER_PAGE as usize) { |
|
| 49 | + | let jobs = fdb::jobs(&app.db, r.id).await.unwrap_or_default(); |
|
| 50 | + | let duration = match r.finished_ms { |
|
| 51 | + | Some(f) => crate::flow::human_ms(f - r.created_ms), |
|
| 52 | + | None if r.live() => crate::flow::human_ms(db::now_ms() - r.created_ms), |
|
| 53 | + | None => "–".into(), |
|
| 54 | + | }; |
|
| 55 | + | runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r }); |
|
| 56 | + | } |
|
| 57 | + | let enabled = app.config.flow.allows(&row.owner, &row.name); |
|
| 58 | + | Ok(Page(RunsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), runs, enabled, older })) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// A job placed in the graph. |
|
| 62 | + | pub struct Node { |
|
| 63 | + | pub j: fdb::Job, |
|
| 64 | + | pub x: i64, |
|
| 65 | + | pub y: i64, |
|
| 66 | + | pub duration: String, |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | pub struct Edge { |
|
| 70 | + | pub d: String, |
|
| 71 | + | pub status: String, |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | pub struct Graph { |
|
| 75 | + | pub nodes: Vec<Node>, |
|
| 76 | + | pub edges: Vec<Edge>, |
|
| 77 | + | pub width: i64, |
|
| 78 | + | pub height: i64, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub const NODE_W: i64 = 200; |
|
| 82 | + | pub const NODE_H: i64 = 48; |
|
| 83 | + | const COL_GAP: i64 = 72; |
|
| 84 | + | const ROW_GAP: i64 = 18; |
|
| 85 | + | const PAD: i64 = 12; |
|
| 86 | + | ||
| 87 | + | /// Columns by dependency depth, jobs in each column in name order. |
|
| 88 | + | pub fn layout(jobs: Vec<fdb::Job>) -> Graph { |
|
| 89 | + | let mut by_depth: std::collections::BTreeMap<i64, Vec<fdb::Job>> = Default::default(); |
|
| 90 | + | for j in jobs { |
|
| 91 | + | by_depth.entry(j.depth).or_default().push(j); |
|
| 92 | + | } |
|
| 93 | + | let rows = by_depth.values().map(Vec::len).max().unwrap_or(0) as i64; |
|
| 94 | + | let height = PAD * 2 + rows * NODE_H + (rows - 1).max(0) * ROW_GAP; |
|
| 95 | + | let mut nodes = Vec::new(); |
|
| 96 | + | for (col, (_, mut js)) in by_depth.into_iter().enumerate() { |
|
| 97 | + | js.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 98 | + | let n = js.len() as i64; |
|
| 99 | + | let column_h = n * NODE_H + (n - 1).max(0) * ROW_GAP; |
|
| 100 | + | let top = (height - column_h) / 2; |
|
| 101 | + | for (i, j) in js.into_iter().enumerate() { |
|
| 102 | + | let duration = crate::flow::duration(&j); |
|
| 103 | + | nodes.push(Node { x: PAD + col as i64 * (NODE_W + COL_GAP), y: top + i as i64 * (NODE_H + ROW_GAP), duration, j }); |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | let mut edges = Vec::new(); |
|
| 107 | + | for n in &nodes { |
|
| 108 | + | for d in n.j.depends_on() { |
|
| 109 | + | if let Some(from) = nodes.iter().find(|m| m.j.name == d) { |
|
| 110 | + | let (x1, y1) = (from.x + NODE_W, from.y + NODE_H / 2); |
|
| 111 | + | let (x2, y2) = (n.x, n.y + NODE_H / 2); |
|
| 112 | + | let mid = (x1 + x2) / 2; |
|
| 113 | + | edges.push(Edge { d: format!("M{x1},{y1} C{mid},{y1} {mid},{y2} {x2},{y2}"), status: from.j.status.clone() }); |
|
| 114 | + | } |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | let cols = nodes.iter().map(|n| n.x).max().map(|x| x + NODE_W + PAD).unwrap_or(PAD * 2); |
|
| 118 | + | Graph { nodes, edges, width: cols, height: height.max(NODE_H + PAD * 2) } |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | pub fn glyph(status: &str) -> &'static str { |
|
| 122 | + | match status { |
|
| 123 | + | "passed" => "✓", |
|
| 124 | + | "failed" => "✕", |
|
| 125 | + | "running" => "●", |
|
| 126 | + | "skipped" => "⤼", |
|
| 127 | + | "error" => "!", |
|
| 128 | + | _ => "○", |
|
| 129 | + | } |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | pub struct JobLog { |
|
| 133 | + | pub j: fdb::Job, |
|
| 134 | + | pub lines: Vec<fdb::LogLine>, |
|
| 135 | + | pub duration: String, |
|
| 136 | + | /// The last line shown; the live view asks for what follows. |
|
| 137 | + | pub last_seq: i64, |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | #[derive(Template)] |
|
| 141 | + | #[template(path = "flow_run.html")] |
|
| 142 | + | pub struct RunPage { |
|
| 143 | + | shell: Shell, |
|
| 144 | + | repo: RepoShell, |
|
| 145 | + | run: fdb::Run, |
|
| 146 | + | graph: Graph, |
|
| 147 | + | logs: Vec<JobLog>, |
|
| 148 | + | duration: String, |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /// Lines of a job's log a page shows before "the rest is loaded as it comes". |
|
| 152 | + | const PAGE_LINES: i64 = 5000; |
|
| 153 | + | ||
| 154 | + | pub async fn run( |
|
| 155 | + | State(app): State<Shared>, |
|
| 156 | + | viewer: Viewer, |
|
| 157 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 158 | + | ) -> Result<Page<RunPage>, Error> { |
|
| 159 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer); |
|
| 160 | + | let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 161 | + | let run = fdb::run(&app.db, row.id, id) |
|
| 162 | + | .await |
|
| 163 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 164 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 165 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 166 | + | let mut logs = Vec::new(); |
|
| 167 | + | for j in &jobs { |
|
| 168 | + | let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default(); |
|
| 169 | + | let last_seq = lines.last().map(|l| l.seq).unwrap_or(0); |
|
| 170 | + | logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), lines, last_seq }); |
|
| 171 | + | } |
|
| 172 | + | let duration = match run.finished_ms { |
|
| 173 | + | Some(f) => crate::flow::human_ms(f - run.created_ms), |
|
| 174 | + | None => crate::flow::human_ms(db::now_ms() - run.created_ms), |
|
| 175 | + | }; |
|
| 176 | + | let graph = layout(jobs); |
|
| 177 | + | Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration })) |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | #[derive(Deserialize)] |
|
| 181 | + | pub struct LiveQuery { |
|
| 182 | + | /// `job:seq,job:seq`: the last log line each open job already shows. |
|
| 183 | + | after: Option<String>, |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// `GET /{owner}/{name}/flow/{id}/live`: statuses and new log lines, for the |
|
| 187 | + | /// run page while it is live. |
|
| 188 | + | pub async fn live( |
|
| 189 | + | State(app): State<Shared>, |
|
| 190 | + | viewer: Viewer, |
|
| 191 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 192 | + | Query(q): Query<LiveQuery>, |
|
| 193 | + | ) -> Result<Response, Error> { |
|
| 194 | + | let shell = Shell::new(&app, "", &viewer); |
|
| 195 | + | let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 196 | + | let run = fdb::run(&app.db, row.id, id) |
|
| 197 | + | .await |
|
| 198 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 199 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 200 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 201 | + | let after: std::collections::HashMap<i64, i64> = q |
|
| 202 | + | .after |
|
| 203 | + | .unwrap_or_default() |
|
| 204 | + | .split(',') |
|
| 205 | + | .filter_map(|p| p.split_once(':')) |
|
| 206 | + | .filter_map(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))) |
|
| 207 | + | .collect(); |
|
| 208 | + | let mut out = Vec::new(); |
|
| 209 | + | for j in &jobs { |
|
| 210 | + | let lines = match after.get(&j.id) { |
|
| 211 | + | Some(seq) => fdb::log(&app.db, j.id, *seq, 1000).await.unwrap_or_default(), |
|
| 212 | + | None => vec![], |
|
| 213 | + | }; |
|
| 214 | + | out.push(serde_json::json!({ |
|
| 215 | + | "id": j.id, |
|
| 216 | + | "name": j.name, |
|
| 217 | + | "status": j.status, |
|
| 218 | + | "glyph": glyph(&j.status), |
|
| 219 | + | "duration": crate::flow::duration(j), |
|
| 220 | + | "exit_code": j.exit_code, |
|
| 221 | + | "lines": lines.iter().map(|l| serde_json::json!({ "seq": l.seq, "t": l.t, "stream": l.stream, "text": l.text })).collect::<Vec<_>>(), |
|
| 222 | + | })); |
|
| 223 | + | } |
|
| 224 | + | Ok(Json(serde_json::json!({ "status": run.status, "live": run.live(), "jobs": out })).into_response()) |
|
| 225 | + | } |
added crates/hub/tests/flow.rs +343 −0
| 1 | + | //! OxygenFlow on the hub (hub spec §13.2), end to end: a real `oxy` push |
|
| 2 | + | //! starts a run, a runner claims jobs through the API in dependency order, |
|
| 3 | + | //! job tokens read only their repository, results become checks and pages. |
|
| 4 | + | ||
| 5 | + | use axum::body::Body; |
|
| 6 | + | use axum::http::{header, Request, StatusCode}; |
|
| 7 | + | use http_body_util::BodyExt; |
|
| 8 | + | use oxy_core::protocol::{open_remote, RemoteOptions}; |
|
| 9 | + | use oxy_core::repo::Repo; |
|
| 10 | + | use oxygenhub::config::Config; |
|
| 11 | + | use oxygenhub::repos::RepoName; |
|
| 12 | + | use oxygenhub::{db, Shared}; |
|
| 13 | + | use std::path::Path; |
|
| 14 | + | use tower::ServiceExt; |
|
| 15 | + | ||
| 16 | + | const ROOTFS: &str = "36fc4e3238044e424219a2b9002bf61bbe1805fec14d52cf37e17fae77c9ff16"; |
|
| 17 | + | ||
| 18 | + | fn hermetic() { |
|
| 19 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 20 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 21 | + | ONCE.call_once(|| { |
|
| 22 | + | let d = tempfile::tempdir().unwrap(); |
|
| 23 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 24 | + | DIR.set(d).ok(); |
|
| 25 | + | }); |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | fn write(root: &Path, rel: &str, content: &str) { |
|
| 29 | + | let p = root.join(rel); |
|
| 30 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 31 | + | std::fs::write(p, content).unwrap(); |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | struct Hub { |
|
| 35 | + | app: Shared, |
|
| 36 | + | base: String, |
|
| 37 | + | _dir: tempfile::TempDir, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | async fn hub() -> Hub { |
|
| 41 | + | hermetic(); |
|
| 42 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 43 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 44 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 45 | + | config.flow.runner_tokens = vec!["runner-secret".into()]; |
|
| 46 | + | config.flow.repos = vec!["andy/*".into()]; |
|
| 47 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 48 | + | let port = listener.local_addr().unwrap().port(); |
|
| 49 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 50 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 51 | + | let router = oxygenhub::router(app.clone()); |
|
| 52 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 53 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | async fn call(app: &Shared, method: &str, path: &str, bearer: Option<&str>, json: Option<String>) -> (StatusCode, String) { |
|
| 57 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 58 | + | if let Some(b) = bearer { |
|
| 59 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); |
|
| 60 | + | } |
|
| 61 | + | let body = match json { |
|
| 62 | + | Some(j) => { |
|
| 63 | + | req = req.header(header::CONTENT_TYPE, "application/json"); |
|
| 64 | + | Body::from(j) |
|
| 65 | + | } |
|
| 66 | + | None => Body::empty(), |
|
| 67 | + | }; |
|
| 68 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 69 | + | let status = res.status(); |
|
| 70 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 71 | + | (status, String::from_utf8_lossy(&bytes).into_owned()) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | async fn claim(app: &Shared) -> Option<serde_json::Value> { |
|
| 75 | + | let (st, body) = |
|
| 76 | + | call(app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(r#"{"runner":"test-1","runtimes":["microvm"]}"#.into())).await; |
|
| 77 | + | match st { |
|
| 78 | + | StatusCode::NO_CONTENT => None, |
|
| 79 | + | StatusCode::OK => Some(serde_json::from_str(&body).unwrap()), |
|
| 80 | + | other => panic!("claim: {other} {body}"), |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | fn receipt(job: &str, pipeline_hash: &str, exit: u32) -> serde_json::Value { |
|
| 85 | + | serde_json::json!({ |
|
| 86 | + | "job_name": job, "pipeline_hash": pipeline_hash, "scope_tree_hash": "11".repeat(32), |
|
| 87 | + | "inputs": [], "environment": "22".repeat(32), "outputs": [], "exit_code": exit, "duration_ms": 640 |
|
| 88 | + | }) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | async fn finish(app: &Shared, id: i64, body: serde_json::Value) -> StatusCode { |
|
| 92 | + | call(app, "POST", &format!("/api/v1/flow/jobs/{id}/finish"), Some("runner-secret"), Some(body.to_string())).await.0 |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | async fn create(app: &Shared, owner: &str, name: &str) { |
|
| 96 | + | app.repos.create(&RepoName::parse(&format!("{owner}/{name}")).unwrap(), None).unwrap(); |
|
| 97 | + | db::repos::create(&app.db, owner, name, "", hub_api::Visibility::Public).await.unwrap(); |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | fn client(url: &str, work: &Path) -> Repo { |
|
| 101 | + | let mut c = Repo::init(work).unwrap(); |
|
| 102 | + | c.config.set("remote.origin.url", url).unwrap(); |
|
| 103 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 104 | + | c.save_config().unwrap(); |
|
| 105 | + | c |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | fn pipeline(extra: &str) -> String { |
|
| 109 | + | format!( |
|
| 110 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n sandbox \"alpine\"\n exec \"make\"\n}}\njob \"test\" {{\n sandbox \"alpine\"\n depends-on \"build\"\n exec \"sh\" \"-eu\" \"test.sh\"\n}}\njob \"lint\" {{\n sandbox \"alpine\"\n exec \"true\"\n}}\n{extra}" |
|
| 111 | + | ) |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | async fn runs_of(app: &Shared, owner: &str, name: &str) -> Vec<db::flow::Run> { |
|
| 115 | + | let row = db::repos::get(&app.db, owner, name).await.unwrap().unwrap(); |
|
| 116 | + | for _ in 0..100 { |
|
| 117 | + | let r = db::flow::runs(&app.db, row.id, 10, None).await.unwrap(); |
|
| 118 | + | if !r.is_empty() { |
|
| 119 | + | return r; |
|
| 120 | + | } |
|
| 121 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 122 | + | } |
|
| 123 | + | vec![] |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 127 | + | async fn a_push_runs_the_pipeline_through_a_runner() { |
|
| 128 | + | let h = hub().await; |
|
| 129 | + | create(&h.app, "andy", "demo").await; |
|
| 130 | + | create(&h.app, "andy", "secret").await; |
|
| 131 | + | create(&h.app, "other", "plain").await; |
|
| 132 | + | let url = format!("{}/andy/demo", h.base); |
|
| 133 | + | let work = tempfile::tempdir().unwrap(); |
|
| 134 | + | let mut c = client(&url, work.path()); |
|
| 135 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 136 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 137 | + | c.snapshot("test").unwrap(); |
|
| 138 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 139 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 140 | + | ||
| 141 | + | // The push started a run with three queued jobs, and checks for them. |
|
| 142 | + | let runs = runs_of(&h.app, "andy", "demo").await; |
|
| 143 | + | assert_eq!(runs.len(), 1); |
|
| 144 | + | let run = &runs[0]; |
|
| 145 | + | assert_eq!((run.bookmark.as_str(), run.status.as_str(), run.actor.as_str()), ("main", "queued", "site token")); |
|
| 146 | + | assert_eq!(run.pipeline_hash.len(), 64); |
|
| 147 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 148 | + | assert_eq!(jobs.iter().map(|j| (j.name.as_str(), j.depth)).collect::<Vec<_>>(), [("build", 0), ("lint", 0), ("test", 1)]); |
|
| 149 | + | let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap(); |
|
| 150 | + | assert_eq!( |
|
| 151 | + | checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(), |
|
| 152 | + | [("flow/build", "queued"), ("flow/lint", "queued"), ("flow/test", "queued")] |
|
| 153 | + | ); |
|
| 154 | + | ||
| 155 | + | // Runners need a runner token. |
|
| 156 | + | assert_eq!( |
|
| 157 | + | call(&h.app, "POST", "/api/v1/flow/claim", Some("site"), Some(r#"{"runner":"x","runtimes":["microvm"]}"#.into())).await.0, |
|
| 158 | + | StatusCode::UNAUTHORIZED |
|
| 159 | + | ); |
|
| 160 | + | assert_eq!( |
|
| 161 | + | call(&h.app, "POST", "/api/v1/flow/claim", None, Some(r#"{"runner":"x","runtimes":["microvm"]}"#.into())).await.0, |
|
| 162 | + | StatusCode::UNAUTHORIZED |
|
| 163 | + | ); |
|
| 164 | + | // A runner without the tier gets nothing. |
|
| 165 | + | let (st, _) = |
|
| 166 | + | call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(r#"{"runner":"x","runtimes":["wasm"]}"#.into())).await; |
|
| 167 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 168 | + | ||
| 169 | + | // Claims come in dependency order; `test` waits for `build`. |
|
| 170 | + | let a = claim(&h.app).await.unwrap(); |
|
| 171 | + | let b = claim(&h.app).await.unwrap(); |
|
| 172 | + | assert_eq!((a["job"]["name"].as_str(), b["job"]["name"].as_str()), (Some("build"), Some("lint"))); |
|
| 173 | + | assert!(claim(&h.app).await.is_none(), "test waits for build"); |
|
| 174 | + | assert_eq!(a["repo"], "andy/demo"); |
|
| 175 | + | assert_eq!(a["clone_url"], url.as_str()); |
|
| 176 | + | assert_eq!(a["sandbox"]["rootfs"], ROOTFS); |
|
| 177 | + | assert_eq!(a["job"]["exec"], serde_json::json!(["make"])); |
|
| 178 | + | assert_eq!(a["timeout_s"], 3600); |
|
| 179 | + | let heads: Vec<String> = a["heads"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); |
|
| 180 | + | assert_eq!(heads.join("+"), run.heads); |
|
| 181 | + | let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap(); |
|
| 182 | + | assert_eq!(checks.iter().find(|c| c.name == "flow/build").unwrap().status, "running"); |
|
| 183 | + | ||
| 184 | + | // The job token reads this repository over the wire, and nothing else. |
|
| 185 | + | let token = a["token"].as_str().unwrap().to_string(); |
|
| 186 | + | assert!(token.starts_with("flowjob_")); |
|
| 187 | + | let opts = RemoteOptions { token: Some(token.clone()), ca: None, repo_id: None }; |
|
| 188 | + | open_remote(&url, &opts).unwrap().refs().unwrap(); |
|
| 189 | + | assert!(open_remote(&format!("{}/andy/secret", h.base), &opts).unwrap().refs().is_err(), "another repository"); |
|
| 190 | + | let bad = RemoteOptions { token: Some("flowjob_00".into()), ca: None, repo_id: None }; |
|
| 191 | + | assert!(open_remote(&url, &bad).unwrap().refs().is_err(), "an unknown job token"); |
|
| 192 | + | // … and cannot push. |
|
| 193 | + | c.config.set("remote.origin.token", &token).unwrap(); |
|
| 194 | + | c.save_config().unwrap(); |
|
| 195 | + | write(work.path(), "more.txt", "x\n"); |
|
| 196 | + | c.snapshot("test").unwrap(); |
|
| 197 | + | c.commit("More", &[]).unwrap(); |
|
| 198 | + | let pushed = c.push("origin", false); |
|
| 199 | + | assert!(pushed.is_err() || pushed.unwrap().iter().all(|r| !r.ok), "a job token cannot push"); |
|
| 200 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 201 | + | c.save_config().unwrap(); |
|
| 202 | + | ||
| 203 | + | // Logs, then results with receipts. |
|
| 204 | + | let a_id = a["job_id"].as_i64().unwrap(); |
|
| 205 | + | let b_id = b["job_id"].as_i64().unwrap(); |
|
| 206 | + | let log = serde_json::json!({ "records": [ |
|
| 207 | + | { "t": 5, "stream": "flow", "text": "booting" }, |
|
| 208 | + | { "t": 120, "stream": "out", "text": "cc -o demo demo.c" }, |
|
| 209 | + | { "t": 130, "stream": "weird", "text": "x".repeat(20_000) }, |
|
| 210 | + | ]}); |
|
| 211 | + | assert_eq!( |
|
| 212 | + | call(&h.app, "POST", &format!("/api/v1/flow/jobs/{a_id}/log"), Some("runner-secret"), Some(log.to_string())).await.0, |
|
| 213 | + | StatusCode::NO_CONTENT |
|
| 214 | + | ); |
|
| 215 | + | let lines = db::flow::log(&h.app.db, a_id, 0, 100).await.unwrap(); |
|
| 216 | + | assert_eq!(lines.len(), 3); |
|
| 217 | + | assert_eq!((lines[2].stream.as_str(), lines[2].text.len()), ("console", 16 * 1024), "streams are known ones, text is capped"); |
|
| 218 | + | let ph = a["pipeline_hash"].as_str().unwrap(); |
|
| 219 | + | assert_eq!( |
|
| 220 | + | finish(&h.app, a_id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": receipt("lint", ph, 0) })).await, |
|
| 221 | + | StatusCode::BAD_REQUEST, |
|
| 222 | + | "a receipt for another job" |
|
| 223 | + | ); |
|
| 224 | + | assert_eq!( |
|
| 225 | + | finish(&h.app, a_id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": receipt("build", ph, 0) })).await, |
|
| 226 | + | StatusCode::NO_CONTENT |
|
| 227 | + | ); |
|
| 228 | + | assert_eq!(finish(&h.app, a_id, serde_json::json!({ "exit_code": 0, "duration_ms": 700 })).await, StatusCode::CONFLICT, "only once"); |
|
| 229 | + | assert!(open_remote(&url, &opts).unwrap().refs().is_err(), "the token ends with its job"); |
|
| 230 | + | assert_eq!( |
|
| 231 | + | finish(&h.app, b_id, serde_json::json!({ "exit_code": 0, "duration_ms": 300, "receipt": receipt("lint", ph, 0) })).await, |
|
| 232 | + | StatusCode::NO_CONTENT |
|
| 233 | + | ); |
|
| 234 | + | let t = claim(&h.app).await.unwrap(); |
|
| 235 | + | assert_eq!(t["job"]["name"], "test"); |
|
| 236 | + | let t_id = t["job_id"].as_i64().unwrap(); |
|
| 237 | + | assert_eq!( |
|
| 238 | + | finish(&h.app, t_id, serde_json::json!({ "exit_code": 3, "duration_ms": 500, "receipt": receipt("test", ph, 3) })).await, |
|
| 239 | + | StatusCode::NO_CONTENT |
|
| 240 | + | ); |
|
| 241 | + | ||
| 242 | + | let run = db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap(); |
|
| 243 | + | assert_eq!(run.status, "failed"); |
|
| 244 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 245 | + | let build = jobs.iter().find(|j| j.name == "build").unwrap(); |
|
| 246 | + | assert_eq!((build.status.as_str(), build.exit_code, build.receipt_hash.len()), ("passed", Some(0), 64)); |
|
| 247 | + | assert_eq!(jobs.iter().find(|j| j.name == "test").unwrap().status, "failed"); |
|
| 248 | + | let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap(); |
|
| 249 | + | assert_eq!( |
|
| 250 | + | checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(), |
|
| 251 | + | [("flow/build", "passed"), ("flow/lint", "passed"), ("flow/test", "failed")] |
|
| 252 | + | ); |
|
| 253 | + | assert!(checks[2].summary.contains("exit 3") && checks[2].url.ends_with(&format!("/andy/demo/flow/{}", run.id))); |
|
| 254 | + | ||
| 255 | + | // Pages: the list, the run with its graph and logs, and the live view. |
|
| 256 | + | let (st, list) = call(&h.app, "GET", "/andy/demo/flow", None, None).await; |
|
| 257 | + | assert_eq!(st, StatusCode::OK); |
|
| 258 | + | assert!(list.contains(&format!("/andy/demo/flow/{}", run.id)) && list.contains("st-failed"), "{list}"); |
|
| 259 | + | let (st, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.id), None, None).await; |
|
| 260 | + | assert_eq!(st, StatusCode::OK); |
|
| 261 | + | assert!(page.contains("<svg class=\"flow-graph\""), "graph"); |
|
| 262 | + | assert_eq!(page.matches("data-job-node=").count(), 3); |
|
| 263 | + | assert_eq!(page.matches("<path class=\"edge").count(), 1, "one dependency edge"); |
|
| 264 | + | assert!(page.contains("cc -o demo demo.c"), "log shown"); |
|
| 265 | + | assert!(!page.contains("data-flow-live"), "a finished run does not poll"); |
|
| 266 | + | let (st, live) = call(&h.app, "GET", &format!("/andy/demo/flow/{}/live?after={a_id}:1", run.id), None, None).await; |
|
| 267 | + | assert_eq!(st, StatusCode::OK); |
|
| 268 | + | let live: serde_json::Value = serde_json::from_str(&live).unwrap(); |
|
| 269 | + | assert_eq!(live["status"], "failed"); |
|
| 270 | + | let build_live = live["jobs"].as_array().unwrap().iter().find(|j| j["name"] == "build").unwrap().clone(); |
|
| 271 | + | assert_eq!(build_live["lines"].as_array().unwrap().len(), 2, "lines after seq 1"); |
|
| 272 | + | assert_eq!(call(&h.app, "GET", "/andy/demo/flow/999", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 273 | + | ||
| 274 | + | // A failed dependency skips what follows; an unrun job is an error. |
|
| 275 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 276 | + | write(work.path(), "test.sh", "echo changed\n"); |
|
| 277 | + | c.snapshot("test").unwrap(); |
|
| 278 | + | c.commit("Change the test", &[]).unwrap(); |
|
| 279 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 280 | + | let mut second = None; |
|
| 281 | + | for _ in 0..100 { |
|
| 282 | + | let r = runs_of(&h.app, "andy", "demo").await; |
|
| 283 | + | if r.len() == 2 { |
|
| 284 | + | second = Some(r[0].clone()); |
|
| 285 | + | break; |
|
| 286 | + | } |
|
| 287 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 288 | + | } |
|
| 289 | + | let second = second.expect("a second run"); |
|
| 290 | + | let x = claim(&h.app).await.unwrap(); |
|
| 291 | + | let y = claim(&h.app).await.unwrap(); |
|
| 292 | + | let (build_id, lint_id) = if x["job"]["name"] == "build" { |
|
| 293 | + | (x["job_id"].as_i64().unwrap(), y["job_id"].as_i64().unwrap()) |
|
| 294 | + | } else { |
|
| 295 | + | (y["job_id"].as_i64().unwrap(), x["job_id"].as_i64().unwrap()) |
|
| 296 | + | }; |
|
| 297 | + | assert_eq!( |
|
| 298 | + | finish(&h.app, build_id, serde_json::json!({ "exit_code": null, "duration_ms": 10, "error": "rootfs is not on this runner" })) |
|
| 299 | + | .await, |
|
| 300 | + | StatusCode::NO_CONTENT |
|
| 301 | + | ); |
|
| 302 | + | assert_eq!(finish(&h.app, lint_id, serde_json::json!({ "exit_code": 0, "duration_ms": 10 })).await, StatusCode::NO_CONTENT); |
|
| 303 | + | assert!(claim(&h.app).await.is_none(), "test was skipped"); |
|
| 304 | + | let jobs = db::flow::jobs(&h.app.db, second.id).await.unwrap(); |
|
| 305 | + | assert_eq!( |
|
| 306 | + | jobs.iter().map(|j| (j.name.as_str(), j.status.as_str())).collect::<Vec<_>>(), |
|
| 307 | + | [("build", "error"), ("lint", "passed"), ("test", "skipped")] |
|
| 308 | + | ); |
|
| 309 | + | assert_eq!(db::flow::run_by_id(&h.app.db, second.id).await.unwrap().unwrap().status, "failed"); |
|
| 310 | + | ||
| 311 | + | // An invalid pipeline is a run in error with the line. |
|
| 312 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("job \"broken\" {\n sandbox \"missing\"\n exec \"x\"\n}\n")); |
|
| 313 | + | c.snapshot("test").unwrap(); |
|
| 314 | + | c.commit("Break the pipeline", &[]).unwrap(); |
|
| 315 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 316 | + | let mut third = None; |
|
| 317 | + | for _ in 0..100 { |
|
| 318 | + | let r = runs_of(&h.app, "andy", "demo").await; |
|
| 319 | + | if r.len() == 3 { |
|
| 320 | + | third = Some(r[0].clone()); |
|
| 321 | + | break; |
|
| 322 | + | } |
|
| 323 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 324 | + | } |
|
| 325 | + | let third = third.expect("a third run"); |
|
| 326 | + | assert_eq!(third.status, "error"); |
|
| 327 | + | assert!(third.error.contains("line 18") && third.error.contains("\"missing\""), "{}", third.error); |
|
| 328 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.id), None, None).await; |
|
| 329 | + | assert!(page.contains("The pipeline is not valid")); |
|
| 330 | + | ||
| 331 | + | // A repository outside [flow] repos starts nothing. |
|
| 332 | + | let work2 = tempfile::tempdir().unwrap(); |
|
| 333 | + | let c2 = client(&format!("{}/other/plain", h.base), work2.path()); |
|
| 334 | + | write(work2.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 335 | + | c2.snapshot("test").unwrap(); |
|
| 336 | + | c2.commit("Pipeline", &[]).unwrap(); |
|
| 337 | + | assert!(c2.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 338 | + | tokio::time::sleep(std::time::Duration::from_millis(500)).await; |
|
| 339 | + | let row = db::repos::get(&h.app.db, "other", "plain").await.unwrap().unwrap(); |
|
| 340 | + | assert!(db::flow::runs(&h.app.db, row.id, 10, None).await.unwrap().is_empty()); |
|
| 341 | + | let (_, page) = call(&h.app, "GET", "/other/plain/flow", None, None).await; |
|
| 342 | + | assert!(page.contains("not enabled for this repository")); |
|
| 343 | + | } |
added templates/flow_run.html +39 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Run #{{ run.id }} · Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% if run.live() %} data-flow-live="{{ repo.url }}/flow/{{ run.id }}/live"{% endif %}{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | + | {% block content %} |
|
| 8 | + | <div class="head"> |
|
| 9 | + | <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.id }} <small data-run-status>{{ run.status }}</small></h1> |
|
| 10 | + | <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> at <a class="px" href="{{ repo.url }}/tree/{{ run.heads }}" title="{{ run.state }}">{{ run.state|short }}</a> · pushed by {{ run.actor }} <time title="{{ run.created_ms|when_i }}">{{ run.created_ms|ago_i }}</time> · {{ duration }}{% if !run.pipeline_hash.is_empty() %} · pipeline <code title="{{ run.pipeline_hash }}">{{ run.pipeline_hash|short }}</code>{% endif %} · <a href="{{ repo.url }}/file/{{ run.heads }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a> · <a href="{{ repo.url }}/flow">all runs</a></p> |
|
| 11 | + | </div> |
|
| 12 | + | {% if run.status == "error" %} |
|
| 13 | + | <section class="panel"><div class="strip bad">The pipeline is not valid, so nothing ran.</div><pre class="snippet">{{ run.error }}</pre><p class="small mute">Run <code>flow check</code> locally to see the same problems.</p></section> |
|
| 14 | + | {% else %} |
|
| 15 | + | <section class="panel flow-graph-box"> |
|
| 16 | + | <svg class="flow-graph" viewBox="0 0 {{ graph.width }} {{ graph.height }}" width="{{ graph.width }}" height="{{ graph.height }}" role="img" aria-label="Job graph of run {{ run.id }}"> |
|
| 17 | + | {% for e in graph.edges %}<path class="edge st-{{ e.status }}" d="{{ e.d }}"/>{% endfor %} |
|
| 18 | + | {% for n in graph.nodes %} |
|
| 19 | + | <a href="#job-{{ n.j.id }}" data-job-node="{{ n.j.id }}" class="node st-{{ n.j.status }}"> |
|
| 20 | + | <rect x="{{ n.x }}" y="{{ n.y }}" width="{{ crate::web::flow::NODE_W }}" height="{{ crate::web::flow::NODE_H }}" rx="6"/> |
|
| 21 | + | <text class="g" x="{{ n.x + 18 }}" y="{{ n.y + 30 }}" text-anchor="middle" data-glyph>{{ crate::web::flow::glyph(n.j.status) }}</text> |
|
| 22 | + | <text class="name" x="{{ n.x + 34 }}" y="{{ n.y + 21 }}">{{ n.j.name }}</text> |
|
| 23 | + | <text class="meta" x="{{ n.x + 34 }}" y="{{ n.y + 37 }}" data-meta>{{ n.j.status }} · {{ n.duration }}</text> |
|
| 24 | + | </a> |
|
| 25 | + | {% endfor %} |
|
| 26 | + | </svg> |
|
| 27 | + | </section> |
|
| 28 | + | {% for l in logs %} |
|
| 29 | + | <details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}> |
|
| 30 | + | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_hash.is_empty() %} <span class="small mute">· receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}</summary> |
|
| 31 | + | {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %} |
|
| 32 | + | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| 33 | + | {% endfor %}</pre> |
|
| 34 | + | {% if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| 35 | + | {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %} |
|
| 36 | + | </details> |
|
| 37 | + | {% endfor %} |
|
| 38 | + | {% endif %} |
|
| 39 | + | {% endblock %} |
added templates/flow_runs.html +48 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Flow <small>pipeline runs</small></h1></div> |
|
| 8 | + | {% if runs.is_empty() %} |
|
| 9 | + | <section class="panel"> |
|
| 10 | + | {% if enabled %} |
|
| 11 | + | <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs.</p> |
|
| 12 | + | {% else %} |
|
| 13 | + | <p>OxygenFlow is not enabled for this repository. A site administrator lists it under <code>[flow] repos</code> in <code>hub.toml</code>.</p> |
|
| 14 | + | {% endif %} |
|
| 15 | + | <pre class="snippet">schema-version 1 |
|
| 16 | + | ||
| 17 | + | sandbox "alpine" { |
|
| 18 | + | runtime "microvm" |
|
| 19 | + | rootfs "cas://blob/…" // flow rootfs import prints this |
|
| 20 | + | memory-limit "512MiB" |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | job "test" { |
|
| 24 | + | sandbox "alpine" |
|
| 25 | + | exec "sh" "-eu" "test.sh" |
|
| 26 | + | }</pre> |
|
| 27 | + | </section> |
|
| 28 | + | {% else %} |
|
| 29 | + | <table class="list flow-runs"> |
|
| 30 | + | <thead><tr><th></th><th>Run</th><th>Jobs</th><th>Bookmark</th><th>State</th><th>By</th><th>When</th><th>Took</th></tr></thead> |
|
| 31 | + | <tbody> |
|
| 32 | + | {% for row in runs %} |
|
| 33 | + | <tr class="st-{{ row.r.status }}"> |
|
| 34 | + | <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td> |
|
| 35 | + | <td><a href="{{ repo.url }}/flow/{{ row.r.id }}"><strong>#{{ row.r.id }}</strong></a> <span class="small mute">{{ row.r.status }}</span></td> |
|
| 36 | + | <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td> |
|
| 37 | + | <td><span class="lbl lbl-bm">{{ row.r.bookmark }}</span></td> |
|
| 38 | + | <td><a class="px" href="{{ repo.url }}/tree/{{ row.r.heads }}" title="{{ row.r.state }}">{{ row.r.state|short }}</a></td> |
|
| 39 | + | <td>{{ row.r.actor }}</td> |
|
| 40 | + | <td><time title="{{ row.r.created_ms|when_i }}">{{ row.r.created_ms|ago_i }}</time></td> |
|
| 41 | + | <td class="small mute">{{ row.duration }}</td> |
|
| 42 | + | </tr> |
|
| 43 | + | {% endfor %} |
|
| 44 | + | </tbody> |
|
| 45 | + | </table> |
|
| 46 | + | {% if let Some(b) = older %}<p class="center"><a class="go small" href="{{ repo.url }}/flow?before={{ b }}">older runs</a></p>{% endif %} |
|
| 47 | + | {% endif %} |
|
| 48 | + | {% endblock %} |
modified Cargo.lock +56 −0
| … | 1058 unchanged lines |
||
| 1059 | 1059 | ] |
|
| 1060 | 1060 | ||
| 1061 | 1061 | [[package]] |
|
| 1062 | + | name = "flow-core" |
|
| 1063 | + | version = "0.1.0" |
|
| 1064 | + | dependencies = [ |
|
| 1065 | + | "blake3", |
|
| 1066 | + | "globset", |
|
| 1067 | + | "hex", |
|
| 1068 | + | "kdl", |
|
| 1069 | + | "miette", |
|
| 1070 | + | "oxy-core", |
|
| 1071 | + | "serde", |
|
| 1072 | + | "serde_json", |
|
| 1073 | + | "thiserror 1.0.69", |
|
| 1074 | + | ] |
|
| 1075 | + | ||
| 1076 | + | [[package]] |
|
| 1062 | 1077 | name = "flume" |
|
| 1063 | 1078 | version = "0.11.1" |
|
| 1064 | 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 572 unchanged lines |
||
| 1637 | 1652 | "cfg-if", |
|
| 1638 | 1653 | "futures-util", |
|
| 1639 | 1654 | "wasm-bindgen", |
|
| 1655 | + | ] |
|
| 1656 | + | ||
| 1657 | + | [[package]] |
|
| 1658 | + | name = "kdl" |
|
| 1659 | + | version = "6.7.1" |
|
| 1660 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1661 | + | checksum = "082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670" |
|
| 1662 | + | dependencies = [ |
|
| 1663 | + | "miette", |
|
| 1664 | + | "num-traits", |
|
| 1665 | + | "serde", |
|
| 1666 | + | "winnow", |
|
| 1640 | 1667 | ] |
|
| 1641 | 1668 | ||
| 1642 | 1669 | [[package]] |
|
| … | 163 unchanged lines |
||
| 1806 | 1833 | version = "2.8.3" |
|
| 1807 | 1834 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1808 | 1835 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
|
| 1836 | + | ||
| 1837 | + | [[package]] |
|
| 1838 | + | name = "miette" |
|
| 1839 | + | version = "7.6.0" |
|
| 1840 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1841 | + | checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" |
|
| 1842 | + | dependencies = [ |
|
| 1843 | + | "cfg-if", |
|
| 1844 | + | "miette-derive", |
|
| 1845 | + | "unicode-width", |
|
| 1846 | + | ] |
|
| 1847 | + | ||
| 1848 | + | [[package]] |
|
| 1849 | + | name = "miette-derive" |
|
| 1850 | + | version = "7.6.0" |
|
| 1851 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1852 | + | checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" |
|
| 1853 | + | dependencies = [ |
|
| 1854 | + | "proc-macro2", |
|
| 1855 | + | "quote", |
|
| 1856 | + | "syn 2.0.119", |
|
| 1857 | + | ] |
|
| 1809 | 1858 | ||
| 1810 | 1859 | [[package]] |
|
| 1811 | 1860 | name = "mime" |
|
| … | 261 unchanged lines |
||
| 2073 | 2122 | "base32", |
|
| 2074 | 2123 | "blake3", |
|
| 2075 | 2124 | "clap", |
|
| 2125 | + | "flow-core", |
|
| 2076 | 2126 | "futures", |
|
| 2077 | 2127 | "hex", |
|
| 2078 | 2128 | "hmac", |
|
| … | 1689 unchanged lines |
||
| 3768 | 3818 | version = "0.1.4" |
|
| 3769 | 3819 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3770 | 3820 | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" |
|
| 3821 | + | ||
| 3822 | + | [[package]] |
|
| 3823 | + | name = "unicode-width" |
|
| 3824 | + | version = "0.1.14" |
|
| 3825 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3826 | + | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" |
|
| 3771 | 3827 | ||
| 3772 | 3828 | [[package]] |
|
| 3773 | 3829 | name = "untrusted" |
|
| … | 605 unchanged lines |
modified Cargo.toml +1 −0
| … | 12 unchanged lines |
||
| 13 | 13 | oxy-core = { path = "../oxygen/crates/oxy-core" } |
|
| 14 | 14 | oxy-server = { path = "../oxygen/crates/oxy-server" } |
|
| 15 | 15 | hub-api = { path = "crates/hub-api" } |
|
| 16 | + | flow-core = { path = "../oxygenflow/crates/flow-core" } |
|
| 16 | 17 | ||
| 17 | 18 | anyhow = "1" |
|
| 18 | 19 | thiserror = "2" |
|
| … | 42 unchanged lines |
modified Makefile +1 −1
| … | 22 unchanged lines |
||
| 23 | 23 | ||
| 24 | 24 | # Developer edition on Earth (docs/deploy.md). |
|
| 25 | 25 | deploy: |
|
| 26 | - | rsync -a --delete --exclude target --exclude node_modules --exclude .oxy/objects/loose ../oxygen ../oxygenhub claude@earth:src/ |
|
| 26 | + | rsync -a --delete --exclude target --exclude node_modules --exclude .oxy/objects/loose ../oxygen ../oxygenflow ../oxygenhub claude@earth:src/ |
|
| 27 | 27 | ssh claude@earth 'source ~/.cargo/env && cd src/oxygenhub && cargo build --release && sudo install -m 0755 target/release/oxygenhub /usr/local/bin/oxygenhub && sudo systemctl restart oxygenhub && systemctl is-active oxygenhub' |
modified assets/hub.js +44 −0
| … | 210 unchanged lines |
||
| 211 | 211 | }); |
|
| 212 | 212 | }); |
|
| 213 | 213 | ||
| 214 | + | /* Flow run page: statuses and new log lines while the run is live. */ |
|
| 215 | + | var liveUrl = document.body.getAttribute("data-flow-live"); |
|
| 216 | + | if (liveUrl) { |
|
| 217 | + | var tick = function () { |
|
| 218 | + | var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) { |
|
| 219 | + | return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq"); |
|
| 220 | + | }).join(","); |
|
| 221 | + | fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } }) |
|
| 222 | + | .then(function (r) { return r.json(); }) |
|
| 223 | + | .then(function (s) { |
|
| 224 | + | s.jobs.forEach(function (j) { |
|
| 225 | + | var cls = "st-" + j.status; |
|
| 226 | + | var node = document.querySelector("[data-job-node='" + j.id + "']"); |
|
| 227 | + | if (node) { |
|
| 228 | + | node.setAttribute("class", "node " + cls); |
|
| 229 | + | node.querySelector("[data-glyph]").textContent = j.glyph; |
|
| 230 | + | node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration; |
|
| 231 | + | } |
|
| 232 | + | var d = document.querySelector("details[data-job='" + j.id + "']"); |
|
| 233 | + | if (!d) return; |
|
| 234 | + | d.className = "panel flow-job " + cls; |
|
| 235 | + | var g = d.querySelector("summary [data-glyph]"); |
|
| 236 | + | g.className = "flow-glyph " + cls; g.textContent = j.glyph; |
|
| 237 | + | d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration; |
|
| 238 | + | if (j.status === "running" || j.status === "failed") d.open = true; |
|
| 239 | + | var log = d.querySelector("[data-log]"); |
|
| 240 | + | var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4; |
|
| 241 | + | j.lines.forEach(function (l) { |
|
| 242 | + | var ln = document.createElement("span"); ln.className = "ln s-" + l.stream; |
|
| 243 | + | var t = document.createElement("span"); t.className = "t"; t.textContent = (l.t / 1000).toFixed(1) + "s"; |
|
| 244 | + | ln.appendChild(t); ln.appendChild(document.createTextNode(l.text)); |
|
| 245 | + | log.appendChild(ln); log.appendChild(document.createTextNode("\n")); |
|
| 246 | + | log.setAttribute("data-seq", l.seq); |
|
| 247 | + | }); |
|
| 248 | + | if (stick && j.lines.length) log.scrollTop = log.scrollHeight; |
|
| 249 | + | }); |
|
| 250 | + | var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status; |
|
| 251 | + | if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800); |
|
| 252 | + | }) |
|
| 253 | + | .catch(function () { setTimeout(tick, 5000); }); |
|
| 254 | + | }; |
|
| 255 | + | setTimeout(tick, 1500); |
|
| 256 | + | } |
|
| 257 | + | ||
| 214 | 258 | /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */ |
|
| 215 | 259 | var pending = null; |
|
| 216 | 260 | document.addEventListener("keydown", function (e) { |
|
| … | 13 unchanged lines |
modified crates/hub/Cargo.toml +1 −0
| … | 14 unchanged lines |
||
| 15 | 15 | oxy-core.workspace = true |
|
| 16 | 16 | oxy-server.workspace = true |
|
| 17 | 17 | hub-api.workspace = true |
|
| 18 | + | flow-core.workspace = true |
|
| 18 | 19 | anyhow.workspace = true |
|
| 19 | 20 | thiserror.workspace = true |
|
| 20 | 21 | serde.workspace = true |
|
| … | 41 unchanged lines |
modified crates/hub/src/api/mod.rs +4 −1
| … | 16 unchanged lines |
||
| 17 | 17 | ||
| 18 | 18 | pub fn router() -> Router<Shared> { |
|
| 19 | 19 | Router::new() |
|
| 20 | + | .route("/flow/claim", post(crate::flow::claim)) |
|
| 21 | + | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 22 | + | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| 20 | 23 | .route("/user", get(me)) |
|
| 21 | 24 | .route("/users/{name}", get(user)) |
|
| 22 | 25 | .route("/orgs/{name}", get(org)) |
|
| … | 86 unchanged lines |
||
| 109 | 112 | Ok((StatusCode::CREATED, Json(r))) |
|
| 110 | 113 | } |
|
| 111 | 114 | ||
| 112 | - | pub struct ApiError(StatusCode, String); |
|
| 115 | + | pub struct ApiError(pub StatusCode, pub String); |
|
| 113 | 116 | ||
| 114 | 117 | impl IntoResponse for ApiError { |
|
| 115 | 118 | fn into_response(self) -> Response { |
|
| … | 1023 unchanged lines |
modified crates/hub/src/config.rs +30 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub email: Email, |
|
| 20 | 20 | pub limits: Limits, |
|
| 21 | 21 | pub retention: Retention, |
|
| 22 | + | pub flow: Flow, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// OxygenFlow runs (hub spec §13.2). |
|
| 26 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 27 | + | #[serde(default, deny_unknown_fields)] |
|
| 28 | + | pub struct Flow { |
|
| 29 | + | /// Bearer tokens runners present to claim jobs. |
|
| 30 | + | pub runner_tokens: Vec<String>, |
|
| 31 | + | /// `owner/*` or `owner/name`: repositories whose pushes start runs. |
|
| 32 | + | pub repos: Vec<String>, |
|
| 33 | + | pub job_timeout_minutes: u64, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl Default for Flow { |
|
| 37 | + | fn default() -> Self { |
|
| 38 | + | Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60 } |
|
| 39 | + | } |
|
| 22 | 40 | } |
|
| 23 | 41 | ||
| 42 | + | impl Flow { |
|
| 43 | + | /// Whether pushes to `owner/name` start runs. |
|
| 44 | + | pub fn allows(&self, owner: &str, name: &str) -> bool { |
|
| 45 | + | self.repos.iter().any(|p| match p.split_once('/') { |
|
| 46 | + | Some((o, "*")) => o == owner, |
|
| 47 | + | Some((o, n)) => o == owner && n == name, |
|
| 48 | + | None => false, |
|
| 49 | + | }) |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 24 | 53 | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 25 | 54 | #[serde(default, deny_unknown_fields)] |
|
| 26 | 55 | pub struct Email { |
|
| … | 151 unchanged lines |
||
| 178 | 207 | email: Email::default(), |
|
| 179 | 208 | limits: Limits::default(), |
|
| 180 | 209 | retention: Retention::default(), |
|
| 210 | + | flow: Flow::default(), |
|
| 181 | 211 | } |
|
| 182 | 212 | } |
|
| 183 | 213 | } |
|
| … | 123 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 2 unchanged lines |
||
| 3 | 3 | ||
| 4 | 4 | pub mod accounts; |
|
| 5 | 5 | pub mod activity; |
|
| 6 | + | pub mod flow; |
|
| 6 | 7 | pub mod hooks; |
|
| 7 | 8 | pub mod issues; |
|
| 8 | 9 | pub mod notify; |
|
| … | 28 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod config; |
|
| 9 | 9 | pub mod db; |
|
| 10 | 10 | pub mod events; |
|
| 11 | + | pub mod flow; |
|
| 11 | 12 | pub mod hooks; |
|
| 12 | 13 | pub mod keys; |
|
| 13 | 14 | pub mod mirror; |
|
| … | 84 unchanged lines |
modified crates/hub/src/protocol.rs +31 −0
| … | 29 unchanged lines |
||
| 30 | 30 | /// A site token from the configuration. |
|
| 31 | 31 | Site, |
|
| 32 | 32 | Token(Box<Account>, Box<db::accounts::Token>), |
|
| 33 | + | /// A running OxygenFlow job: reads its own repository, nothing else (hub spec §13.2). |
|
| 34 | + | Job { |
|
| 35 | + | repo_id: i64, |
|
| 36 | + | }, |
|
| 33 | 37 | } |
|
| 34 | 38 | ||
| 35 | 39 | impl HubBackend { |
|
| … | 26 unchanged lines |
||
| 62 | 66 | } |
|
| 63 | 67 | let hash = auth::token_hash(presented); |
|
| 64 | 68 | let pool = self.pool.clone(); |
|
| 69 | + | if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
|
| 70 | + | let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?; |
|
| 71 | + | let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) }; |
|
| 72 | + | let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?; |
|
| 73 | + | let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) }; |
|
| 74 | + | return Ok(Caller::Job { repo_id: run.repo_id }); |
|
| 75 | + | } |
|
| 65 | 76 | let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?; |
|
| 66 | 77 | let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) }; |
|
| 67 | 78 | if token.expired(db::now_ms()) { |
|
| … | 30 unchanged lines |
||
| 98 | 109 | let parsed = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 99 | 110 | let row = self.row(&parsed)?; |
|
| 100 | 111 | let caller = self.caller(headers)?; |
|
| 112 | + | if let Caller::Job { repo_id } = caller { |
|
| 113 | + | // Only reads, only of the job's own repository. |
|
| 114 | + | return match access { |
|
| 115 | + | oxy_server::Access::Read if repo_id == row.id => Ok(()), |
|
| 116 | + | oxy_server::Access::Read => Err(not_found()), |
|
| 117 | + | oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())), |
|
| 118 | + | }; |
|
| 119 | + | } |
|
| 101 | 120 | let (role, scope_ok) = match &caller { |
|
| 121 | + | Caller::Job { .. } => unreachable!(), |
|
| 102 | 122 | Caller::Site => (Some(Role::Admin), true), |
|
| 103 | 123 | Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true), |
|
| 104 | 124 | Caller::Token(account, token) => { |
|
| … | 64 unchanged lines |
||
| 169 | 189 | } else { |
|
| 170 | 190 | vec![] |
|
| 171 | 191 | }; |
|
| 192 | + | // Bookmarks that moved, with their new heads, for OxygenFlow runs. |
|
| 193 | + | let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report |
|
| 194 | + | .updated |
|
| 195 | + | .iter() |
|
| 196 | + | .filter(|u| report.results.iter().any(|r| r.ok && r.name == u.name)) |
|
| 197 | + | .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone())) |
|
| 198 | + | .collect(); |
|
| 172 | 199 | let app_hint = self.app_hint.clone(); |
|
| 173 | 200 | self.runtime.spawn(async move { |
|
| 174 | 201 | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())); |
|
| … | 14 unchanged lines |
||
| 189 | 216 | if !accepted.is_empty() { |
|
| 190 | 217 | let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 191 | 218 | crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await; |
|
| 219 | + | } |
|
| 220 | + | let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 221 | + | for (bookmark, state, heads) in &moved { |
|
| 222 | + | crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await; |
|
| 192 | 223 | } |
|
| 193 | 224 | } |
|
| 194 | 225 | }); |
|
| … | 28 unchanged lines |
modified crates/hub/src/web/edit.rs +1 −0
| … | 222 unchanged lines |
||
| 223 | 223 | }; |
|
| 224 | 224 | db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[outcome]).await.ok(); |
|
| 225 | 225 | crate::hooks::emit(&app, &row, "push", &me.account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await; |
|
| 226 | + | crate::flow::on_push(&app, &row, &done.bookmark, &done.new_state, &done.heads, &me.account.username).await; |
|
| 226 | 227 | if done.bookmark == row.default_bookmark { |
|
| 227 | 228 | super::issues::close_from_messages(&app, &row, &[(done.change.to_hex(), message.clone())]).await; |
|
| 228 | 229 | crate::mirror::refresh(&app, &row).await; |
|
| … | 26 unchanged lines |
modified crates/hub/src/web/mod.rs +4 −0
| … | 6 unchanged lines |
||
| 7 | 7 | mod avatar; |
|
| 8 | 8 | pub mod edit; |
|
| 9 | 9 | pub mod filters; |
|
| 10 | + | pub mod flow; |
|
| 10 | 11 | mod forks; |
|
| 11 | 12 | pub mod history; |
|
| 12 | 13 | mod home; |
|
| … | 104 unchanged lines |
||
| 117 | 118 | .route("/{owner}/{name}/edit/{r}", post(edit::save)) |
|
| 118 | 119 | .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form)) |
|
| 119 | 120 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 121 | + | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 122 | + | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| 123 | + | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 120 | 124 | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| 121 | 125 | .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create)) |
|
| 122 | 126 | .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show)) |
|
| … | 430 unchanged lines |
modified docs/runner.md +2 −0
| 1 | 1 | # Oxygen Runner — Specification |
|
| 2 | 2 | ||
| 3 | + | > **Superseded** by OxygenFlow (`oxygenflow/docs/spec.md`, first slice `oxygenflow/docs/m0.md`). Jobs run in Flow's own isolation tiers, not containers; the hub side is hub spec §13.2. This draft is kept for its hub-side ideas (environments, approvals, caches, artifacts), which become later Flow milestones. |
|
| 4 | + | ||
| 3 | 5 | | | | |
|
| 4 | 6 | |---|---| |
|
| 5 | 7 | | **Status** | Draft 0.1, for review | |
|
| … | 189 unchanged lines |
modified docs/spec.md +13 −14
| … | 26 unchanged lines |
||
| 27 | 27 | 10. [Notifications](#10-notifications) |
|
| 28 | 28 | 11. [Search](#11-search) |
|
| 29 | 29 | 12. [HTTP API and webhooks](#12-http-api-and-webhooks) |
|
| 30 | - | 13. [Checks and the runner](#13-checks-and-the-runner) |
|
| 30 | + | 13. [Checks and OxygenFlow](#13-checks-and-oxygenflow) |
|
| 31 | 31 | 14. [Git interoperability](#14-git-interoperability) |
|
| 32 | 32 | 15. [Security](#15-security) |
|
| 33 | 33 | 16. [Performance and caching](#16-performance-and-caching) |
|
| … | 349 unchanged lines |
||
| 383 | 383 | ||
| 384 | 384 | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 385 | 385 | ||
| 386 | - | ## 13. Checks and the runner |
|
| 386 | + | ## 13. Checks and OxygenFlow |
|
| 387 | 387 | ||
| 388 | 388 | ### 13.1 Checks |
|
| 389 | 389 | ||
| … | 1 unchanged lines |
||
| 391 | 391 | ||
| 392 | 392 | Because a state is content addressed, the hub deduplicates: a check that passed for a state hash is shown for every proposal and bookmark that reaches that same state. |
|
| 393 | 393 | ||
| 394 | - | ### 13.2 Oxygen Runner (later; `docs/runner.md`) |
|
| 394 | + | ### 13.2 OxygenFlow runs |
|
| 395 | 395 | ||
| 396 | - | The runner is a separate Rust program and a separate specification, not required for hub 1.0. The hub side is only §13.1 plus a `runner` scope for tokens and a job-claim API. The runner's design principles, recorded here so the hub does not close doors: |
|
| 396 | + | Pipelines run in **OxygenFlow**, a separate project and specification (`oxygenflow/docs/spec.md`; the first slice is `oxygenflow/docs/m0.md`). A pipeline is `.flow/pipeline.kdl` in the state being built; jobs run in Flow's isolation tiers (MicroVM by default, a syscall sandbox, WASM), never in containers on the hub. The hub's side: |
|
| 397 | 397 | ||
| 398 | - | 1. **Pipelines are typed, validated documents, not templated YAML.** The definition lives in the repository (`.oxyhub/pipelines/*.kdl`; not under `.oxy/`, the repository's own metadata directory), has a schema, and is validated by `oxyrun check` and by the hub on push before anything runs. Errors point at a line. There is no string templating stage; parameters are typed values. |
|
| 399 | - | 2. **Same execution locally and remotely.** `oxyrun` runs the pipeline on a laptop in the same OCI container image with the same inputs and produces the same result. "Works on CI but not here" is a bug in the runner. |
|
| 400 | - | 3. **Content-addressed steps and images.** A step references an action by an Oxygen state hash or a Git SHA, never a mutable tag; images by digest. The hub refuses a definition that pins nothing. Actions are ordinary Oxygen repositories with signed tags, so `uses` is verifiable. |
|
| 401 | - | 4. **Proposals from outsiders run with no secrets.** There is no `pull_request_target`. Secrets are bound to named environments; an environment lists who may deploy to it and whether a proposal may use it, and using one from a proposal needs an explicit approval by a maintainer on that projected state. |
|
| 402 | - | 5. **Results are seals.** A green job is a seal over the state's changes signed by the runner's key, admitted by policy as a checker. It is the same object §13.1 stores, verifiable by any client offline. |
|
| 403 | - | 6. **The state is the unit, not the commit.** A job runs on a state hash and is cached by it. Two proposals reaching the same state run once. |
|
| 404 | - | 7. **Structured logs, streamed and kept.** Log lines carry step, timestamp and level; the UI groups and folds them; retention is configurable; artefacts are content addressed. |
|
| 405 | - | 8. **Caches keyed by content.** Cache entries are keyed by the BLAKE3 of the declared inputs, never by a hand-written key string. |
|
| 406 | - | 9. **No implicit shell.** Each step declares its program and arguments; a `sh` step is explicit about the shell and `set -eu` is on by default. |
|
| 407 | - | 10. **Runners are cattle.** A runner registers with a token, claims jobs over a long-poll API, runs each in a fresh container or VM, and reports; no state survives a job. Runner groups and labels select where jobs run. |
|
| 398 | + | - **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs; empty means none) and `job_timeout_minutes` (60). |
|
| 399 | + | - **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state: the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. |
|
| 400 | + | - **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`. |
|
| 401 | + | - **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it. |
|
| 402 | + | - **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories. |
|
| 403 | + | - **Checks.** Each job is also the check `flow/<job>` on the run's state (§13.1), so proposals, required checks and the editor's status bar see it. |
|
| 404 | + | - **Pages.** A **Flow** tab lists runs (status, bookmark, state, pusher, when, duration). A run page draws the job graph as SVG, with columns by dependency depth and status by colour and glyph, and each job's log below (folded, the failed ones open); it refreshes while the run is live. Readers of the repository can see both. |
|
| 405 | + | ||
| 406 | + | The older runner draft (`docs/runner.md`) is superseded by OxygenFlow; its hub-side ideas (environments, approvals for outsider proposals, caches, artifacts) carry over as later Flow milestones. |
|
| 408 | 407 | ||
| 409 | 408 | ## 14. Git interoperability |
|
| 410 | 409 | ||
| … | 145 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| 8 | 8 | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a> |
|
| 9 | + | <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on"{% endif %}>Flow</a> |
|
| 9 | 10 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a> |
|
| 10 | 11 | <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a> |
|
| 11 | 12 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a> |
|
| … | 62 unchanged lines |
modified templates/style.css +36 −0
| … | 408 unchanged lines |
||
| 409 | 409 | .file-layout { grid-template-columns: minmax(0, 1fr); } |
|
| 410 | 410 | .file-side { display: none; } |
|
| 411 | 411 | } |
|
| 412 | + | ||
| 413 | + | /* OxygenFlow (hub spec §13.2): runs, the job graph and logs. Status is |
|
| 414 | + | carried by a glyph as well as colour. */ |
|
| 415 | + | .flow-glyph { display: inline-block; width: 1.2em; text-align: center; font-weight: 700; } |
|
| 416 | + | .flow-glyph.st-passed, .node.st-passed .g { color: var(--ok); fill: var(--ok); } |
|
| 417 | + | .flow-glyph.st-failed, .flow-glyph.st-error, .node.st-failed .g, .node.st-error .g { color: var(--bad); fill: var(--bad); } |
|
| 418 | + | .flow-glyph.st-running, .node.st-running .g { color: var(--acc); fill: var(--acc); } |
|
| 419 | + | .flow-glyph.st-queued, .flow-glyph.st-skipped, .node.st-queued .g, .node.st-skipped .g { color: var(--dim); fill: var(--dim); } |
|
| 420 | + | .flow-glyph.st-running, .node.st-running .g { animation: flow-pulse 1.2s ease-in-out infinite; } |
|
| 421 | + | @keyframes flow-pulse { 50% { opacity: .35; } } |
|
| 422 | + | @media (prefers-reduced-motion: reduce) { .flow-glyph.st-running, .node.st-running .g { animation: none; } } |
|
| 423 | + | .flow-dots .flow-glyph { width: 1em; } |
|
| 424 | + | .flow-graph-box { overflow-x: auto; padding: 12px; } |
|
| 425 | + | .flow-graph { display: block; max-width: none; font-family: "Barlow", sans-serif; } |
|
| 426 | + | .flow-graph .edge { fill: none; stroke: var(--line); stroke-width: 2; } |
|
| 427 | + | .flow-graph .edge.st-passed { stroke: color-mix(in srgb, var(--ok) 60%, var(--line)); } |
|
| 428 | + | .flow-graph .edge.st-failed, .flow-graph .edge.st-error { stroke: color-mix(in srgb, var(--bad) 60%, var(--line)); stroke-dasharray: 4 4; } |
|
| 429 | + | .flow-graph .node rect { fill: var(--panel); stroke: var(--line); stroke-width: 1.5; } |
|
| 430 | + | .flow-graph .node:hover rect, .flow-graph .node:focus rect { stroke: var(--acc); } |
|
| 431 | + | .flow-graph .node.st-passed rect { stroke: var(--ok); } |
|
| 432 | + | .flow-graph .node.st-failed rect, .flow-graph .node.st-error rect { stroke: var(--bad); fill: color-mix(in srgb, var(--bad) 8%, var(--panel)); } |
|
| 433 | + | .flow-graph .node.st-running rect { stroke: var(--acc); fill: color-mix(in srgb, var(--acc) 8%, var(--panel)); } |
|
| 434 | + | .flow-graph .node.st-skipped rect { stroke-dasharray: 4 3; } |
|
| 435 | + | .flow-graph .node .g { font-size: 18px; } |
|
| 436 | + | .flow-graph .node .name { fill: var(--text); font-size: 14px; font-weight: 600; } |
|
| 437 | + | .flow-graph .node .meta { fill: var(--dim); font-size: 11px; } |
|
| 438 | + | details.flow-job { padding: 0; margin-top: 10px; } |
|
| 439 | + | details.flow-job > summary { padding: 10px 12px; cursor: pointer; list-style-position: inside; } |
|
| 440 | + | details.flow-job .pad { padding: 0 12px 10px; margin: 0; } |
|
| 441 | + | pre.flow-log { margin: 0; padding: 8px 0; max-height: 70vh; overflow: auto; border-top: 1px solid var(--line); background: var(--soft); font-size: 12.5px; line-height: 1.5; } |
|
| 442 | + | pre.flow-log:empty { display: none; } |
|
| 443 | + | pre.flow-log .ln { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-word; } |
|
| 444 | + | pre.flow-log .t { display: inline-block; min-width: 5.5em; color: var(--dim); user-select: none; } |
|
| 445 | + | pre.flow-log .s-err { color: color-mix(in srgb, var(--bad) 75%, var(--text)); } |
|
| 446 | + | pre.flow-log .s-flow, pre.flow-log .s-console { color: var(--dim); font-style: italic; } |
|
| 447 | + | table.flow-runs td.st { width: 1.5em; } |