worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Continuous integration. |
| 2 | // |
| 3 | // A push writes a queued run; an executor in the server process picks it up, checks |
| 4 | // the revision out into a scratch workspace and runs the steps in the repository's |
| 5 | // own `.<name>.toml`. |
| 6 | // |
| 7 | // The queue is the run records themselves (`ci::run::RunStore::queued`) rather than a |
| 8 | // separate channel, so a push that lands while the server is down is still picked up |
| 9 | // when it returns, and the API and the executor cannot disagree about what is |
| 10 | // pending. |
| 11 | |
| 12 | pub mod run; |
| 13 | pub mod runner; |
| 14 | pub mod spec; |
| 15 | |
| 16 | use crate::git::validate; |
| 17 | use crate::http::AppState; |
| 18 | use run::{Run, Status}; |
| 19 | use std::sync::Arc; |
| 20 | use std::time::Duration; |
| 21 | |
| 22 | /// How often the executor looks for queued work. |
| 23 | /// |
| 24 | /// The hook runs in its own process and writes a record; nothing signals this one. |
| 25 | /// So this interval is the latency of every run, not just of runs queued while the |
| 26 | /// process was down. A socket would remove it; the durable record is what makes the |
| 27 | /// queue survive a restart, and that mattered more. |
| 28 | const POLL: Duration = Duration::from_secs(5); |
| 29 | |
| 30 | /// Drain the queue forever, respecting the per-host concurrency limit. |
| 31 | pub async fn serve(state: Arc<AppState>) { |
| 32 | if !state.config.ci_enabled { |
| 33 | return; |
| 34 | } |
| 35 | eprintln!( |
| 36 | "[ci] executor running, {} concurrent", |
| 37 | state.config.ci_max_concurrent |
| 38 | ); |
| 39 | |
| 40 | loop { |
| 41 | // Claim what fits, run it, then look again. |
| 42 | let capacity = state |
| 43 | .config |
| 44 | .ci_max_concurrent |
| 45 | .saturating_sub(state.running_runs()); |
| 46 | |
| 47 | if capacity > 0 { |
| 48 | for queued in state.runs.queued().into_iter().take(capacity) { |
| 49 | spawn_run(Arc::clone(&state), queued); |
| 50 | } |
| 51 | } |
| 52 | tokio::time::sleep(POLL).await; |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | fn spawn_run(state: Arc<AppState>, mut run: Run) { |
| 57 | let (Ok(account), Ok(repo)) = (validate::name(&run.account), validate::name(&run.repo)) else { |
| 58 | return; |
| 59 | }; |
| 60 | |
| 61 | // An in-process claim, so two executor ticks in THIS process cannot both pick up |
| 62 | // the same run. It is not durable: two processes sharing a data directory would |
| 63 | // both run it. Nothing supports that arrangement today, and the control plane |
| 64 | // gives each tenant its own. |
| 65 | if !state.claim_run(&run.id) { |
| 66 | return; |
| 67 | } |
| 68 | |
| 69 | tokio::spawn(async move { |
| 70 | run.status = Status::Running; |
| 71 | run.started_at = Some(crate::account::token::now_secs()); |
| 72 | let _ = state.runs.put(&account, &repo, &run); |
| 73 | |
| 74 | let outcome = match state.git.repo_path(&account, &repo) { |
| 75 | Ok(git_dir) => { |
| 76 | runner::execute(&state.config, &state.runs, &account, &repo, &run, &git_dir).await |
| 77 | } |
| 78 | Err(e) => runner::Outcome { |
| 79 | status: Status::Failed, |
| 80 | exit_code: None, |
| 81 | detail: Some(e.detail_for_user()), |
| 82 | }, |
| 83 | }; |
| 84 | |
| 85 | // A cancellation recorded while the run was in flight wins: the operator |
| 86 | // asked for it to stop, and reporting "succeeded" afterwards would be a lie. |
| 87 | let cancelled = state |
| 88 | .runs |
| 89 | .get(&account, &repo, &run.id) |
| 90 | .ok() |
| 91 | .flatten() |
| 92 | .is_some_and(|current| current.status == Status::Cancelled); |
| 93 | |
| 94 | run.status = if cancelled { |
| 95 | Status::Cancelled |
| 96 | } else { |
| 97 | outcome.status |
| 98 | }; |
| 99 | run.exit_code = outcome.exit_code; |
| 100 | run.detail = outcome.detail; |
| 101 | run.finished_at = Some(crate::account::token::now_secs()); |
| 102 | let _ = state.runs.put(&account, &repo, &run); |
| 103 | |
| 104 | state.release_run(&run.id); |
| 105 | eprintln!( |
| 106 | "[ci] {}/{} {} -> {:?}", |
| 107 | run.account, run.repo, run.id, run.status |
| 108 | ); |
| 109 | }); |
| 110 | } |