Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1 | //! A run's life: made, its jobs waiting on the jobs they need, each job |
| 2 | //! skipped or expanded into its matrix and queued, started in a sandbox | |
| 3 | //! when its workspace has room, reporting its steps and logs as it goes, | |
| 4 | //! and finished; the run finishes with its last job. | |
| 5 | ||
| 6 | use g1t_actions::events::{RunInfo, runner_context}; | |
| 7 | use g1t_actions::expr::{self, Scope, Status}; | |
| 8 | use g1t_actions::matrix; | |
| 9 | use g1t_actions::workflow::{self, Workflow}; | |
| 10 | use g1t_contracts::access::Capability; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 11 | use g1t_contracts::actions::{JobCallArgs, RunActionArgs, RunApproval, StartJobArgs, WorkflowRun}; |
| 12 | use g1t_contracts::identity::{CreateJobTokenArgs, CreatedAccessToken, RevokeJobTokensArgs}; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 13 | use g1t_contracts::repos::{Repo, RepoPath}; |
| 14 | use g1t_contracts::time::rfc3339; | |
| 15 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 16 | use g1t_kit::now_ms; | |
| 17 | use g1t_secrets::{random_hex, same, sha256_hex}; | |
| 18 | use serde::Deserialize; | |
| 19 | use serde_json::{Map, Value, json}; | |
| 20 | use worker::Result; | |
| 21 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 22 | use crate::protection::Gate; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 23 | use crate::sync::WorkflowRow; |
| 24 | use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path}; | |
| 25 | use g1t_contracts::runners::{Wanted, waiting_reason}; | |
| 26 | ||
| 27 | /// The most log one job keeps, in bytes; past it, the log says so and stops. | |
| 28 | const MAX_LOG_BYTES: usize = 4 * 1024 * 1024; | |
| 29 | /// The most a single log report may add. | |
| 30 | const MAX_CHUNK_BYTES: usize = 256 * 1024; | |
| 31 | const MAX_ANNOTATIONS: usize = 50; | |
| A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so. | 32 | /// A batch of a job's log, under the next `seq` of that job's, worked out |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 33 | /// in the same statement (SQLite runs one write at a time). `?4` is the |
| 34 | /// runner's number for the batch (null from runners that send none): a | |
| 35 | /// batch sent again, after an answer the runner did not hear, has the | |
| 36 | /// number of one already kept, and is not kept twice (migration 0016). | |
| 37 | const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk) | |
| 38 | SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1 | |
| 39 | ON CONFLICT (job_id, chunk) DO NOTHING"; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 40 | /// The most one step's job summary keeps, in bytes, and how many steps of a |
| 41 | /// job may have one, as on GitHub. | |
| 42 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| 43 | pub(crate) const MAX_SUMMARIES: u32 = 20; | |
| 44 | ||
| 45 | /// Whether a step's summary of `adding` bytes is kept: a job keeps up to | |
| 46 | /// 20 steps' summaries (`steps` it has, `mine` this step's bytes so far) | |
| 47 | /// of up to 1 MiB each. | |
| 48 | pub(crate) fn summary_fits(steps: u32, mine: Option<usize>, adding: usize) -> bool { | |
| 49 | if adding == 0 { | |
| 50 | return false; | |
| 51 | } | |
| 52 | match mine { | |
| 53 | Some(used) => used + adding <= MAX_SUMMARY_BYTES, | |
| 54 | None => steps < MAX_SUMMARIES && adding <= MAX_SUMMARY_BYTES, | |
| 55 | } | |
| 56 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 57 | |
| 58 | /// The repository whose unfinished runs `event` stops, by id: one deleted, | |
| 59 | /// or archived (not unarchived). | |
| 60 | pub fn stops_runs(event: &g1t_contracts::events::Event) -> Option<String> { | |
| 61 | let stops = match event.kind.as_str() { | |
| 62 | "repo.deleted" => true, | |
| 63 | "repo.archived" => event.data["archived"].as_bool() != Some(false), | |
| 64 | _ => false, | |
| 65 | }; | |
| 66 | if !stops { | |
| 67 | return None; | |
| 68 | } | |
| 69 | event.repo_id.clone().or_else(|| event.data["repoId"].as_str().map(str::to_owned)) | |
| 70 | } | |
| 71 | ||
| 72 | pub struct NewRun { | |
| 73 | pub repo: Repo, | |
| 74 | pub path: String, | |
| 75 | pub source: String, | |
| 76 | pub workflow: Workflow, | |
| 77 | pub info: RunInfo, | |
| 78 | pub action: Option<String>, | |
| 79 | pub pull: Option<u32>, | |
| 80 | pub title: String, | |
| 81 | pub inputs: Map<String, Value>, | |
| 82 | pub event_key: String, | |
| 83 | pub actor_id: Option<String>, | |
| 84 | pub actor: Option<String>, | |
| 85 | pub trusted: bool, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 86 | /// Why the run waits for someone with the Write role to approve it |
| 87 | /// before anything starts: a pull request from outside (protection.rs). | |
| 88 | pub approval: Option<String>, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 89 | /// The commit the run is listed, and its statuses and checks reported, |
| 90 | /// on when it is not the one it builds (`info.sha`): a pull request's | |
| 91 | /// head, when the run builds its test merge commit, as on GitHub. | |
| 92 | pub head_sha: Option<String>, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 93 | } |
| 94 | ||
| 95 | #[derive(Clone, Deserialize)] | |
| 96 | pub struct RunRow { | |
| 97 | pub id: String, | |
| 98 | pub workflow_id: String, | |
| 99 | pub repo_id: String, | |
| 100 | pub repo: String, | |
| 101 | pub path: String, | |
| 102 | pub name: String, | |
| 103 | pub title: String, | |
| 104 | pub number: u64, | |
| 105 | pub attempt: u64, | |
| 106 | pub event: String, | |
| 107 | pub action: Option<String>, | |
| 108 | pub git_ref: String, | |
| 109 | pub sha: String, | |
| 110 | pub pull: Option<u32>, | |
| 111 | pub status: String, | |
| 112 | pub conclusion: Option<String>, | |
| 113 | pub error: Option<String>, | |
| 114 | pub actor: Option<String>, | |
| 115 | pub actor_id: Option<String>, | |
| 116 | pub source: String, | |
| 117 | pub info: String, | |
| 118 | pub inputs: String, | |
| 119 | pub trusted: u32, | |
| 120 | pub concurrency_group: Option<String>, | |
| 121 | pub created_at: String, | |
| 122 | pub started_at: Option<String>, | |
| 123 | pub finished_at: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 124 | /// JSON `RunApproval`, for a run that needed approval (migration 0006). |
| 125 | #[serde(default)] | |
| 126 | pub approval: Option<String>, | |
| 127 | /// Whether its concurrency group cancels what it replaces. | |
| 128 | #[serde(default)] | |
| 129 | pub cancel_in_progress: u32, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 130 | /// Who started the current attempt, once it is re-run (migration 0009). |
| 131 | #[serde(default)] | |
| 132 | pub triggering_actor: Option<String>, | |
| 133 | /// The current attempt runs with debug logging. | |
| 134 | #[serde(default)] | |
| 135 | pub debug: u32, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 136 | } |
| 137 | ||
| 138 | impl RunRow { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 139 | pub fn approval(&self) -> Option<RunApproval> { |
| 140 | self.approval.as_deref().and_then(|text| serde_json::from_str(text).ok()) | |
| 141 | } | |
| 142 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 143 | pub fn info(&self) -> RunInfo { |
| 144 | let mut info: RunInfo = serde_json::from_str(&self.info).unwrap_or_default(); | |
| 145 | info.run_id = self.id.clone(); | |
| 146 | info.run_number = self.number; | |
| 147 | info.run_attempt = self.attempt; | |
| 148 | info.workflow_path = self.path.clone(); | |
| 149 | if info.workflow.is_empty() { | |
| 150 | info.workflow = self.name.clone(); | |
| 151 | } | |
| 152 | info | |
| 153 | } | |
| 154 | ||
| 155 | pub fn inputs(&self) -> Map<String, Value> { | |
| 156 | serde_json::from_str(&self.inputs).unwrap_or_default() | |
| 157 | } | |
| 158 | ||
| 159 | pub fn summary(&self) -> WorkflowRun { | |
| 160 | WorkflowRun { | |
| 161 | id: self.id.clone(), | |
| 162 | workflow_id: self.workflow_id.clone(), | |
| 163 | path: self.path.clone(), | |
| 164 | name: self.name.clone(), | |
| 165 | title: self.title.clone(), | |
| 166 | number: self.number, | |
| 167 | attempt: self.attempt, | |
| 168 | event: self.event.clone(), | |
| 169 | git_ref: self.git_ref.clone(), | |
| 170 | sha: self.sha.clone(), | |
| 171 | pull: self.pull, | |
| 172 | status: self.status.clone(), | |
| 173 | conclusion: self.conclusion.clone(), | |
| 174 | error: self.error.clone(), | |
| 175 | actor: self.actor.clone(), | |
| 176 | created_at: self.created_at.clone(), | |
| 177 | started_at: self.started_at.clone(), | |
| 178 | finished_at: self.finished_at.clone(), | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | #[derive(Clone, Deserialize)] | |
| 184 | pub struct JobRow { | |
| 185 | pub id: String, | |
| 186 | pub run_id: String, | |
| 187 | pub repo_id: String, | |
| 188 | pub namespace: String, | |
| 189 | pub key: String, | |
| 190 | pub ordinal: u32, | |
| 191 | pub name: String, | |
| 192 | pub needs: String, | |
| 193 | pub matrix: Option<String>, | |
| 194 | pub status: String, | |
| 195 | pub conclusion: Option<String>, | |
| 196 | pub steps: String, | |
| 197 | pub annotations: String, | |
| 198 | pub outputs: String, | |
| 199 | pub reason: Option<String>, | |
| 200 | pub token_hash: Option<String>, | |
| 201 | pub timeout_minutes: u32, | |
| 202 | pub continue_on_error: u32, | |
| 203 | pub max_parallel: Option<u32>, | |
| 204 | /// Set for a job that calls a reusable workflow, and for that | |
| 205 | /// workflow's jobs (see migration 0002). | |
| 206 | pub call: Option<String>, | |
| 207 | pub seen_at: Option<String>, | |
| 208 | pub started_at: Option<String>, | |
| 209 | pub finished_at: Option<String>, | |
| 210 | /// For a job whose `runs-on` names self-hosted runners: what it asks | |
| 211 | /// for, as a JSON array (see `g1t_contracts::runners::Wanted`), when it | |
| 212 | /// started waiting, and the runner that took it (migration 0004). | |
| 213 | #[serde(default)] | |
| 214 | pub labels: Option<String>, | |
| 215 | #[serde(default)] | |
| 216 | pub queued_at: Option<String>, | |
| 217 | #[serde(default)] | |
| 218 | pub runner_id: Option<String>, | |
| 219 | #[serde(default)] | |
| 220 | pub runner_name: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 221 | /// The environment it names, read when its needs were done; a job its |
| 222 | /// rules hold is `pending` (migration 0006). | |
| 223 | #[serde(default)] | |
| 224 | pub environment: Option<String>, | |
| 225 | /// Its own `concurrency` group, and whether that cancels what it replaces. | |
| 226 | #[serde(default)] | |
| 227 | pub concurrency_group: Option<String>, | |
| 228 | #[serde(default)] | |
| 229 | pub cancel_in_progress: u32, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 230 | /// When it was told to stop, while it runs its cleanup steps |
| 231 | /// (migration 0009). | |
| 232 | #[serde(default)] | |
| 233 | pub cancel_requested_at: Option<String>, | |
| 234 | } | |
| 235 | ||
| 236 | /// How long a cancelled job has to run its `if: always()` and `cancelled()` | |
| 237 | /// steps and its post steps before it is stopped outright, as on GitHub. | |
| 238 | pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000; | |
| 239 | ||
| 240 | /// Which jobs a re-run runs again. | |
| 241 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 242 | pub enum Rerun<'a> { | |
| 243 | All, | |
| 244 | /// Those that did not succeed. | |
| 245 | Failed, | |
| 246 | /// One job's key. | |
| 247 | Job(&'a str), | |
| 248 | } | |
| 249 | ||
| 250 | /// The keys a re-run runs again, in `order`: those `which` names and, | |
| 251 | /// transitively, every key that needs one of them. `needs` gives a key's | |
| 252 | /// needs; `succeeded` whether every job of a key succeeded. | |
| 253 | pub fn rerun_keys(order: &[&str], needs: impl Fn(&str) -> Vec<String>, succeeded: impl Fn(&str) -> bool, which: Rerun) -> Vec<String> { | |
| 254 | let mut again: Vec<String> = Vec::new(); | |
| 255 | for key in order { | |
| 256 | let named = match which { | |
| 257 | Rerun::All => true, | |
| 258 | Rerun::Failed => !succeeded(key), | |
| 259 | Rerun::Job(job) => *key == job, | |
| 260 | }; | |
| 261 | if named || needs(key).iter().any(|need| again.contains(need)) { | |
| 262 | again.push((*key).to_owned()); | |
| 263 | } | |
| 264 | } | |
| 265 | again | |
| 266 | } | |
| 267 | ||
| 268 | /// The key under `jobs:` a job belongs to: a called workflow's jobs | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 269 | /// (`build/test`, or `build[1]/test` for a matrix's second call) run again |
| 270 | /// with the job that calls it (`build`). | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 271 | pub fn top_key(key: &str) -> &str { |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 272 | let first = key.split('/').next().unwrap_or(key); |
| 273 | first.split('[').next().unwrap_or(first) | |
| 274 | } | |
| 275 | ||
| 276 | /// What the keys of the jobs a caller's call makes start with: the | |
| 277 | /// caller's key, and for a matrix the combination's index after it | |
| 278 | /// (`build[1]`), so each combination's call has jobs of its own. A job id | |
| 279 | /// has no `[`, so the two never meet. | |
| 280 | fn call_prefix(key: &str, index: Option<usize>) -> String { | |
| 281 | match index { | |
| 282 | Some(index) => format!("{key}[{index}]"), | |
| 283 | None => key.to_owned(), | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | /// Whether a run may call one more reusable workflow, `file`, `depth` | |
| 288 | /// levels below the workflow it started from, having called `called` | |
| 289 | /// already (one name per call; repeats are one workflow). As on GitHub: ten | |
| 290 | /// levels of workflows at most, the top one and nine reusable ones below | |
| 291 | /// it, and 50 different reusable workflows in a run. | |
| 292 | fn call_allowed(depth: u64, called: &[&str], file: &str) -> std::result::Result<(), String> { | |
| 293 | if depth > MAX_CALL_DEPTH { | |
| 294 | return Err(format!( | |
| 295 | "`{file}` would be the {}th level of workflows: a run connects at most ten, the workflow it started from and nine levels of reusable workflows below it.", | |
| 296 | depth + 1 | |
| 297 | )); | |
| 298 | } | |
| 299 | let mut unique: Vec<&str> = called.to_vec(); | |
| 300 | unique.push(file); | |
| 301 | unique.sort_unstable(); | |
| 302 | unique.dedup(); | |
| 303 | if unique.len() > MAX_CALLED_WORKFLOWS { | |
| 304 | return Err(format!("Calling `{file}` would make {} reusable workflows in this run: a run calls at most {MAX_CALLED_WORKFLOWS} different ones.", unique.len())); | |
| 305 | } | |
| 306 | Ok(()) | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 307 | } |
| 308 | ||
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 309 | /// What a job is shown as: its `name` (else its id), under the job whose |
| 310 | /// call made it (`caller`), read with its matrix when it has an | |
| 311 | /// expression, else with the matrix's values after it. | |
| 312 | fn shown_name(job: &workflow::Job, caller: Option<&str>, combination: &Map<String, Value>, scope: &Scope) -> String { | |
| 313 | let base = job.name.clone().unwrap_or(job.id.clone()); | |
| 314 | let base = match caller { | |
| 315 | Some(caller) => format!("{caller} / {base}"), | |
| 316 | None => base, | |
| 317 | }; | |
| 318 | if expr::has_expression(&base) { | |
| 319 | expr::interpolate(&base, scope).unwrap_or(base) | |
| 320 | } else if job.matrix.is_some() { | |
| 321 | matrix::job_name(&base, combination) | |
| 322 | } else { | |
| 323 | base | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 327 | /// Debug logging for a job, as a re-run with it turned on gives GitHub's: |
| 328 | /// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and | |
| 329 | /// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of | |
| 330 | /// those names (`::debug::` lines shown, and how each step's `if` read). | |
| 331 | pub fn debug_logging(variables: &mut Map<String, Value>, runner: &mut Value) { | |
| 332 | variables.insert("RUNNER_DEBUG".into(), json!("1")); | |
| 333 | variables.insert("ACTIONS_STEP_DEBUG".into(), json!("true")); | |
| 334 | variables.insert("ACTIONS_RUNNER_DEBUG".into(), json!("true")); | |
| 335 | runner["debug"] = json!("1"); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 336 | } |
| 337 | ||
| 338 | impl JobRow { | |
| 339 | pub fn needs(&self) -> Vec<String> { | |
| 340 | serde_json::from_str(&self.needs).unwrap_or_default() | |
| 341 | } | |
| 342 | ||
| 343 | pub fn call(&self) -> Option<Value> { | |
| 344 | self.call.as_deref().and_then(|call| serde_json::from_str(call).ok()) | |
| 345 | } | |
| 346 | ||
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 347 | /// For a job that calls a reusable workflow: what the keys of the jobs |
| 348 | /// its call makes start with (`call_prefix`). | |
| 349 | pub fn call_prefix(&self) -> Option<String> { | |
| 350 | let call = self.call().filter(|call| call["role"] == "caller")?; | |
| 351 | Some(call["prefix"].as_str().unwrap_or(&self.key).to_owned()) | |
| 352 | } | |
| 353 | ||
| 354 | /// A job that calls a reusable workflow and has not called it yet: it | |
| 355 | /// waits for room under its matrix's `max-parallel`, or in its | |
| 356 | /// concurrency group. | |
| 357 | pub fn holds_call(&self) -> bool { | |
| 358 | self.status == "waiting" && self.call_prefix().is_some() | |
| 359 | } | |
| 360 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 361 | /// For a job of a called workflow: that workflow, the job's own id in |
| 362 | /// it, and the job. | |
| 363 | pub fn callee(&self) -> Option<(Workflow, workflow::Job, Value)> { | |
| 364 | let call = self.call().filter(|call| call["role"] == "callee")?; | |
| 365 | let called = workflow::parse(call["source"].as_str()?).ok()?; | |
| 366 | let job = called.jobs.iter().find(|job| call["job"].as_str() == Some(job.id.as_str()))?.clone(); | |
| 367 | Some((called, job, call)) | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | /// A job to decide on: its key, its definition, and what it needs, as | |
| 372 | /// (name in `needs`, key of the jobs). | |
| 373 | type Unit = (String, workflow::Job, Vec<(String, String)>); | |
| 374 | ||
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 375 | /// How many levels of reusable workflows may sit below the workflow a run |
| 376 | /// started from, as on GitHub (ten levels of workflows in all). | |
| 377 | const MAX_CALL_DEPTH: u64 = 9; | |
| 378 | /// How many different reusable workflows a run may call, as on GitHub. | |
| 379 | const MAX_CALLED_WORKFLOWS: usize = 50; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 380 | |
| 381 | /// What the jobs of one key came to, for `needs.<key>`. | |
| 382 | fn key_result(rows: &[&JobRow]) -> &'static str { | |
| 383 | let failed = |row: &&&JobRow| row.conclusion.as_deref() == Some("failure") && row.continue_on_error == 0; | |
| 384 | if rows.iter().any(|row| failed(&row)) { | |
| 385 | "failure" | |
| 386 | } else if rows.iter().any(|row| row.conclusion.as_deref() == Some("cancelled")) { | |
| 387 | "cancelled" | |
| 388 | } else if rows.iter().all(|row| row.conclusion.as_deref() == Some("skipped")) { | |
| 389 | "skipped" | |
| 390 | } else { | |
| 391 | "success" | |
| 392 | } | |
| 393 | } | |
| 394 | ||
| 395 | /// Whether any job before `key` failed: one it needs, or one those need, | |
| 396 | /// however far back. A job after a skipped one still sees the failure | |
| 397 | /// that skipped it, as GitHub's failure() does. `needs_of`: each key's | |
| 398 | /// needs, as keys; `failed`: whether a key's jobs came to a failure. | |
| 399 | fn ancestor_failed(needs_of: &std::collections::HashMap<&str, Vec<&str>>, key: &str, failed: impl Fn(&str) -> bool) -> bool { | |
| 400 | let mut seen = std::collections::HashSet::new(); | |
| 401 | let mut stack: Vec<&str> = needs_of.get(key).cloned().unwrap_or_default(); | |
| 402 | while let Some(next) = stack.pop() { | |
| 403 | if !seen.insert(next) { | |
| 404 | continue; | |
| 405 | } | |
| 406 | if failed(next) { | |
| 407 | return true; | |
| 408 | } | |
| 409 | stack.extend(needs_of.get(next).into_iter().flatten().copied()); | |
| 410 | } | |
| 411 | false | |
| 412 | } | |
| 413 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 414 | /// A job's `environment:`, as deployments read it: its name, the address in |
| 415 | /// `environment.url` once its expressions are filled in from the run, and | |
| 416 | /// whether the job deploys to it. A job with `deployment: false` only reads | |
| 417 | /// the environment's secrets and variables, and makes no deployment. | |
| 418 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 419 | pub(crate) struct JobEnvironment { | |
| 420 | pub(crate) name: String, | |
| 421 | pub(crate) url: Option<String>, | |
| 422 | pub(crate) deploys: bool, | |
| 423 | } | |
| 424 | ||
| 425 | /// The environment `environment:` names, as written in `raw` (a job), with | |
| 426 | /// `contexts` to fill in expressions: null when it names none, or only | |
| 427 | /// through an expression this cannot read before the job runs. | |
| 428 | pub(crate) fn environment_of(raw: &Value, contexts: &Map<String, Value>) -> Option<JobEnvironment> { | |
| 429 | let scope = Scope { contexts, status: Status::Success, hash_files: None }; | |
| 430 | let plain = |value: &Value| -> Option<String> { | |
| 431 | let text = match value { | |
| 432 | Value::String(text) if text.contains("${{") => expr::interpolate_value(value, &scope).ok().map(|v| expr::to_text(&v))?, | |
| 433 | Value::String(text) => text.clone(), | |
| 434 | _ => return None, | |
| 435 | }; | |
| 436 | let text = text.trim().to_owned(); | |
| 437 | (!text.is_empty()).then_some(text) | |
| 438 | }; | |
| 439 | match raw.get("environment")? { | |
| Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9 | 440 | name @ Value::String(_) => Some(JobEnvironment { name: plain(name)?, url: None, deploys: workflow::records_deployment(raw) }), |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 441 | Value::Object(env) => { |
| 442 | let name = plain(env.get("name")?)?; | |
| 443 | let url = env | |
| 444 | .get("url") | |
| 445 | .and_then(plain) | |
| 446 | .filter(|url| url.starts_with("https://") || url.starts_with("http://")); | |
| Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9 | 447 | Some(JobEnvironment { name, url, deploys: workflow::records_deployment(raw) }) |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 448 | } |
| 449 | _ => None, | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | /// The contexts a job's `runs-on` and `environment` are read with before it | |
| 454 | /// runs: the run's `github`, its inputs, and the job's matrix. | |
| 455 | fn start_contexts(run: &RunRow, job: &JobRow, key: &str) -> Map<String, Value> { | |
| 456 | let mut contexts = Map::new(); | |
| 457 | contexts.insert("github".into(), run.info().context(key, "", run.action.as_deref())); | |
| 458 | contexts.insert("inputs".into(), Value::Object(run.inputs())); | |
| 459 | contexts.insert("matrix".into(), job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or_else(|| json!({}))); | |
| 460 | contexts | |
| 461 | } | |
| 462 | ||
| 463 | /// The job as its workflow (or the workflow it calls) defines it. | |
| 464 | fn job_spec(run: &RunRow, job: &JobRow) -> Option<workflow::Job> { | |
| 465 | match job.callee() { | |
| 466 | Some((_, spec, _)) => Some(spec), | |
| 467 | None => workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)), | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 471 | /// The environment a job deploys to, if it deploys: by the name read when |
| 472 | /// its needs were done (which may have needed their outputs), else as | |
| 473 | /// its `environment:` reads now. | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 474 | pub(crate) fn deploys_to(run: &RunRow, job: &JobRow) -> Option<JobEnvironment> { |
| 475 | let spec = job_spec(run, job)?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 476 | let read = environment_of(&spec.raw, &start_contexts(run, job, &spec.id)); |
| 477 | let env = match (read, &job.environment) { | |
| 478 | (Some(env), Some(name)) => Some(JobEnvironment { name: name.clone(), ..env }), | |
| 479 | (Some(env), None) => Some(env), | |
| Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9 | 480 | (None, Some(name)) => Some(JobEnvironment { name: name.clone(), url: None, deploys: workflow::records_deployment(&spec.raw) }), |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 481 | (None, None) => None, |
| 482 | }; | |
| 483 | env.filter(|env| env.deploys) | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 484 | } |
| 485 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 486 | /// What the runner is told about a job it starts, from its workflow: the |
| 487 | /// environment it names plainly, and the machine its `runs-on` asks for | |
| 488 | /// (`instance_for`; none for the standard one). | |
| 489 | #[derive(Default)] | |
| 490 | struct StartDetails { | |
| 491 | environment: Option<String>, | |
| 492 | instance: Option<String>, | |
| 493 | } | |
| 494 | ||
| 495 | fn start_details(run: &RunRow, job: &JobRow) -> StartDetails { | |
| 496 | let spec = match job.callee() { | |
| 497 | Some((_, spec, _)) => spec, | |
| 498 | None => match workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)) { | |
| 499 | Some(spec) => spec, | |
| 500 | None => return StartDetails::default(), | |
| 501 | }, | |
| 502 | }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 503 | // As read when its needs were done, an expression's included. |
| 504 | let environment = job.environment.clone(); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 505 | // `runs-on` as the job was queued with: its matrix and the run's |
| 506 | // inputs. A label that needs more than those is the standard machine. | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 507 | let contexts = start_contexts(run, job, &spec.id); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 508 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 509 | let labels: Vec<String> = match expr::interpolate_value(&spec.runs_on, &scope).unwrap_or(Value::Null) { | |
| 510 | Value::String(label) => vec![label], | |
| 511 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 512 | Value::Object(given) => match given.get("labels") { | |
| 513 | Some(Value::Array(labels)) => labels.iter().map(expr::to_text).collect(), | |
| 514 | Some(label) => vec![expr::to_text(label)], | |
| 515 | None => Vec::new(), | |
| 516 | }, | |
| 517 | _ => Vec::new(), | |
| 518 | }; | |
| 519 | let instance = g1t_contracts::actions::instance_for(&labels); | |
| 520 | StartDetails { | |
| 521 | environment, | |
| 522 | instance: (instance != g1t_contracts::actions::STANDARD_INSTANCE).then(|| instance.label.to_owned()), | |
| 523 | } | |
| 524 | } | |
| 525 | ||
| 526 | fn now() -> String { | |
| 527 | rfc3339(now_ms()) | |
| 528 | } | |
| 529 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 530 | /// How a finished run went for one environment, from the conclusions of |
| 531 | /// the jobs that deploy to it: failed if any failed, an error if any was | |
| 532 | /// cancelled, else a success; nothing when none ran (all skipped). | |
| 533 | pub(crate) fn deployment_outcome(conclusions: &[Option<String>]) -> Option<&'static str> { | |
| 534 | let ran: Vec<&str> = conclusions.iter().flatten().map(String::as_str).filter(|c| *c != "skipped").collect(); | |
| 535 | if ran.is_empty() { | |
| 536 | return None; | |
| 537 | } | |
| 538 | if ran.iter().any(|c| *c == "failure" || *c == "timed_out") { | |
| 539 | return Some("failure"); | |
| 540 | } | |
| 541 | if ran.contains(&"cancelled") { | |
| 542 | return Some("error"); | |
| 543 | } | |
| 544 | Some("success") | |
| 545 | } | |
| 546 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 547 | impl Actions { |
| 548 | pub async fn run_row(&self, id: &str) -> Result<Option<RunRow>> { | |
| 549 | self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<RunRow>(None).await | |
| 550 | } | |
| 551 | ||
| 552 | pub async fn job_rows(&self, run_id: &str) -> Result<Vec<JobRow>> { | |
| 553 | self.db | |
| 554 | .prepare("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid") | |
| 555 | .bind(&[run_id.into()])? | |
| 556 | .all() | |
| 557 | .await? | |
| 558 | .results::<JobRow>() | |
| 559 | } | |
| 560 | ||
| 561 | pub async fn run_summary(&self, id: &str) -> Result<Outcome<WorkflowRun>> { | |
| 562 | Ok(match self.run_row(id).await? { | |
| 563 | Some(row) => Outcome::Ok(row.summary()), | |
| 564 | None => fail(FailureCode::NotFound, "No such run."), | |
| 565 | }) | |
| 566 | } | |
| 567 | ||
| 568 | /// The contexts every expression outside a job's steps may use. | |
| 569 | fn base_contexts(run: &RunRow, vars: &Map<String, Value>, job: &str) -> Map<String, Value> { | |
| 570 | let mut contexts = Map::new(); | |
| 571 | contexts.insert("github".into(), run.info().context(job, "", run.action.as_deref())); | |
| 572 | contexts.insert("inputs".into(), Value::Object(run.inputs())); | |
| 573 | contexts.insert("vars".into(), Value::Object(vars.clone())); | |
| 574 | contexts.insert("needs".into(), json!({})); | |
| 575 | contexts.insert("runner".into(), runner_context()); | |
| 576 | contexts | |
| 577 | } | |
| 578 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 579 | /// Whether `namespace` is a person's own workspace: usernames and |
| 580 | /// workspaces share one namespace, so it is when someone has that | |
| 581 | /// username. Kept in the isolate for a few minutes, so a burst of | |
| 582 | /// runs asks identity once. | |
| 583 | async fn personal_namespace(&self, namespace: &str) -> Result<bool> { | |
| 584 | const KEEP_MS: u64 = 5 * 60 * 1000; | |
| 585 | thread_local! { | |
| 586 | static PERSONAL: std::cell::RefCell<std::collections::HashMap<String, (bool, u64)>> = Default::default(); | |
| 587 | } | |
| 588 | let now = now_ms(); | |
| 589 | let key = namespace.to_ascii_lowercase(); | |
| 590 | if let Some(personal) = PERSONAL.with(|kept| kept.borrow().get(&key).filter(|(_, at)| now.saturating_sub(*at) < KEEP_MS).map(|(personal, _)| *personal)) { | |
| 591 | return Ok(personal); | |
| 592 | } | |
| 593 | let found: g1t_contracts::Viewer = g1t_kit::call( | |
| 594 | &self.identity, | |
| 595 | "user_by_username", | |
| 596 | &g1t_contracts::identity::UsernameArgs { username: key.clone() }, | |
| 597 | ) | |
| 598 | .await?; | |
| 599 | let personal = found.is_some_and(|user| user.username.eq_ignore_ascii_case(namespace)); | |
| 600 | PERSONAL.with(|kept| { | |
| 601 | let mut kept = kept.borrow_mut(); | |
| 602 | if kept.len() > 1000 { | |
| 603 | kept.clear(); | |
| 604 | } | |
| 605 | kept.insert(key, (personal, now)); | |
| 606 | }); | |
| 607 | Ok(personal) | |
| 608 | } | |
| 609 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 610 | /// Makes a run and its jobs, and starts what can start. `None` when the |
| 611 | /// event already started this workflow. | |
| 612 | pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> { | |
| 613 | // Nothing starts on an archived repository. A deleted one is never | |
| 614 | // found to start anything on. | |
| 615 | if new.repo.archived() { | |
| 616 | return Ok(None); | |
| 617 | } | |
| 618 | let workflow_row = self.workflow_row(&new.repo, &new.path, &new.workflow.display_name(&new.path), &new.source).await?; | |
| 619 | let numbered = self | |
| 620 | .db | |
| 621 | .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n") | |
| 622 | .bind(&[workflow_row.id.as_str().into()])? | |
| 623 | .first::<Count>(None) | |
| 624 | .await? | |
| 625 | .map_or(1, |count| count.n); | |
| 626 | let id = new_id("run", now_ms()); | |
| 627 | let mut info = new.info.clone(); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 628 | // A personal workspace's repositories are a `User`'s, as on GitHub. |
| 629 | if self.personal_namespace(&new.repo.namespace).await? { | |
| 630 | crate::payload::owned_by_person(&mut info.event, &new.repo.namespace); | |
| 631 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 632 | info.workflow = new.workflow.display_name(&new.path); |
| 633 | info.workflow_path = new.path.clone(); | |
| 634 | info.run_id = id.clone(); | |
| 635 | info.run_number = u64::from(numbered); | |
| 636 | let vars = self | |
| 637 | .variables_for(&new.repo.id, &format!("{}/{}", new.repo.namespace, new.repo.name), None, new.trusted) | |
| 638 | .await?; | |
| 639 | ||
| 640 | // run-name and the concurrency group read github, inputs and vars. | |
| 641 | let mut contexts = Map::new(); | |
| 642 | contexts.insert("github".into(), info.context("", "", new.action.as_deref())); | |
| 643 | contexts.insert("inputs".into(), Value::Object(new.inputs.clone())); | |
| 644 | contexts.insert("vars".into(), Value::Object(vars)); | |
| 645 | let scope = Scope { | |
| 646 | contexts: &contexts, | |
| 647 | status: Status::Success, | |
| 648 | hash_files: None, | |
| 649 | }; | |
| 650 | let title = new | |
| 651 | .workflow | |
| 652 | .run_name | |
| 653 | .as_deref() | |
| 654 | .and_then(|run_name| expr::interpolate(run_name, &scope).ok()) | |
| 655 | .filter(|title| !title.trim().is_empty()) | |
| 656 | .unwrap_or(new.title.clone()); | |
| 657 | let group = new.workflow.concurrency.as_ref().and_then(|c| expr::interpolate(&c.group, &scope).ok()); | |
| 658 | let cancel_in_progress = new | |
| 659 | .workflow | |
| 660 | .concurrency | |
| 661 | .as_ref() | |
| 662 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 663 | .is_some_and(|value| expr::truthy(&value)); | |
| 664 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 665 | let approval = new.approval.as_ref().map(|reason| RunApproval { state: "required".into(), reason: reason.clone(), approved_by: None }); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 666 | let inserted = self |
| 667 | .db | |
| 668 | .prepare( | |
| 669 | "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, action, git_ref, sha, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 670 | pull, status, actor, actor_id, source, info, inputs, trusted, concurrency_group, event_key, created_at, |
| 671 | approval, cancel_in_progress) | |
| 672 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 673 | ) |
| 674 | .bind(&[ | |
| 675 | id.as_str().into(), | |
| 676 | workflow_row.id.as_str().into(), | |
| 677 | new.repo.id.as_str().into(), | |
| 678 | format!("{}/{}", new.repo.namespace, new.repo.name).into(), | |
| 679 | new.path.as_str().into(), | |
| 680 | info.workflow.as_str().into(), | |
| 681 | title.as_str().into(), | |
| 682 | numbered.into(), | |
| 683 | info.event_name.as_str().into(), | |
| 684 | optional(new.action.as_deref()), | |
| 685 | info.git_ref.as_str().into(), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 686 | new.head_sha.as_deref().unwrap_or(&info.sha).into(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 687 | new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 688 | if approval.is_some() { "action_required" } else { "queued" }.into(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 689 | optional(new.actor.as_deref()), |
| 690 | optional(new.actor_id.as_deref()), | |
| 691 | new.source.as_str().into(), | |
| 692 | serde_json::to_string(&info)?.into(), | |
| 693 | serde_json::to_string(&new.inputs)?.into(), | |
| 694 | u32::from(new.trusted).into(), | |
| 695 | optional(group.as_deref()), | |
| 696 | new.event_key.as_str().into(), | |
| 697 | now().into(), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 698 | approval.as_ref().map(serde_json::to_string).transpose()?.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 699 | u32::from(cancel_in_progress).into(), | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 700 | ])? |
| 701 | .first::<Value>(None) | |
| 702 | .await?; | |
| 703 | if inserted.is_none() { | |
| 704 | return Ok(None); | |
| 705 | } | |
| 706 | if let Some(run) = self.run_row(&id).await? { | |
| 707 | self.report_pending(&run).await?; | |
| 708 | } | |
| 709 | ||
| 710 | // Every job, waiting; each is expanded when the jobs it needs are done. | |
| 711 | let mut statements = Vec::new(); | |
| 712 | for job in &new.workflow.jobs { | |
| 713 | statements.push( | |
| 714 | self.db | |
| 715 | .prepare( | |
| 716 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting')", | |
| 717 | ) | |
| 718 | .bind(&[ | |
| 719 | new_id("job", now_ms()).into(), | |
| 720 | id.as_str().into(), | |
| 721 | new.repo.id.as_str().into(), | |
| 722 | new.repo.namespace.as_str().into(), | |
| 723 | job.id.as_str().into(), | |
| 724 | job.name.clone().filter(|n| !expr::has_expression(n)).unwrap_or(job.id.clone()).into(), | |
| 725 | serde_json::to_string(&job.needs)?.into(), | |
| 726 | ])?, | |
| 727 | ); | |
| 728 | } | |
| 729 | self.db.batch(statements).await?; | |
| 730 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 731 | // A pull request's run from outside waits to be approved; it joins |
| 732 | // its concurrency group once it is (protection.rs, approve_run). | |
| 733 | if approval.is_some() { | |
| 734 | return Ok(Some(id)); | |
| 735 | } | |
| 736 | if let Some(run) = self.run_row(&id).await? { | |
| 737 | self.enter_group(&run).await?; | |
| 738 | } | |
| 739 | Ok(Some(id)) | |
| 740 | } | |
| 741 | ||
| 742 | /// A run about to start: one run at a time per concurrency group, as on | |
| 743 | /// GitHub. A newer run replaces one that waits in the group, and with | |
| 744 | /// `cancel-in-progress` the one that runs; otherwise it waits as | |
| 745 | /// `pending` for the one that runs. Then it moves along. | |
| 746 | pub(crate) async fn enter_group(&self, run: &RunRow) -> Result<()> { | |
| 747 | if let Some(group) = &run.concurrency_group { | |
| 748 | let cancel_in_progress = run.cancel_in_progress != 0; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 749 | let others = self |
| 750 | .db | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 751 | .prepare( |
| 752 | "SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND id != ? AND status NOT IN ('completed', 'action_required') ORDER BY id", | |
| 753 | ) | |
| 754 | .bind(&[run.repo_id.as_str().into(), group.as_str().into(), run.id.as_str().into()])? | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 755 | .all() |
| 756 | .await? | |
| 757 | .results::<RunRow>()?; | |
| 758 | for other in &others { | |
| 759 | if cancel_in_progress || other.status == "pending" { | |
| 760 | // A newer run replaces a waiting one, as on GitHub. | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 761 | self.cancel_run(other, "A newer run in the same concurrency group replaced it.", false).await?; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 762 | } |
| 763 | } | |
| 764 | if !cancel_in_progress && others.iter().any(|other| other.status != "pending") { | |
| 765 | self.db | |
| 766 | .prepare("UPDATE runs SET status = 'pending' WHERE id = ?") | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 767 | .bind(&[run.id.as_str().into()])? |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 768 | .run() |
| 769 | .await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 770 | return Ok(()); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 771 | } |
| 772 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 773 | self.advance(&run.id).await |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 774 | } |
| 775 | ||
| 776 | /// A run that could not start, such as for a workflow file that does not read. | |
| 777 | #[allow(clippy::too_many_arguments)] | |
| 778 | pub async fn record_failed_run( | |
| 779 | &self, | |
| 780 | row: &WorkflowRow, | |
| 781 | git_ref: &str, | |
| 782 | sha: &str, | |
| 783 | event_key: &str, | |
| 784 | actor_id: Option<&str>, | |
| 785 | actor: &str, | |
| 786 | problem: &str, | |
| 787 | ) -> Result<()> { | |
| 788 | let numbered = self | |
| 789 | .db | |
| 790 | .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n") | |
| 791 | .bind(&[row.id.as_str().into()])? | |
| 792 | .first::<Count>(None) | |
| 793 | .await? | |
| 794 | .map_or(1, |count| count.n); | |
| 795 | let at = now(); | |
| 796 | self.db | |
| 797 | .prepare( | |
| 798 | "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, git_ref, sha, status, | |
| 799 | conclusion, error, actor, actor_id, source, info, event_key, created_at, finished_at) | |
| 800 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'push', ?, ?, 'completed', 'failure', ?, ?, ?, ?, '{}', ?, ?, ?)", | |
| 801 | ) | |
| 802 | .bind(&[ | |
| 803 | new_id("run", now_ms()).into(), | |
| 804 | row.id.as_str().into(), | |
| 805 | row.repo_id.as_str().into(), | |
| 806 | row.repo.as_str().into(), | |
| 807 | row.path.as_str().into(), | |
| 808 | row.path.as_str().into(), | |
| 809 | "Invalid workflow file".into(), | |
| 810 | numbered.into(), | |
| 811 | git_ref.into(), | |
| 812 | sha.into(), | |
| 813 | problem.into(), | |
| 814 | actor.into(), | |
| 815 | optional(actor_id), | |
| 816 | row.source.as_str().into(), | |
| 817 | event_key.into(), | |
| 818 | at.as_str().into(), | |
| 819 | at.as_str().into(), | |
| 820 | ])? | |
| 821 | .run() | |
| 822 | .await?; | |
| 823 | Ok(()) | |
| 824 | } | |
| 825 | ||
| 826 | /// Moves a run along: jobs whose needs are done are decided on, and | |
| 827 | /// jobs that can start are started. | |
| 828 | pub async fn advance(&self, run_id: &str) -> Result<()> { | |
| 829 | // Each pass may finish jobs (skipped ones), which may free others. | |
| 830 | for _ in 0..20 { | |
| 831 | let Some(run) = self.run_row(run_id).await? else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 832 | if matches!(run.status.as_str(), "completed" | "pending" | "action_required") { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 833 | return Ok(()); |
| 834 | } | |
| 835 | let workflow = match workflow::parse(&run.source) { | |
| 836 | Ok(workflow) => workflow, | |
| 837 | Err(problem) => { | |
| 838 | self.finish_run(&run, Some(&problem)).await?; | |
| 839 | return Ok(()); | |
| 840 | } | |
| 841 | }; | |
| 842 | let jobs = self.job_rows(run_id).await?; | |
| 843 | let mut changed = false; | |
| 844 | // What to decide on: the workflow's jobs, and the jobs of the | |
| 845 | // workflows they call, each with its needs as (name, key). | |
| 846 | let mut units: Vec<Unit> = workflow | |
| 847 | .jobs | |
| 848 | .iter() | |
| 849 | .map(|job| (job.id.clone(), job.clone(), job.needs.iter().map(|n| (n.clone(), n.clone())).collect())) | |
| 850 | .collect(); | |
| 851 | let mut seen = std::collections::HashSet::new(); | |
| 852 | for row in &jobs { | |
| 853 | if !seen.insert(row.key.clone()) { | |
| 854 | continue; | |
| 855 | } | |
| 856 | if let Some((_, job, call)) = row.callee() { | |
| 857 | let parent = call["parent"].as_str().unwrap_or_default().to_owned(); | |
| 858 | let needs = job.needs.iter().map(|n| (n.clone(), format!("{parent}/{n}"))).collect(); | |
| 859 | units.push((row.key.clone(), job, needs)); | |
| 860 | } | |
| 861 | } | |
| 862 | // Each key's needs, as keys, to look back through for failure(). | |
| 863 | let needs_of: std::collections::HashMap<&str, Vec<&str>> = | |
| 864 | units.iter().map(|(key, _, needs)| (key.as_str(), needs.iter().map(|(_, need)| need.as_str()).collect())).collect(); | |
| 865 | let key_failed = |key: &str| key_result(&jobs.iter().filter(|row| row.key == key).collect::<Vec<_>>()) == "failure"; | |
| 866 | for (key, job, needs) in &units { | |
| 867 | let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == key).collect(); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 868 | if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting" && !row.holds_call()) { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 869 | continue; |
| 870 | } | |
| 871 | let needed: Vec<(&String, Vec<&JobRow>)> = | |
| 872 | needs.iter().map(|(name, need)| (name, jobs.iter().filter(|row| &row.key == need).collect())).collect(); | |
| 873 | if !needed.iter().all(|(_, rows)| rows.iter().all(|row| row.status == "completed")) { | |
| 874 | continue; | |
| 875 | } | |
| 876 | let failed_before = ancestor_failed(&needs_of, key, key_failed); | |
| 877 | self.decide(&run, job, rows[0], &needed, failed_before).await?; | |
| 878 | changed = true; | |
| 879 | } | |
| 880 | // A job that called a workflow finishes with that workflow's jobs. | |
| 881 | for row in jobs.iter().filter(|row| row.status == "calling") { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 882 | let prefix = row.call_prefix().unwrap_or(row.key.clone()); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 883 | let children: Vec<&JobRow> = jobs |
| 884 | .iter() | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 885 | .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(prefix.as_str()))) |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 886 | .collect(); |
| 887 | if !children.is_empty() && children.iter().all(|child| child.status == "completed") { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 888 | self.finish_call(&run, row, &children).await?; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 889 | changed = true; |
| 890 | } | |
| 891 | } | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 892 | // Calls that wait start while their matrix's `max-parallel` and |
| 893 | // their concurrency group have room. | |
| 894 | let mut calling: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); | |
| 895 | for row in jobs.iter().filter(|row| row.status == "calling") { | |
| 896 | *calling.entry(row.key.as_str()).or_default() += 1; | |
| 897 | } | |
| 898 | for row in jobs.iter().filter(|row| row.holds_call()) { | |
| 899 | let running = calling.get(row.key.as_str()).copied().unwrap_or(0); | |
| 900 | if row.max_parallel.is_some_and(|max| running >= max) || self.group_busy(row).await? { | |
| 901 | continue; | |
| 902 | } | |
| 903 | if self.start_call(row).await? { | |
| 904 | calling.insert(row.key.as_str(), running + 1); | |
| 905 | changed = true; | |
| 906 | } | |
| 907 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 908 | if !changed { |
| 909 | break; | |
| 910 | } | |
| 911 | } | |
| 912 | self.start_queued().await?; | |
| 913 | self.finish_if_done(run_id).await | |
| 914 | } | |
| 915 | ||
| 916 | /// Decides on one job whose needs are done: skip it, fail it, or expand | |
| 917 | /// it into its matrix and queue it. `failed_before`: whether any job | |
| 918 | /// before it failed, however far back (`ancestor_failed`). | |
| 919 | async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)], failed_before: bool) -> Result<()> { | |
| 920 | let vars = self.variables_for(&run.repo_id, &run.repo, None, run.trusted != 0).await?; | |
| 921 | let mut contexts = Self::base_contexts(run, &vars, &job.id); | |
| 922 | // A called workflow's jobs read the inputs they were called with. | |
| 923 | let call = row.call(); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 924 | // Shown under the job whose call made it, by that job's name. |
| 925 | let caller = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| { | |
| 926 | c["title"].as_str().map(str::to_owned).or_else(|| c["parent"].as_str().map(|parent| parent.replace('/', " / "))) | |
| 927 | }); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 928 | if let Some(call) = call.as_ref().filter(|c| c["role"] == "callee") { |
| 929 | contexts.insert("inputs".into(), call["inputs"].clone()); | |
| 930 | } | |
| 931 | let mut needs = Map::new(); | |
| 932 | let mut results = Vec::new(); | |
| 933 | for (key, rows) in needed { | |
| 934 | let result = key_result(rows); | |
| 935 | let mut outputs = Map::new(); | |
| 936 | for row in rows { | |
| 937 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) { | |
| 938 | outputs.extend(more); | |
| 939 | } | |
| 940 | } | |
| 941 | results.push(result); | |
| 942 | needs.insert((*key).clone(), json!({ "result": result, "outputs": outputs })); | |
| 943 | } | |
| 944 | // As on GitHub: a need that was skipped makes success() false but | |
| 945 | // not failure(); failure() is a failure anywhere before the job. | |
| 946 | let status = expr::job_status(results, failed_before, run.conclusion.as_deref() == Some("cancelled")); | |
| 947 | contexts.insert("needs".into(), Value::Object(needs)); | |
| 948 | let scope = Scope { | |
| 949 | contexts: &contexts, | |
| 950 | status, | |
| 951 | hash_files: None, | |
| 952 | }; | |
| 953 | let condition = job.condition.as_deref().unwrap_or_default(); | |
| 954 | match expr::condition(condition, &scope) { | |
| 955 | Ok(true) => {} | |
| 956 | Ok(false) => return self.skip_job(row, None).await, | |
| 957 | Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await, | |
| 958 | } | |
| 959 | // Its matrix, which may come from a needed job's outputs. | |
| 960 | let combinations = match &job.matrix { | |
| 961 | None => vec![Map::new()], | |
| 962 | Some(matrix) => { | |
| 963 | let value = match expr::interpolate_value(matrix, &scope) { | |
| 964 | Ok(value) => value, | |
| 965 | Err(problem) => return self.fail_job(row, &format!("Its matrix does not read: {problem}")).await, | |
| 966 | }; | |
| 967 | match matrix::expand(&value) { | |
| 968 | Ok(combinations) if !combinations.is_empty() => combinations, | |
| 969 | Ok(_) => return self.fail_job(row, "Its matrix makes no jobs.").await, | |
| 970 | Err(problem) => return self.fail_job(row, &problem).await, | |
| 971 | } | |
| 972 | } | |
| 973 | }; | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 974 | // A job that calls a reusable workflow calls it once for each. |
| 975 | if let Some(uses) = &job.uses { | |
| 976 | return self.call_workflow(run, job, row, uses, &contexts, &combinations, caller.as_deref()).await; | |
| 977 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 978 | let total = combinations.len(); |
| 979 | let raw = job.raw.as_object().cloned().unwrap_or_default(); | |
| 980 | // Whether pull requests from forks may use self-hosted runners here, | |
| 981 | // asked once, and only for a run that is not trusted. | |
| 982 | let mut forks_allowed: Option<bool> = None; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 983 | // Each concurrency group its jobs join, and whether it cancels. |
| 984 | let mut groups: Vec<(String, bool)> = Vec::new(); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 985 | let mut statements = Vec::new(); |
| 986 | for (index, combination) in combinations.iter().enumerate() { | |
| 987 | let mut contexts = contexts.clone(); | |
| 988 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 989 | contexts.insert( | |
| 990 | "strategy".into(), | |
| 991 | json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }), | |
| 992 | ); | |
| 993 | let scope = Scope { | |
| 994 | contexts: &contexts, | |
| 995 | status: Status::Success, | |
| 996 | hash_files: None, | |
| 997 | }; | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 998 | let name = shown_name(job, caller.as_deref(), combination, &scope); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 999 | let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null); |
| 1000 | let labels = match &runs_on { | |
| 1001 | Value::String(label) => vec![label.clone()], | |
| 1002 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 1003 | Value::Object(spec) => spec.get("labels").map(|l| match l { | |
| 1004 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 1005 | other => vec![expr::to_text(other)], | |
| 1006 | }).unwrap_or_default(), | |
| 1007 | _ => Vec::new(), | |
| 1008 | }; | |
| 1009 | // `runs-on: self-hosted` (or a group): the workspace's own | |
| 1010 | // machines, which may run any OS. Otherwise g1t's Linux sandboxes. | |
| 1011 | let group = match &runs_on { | |
| 1012 | Value::Object(spec) => spec.get("group").map(expr::to_text), | |
| 1013 | _ => None, | |
| 1014 | }; | |
| 1015 | let wanted = Wanted::of(&labels, group.as_deref()); | |
| 1016 | let mut reason = if wanted.self_hosted { | |
| 1017 | None | |
| 1018 | } else { | |
| 1019 | labels | |
| 1020 | .iter() | |
| 1021 | .find(|label| { | |
| 1022 | let lower = label.to_ascii_lowercase(); | |
| 1023 | lower.contains("windows") || lower.contains("macos") | |
| 1024 | }) | |
| 1025 | .map(|label| { | |
| 1026 | let os = if label.to_ascii_lowercase().contains("windows") { "windows" } else { "macos" }; | |
| 1027 | format!("`runs-on: {label}`: g1t's own runners are Linux. A self-hosted runner can run it: `runs-on: [self-hosted, {os}]`.") | |
| 1028 | }) | |
| 1029 | }; | |
| 1030 | // A pull request from a fork runs code anyone could write: never | |
| 1031 | // on the workspace's machines unless it said they may. | |
| 1032 | if wanted.self_hosted && run.trusted == 0 { | |
| 1033 | let allowed = match forks_allowed { | |
| 1034 | Some(allowed) => allowed, | |
| 1035 | None => { | |
| 1036 | let allowed = self.effective_runner_settings(&row.namespace, Some(&run.repo_id)).await?.fork_pull_requests; | |
| 1037 | forks_allowed = Some(allowed); | |
| 1038 | allowed | |
| 1039 | } | |
| 1040 | }; | |
| 1041 | if !allowed { | |
| 1042 | reason = Some( | |
| 1043 | "Pull requests from forks do not run on self-hosted runners here. An admin can allow it under Settings, Runners.".to_owned(), | |
| 1044 | ); | |
| 1045 | } | |
| 1046 | } | |
| 1047 | let max_minutes = if wanted.self_hosted { SELF_HOSTED_MAX_TIMEOUT_MINUTES } else { MAX_TIMEOUT_MINUTES }; | |
| 1048 | let timeout = raw | |
| 1049 | .get("timeout-minutes") | |
| 1050 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) | |
| 1051 | .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok())) | |
| What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so. | 1052 | .map_or(g1t_actions::workflow::DEFAULT_TIMEOUT_MINUTES.min(max_minutes), |minutes| (minutes.ceil() as u32).clamp(1, max_minutes)); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1053 | let continue_on_error = raw |
| 1054 | .get("continue-on-error") | |
| 1055 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) | |
| 1056 | .is_some_and(|value| expr::truthy(&value)); | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1057 | let (mut status, mut conclusion, mut finished) = match &reason { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1058 | Some(_) => ("completed", Some("failure"), Some(now())), |
| 1059 | None => ("queued", None, None), | |
| 1060 | }; | |
| 1061 | // A self-hosted job waits, saying for what, until a runner takes it. | |
| 1062 | let (labels_json, queued_at) = if wanted.self_hosted && reason.is_none() { | |
| 1063 | reason = Some(waiting_reason(&wanted)); | |
| 1064 | (Some(serde_json::to_string(&wanted.stored())?), Some(now())) | |
| 1065 | } else { | |
| 1066 | (None, None) | |
| 1067 | }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1068 | // The environment it names, an expression read now, and what |
| 1069 | // that environment's protection rules say: it starts, waits as | |
| 1070 | // `pending` (protection.rs), or may not deploy there. | |
| 1071 | let environment = environment_of(&job.raw, &contexts).map(|env| env.name); | |
| 1072 | if status == "queued" | |
| 1073 | && let Some(name) = &environment | |
| 1074 | { | |
| 1075 | match self.gate(run, name).await? { | |
| 1076 | Gate::Open => {} | |
| 1077 | Gate::Held(why) => { | |
| 1078 | status = "pending"; | |
| 1079 | reason = Some(why); | |
| 1080 | } | |
| 1081 | Gate::Refused(why) => { | |
| 1082 | (status, conclusion, finished) = ("completed", Some("failure"), Some(now())); | |
| 1083 | reason = Some(why); | |
| 1084 | } | |
| 1085 | } | |
| 1086 | } | |
| 1087 | // Its own concurrency group. | |
| 1088 | let job_group = job | |
| 1089 | .concurrency | |
| 1090 | .as_ref() | |
| 1091 | .and_then(|c| expr::interpolate(&c.group, &scope).ok()) | |
| 1092 | .map(|group| group.trim().to_owned()) | |
| 1093 | .filter(|group| !group.is_empty()); | |
| 1094 | let job_cancel = job | |
| 1095 | .concurrency | |
| 1096 | .as_ref() | |
| 1097 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 1098 | .is_some_and(|value| expr::truthy(&value)); | |
| 1099 | if status != "completed" | |
| 1100 | && let Some(group) = &job_group | |
| 1101 | && !groups.iter().any(|(known, _)| known == group) | |
| 1102 | { | |
| 1103 | groups.push((group.clone(), job_cancel)); | |
| 1104 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1105 | let values: Vec<worker::wasm_bindgen::JsValue> = vec![ |
| 1106 | name.into(), | |
| 1107 | serde_json::to_string(combination)?.into(), | |
| 1108 | status.into(), | |
| 1109 | optional(conclusion), | |
| 1110 | optional(reason.as_deref()), | |
| 1111 | timeout.into(), | |
| 1112 | u32::from(continue_on_error).into(), | |
| 1113 | job.max_parallel.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1114 | optional(finished.as_deref()), | |
| 1115 | optional(labels_json.as_deref()), | |
| 1116 | optional(queued_at.as_deref()), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1117 | optional(environment.as_deref()), |
| 1118 | optional(job_group.as_deref()), | |
| 1119 | u32::from(job_cancel).into(), | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1120 | ]; |
| 1121 | if index == 0 { | |
| 1122 | let mut bound = values; | |
| 1123 | bound.push(row.id.as_str().into()); | |
| 1124 | statements.push( | |
| 1125 | self.db | |
| 1126 | .prepare( | |
| 1127 | "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, timeout_minutes = ?, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1128 | continue_on_error = ?, max_parallel = ?, finished_at = ?, labels = ?, queued_at = ?, environment = ?, |
| 1129 | concurrency_group = ?, cancel_in_progress = ? WHERE id = ?", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1130 | ) |
| 1131 | .bind(&bound)?, | |
| 1132 | ); | |
| 1133 | } else { | |
| 1134 | let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1135 | new_id("job", now_ms()).into(), | |
| 1136 | row.run_id.as_str().into(), | |
| 1137 | row.repo_id.as_str().into(), | |
| 1138 | row.namespace.as_str().into(), | |
| 1139 | row.key.as_str().into(), | |
| 1140 | (index as u32).into(), | |
| 1141 | row.needs.as_str().into(), | |
| 1142 | optional(row.call.as_deref()), | |
| 1143 | ]; | |
| 1144 | bound.extend(values); | |
| 1145 | statements.push( | |
| 1146 | self.db | |
| 1147 | .prepare( | |
| 1148 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, call, name, matrix, status, conclusion, reason, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1149 | timeout_minutes, continue_on_error, max_parallel, finished_at, labels, queued_at, environment, |
| 1150 | concurrency_group, cancel_in_progress) | |
| 1151 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1152 | ) |
| 1153 | .bind(&bound)?, | |
| 1154 | ); | |
| 1155 | } | |
| 1156 | } | |
| 1157 | self.db.batch(statements).await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1158 | self.replace_in_groups(run, &row.key, &groups).await |
| 1159 | } | |
| 1160 | ||
| 1161 | /// A job queued in its own concurrency group: with `cancel-in-progress` | |
| 1162 | /// it cancels the group's other jobs; otherwise it replaces any that | |
| 1163 | /// are queued and not started, and waits for the one running | |
| 1164 | /// (`start_queued` starts one of a group at a time). As on GitHub. | |
| 1165 | async fn replace_in_groups(&self, run: &RunRow, key: &str, groups: &[(String, bool)]) -> Result<()> { | |
| 1166 | if groups.is_empty() { | |
| 1167 | return Ok(()); | |
| 1168 | } | |
| 1169 | #[derive(Deserialize)] | |
| 1170 | struct Id { | |
| 1171 | id: String, | |
| 1172 | } | |
| 1173 | let mine: Vec<String> = self | |
| 1174 | .db | |
| 1175 | .prepare("SELECT id FROM jobs WHERE run_id = ? AND key = ?") | |
| 1176 | .bind(&[run.id.as_str().into(), key.into()])? | |
| 1177 | .all() | |
| 1178 | .await? | |
| 1179 | .results::<Id>()? | |
| 1180 | .into_iter() | |
| 1181 | .map(|row| row.id) | |
| 1182 | .collect(); | |
| 1183 | let mut moved: Vec<String> = Vec::new(); | |
| 1184 | for (group, cancel) in groups { | |
| 1185 | let others = self | |
| 1186 | .db | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1187 | // A call that waits in the group, or calls, is in it too. |
| 1188 | .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('waiting', 'queued', 'pending', 'in_progress', 'calling')") | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1189 | .bind(&[run.repo_id.as_str().into(), group.as_str().into()])? |
| 1190 | .all() | |
| 1191 | .await? | |
| 1192 | .results::<JobRow>()?; | |
| 1193 | for other in others.iter().filter(|other| !mine.contains(&other.id)) { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1194 | if *cancel || matches!(other.status.as_str(), "queued" | "waiting") { |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1195 | self.stop_job(other, "A newer job in the same concurrency group replaced it.").await?; |
| 1196 | if !moved.contains(&other.run_id) { | |
| 1197 | moved.push(other.run_id.clone()); | |
| 1198 | } | |
| 1199 | } | |
| 1200 | } | |
| 1201 | } | |
| 1202 | for other_run in moved.iter().filter(|id| **id != run.id) { | |
| 1203 | Box::pin(self.advance(other_run)).await?; | |
| 1204 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1205 | Ok(()) |
| 1206 | } | |
| 1207 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1208 | /// A job that calls a reusable workflow, in the repository or another |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1209 | /// (reach.rs): once for each combination of its matrix, as on GitHub, |
| 1210 | /// each with the inputs and secrets it passes read with that | |
| 1211 | /// combination's `matrix`. Each call waits (`holds_call`) until its | |
| 1212 | /// matrix's `max-parallel` and its concurrency group have room, when | |
| 1213 | /// `start_call` puts the workflow's jobs in the run under it. | |
| 1214 | #[allow(clippy::too_many_arguments)] | |
| 1215 | async fn call_workflow( | |
| 1216 | &self, | |
| 1217 | run: &RunRow, | |
| 1218 | job: &workflow::Job, | |
| 1219 | row: &JobRow, | |
| 1220 | uses: &str, | |
| 1221 | contexts: &Map<String, Value>, | |
| 1222 | combinations: &[Map<String, Value>], | |
| 1223 | caller: Option<&str>, | |
| 1224 | ) -> Result<()> { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1225 | let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1; |
| 1226 | if depth > MAX_CALL_DEPTH { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1227 | return self.fail_job(row, &call_allowed(depth, &[], uses).unwrap_err()).await; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1228 | } |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1229 | let (file, source, origin) = match self.called_workflow(run, row, uses).await? { |
| 1230 | Ok(found) => found, | |
| 1231 | Err(why) => return self.fail_job(row, &why).await, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1232 | }; |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1233 | let jobs = self.job_rows(&run.id).await?; |
| 1234 | // Each call so far, by the workflow it called. | |
| 1235 | let called_paths: Vec<String> = jobs | |
| 1236 | .iter() | |
| 1237 | .filter(|other| other.call_prefix().is_some()) | |
| 1238 | .filter_map(JobRow::call) | |
| 1239 | .filter_map(|call| call["path"].as_str().map(str::to_owned)) | |
| 1240 | .collect(); | |
| 1241 | let called_already: Vec<&str> = called_paths.iter().map(String::as_str).collect(); | |
| 1242 | if let Err(why) = call_allowed(depth, &called_already, &file) { | |
| 1243 | return self.fail_job(row, &why).await; | |
| 1244 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1245 | let called = match workflow::parse(&source) { |
| 1246 | Ok(called) => called, | |
| 1247 | Err(problem) => return self.fail_job(row, &format!("`{file}` does not read: {problem}")).await, | |
| 1248 | }; | |
| 1249 | let Some(trigger) = called.trigger("workflow_call") else { | |
| 1250 | return self.fail_job(row, &format!("`{file}` cannot be called: it has no `on: workflow_call`.")).await; | |
| 1251 | }; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1252 | // Secrets: none but the job's token unless `secrets:` passes them, |
| 1253 | // by name or with `inherit`; read when each job starts (job_spec). | |
| 1254 | let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1255 | let total = combinations.len(); |
| 1256 | let mut groups: Vec<(String, bool)> = Vec::new(); | |
| 1257 | let mut statements = Vec::new(); | |
| 1258 | for (index, combination) in combinations.iter().enumerate() { | |
| 1259 | let mut contexts = contexts.clone(); | |
| 1260 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 1261 | contexts.insert( | |
| 1262 | "strategy".into(), | |
| 1263 | json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }), | |
| 1264 | ); | |
| 1265 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 1266 | let name = shown_name(job, caller, combination, &scope); | |
| 1267 | // Inputs: what `with:` passes, as the called workflow types them. | |
| 1268 | let given = match job.raw.get("with") { | |
| 1269 | Some(with) => match expr::interpolate_value(with, &scope) { | |
| 1270 | Ok(Value::Object(given)) => Ok(given), | |
| 1271 | Ok(_) => Ok(Map::new()), | |
| 1272 | Err(problem) => Err(format!("Its `with` does not read: {problem}")), | |
| 1273 | }, | |
| 1274 | None => Ok(Map::new()), | |
| 1275 | }; | |
| 1276 | let inputs = given.and_then(|given| crate::reach::call_inputs(&file, &trigger.inputs, &given)); | |
| 1277 | let secrets = crate::reach::secrets_plan(&job.raw, &contexts, outer.clone()); | |
| 1278 | let problem = match &inputs { | |
| 1279 | Err(why) => Some(why.clone()), | |
| 1280 | Ok(_) => crate::reach::missing_secrets(&called.raw, &secrets) | |
| 1281 | .first() | |
| 1282 | .map(|name| format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`.")) | |
| 1283 | .or_else(|| { | |
| 1284 | crate::reach::undeclared_secrets(&called.raw, &secrets) | |
| 1285 | .first() | |
| 1286 | .map(|name| format!("Invalid secret: `{name}` is not defined in `{file}`.")) | |
| 1287 | }), | |
| 1288 | }; | |
| 1289 | // Its own concurrency group, which it holds while its workflow runs. | |
| 1290 | let group = job | |
| 1291 | .concurrency | |
| 1292 | .as_ref() | |
| 1293 | .and_then(|c| expr::interpolate(&c.group, &scope).ok()) | |
| 1294 | .map(|group| group.trim().to_owned()) | |
| 1295 | .filter(|group| !group.is_empty()); | |
| 1296 | let cancel = job | |
| 1297 | .concurrency | |
| 1298 | .as_ref() | |
| 1299 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 1300 | .is_some_and(|value| expr::truthy(&value)); | |
| 1301 | if problem.is_none() | |
| 1302 | && let Some(group) = &group | |
| 1303 | && !groups.iter().any(|(known, _)| known == group) | |
| 1304 | { | |
| 1305 | groups.push((group.clone(), cancel)); | |
| 1306 | } | |
| 1307 | let call = json!({ | |
| 1308 | "role": "caller", "path": file, "source": source, "origin": origin, "depth": depth, | |
| 1309 | "prefix": call_prefix(&row.key, job.matrix.is_some().then_some(index)), "title": name, | |
| 1310 | "inputs": inputs.unwrap_or_default(), "secrets": secrets, "fail_fast": job.fail_fast, | |
| 1311 | }); | |
| 1312 | let (status, conclusion, finished) = match &problem { | |
| 1313 | Some(_) => ("completed", Some("failure"), Some(now())), | |
| 1314 | None => ("waiting", None, None), | |
| 1315 | }; | |
| 1316 | let values: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1317 | name.as_str().into(), | |
| 1318 | serde_json::to_string(combination)?.into(), | |
| 1319 | status.into(), | |
| 1320 | optional(conclusion), | |
| 1321 | optional(problem.as_deref()), | |
| 1322 | job.max_parallel.map(|max| max.max(1)).map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1323 | optional(finished.as_deref()), | |
| 1324 | optional(group.as_deref()), | |
| 1325 | u32::from(cancel).into(), | |
| 1326 | serde_json::to_string(&call)?.into(), | |
| 1327 | ]; | |
| 1328 | if index == 0 { | |
| 1329 | let mut bound = values; | |
| 1330 | bound.push(row.id.as_str().into()); | |
| 1331 | statements.push( | |
| 1332 | self.db | |
| 1333 | .prepare( | |
| 1334 | "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, max_parallel = ?, finished_at = ?, | |
| 1335 | concurrency_group = ?, cancel_in_progress = ?, call = ? WHERE id = ?", | |
| 1336 | ) | |
| 1337 | .bind(&bound)?, | |
| 1338 | ); | |
| 1339 | } else { | |
| 1340 | let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1341 | new_id("job", now_ms()).into(), | |
| 1342 | row.run_id.as_str().into(), | |
| 1343 | row.repo_id.as_str().into(), | |
| 1344 | row.namespace.as_str().into(), | |
| 1345 | row.key.as_str().into(), | |
| 1346 | (index as u32).into(), | |
| 1347 | row.needs.as_str().into(), | |
| 1348 | ]; | |
| 1349 | bound.extend(values); | |
| 1350 | statements.push( | |
| 1351 | self.db | |
| 1352 | .prepare( | |
| 1353 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason, | |
| 1354 | max_parallel, finished_at, concurrency_group, cancel_in_progress, call) | |
| 1355 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1356 | ) | |
| 1357 | .bind(&bound)?, | |
| 1358 | ); | |
| 1359 | } | |
| 1360 | } | |
| 1361 | self.db.batch(statements).await?; | |
| 1362 | self.replace_in_groups(run, &row.key, &groups).await | |
| 1363 | } | |
| 1364 | ||
| 1365 | /// Whether another job of `row`'s concurrency group runs, or calls a | |
| 1366 | /// workflow, so that `row` waits for it. | |
| 1367 | async fn group_busy(&self, row: &JobRow) -> Result<bool> { | |
| 1368 | let Some(group) = &row.concurrency_group else { return Ok(false) }; | |
| 1369 | let busy = self | |
| 1370 | .db | |
| 1371 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?") | |
| 1372 | .bind(&[row.repo_id.as_str().into(), group.as_str().into(), row.id.as_str().into()])? | |
| 1373 | .first::<Count>(None) | |
| 1374 | .await? | |
| 1375 | .map_or(0, |count| count.n); | |
| 1376 | Ok(busy > 0) | |
| 1377 | } | |
| 1378 | ||
| 1379 | /// A call that waited (`holds_call`) is made: the called workflow's | |
| 1380 | /// jobs join the run under it, named under its name and keyed under its | |
| 1381 | /// prefix. False when it no longer waits. | |
| 1382 | async fn start_call(&self, row: &JobRow) -> Result<bool> { | |
| 1383 | let Some(call) = row.call() else { return Ok(false) }; | |
| 1384 | let Some(prefix) = row.call_prefix() else { return Ok(false) }; | |
| 1385 | let Some(called) = call["source"].as_str().and_then(|source| workflow::parse(source).ok()) else { return Ok(false) }; | |
| 1386 | let file = call["path"].as_str().unwrap_or_default(); | |
| 1387 | let started = self | |
| 1388 | .db | |
| 1389 | .prepare("UPDATE jobs SET status = 'calling', reason = ?, started_at = ? WHERE id = ? AND status = 'waiting' RETURNING id") | |
| 1390 | .bind(&[format!("Calls `{file}`.").into(), now().into(), row.id.as_str().into()])? | |
| 1391 | .first::<Value>(None) | |
| 1392 | .await?; | |
| 1393 | if started.is_none() { | |
| 1394 | return Ok(false); | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1395 | } |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1396 | let mut statements = Vec::new(); |
| 1397 | for called_job in &called.jobs { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1398 | let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{prefix}/{n}")).collect(); |
| 1399 | let callee = json!({ | |
| 1400 | "role": "callee", "parent": prefix, "title": row.name, "job": called_job.id, "path": file, | |
| 1401 | "source": call["source"], "inputs": call["inputs"], "depth": call["depth"], "origin": call["origin"], "secrets": call["secrets"], | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1402 | }); |
| 1403 | statements.push( | |
| 1404 | self.db | |
| 1405 | .prepare("INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status, call) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?)") | |
| 1406 | .bind(&[ | |
| 1407 | new_id("job", now_ms()).into(), | |
| 1408 | row.run_id.as_str().into(), | |
| 1409 | row.repo_id.as_str().into(), | |
| 1410 | row.namespace.as_str().into(), | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1411 | format!("{prefix}/{}", called_job.id).into(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1412 | format!("{} / {}", row.name, called_job.name.clone().unwrap_or(called_job.id.clone())).into(), |
| 1413 | serde_json::to_string(&needs)?.into(), | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1414 | serde_json::to_string(&callee)?.into(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1415 | ])?, |
| 1416 | ); | |
| 1417 | } | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1418 | if !statements.is_empty() { |
| 1419 | self.db.batch(statements).await?; | |
| 1420 | } | |
| 1421 | Ok(true) | |
| 1422 | } | |
| 1423 | ||
| 1424 | /// The runs with a call waiting in `group`, but `run_id`, moved along | |
| 1425 | /// now that a job of the group has finished. | |
| 1426 | async fn wake_group(&self, repo_id: &str, group: &str, run_id: &str) -> Result<()> { | |
| 1427 | #[derive(Deserialize)] | |
| 1428 | struct RunId { | |
| 1429 | run_id: String, | |
| 1430 | } | |
| 1431 | let waiting = self | |
| 1432 | .db | |
| 1433 | .prepare("SELECT DISTINCT run_id FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'waiting' AND run_id != ?") | |
| 1434 | .bind(&[repo_id.into(), group.into(), run_id.into()])? | |
| 1435 | .all() | |
| 1436 | .await? | |
| 1437 | .results::<RunId>()?; | |
| 1438 | for other in waiting { | |
| 1439 | Box::pin(self.advance(&other.run_id)).await?; | |
| 1440 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1441 | Ok(()) |
| 1442 | } | |
| 1443 | ||
| 1444 | /// A job that called a workflow, finished with its jobs: their result, | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1445 | /// and the outputs the workflow declares. A failed call of a fail-fast |
| 1446 | /// matrix stops the matrix's other calls. | |
| 1447 | async fn finish_call(&self, run: &RunRow, row: &JobRow, children: &[&JobRow]) -> Result<()> { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1448 | let call = row.call().unwrap_or_default(); |
| 1449 | let called = call["source"].as_str().and_then(|s| workflow::parse(s).ok()); | |
| 1450 | let mut jobs_context = Map::new(); | |
| 1451 | let mut by_key: std::collections::BTreeMap<String, Vec<&JobRow>> = std::collections::BTreeMap::new(); | |
| 1452 | for child in children { | |
| 1453 | by_key.entry(child.key.clone()).or_default().push(child); | |
| 1454 | } | |
| 1455 | for (key, rows) in &by_key { | |
| 1456 | let mut outputs = Map::new(); | |
| 1457 | for child in rows { | |
| 1458 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&child.outputs) { | |
| 1459 | outputs.extend(more); | |
| 1460 | } | |
| 1461 | } | |
| 1462 | let id = key.rsplit('/').next().unwrap_or(key); | |
| 1463 | jobs_context.insert(id.to_owned(), json!({ "result": key_result(rows), "outputs": outputs })); | |
| 1464 | } | |
| 1465 | let inputs = children.first().and_then(|c| c.call()).map(|c| c["inputs"].clone()).unwrap_or(json!({})); | |
| 1466 | let mut contexts = Map::new(); | |
| 1467 | contexts.insert("jobs".into(), Value::Object(jobs_context)); | |
| 1468 | contexts.insert("inputs".into(), inputs); | |
| 1469 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 1470 | let mut outputs = Map::new(); | |
| 1471 | if let Some(Value::Object(declared)) = called.as_ref().map(|w| { | |
| 1472 | let on = w.raw.get("on").or_else(|| w.raw.get("true")).cloned().unwrap_or(Value::Null); | |
| 1473 | on.get("workflow_call").and_then(|c| c.get("outputs")).cloned().unwrap_or(Value::Null) | |
| 1474 | }) { | |
| 1475 | for (name, spec) in declared { | |
| 1476 | if let Some(value) = spec.get("value") { | |
| 1477 | let value = expr::interpolate_value(value, &scope).unwrap_or(Value::Null); | |
| 1478 | outputs.insert(name, Value::String(expr::to_text(&value))); | |
| 1479 | } | |
| 1480 | } | |
| 1481 | } | |
| 1482 | let conclusion = key_result(children); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1483 | let finished = self |
| 1484 | .db | |
| 1485 | .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling' RETURNING id") | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1486 | .bind(&[conclusion.into(), serde_json::to_string(&outputs)?.into(), now().into(), row.id.as_str().into()])? |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1487 | .first::<Value>(None) |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1488 | .await?; |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1489 | if finished.is_none() { |
| 1490 | return Ok(()); | |
| 1491 | } | |
| 1492 | if conclusion == "failure" && row.matrix.as_deref().is_some_and(|m| m != "{}") && call["fail_fast"] != json!(false) { | |
| 1493 | let siblings = self | |
| 1494 | .db | |
| 1495 | .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'") | |
| 1496 | .bind(&[row.run_id.as_str().into(), row.key.as_str().into()])? | |
| 1497 | .all() | |
| 1498 | .await? | |
| 1499 | .results::<JobRow>()?; | |
| 1500 | for sibling in siblings { | |
| 1501 | self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?; | |
| 1502 | } | |
| 1503 | } | |
| 1504 | if let Some(group) = &row.concurrency_group { | |
| 1505 | self.wake_group(&run.repo_id, group, &run.id).await?; | |
| 1506 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1507 | Ok(()) |
| 1508 | } | |
| 1509 | ||
| 1510 | /// A file's text at a commit, if it is there. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1511 | pub(crate) async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1512 | let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call( |
| 1513 | &self.repos, | |
| 1514 | "blob", | |
| 1515 | &g1t_contracts::repos::BlobArgs { | |
| 1516 | path: path.clone(), | |
| 1517 | viewer: Some(ws.clone()), | |
| 1518 | git_ref: sha.to_owned(), | |
| 1519 | file_path: file.to_owned(), | |
| 1520 | }, | |
| 1521 | ) | |
| 1522 | .await?; | |
| 1523 | Ok(match blob { | |
| 1524 | Outcome::Ok(view) => view.text, | |
| 1525 | Outcome::Fail(_) => None, | |
| 1526 | }) | |
| 1527 | } | |
| 1528 | ||
| 1529 | async fn skip_job(&self, row: &JobRow, reason: Option<&str>) -> Result<()> { | |
| 1530 | self.db | |
| 1531 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'skipped', reason = ?, finished_at = ? WHERE id = ?") | |
| 1532 | .bind(&[optional(reason), now().into(), row.id.as_str().into()])? | |
| 1533 | .run() | |
| 1534 | .await?; | |
| 1535 | Ok(()) | |
| 1536 | } | |
| 1537 | ||
| 1538 | async fn fail_job(&self, row: &JobRow, reason: &str) -> Result<()> { | |
| 1539 | self.db | |
| 1540 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status != 'completed'") | |
| 1541 | .bind(&[reason.into(), now().into(), row.id.as_str().into()])? | |
| 1542 | .run() | |
| 1543 | .await?; | |
| 1544 | Ok(()) | |
| 1545 | } | |
| 1546 | ||
| 1547 | /// Starts queued jobs, oldest first, while their workspace has room. | |
| 1548 | pub async fn start_queued(&self) -> Result<()> { | |
| 1549 | let queued = self | |
| 1550 | .db | |
| 1551 | // Self-hosted jobs are taken by their runners (runners.rs). | |
| 1552 | .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NULL ORDER BY rowid LIMIT 50") | |
| 1553 | .all() | |
| 1554 | .await? | |
| 1555 | .results::<JobRow>()?; | |
| 1556 | let mut running: std::collections::HashMap<String, u32> = std::collections::HashMap::new(); | |
| 1557 | for job in queued { | |
| 1558 | let in_workspace = match running.get(&job.namespace) { | |
| 1559 | Some(n) => *n, | |
| 1560 | None => { | |
| 1561 | let n = self | |
| 1562 | .db | |
| 1563 | // Only g1t's own sandboxes count against the workspace's room. | |
| 1564 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND namespace = ? AND runner_id IS NULL") | |
| 1565 | .bind(&[job.namespace.as_str().into()])? | |
| 1566 | .first::<Count>(None) | |
| 1567 | .await? | |
| 1568 | .map_or(0, |count| count.n); | |
| 1569 | running.insert(job.namespace.clone(), n); | |
| 1570 | n | |
| 1571 | } | |
| 1572 | }; | |
| 1573 | if in_workspace >= RUNNING_PER_WORKSPACE { | |
| 1574 | continue; | |
| 1575 | } | |
| 1576 | if let Some(max) = job.max_parallel { | |
| 1577 | let siblings = self | |
| 1578 | .db | |
| 1579 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'") | |
| 1580 | .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])? | |
| 1581 | .first::<Count>(None) | |
| 1582 | .await? | |
| 1583 | .map_or(0, |count| count.n); | |
| 1584 | if siblings >= max { | |
| 1585 | continue; | |
| 1586 | } | |
| 1587 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1588 | // One job of a concurrency group runs at a time. |
| 1589 | if let Some(group) = &job.concurrency_group { | |
| 1590 | let running = self | |
| 1591 | .db | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1592 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?") |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1593 | .bind(&[job.repo_id.as_str().into(), group.as_str().into(), job.id.as_str().into()])? |
| 1594 | .first::<Count>(None) | |
| 1595 | .await? | |
| 1596 | .map_or(0, |count| count.n); | |
| 1597 | if running > 0 { | |
| 1598 | continue; | |
| 1599 | } | |
| 1600 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1601 | let token = random_hex(24); |
| 1602 | let at = now(); | |
| 1603 | let claimed = self | |
| 1604 | .db | |
| 1605 | .prepare( | |
| 1606 | "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id", | |
| 1607 | ) | |
| 1608 | .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])? | |
| 1609 | .first::<Value>(None) | |
| 1610 | .await?; | |
| 1611 | if claimed.is_none() { | |
| 1612 | continue; | |
| 1613 | } | |
| 1614 | running.insert(job.namespace.clone(), in_workspace + 1); | |
| 1615 | self.db | |
| 1616 | .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'") | |
| 1617 | .bind(&[at.as_str().into(), job.run_id.as_str().into()])? | |
| 1618 | .run() | |
| 1619 | .await?; | |
| 1620 | let run = self.run_row(&job.run_id).await?; | |
| 1621 | let repo: RepoPath = run.as_ref().map(|run| repo_path(&run.repo)).unwrap_or(RepoPath { | |
| 1622 | namespace: job.namespace.clone(), | |
| 1623 | name: String::new(), | |
| 1624 | }); | |
| 1625 | // Its environment and the machine it asked for, for the runner. | |
| 1626 | let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default(); | |
| 1627 | let started: Outcome<Value> = g1t_kit::call( | |
| 1628 | &self.runner, | |
| 1629 | "start_actions_job", | |
| 1630 | &StartJobArgs { | |
| 1631 | job: job.id.clone(), | |
| 1632 | token, | |
| 1633 | repo, | |
| 1634 | timeout_minutes: job.timeout_minutes, | |
| 1635 | workflow: run.as_ref().map(|run| run.path.clone()), | |
| 1636 | environment: details.environment, | |
| 1637 | trusted: run.as_ref().is_some_and(|run| run.trusted != 0), | |
| 1638 | instance: details.instance, | |
| 1639 | }, | |
| 1640 | ) | |
| 1641 | .await | |
| 1642 | .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}"))); | |
| 1643 | if let Outcome::Fail(refused) = started { | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1644 | // Billing would not pay for a scheduled run's job: the |
| 1645 | // schedule waits rather than making runs only to refuse them. | |
| 1646 | if refused.code == FailureCode::PaymentRequired | |
| 1647 | && let Some(run) = run.as_ref().filter(|run| run.event == "schedule") | |
| 1648 | { | |
| 1649 | self.db | |
| 1650 | .prepare("UPDATE workflows SET schedule_refused_until = ? WHERE id = ?") | |
| 1651 | .bind(&[rfc3339(now_ms() + crate::SCHEDULE_REFUSED_MS).into(), run.workflow_id.as_str().into()])? | |
| 1652 | .run() | |
| 1653 | .await?; | |
| 1654 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1655 | Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1656 | } else { |
| 1657 | self.job_started(&job.id).await?; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1658 | } |
| 1659 | } | |
| 1660 | Ok(()) | |
| 1661 | } | |
| 1662 | ||
| 1663 | /// Finishes a job and moves its run along. | |
| 1664 | pub async fn finish_job(&self, job_id: &str, conclusion: &str, reason: Option<&str>, outputs: Option<&Map<String, Value>>) -> Result<()> { | |
| 1665 | let finished = self | |
| 1666 | .db | |
| 1667 | .prepare( | |
| 1668 | "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs), | |
| 1669 | finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *", | |
| 1670 | ) | |
| 1671 | .bind(&[ | |
| 1672 | conclusion.into(), | |
| 1673 | optional(reason), | |
| 1674 | outputs.map(|o| serde_json::to_string(o).unwrap_or_default()).as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1675 | now().into(), | |
| 1676 | job_id.into(), | |
| 1677 | ])? | |
| 1678 | .first::<JobRow>(None) | |
| 1679 | .await?; | |
| 1680 | let Some(job) = finished else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a5c94df323642630e' | 1681 | self.tell_live(&job.repo_id, &job.run_id, None, None).await; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1682 | // Its G1T_TOKEN stops working with it. |
| 1683 | self.revoke_job_tokens(&job.id).await; | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 1684 | // A g1t.page build it opened and did not finish fails now, not at |
| 1685 | // the deployments sweep. Only a job that ran can have opened one. | |
| 1686 | if job.started_at.is_some() { | |
| 1687 | self.end_job_builds(&job.id, conclusion).await; | |
| 1688 | } | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1689 | // A job that deploys failed: so did its run's deployment, now. |
| 1690 | if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some() | |
| 1691 | && let Some(run) = self.run_row(&job.run_id).await? | |
| 1692 | && let Some(env) = deploys_to(&run, &job) | |
| 1693 | { | |
| 1694 | self.report_deployment(&run, &env, "failure", false).await; | |
| 1695 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1696 | // A self-hosted runner's job: the runner is free again, and its time |
| 1697 | // is recorded, at nothing. | |
| 1698 | if job.runner_id.is_some() { | |
| 1699 | self.released(&job).await?; | |
| 1700 | } | |
| 1701 | // Steps still marked as going are not going any more. | |
| 1702 | let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 1703 | let mut touched = false; | |
| 1704 | for step in steps.iter_mut() { | |
| 1705 | if step["status"] != "completed" { | |
| 1706 | let was_running = step["status"] == "in_progress"; | |
| 1707 | step["status"] = json!("completed"); | |
| 1708 | step["conclusion"] = json!(if was_running { conclusion } else { "skipped" }); | |
| 1709 | touched = true; | |
| 1710 | } | |
| 1711 | } | |
| 1712 | if touched { | |
| 1713 | self.db | |
| 1714 | .prepare("UPDATE jobs SET steps = ? WHERE id = ?") | |
| 1715 | .bind(&[serde_json::to_string(&steps)?.into(), job.id.as_str().into()])? | |
| 1716 | .run() | |
| 1717 | .await?; | |
| 1718 | } | |
| 1719 | // fail-fast: one failed combination stops the rest of its matrix. | |
| 1720 | if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") { | |
| 1721 | let run = self.run_row(&job.run_id).await?; | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1722 | // As its workflow, or the workflow it is a job of, says. |
| 1723 | let fail_fast = run.as_ref().and_then(|run| job_spec(run, &job)).is_none_or(|j| j.fail_fast); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1724 | if fail_fast { |
| 1725 | let siblings = self | |
| 1726 | .db | |
| 1727 | .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'") | |
| 1728 | .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])? | |
| 1729 | .all() | |
| 1730 | .await? | |
| 1731 | .results::<JobRow>()?; | |
| 1732 | for sibling in siblings { | |
| 1733 | self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?; | |
| 1734 | } | |
| 1735 | } | |
| 1736 | } | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1737 | // A call waiting in its concurrency group, in another run, may start. |
| 1738 | if let Some(group) = &job.concurrency_group { | |
| 1739 | self.wake_group(&job.repo_id, group, &job.run_id).await?; | |
| 1740 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1741 | Box::pin(self.advance(&job.run_id)).await |
| 1742 | } | |
| 1743 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1744 | /// Cancels a job. One that is running is told to stop (the answer to |
| 1745 | /// its next report), and runs its `if: always()` and `cancelled()` | |
| 1746 | /// steps and its post steps before it ends `cancelled`; one that has | |
| 1747 | /// not started is cancelled at once. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1748 | async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> { |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 1749 | // A job calling a workflow stops with that workflow's jobs. |
| 1750 | if job.status == "calling" | |
| 1751 | && let Some(prefix) = job.call_prefix() | |
| 1752 | { | |
| 1753 | let under = format!("{prefix}/"); | |
| 1754 | for child in self.job_rows(&job.run_id).await?.iter().filter(|child| child.key.starts_with(&under) && child.status != "completed") { | |
| 1755 | Box::pin(self.stop_job(child, reason)).await?; | |
| 1756 | } | |
| 1757 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1758 | if job.status == "in_progress" && job.started_at.is_some() { |
| 1759 | self.db | |
| 1760 | .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'") | |
| 1761 | .bind(&[now().into(), reason.into(), job.id.as_str().into()])? | |
| 1762 | .run() | |
| 1763 | .await?; | |
| 1764 | return Ok(()); | |
| 1765 | } | |
| 1766 | self.hard_stop(job, reason).await | |
| 1767 | } | |
| 1768 | ||
| 1769 | /// Cancels a job outright, stopping its sandbox if it has one. | |
| 1770 | async fn hard_stop(&self, job: &JobRow, reason: &str) -> Result<()> { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1771 | // A self-hosted runner hears it was cancelled on its next poll. |
| 1772 | if job.status == "in_progress" && job.runner_id.is_none() { | |
| 1773 | let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await; | |
| 1774 | } | |
| 1775 | let stopped = self | |
| 1776 | .db | |
| 1777 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *") | |
| 1778 | .bind(&[reason.into(), now().into(), job.id.as_str().into()])? | |
| 1779 | .first::<JobRow>(None) | |
| 1780 | .await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1781 | if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) { |
| 1782 | self.revoke_job_tokens(&job.id).await; | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 1783 | self.end_job_builds(&job.id, "cancelled").await; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1784 | } |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1785 | if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) { |
| 1786 | self.released(&stopped).await?; | |
| 1787 | } | |
| 1788 | Ok(()) | |
| 1789 | } | |
| 1790 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1791 | /// Ends a job's tokens at once. A failure is logged: the token expires |
| 1792 | /// on its own soon after the job's time limit. | |
| 1793 | async fn revoke_job_tokens(&self, job_id: &str) { | |
| 1794 | let revoked: Result<bool> = | |
| 1795 | g1t_kit::call(&self.identity, "revoke_job_tokens", &RevokeJobTokensArgs { job_id: job_id.to_owned() }).await; | |
| 1796 | if let Err(error) = revoked { | |
| 1797 | worker::console_error!("actions: the tokens of job {job_id} were not revoked: {error}"); | |
| 1798 | } | |
| 1799 | } | |
| 1800 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1801 | /// Finishes the run when every job has. |
| 1802 | async fn finish_if_done(&self, run_id: &str) -> Result<()> { | |
| 1803 | let Some(run) = self.run_row(run_id).await? else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1804 | if matches!(run.status.as_str(), "completed" | "pending" | "action_required") { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1805 | return Ok(()); |
| 1806 | } | |
| 1807 | let jobs = self.job_rows(run_id).await?; | |
| 1808 | if !jobs.iter().all(|job| job.status == "completed") { | |
| 1809 | return Ok(()); | |
| 1810 | } | |
| 1811 | self.finish_run(&run, None).await | |
| 1812 | } | |
| 1813 | ||
| 1814 | async fn finish_run(&self, run: &RunRow, error: Option<&str>) -> Result<()> { | |
| 1815 | let jobs = self.job_rows(&run.id).await?; | |
| 1816 | let rows: Vec<&JobRow> = jobs.iter().collect(); | |
| 1817 | let conclusion = if error.is_some() { | |
| 1818 | "failure" | |
| 1819 | } else if run.conclusion.as_deref() == Some("cancelled") { | |
| 1820 | "cancelled" | |
| 1821 | } else if rows.is_empty() { | |
| 1822 | "skipped" | |
| 1823 | } else { | |
| 1824 | key_result(&rows) | |
| 1825 | }; | |
| 1826 | let done = self | |
| 1827 | .db | |
| 1828 | .prepare("UPDATE runs SET status = 'completed', conclusion = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ? AND status != 'completed' RETURNING id") | |
| 1829 | .bind(&[conclusion.into(), optional(error), now().into(), run.id.as_str().into()])? | |
| 1830 | .first::<Value>(None) | |
| 1831 | .await?; | |
| 1832 | if done.is_none() { | |
| 1833 | return Ok(()); | |
| 1834 | } | |
| 1835 | self.report_status(run, conclusion).await?; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1836 | self.settle_deployments(run, &jobs, conclusion == "cancelled").await; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1837 | let published: Result<()> = g1t_kit::call( |
| 1838 | &self.events, | |
| 1839 | "publish", | |
| 1840 | &g1t_contracts::events::Publish { | |
| 1841 | events: vec![g1t_contracts::events::NewEvent { | |
| 1842 | kind: "workflow.completed", | |
| 1843 | source: "actions", | |
| 1844 | repo_id: Some(run.repo_id.clone()), | |
| 1845 | actor: run.actor_id.clone(), | |
| 1846 | data: g1t_contracts::events::WorkflowEvent { | |
| 1847 | run_id: run.id.clone(), | |
| 1848 | repo_id: run.repo_id.clone(), | |
| 1849 | workflow: run.name.clone(), | |
| 1850 | path: run.path.clone(), | |
| 1851 | number: run.number, | |
| 1852 | event: run.event.clone(), | |
| 1853 | conclusion: conclusion.to_owned(), | |
| 1854 | git_ref: run.git_ref.clone(), | |
| 1855 | sha: run.sha.clone(), | |
| 1856 | pull: run.pull, | |
| 1857 | }, | |
| 1858 | }], | |
| 1859 | }, | |
| 1860 | ) | |
| 1861 | .await; | |
| 1862 | if let Err(error) = published { | |
| 1863 | worker::console_error!("actions: could not publish workflow.completed: {error}"); | |
| 1864 | } | |
| 1865 | // The next run waiting in its concurrency group. | |
| 1866 | if let Some(group) = &run.concurrency_group { | |
| 1867 | let next = self | |
| 1868 | .db | |
| 1869 | .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND status = 'pending' ORDER BY id LIMIT 1") | |
| 1870 | .bind(&[run.repo_id.as_str().into(), group.as_str().into()])? | |
| 1871 | .first::<RunRow>(None) | |
| 1872 | .await?; | |
| 1873 | if let Some(next) = next { | |
| 1874 | self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[next.id.as_str().into()])?.run().await?; | |
| 1875 | Box::pin(self.advance(&next.id)).await?; | |
| 1876 | } | |
| 1877 | } | |
| 1878 | Ok(()) | |
| 1879 | } | |
| 1880 | ||
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 1881 | /// Tells the deployments service that a job ended (`workflow_job_ended`), |
| 1882 | /// so a build it opened with `start_from_workflow` and left open fails | |
| 1883 | /// at once. Never fails the job: an answer that does not come is | |
| 1884 | /// logged, and the sweep fails the build later. | |
| 1885 | async fn end_job_builds(&self, job_id: &str, conclusion: &str) { | |
| 1886 | let ended: Result<Value> = | |
| 1887 | g1t_kit::call(&self.deployments, "workflow_job_ended", &json!({ "jobId": job_id, "conclusion": conclusion })).await; | |
| 1888 | if let Err(error) = ended { | |
| 1889 | worker::console_log!("actions: could not tell deployments that job {job_id} ended: {error}"); | |
| 1890 | } | |
| 1891 | } | |
| 1892 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1893 | /// Tells the deployments service how a run's deployment to `env` stands: |
| 1894 | /// made when its first job naming the environment starts, failed when one | |
| 1895 | /// of them fails, and settled (`last`) when the run finishes. Never fails | |
| 1896 | /// the run: a deployment that could not be recorded is logged. | |
| 1897 | async fn report_deployment(&self, run: &RunRow, env: &JobEnvironment, state: &str, last: bool) { | |
| 1898 | let path = repo_path(&run.repo); | |
| 1899 | let reported: Result<Value> = g1t_kit::call( | |
| 1900 | &self.deployments, | |
| 1901 | "actions_deployment", | |
| 1902 | &json!({ | |
| 1903 | "repoId": run.repo_id, | |
| 1904 | "repo": { "namespace": path.namespace, "name": path.name }, | |
| 1905 | "runId": run.id, | |
| 1906 | "attempt": run.attempt, | |
| 1907 | "runUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), | |
| 1908 | "environment": env.name, | |
| 1909 | "url": env.url, | |
| 1910 | "ref": run.git_ref, | |
| 1911 | "sha": run.sha, | |
| 1912 | "state": state, | |
| 1913 | "final": last, | |
| 1914 | "creator": run.actor, | |
| 1915 | "workflow": run.name, | |
| 1916 | }), | |
| 1917 | ) | |
| 1918 | .await; | |
| 1919 | if let Err(error) = reported { | |
| 1920 | worker::console_error!("actions: deployment to {} not recorded for run {}: {error}", env.name, run.id); | |
| 1921 | } | |
| 1922 | } | |
| 1923 | ||
| 1924 | /// A job that deploys has started: its run's deployment to the | |
| 1925 | /// environment is under way. | |
| 1926 | pub(crate) async fn job_started(&self, job_id: &str) -> Result<()> { | |
| 1927 | let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job_id.into()])?.first::<JobRow>(None).await? else { | |
| 1928 | return Ok(()); | |
| 1929 | }; | |
| 1930 | let Some(run) = self.run_row(&job.run_id).await? else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a5c94df323642630e' | 1931 | self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1932 | if let Some(env) = deploys_to(&run, &job) { |
| 1933 | self.report_deployment(&run, &env, "in_progress", false).await; | |
| 1934 | } | |
| 1935 | Ok(()) | |
| 1936 | } | |
| 1937 | ||
| Merge branch 'worktree-agent-a5c94df323642630e' | 1938 | /// Pages open on the repository hear that a run's job started or |
| 1939 | /// finished, which is no event (`g1t_contracts::live`). Logged and left | |
| 1940 | /// when notify cannot be reached: the pages catch up by themselves. | |
| 1941 | pub(crate) async fn tell_live(&self, repo_id: &str, run_id: &str, sha: Option<&str>, pull: Option<u32>) { | |
| 1942 | let Some(notify) = &self.notify else { return }; | |
| 1943 | let change = g1t_contracts::live::RepoChange { | |
| 1944 | repo_id: repo_id.to_owned(), | |
| 1945 | kind: g1t_contracts::live::RUN_UPDATED.to_owned(), | |
| 1946 | run: Some(run_id.to_owned()), | |
| 1947 | sha: sha.map(str::to_owned), | |
| 1948 | number: pull, | |
| 1949 | at: now(), | |
| 1950 | ..Default::default() | |
| 1951 | }; | |
| 1952 | let args = g1t_contracts::live::RepoChangedArgs { changes: vec![change] }; | |
| 1953 | let told: Result<Value> = g1t_kit::call(notify, g1t_contracts::live::REPO_CHANGED, &args).await; | |
| 1954 | if let Err(error) = told { | |
| 1955 | worker::console_error!("actions: live pages not told of run {run_id}: {error}"); | |
| 1956 | } | |
| 1957 | } | |
| 1958 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1959 | /// The run is over: each environment its jobs deployed to takes the |
| 1960 | /// outcome of those jobs (`deployment_outcome`). | |
| 1961 | async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) { | |
| 1962 | let mut seen: Vec<(JobEnvironment, Vec<Option<String>>)> = Vec::new(); | |
| 1963 | for job in jobs { | |
| 1964 | let Some(env) = deploys_to(run, job) else { continue }; | |
| 1965 | let conclusion = if cancelled && job.conclusion.as_deref() != Some("skipped") && job.started_at.is_some() { | |
| 1966 | Some("cancelled".to_owned()) | |
| 1967 | } else if job.started_at.is_none() { | |
| 1968 | Some("skipped".to_owned()) | |
| 1969 | } else { | |
| 1970 | job.conclusion.clone() | |
| 1971 | }; | |
| 1972 | match seen.iter_mut().find(|(known, _)| known.name.eq_ignore_ascii_case(&env.name)) { | |
| 1973 | Some((known, conclusions)) => { | |
| 1974 | if known.url.is_none() { | |
| 1975 | known.url = env.url.clone(); | |
| 1976 | } | |
| 1977 | conclusions.push(conclusion); | |
| 1978 | } | |
| 1979 | None => seen.push((env, vec![conclusion])), | |
| 1980 | } | |
| 1981 | } | |
| 1982 | for (env, conclusions) in seen { | |
| 1983 | if let Some(state) = deployment_outcome(&conclusions) { | |
| 1984 | self.report_deployment(run, &env, state, true).await; | |
| 1985 | } | |
| 1986 | } | |
| 1987 | } | |
| 1988 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1989 | /// Tells the pull request (or commit) how the run went, as a status. |
| 1990 | async fn report_status(&self, run: &RunRow, conclusion: &str) -> Result<()> { | |
| 1991 | let state = match conclusion { | |
| 1992 | "success" | "skipped" => "success", | |
| 1993 | "cancelled" => "error", | |
| 1994 | _ => "failure", | |
| 1995 | }; | |
| 1996 | let _: Result<Value> = g1t_kit::call( | |
| 1997 | &self.work, | |
| 1998 | "set_commit_status", | |
| 1999 | &json!({ | |
| 2000 | "repoId": run.repo_id, | |
| 2001 | "sha": run.sha, | |
| 2002 | "context": format!("{} / {}", run.name, run.event), | |
| 2003 | "state": state, | |
| 2004 | "description": format!("{} {}", run.name, match conclusion { | |
| 2005 | "success" => "passed", | |
| 2006 | "skipped" => "was skipped", | |
| 2007 | "cancelled" => "was cancelled", | |
| 2008 | _ => "failed", | |
| 2009 | }), | |
| 2010 | "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2011 | "source": "actions", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2012 | }), |
| 2013 | ) | |
| 2014 | .await; | |
| 2015 | Ok(()) | |
| 2016 | } | |
| 2017 | ||
| 2018 | /// Tells the pull request a run has started on its head. | |
| 2019 | pub async fn report_pending(&self, run: &RunRow) -> Result<()> { | |
| 2020 | let _: Result<Value> = g1t_kit::call( | |
| 2021 | &self.work, | |
| 2022 | "set_commit_status", | |
| 2023 | &json!({ | |
| 2024 | "repoId": run.repo_id, | |
| 2025 | "sha": run.sha, | |
| 2026 | "context": format!("{} / {}", run.name, run.event), | |
| 2027 | "state": "pending", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2028 | "description": if run.status == "action_required" { |
| 2029 | format!("{} is waiting for approval", run.name) | |
| 2030 | } else { | |
| 2031 | format!("{} is running", run.name) | |
| 2032 | }, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2033 | "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2034 | "source": "actions", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2035 | }), |
| 2036 | ) | |
| 2037 | .await; | |
| 2038 | Ok(()) | |
| 2039 | } | |
| 2040 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2041 | /// Cancels a run: its waiting and queued jobs at once, and its running |
| 2042 | /// ones gracefully (`stop_job`), or outright when `force`. | |
| 2043 | pub async fn cancel_run(&self, run: &RunRow, reason: &str, force: bool) -> Result<()> { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2044 | self.db |
| 2045 | .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'") | |
| 2046 | .bind(&[run.id.as_str().into()])? | |
| 2047 | .run() | |
| 2048 | .await?; | |
| 2049 | for job in self.job_rows(&run.id).await?.iter().filter(|job| job.status != "completed") { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2050 | if force { |
| 2051 | self.hard_stop(job, reason).await?; | |
| 2052 | } else { | |
| 2053 | self.stop_job(job, reason).await?; | |
| 2054 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2055 | } |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2056 | if run.status == "pending" || run.status == "action_required" { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2057 | self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?; |
| 2058 | } | |
| 2059 | self.advance(&run.id).await | |
| 2060 | } | |
| 2061 | ||
| 2062 | /// Cancels every run of the repository that has not finished, for | |
| 2063 | /// `repo.deleted` and `repo.archived`. | |
| 2064 | pub async fn stop_runs(&self, repo_id: &str) -> Result<()> { | |
| 2065 | let runs = self | |
| 2066 | .db | |
| 2067 | .prepare("SELECT * FROM runs WHERE repo_id = ? AND status != 'completed'") | |
| 2068 | .bind(&[repo_id.into()])? | |
| 2069 | .all() | |
| 2070 | .await? | |
| 2071 | .results::<RunRow>()?; | |
| 2072 | for run in runs { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2073 | self.cancel_run(&run, "The repository was archived or deleted.", true).await?; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2074 | } |
| 2075 | Ok(()) | |
| 2076 | } | |
| 2077 | ||
| 2078 | pub async fn cancel(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> { | |
| 2079 | if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? { | |
| 2080 | return Ok(Outcome::Fail(refused)); | |
| 2081 | } | |
| 2082 | let run = check!(self.run_in(&a.repo, &a.id).await?); | |
| 2083 | if run.status == "completed" { | |
| 2084 | return Ok(fail(FailureCode::Conflict, "The run has already finished.")); | |
| 2085 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2086 | // Cancelling a run that is already cancelling stops its jobs |
| 2087 | // outright, without waiting for their cleanup steps. | |
| 2088 | let force = a.force || run.conclusion.as_deref() == Some("cancelled"); | |
| 2089 | let reason = if force { | |
| 2090 | format!("{} stopped the run without waiting for its cleanup steps.", a.actor.username) | |
| 2091 | } else { | |
| 2092 | format!("{} cancelled the run.", a.actor.username) | |
| 2093 | }; | |
| 2094 | self.cancel_run(&run, &reason, force).await?; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2095 | self.run_summary(&run.id).await |
| 2096 | } | |
| 2097 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2098 | /// Runs again, as a new attempt: every job, with `failed_only` those |
| 2099 | /// that did not succeed, or with `job` that one; each with the jobs that | |
| 2100 | /// need them. The attempt that ends is kept, its jobs and their logs and | |
| 2101 | /// summaries with it (`job_attempts`, `run_attempts`). | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2102 | pub async fn rerun(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> { |
| 2103 | if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? { | |
| 2104 | return Ok(Outcome::Fail(refused)); | |
| 2105 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2106 | // A job alone (`POST …/jobs/{job}/rerun`) names its run. |
| 2107 | let run_id = match (&a.job, a.id.is_empty()) { | |
| 2108 | (Some(job), true) => { | |
| 2109 | #[derive(Deserialize)] | |
| 2110 | struct Of { | |
| 2111 | run_id: String, | |
| 2112 | } | |
| 2113 | let of = self.db.prepare("SELECT run_id FROM jobs WHERE id = ?").bind(&[job.as_str().into()])?.first::<Of>(None).await?; | |
| 2114 | match of { | |
| 2115 | Some(of) => of.run_id, | |
| 2116 | None => return Ok(fail(FailureCode::NotFound, "No such job.")), | |
| 2117 | } | |
| 2118 | } | |
| 2119 | _ => a.id.clone(), | |
| 2120 | }; | |
| 2121 | let run = check!(self.run_in(&a.repo, &run_id).await?); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2122 | if run.status != "completed" { |
| 2123 | return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first.")); | |
| 2124 | } | |
| 2125 | // Nothing starts again on an archived repository. | |
| 2126 | match self.visible_repo(&a.repo, &Some(a.actor.clone())).await? { | |
| 2127 | Some(repo) if repo.archived() => { | |
| 2128 | return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name))); | |
| 2129 | } | |
| 2130 | Some(_) => {} | |
| 2131 | None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")), | |
| 2132 | } | |
| 2133 | if run.error.is_some() { | |
| 2134 | return Ok(fail(FailureCode::Conflict, "This run never started: fix the workflow file and push again.")); | |
| 2135 | } | |
| 2136 | let jobs = self.job_rows(&run.id).await?; | |
| 2137 | let workflow = workflow::parse(&run.source).ok(); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2138 | let target = match &a.job { |
| 2139 | Some(id) => match jobs.iter().find(|job| &job.id == id) { | |
| 2140 | Some(job) => Some(top_key(&job.key).to_owned()), | |
| 2141 | None => return Ok(fail(FailureCode::NotFound, "That job is not in the run's latest attempt.")), | |
| 2142 | }, | |
| 2143 | None => None, | |
| 2144 | }; | |
| 2145 | let which = match (&target, a.failed_only) { | |
| 2146 | (Some(key), _) => Rerun::Job(key), | |
| 2147 | (None, true) => Rerun::Failed, | |
| 2148 | (None, false) => Rerun::All, | |
| 2149 | }; | |
| 2150 | let order = workflow.as_ref().map(|w| w.job_order()).unwrap_or_default(); | |
| 2151 | let again = rerun_keys( | |
| 2152 | &order, | |
| 2153 | |key| jobs.iter().find(|j| j.key == key).map(JobRow::needs).unwrap_or_default(), | |
| 2154 | |key| jobs.iter().filter(|j| j.key == key).all(|j| j.conclusion.as_deref() == Some("success")), | |
| 2155 | which, | |
| 2156 | ); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2157 | if again.is_empty() { |
| 2158 | return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again.")); | |
| 2159 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2160 | // The jobs that run again, a called workflow's with the job calling it. |
| 2161 | let rerun_ids: Vec<&str> = jobs.iter().filter(|job| again.iter().any(|key| key == top_key(&job.key))).map(|job| job.id.as_str()).collect(); | |
| 2162 | let ids = serde_json::to_string(&rerun_ids)?; | |
| 2163 | let ended = run.attempt.to_string(); | |
| 2164 | let ended = ended.as_str(); | |
| 2165 | let mut statements = vec![ | |
| 2166 | // The attempt that ends, as it ended. | |
| 2167 | self.db | |
| 2168 | .prepare( | |
| 2169 | "INSERT OR REPLACE INTO run_attempts (run_id, attempt, repo_id, conclusion, actor, debug, started_at, finished_at) | |
| 2170 | SELECT id, attempt, repo_id, conclusion, COALESCE(triggering_actor, actor), debug, started_at, finished_at FROM runs WHERE id = ?", | |
| 2171 | ) | |
| 2172 | .bind(&[run.id.as_str().into()])?, | |
| 2173 | // Earlier attempts that showed a job's logs from where it ran | |
| 2174 | // then now find them where they move to. | |
| 2175 | self.db | |
| 2176 | .prepare("UPDATE job_attempts SET log_id = log_id || '.' || ?1 WHERE run_id = ?2 AND log_id IN (SELECT value FROM json_each(?3))") | |
| 2177 | .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?, | |
| 2178 | // Each of its jobs: one that runs again keeps its logs under | |
| 2179 | // `{id}.{attempt}`; one left alone is still the live job's. | |
| 2180 | self.db | |
| 2181 | .prepare( | |
| 2182 | "INSERT OR REPLACE INTO job_attempts (id, run_id, repo_id, attempt, job_id, log_id, key, ordinal, name, needs, status, conclusion, | |
| 2183 | steps, annotations, reason, environment, labels, runner_name, started_at, finished_at) | |
| 2184 | SELECT id || '.' || ?1, run_id, repo_id, ?1, id, | |
| 2185 | CASE WHEN id IN (SELECT value FROM json_each(?3)) THEN id || '.' || ?1 ELSE id END, | |
| 2186 | key, ordinal, name, needs, status, conclusion, steps, annotations, reason, environment, labels, runner_name, started_at, finished_at | |
| 2187 | FROM jobs WHERE run_id = ?2 ORDER BY rowid", | |
| 2188 | ) | |
| 2189 | .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?, | |
| 2190 | self.db | |
| 2191 | .prepare("UPDATE logs SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))") | |
| 2192 | .bind(&[ended.into(), ids.as_str().into()])?, | |
| 2193 | self.db | |
| 2194 | .prepare("UPDATE job_summaries SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))") | |
| 2195 | .bind(&[ended.into(), ids.as_str().into()])?, | |
| 2196 | ]; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2197 | for key in &again { |
| 2198 | statements.push(self.db.prepare("DELETE FROM jobs WHERE run_id = ? AND key = ? AND ordinal > 0").bind(&[run.id.as_str().into(), key.as_str().into()])?); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2199 | // The jobs of a workflow it called (each matrix combination's |
| 2200 | // under `key[i]/`) are made again when it calls it again. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2201 | statements.push( |
| 2202 | self.db | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2203 | .prepare("DELETE FROM jobs WHERE run_id = ? AND (key LIKE ? OR key LIKE ?)") |
| 2204 | .bind(&[run.id.as_str().into(), format!("{key}/%").into(), format!("{key}[%").into()])?, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2205 | ); |
| 2206 | statements.push( | |
| 2207 | self.db | |
| 2208 | .prepare( | |
| 2209 | "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL, | |
| 2210 | matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2211 | labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL, |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2212 | concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2213 | ) |
| 2214 | .bind(&[run.id.as_str().into(), key.as_str().into()])?, | |
| 2215 | ); | |
| 2216 | } | |
| 2217 | statements.push( | |
| 2218 | self.db | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2219 | .prepare( |
| 2220 | "UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL, | |
| 2221 | triggering_actor = ?, debug = ? WHERE id = ?", | |
| 2222 | ) | |
| 2223 | .bind(&[a.actor.username.as_str().into(), u32::from(a.debug).into(), run.id.as_str().into()])?, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2224 | ); |
| 2225 | self.db.batch(statements).await?; | |
| 2226 | if let Some(run) = self.run_row(&run.id).await? { | |
| 2227 | self.report_pending(&run).await?; | |
| 2228 | } | |
| 2229 | self.advance(&run.id).await?; | |
| Merge the run page fixes: Cancel run shows on every view of a run that is still going, including an earlier attempt's and job links, and pages refresh when a run is re-run from anywhere; a job's log follows to its true end, above anything covering the bottom of the window, and Jump to latest lands there. | 2230 | // A page left open on the attempt that ended, which asks for nothing |
| 2231 | // while its run is over, hears that it is going again: re-run from | |
| 2232 | // the API, the MCP tool, a pull request or another tab, as well as | |
| 2233 | // from that page, it offers Cancel run and not the re-runs. | |
| 2234 | self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2235 | self.run_summary(&run.id).await |
| 2236 | } | |
| 2237 | ||
| 2238 | pub async fn run_in(&self, repo: &RepoPath, id: &str) -> Result<Outcome<RunRow>> { | |
| 2239 | let row = self | |
| 2240 | .db | |
| 2241 | .prepare("SELECT * FROM runs WHERE id = ? AND lower(repo) = lower(?)") | |
| 2242 | .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])? | |
| 2243 | .first::<RunRow>(None) | |
| 2244 | .await?; | |
| 2245 | Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such run."), Outcome::Ok)) | |
| 2246 | } | |
| 2247 | ||
| 2248 | // --- The sandbox's side ----------------------------------------------------- | |
| 2249 | ||
| 2250 | pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> { | |
| 2251 | let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?; | |
| 2252 | Ok(match job { | |
| 2253 | Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => { | |
| 2254 | Outcome::Ok(job) | |
| 2255 | } | |
| 2256 | _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."), | |
| 2257 | }) | |
| 2258 | } | |
| 2259 | ||
| 2260 | /// `job_auth`: which run and repository a running job's token is for, | |
| 2261 | /// so the API can keep its artifacts and cache. | |
| 2262 | pub async fn job_auth(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2263 | let job = check!(self.job_for_token(&a).await?); | |
| 2264 | Ok(Outcome::Ok(json!({ "run": job.run_id, "repoId": job.repo_id }))) | |
| 2265 | } | |
| 2266 | ||
| 2267 | /// `job_spec`: everything the sandbox needs to run the job. | |
| 2268 | pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2269 | let job = check!(self.job_for_token(&a).await?); | |
| 2270 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 2271 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 2272 | }; | |
| 2273 | let Ok(caller) = workflow::parse(&run.source) else { | |
| 2274 | return Ok(fail(FailureCode::Invalid, "The workflow no longer reads.")); | |
| 2275 | }; | |
| 2276 | // A called workflow's job runs as that workflow defines it. | |
| 2277 | let callee = job.callee(); | |
| 2278 | let (workflow, spec, call_inputs) = match callee { | |
| 2279 | Some((called, spec, call)) => (called, spec, Some(call["inputs"].clone())), | |
| 2280 | None => match caller.jobs.iter().find(|j| j.id == job.key) { | |
| 2281 | Some(spec) => (caller.clone(), spec.clone(), None), | |
| 2282 | None => return Ok(fail(FailureCode::NotFound, "The job is not in the workflow.")), | |
| 2283 | }, | |
| 2284 | }; | |
| 2285 | let spec = &spec; | |
| 2286 | let repo = repo_path(&run.repo); | |
| 2287 | let trusted = run.trusted != 0; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2288 | // The job's `environment:`, by name, as read when its needs were done |
| 2289 | // (an expression included), once the environment's protection rules | |
| 2290 | // let it start: entries with a value for it give that value instead | |
| 2291 | // of their default, as GitHub's environment secrets do. | |
| 2292 | let environment: Option<String> = job.environment.clone(); | |
| 2293 | // What its token may do: its `permissions:` (a called workflow's | |
| 2294 | // jobs no more than the job that calls it), else the repository's | |
| 2295 | // default; read-only for a pull request from outside. | |
| 2296 | // The repository's default, its workspace's taken in, and whether | |
| 2297 | // its jobs may open and approve pull requests. | |
| 2298 | let (default, pull_requests) = self.token_policy(&run.repo_id).await?; | |
| 2299 | let mut permissions = spec.permissions(&workflow, default); | |
| 2300 | if let Some(parent) = &job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned)) { | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2301 | let top = top_key(parent); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2302 | if let Some(caller_job) = caller.jobs.iter().find(|j| j.id == top) { |
| 2303 | permissions = permissions.capped_by(&caller_job.permissions(&caller, default)); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2304 | } |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2305 | } |
| 2306 | if !trusted { | |
| 2307 | permissions = permissions.read_only(); | |
| 2308 | } | |
| 2309 | // G1T_TOKEN, and GITHUB_TOKEN as its alias: a token of the | |
| 2310 | // workspace's that reaches this repository only, with the scopes | |
| 2311 | // its permissions give, until the job ends. | |
| 2312 | let token = match self.workspace_actor(&repo.namespace).await? { | |
| 2313 | Some(workspace) => { | |
| 2314 | let created: CreatedAccessToken = g1t_kit::call( | |
| 2315 | &self.identity, | |
| 2316 | "create_job_token", | |
| 2317 | &CreateJobTokenArgs { | |
| 2318 | workspace, | |
| 2319 | repo: repo.clone(), | |
| 2320 | run_id: run.id.clone(), | |
| 2321 | job_id: job.id.clone(), | |
| 2322 | name: format!("G1T_TOKEN for {} run {}", run.repo, run.number), | |
| 2323 | ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600, | |
| 2324 | scopes: permissions.scopes().into_iter().map(str::to_owned).collect(), | |
| 2325 | pull_requests: pull_requests && trusted, | |
| 2326 | }, | |
| 2327 | ) | |
| 2328 | .await?; | |
| 2329 | created.token | |
| 2330 | } | |
| 2331 | None => String::new(), | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2332 | }; |
| 2333 | // A run that is not trusted (a pull request from outside the | |
| 2334 | // workspace) gets no secrets and an empty token. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 2335 | let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); |
| 2336 | let mut secrets = match (trusted, passed) { | |
| 2337 | (false, _) => Map::new(), | |
| 2338 | (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?, | |
| 2339 | // A called workflow's job: what its callers passed it (reach.rs), | |
| 2340 | // and its own environment's secrets over them. | |
| 2341 | (true, Some(plan)) => { | |
| 2342 | let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2343 | let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2344 | let github = run.info().context(&job.key, "", run.action.as_deref()); | |
| 2345 | let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars); | |
| 2346 | if let Some(name) = environment.as_deref() { | |
| 2347 | let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?; | |
| 2348 | for (key, value) in own { | |
| 2349 | if base.get(&key) != Some(&value) { | |
| 2350 | passed.insert(key, value); | |
| 2351 | } | |
| 2352 | } | |
| 2353 | } | |
| 2354 | passed | |
| 2355 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2356 | }; |
| 2357 | secrets.insert("G1T_TOKEN".into(), Value::String(token.clone())); | |
| 2358 | secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone())); | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2359 | // Each secret as it is, a line at a time, base64 and JSON-escaped. |
| 2360 | let mut masks: Vec<String> = g1t_actions::mask::all_variants(secrets.values().filter_map(|v| v.as_str())); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2361 | let vars = self.variables_for(&run.repo_id, &run.repo, environment.as_deref(), trusted).await?; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2362 | // The toolkit's runtime token (runtime.rs), for as long as the job |
| 2363 | // may run; the API puts it and the toolkit's addresses in the | |
| 2364 | // job's variables. | |
| 2365 | let runtime_token = crate::runtime::runtime_token( | |
| 2366 | &job.id, | |
| 2367 | &job.run_id, | |
| 2368 | job.token_hash.as_deref().unwrap_or_default(), | |
| 2369 | now_ms() / 1000, | |
| 2370 | u64::from(job.timeout_minutes) * 60 + 600, | |
| 2371 | ); | |
| 2372 | masks.push(runtime_token.clone()); | |
| 2373 | let retention_days = self.retention_setting(&run.repo_id).await?; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2374 | |
| 2375 | let jobs = self.job_rows(&run.id).await?; | |
| 2376 | let mut needs = Map::new(); | |
| 2377 | // In a called workflow, its jobs' keys sit under the job that called it. | |
| 2378 | let parent = job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned)); | |
| 2379 | for need in &spec.needs { | |
| 2380 | let key = match &parent { | |
| 2381 | Some(parent) => format!("{parent}/{need}"), | |
| 2382 | None => need.clone(), | |
| 2383 | }; | |
| 2384 | let rows: Vec<&JobRow> = jobs.iter().filter(|row| row.key == key).collect(); | |
| 2385 | let mut outputs = Map::new(); | |
| 2386 | for row in &rows { | |
| 2387 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) { | |
| 2388 | outputs.extend(more); | |
| 2389 | } | |
| 2390 | } | |
| 2391 | needs.insert(need.clone(), json!({ "result": key_result(&rows), "outputs": outputs })); | |
| 2392 | } | |
| 2393 | let siblings = jobs.iter().filter(|row| row.key == job.key).count(); | |
| 2394 | let matrix: Value = job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({})); | |
| 2395 | let info = run.info(); | |
| 2396 | let mut github = info.context(&job.key, &token, run.action.as_deref()); | |
| 2397 | github["token"] = json!(token); | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2398 | github["retention_days"] = json!(retention_days); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2399 | // On a self-hosted runner, `runner` and `RUNNER_*` describe that |
| 2400 | // machine rather than g1t's sandbox. | |
| 2401 | let mut variables = info.variables(&job.key); | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2402 | variables.insert("GITHUB_RETENTION_DAYS".into(), json!(retention_days.to_string())); |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2403 | let mut runner = match &job.runner_id { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2404 | Some(id) => self.runner_context_for(id, &mut variables).await?, |
| 2405 | None => runner_context(), | |
| 2406 | }; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2407 | // A re-run with debug logging: what GitHub sets for one. |
| 2408 | if run.debug != 0 { | |
| 2409 | debug_logging(&mut variables, &mut runner); | |
| 2410 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2411 | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2412 | // Where to check out: the repository, which holds a pull request's |
| 2413 | // test merge commit; a pull request's fork when the run builds its | |
| 2414 | // head instead (`run.sha` is the head, `info.sha` what is built). | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2415 | let clone_url = match run.pull { |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2416 | Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" && info.sha == run.sha => { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2417 | let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call( |
| 2418 | &self.work, | |
| 2419 | "get_pull", | |
| 2420 | &g1t_contracts::work::ViewArgs { | |
| 2421 | repo: repo.clone(), | |
| 2422 | number, | |
| 2423 | viewer: self.workspace_actor(&repo.namespace).await?, | |
| 2424 | after_seq: 0, | |
| 2425 | }, | |
| 2426 | ) | |
| 2427 | .await?; | |
| 2428 | match located { | |
| 2429 | Outcome::Ok(detail) => match detail.pull.fork { | |
| 2430 | Some(fork) => format!("{SITE}/{}/{}.git", fork.namespace, fork.name), | |
| 2431 | None => format!("{SITE}/{}.git", run.repo), | |
| 2432 | }, | |
| 2433 | Outcome::Fail(_) => format!("{SITE}/{}.git", run.repo), | |
| 2434 | } | |
| 2435 | } | |
| 2436 | _ => format!("{SITE}/{}.git", run.repo), | |
| 2437 | }; | |
| 2438 | ||
| 2439 | Ok(Outcome::Ok(json!({ | |
| 2440 | "job": job.id, | |
| 2441 | "run": run.id, | |
| 2442 | "key": job.key, | |
| 2443 | "name": job.name, | |
| 2444 | "spec": spec.raw, | |
| 2445 | "workflow": { | |
| 2446 | "env": workflow.env, | |
| 2447 | "defaults": workflow.raw.get("defaults").cloned().unwrap_or(Value::Null), | |
| 2448 | }, | |
| 2449 | "github": github, | |
| 2450 | "variables": variables, | |
| 2451 | "event": info.event, | |
| 2452 | "contexts": { | |
| 2453 | "vars": vars, | |
| 2454 | "secrets": secrets, | |
| 2455 | "inputs": call_inputs.unwrap_or_else(|| Value::Object(run.inputs())), | |
| 2456 | "matrix": matrix, | |
| 2457 | "needs": needs, | |
| 2458 | "strategy": { | |
| 2459 | "fail-fast": spec.fail_fast, | |
| 2460 | "job-index": job.ordinal, | |
| 2461 | "job-total": siblings, | |
| 2462 | "max-parallel": spec.max_parallel.unwrap_or(siblings as u32), | |
| 2463 | }, | |
| 2464 | "runner": runner, | |
| 2465 | }, | |
| 2466 | "checkout": { | |
| 2467 | "repository": run.repo, | |
| 2468 | "url": clone_url, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2469 | "sha": info.sha, |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2470 | "ref": run.git_ref, |
| 2471 | "token": token, | |
| 2472 | }, | |
| 2473 | "timeoutMinutes": job.timeout_minutes, | |
| 2474 | "masks": masks, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2475 | // As the job's log lists them at its start. |
| 2476 | "permissions": permissions.listed().into_iter().map(|(name, access)| (name.to_owned(), json!(access.as_str()))).collect::<Map<String, Value>>(), | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2477 | // Whether the job may ask for an OIDC token decides whether it |
| 2478 | // is told where to. | |
| 2479 | "runtime": { | |
| 2480 | "token": runtime_token, | |
| 2481 | "idToken": self.oidc_allowed(&run, &job), | |
| 2482 | }, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2483 | }))) |
| 2484 | } | |
| 2485 | ||
| 2486 | /// `job_report`: the sandbox telling how the job is going. | |
| 2487 | pub async fn job_report(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2488 | let job = check!(self.job_for_token(&a).await?); | |
| 2489 | let report = &a.report; | |
| 2490 | let at = now(); | |
| 2491 | match report["kind"].as_str().unwrap_or_default() { | |
| 2492 | "steps" => { | |
| 2493 | // The list can grow as the job goes (post steps), so steps | |
| 2494 | // already reported keep where they stand. | |
| 2495 | let known: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 2496 | let steps: Vec<Value> = report["steps"] | |
| 2497 | .as_array() | |
| 2498 | .map(|names| { | |
| 2499 | names | |
| 2500 | .iter() | |
| 2501 | .enumerate() | |
| 2502 | .map(|(i, name)| match known.get(i) { | |
| 2503 | Some(step) if step["status"] != "queued" => step.clone(), | |
| 2504 | _ => json!({ "number": i + 1, "name": expr::to_text(name), "status": "queued", "conclusion": null, "startedAt": null, "finishedAt": null }), | |
| 2505 | }) | |
| 2506 | .collect() | |
| 2507 | }) | |
| 2508 | .unwrap_or_default(); | |
| 2509 | self.db | |
| 2510 | .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?") | |
| 2511 | .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2512 | .run() | |
| 2513 | .await?; | |
| 2514 | } | |
| 2515 | "step" => { | |
| 2516 | let number = report["number"].as_u64().unwrap_or(0) as usize; | |
| 2517 | let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 2518 | if let Some(step) = number.checked_sub(1).and_then(|i| steps.get_mut(i)) { | |
| 2519 | let status = report["status"].as_str().unwrap_or("in_progress"); | |
| 2520 | step["status"] = json!(status); | |
| 2521 | if status == "in_progress" { | |
| 2522 | step["startedAt"] = json!(at); | |
| 2523 | } | |
| 2524 | if status == "completed" { | |
| 2525 | step["finishedAt"] = json!(at); | |
| 2526 | step["conclusion"] = report["conclusion"].clone(); | |
| 2527 | } | |
| 2528 | if let Some(name) = report["name"].as_str() { | |
| 2529 | step["name"] = json!(name); | |
| 2530 | } | |
| 2531 | } | |
| 2532 | self.db | |
| 2533 | .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?") | |
| 2534 | .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2535 | .run() | |
| 2536 | .await?; | |
| 2537 | } | |
| 2538 | "log" => { | |
| 2539 | let mut text = report["text"].as_str().unwrap_or_default().to_owned(); | |
| 2540 | if text.len() > MAX_CHUNK_BYTES { | |
| 2541 | let mut cut = MAX_CHUNK_BYTES; | |
| 2542 | while !text.is_char_boundary(cut) { | |
| 2543 | cut -= 1; | |
| 2544 | } | |
| 2545 | text.truncate(cut); | |
| 2546 | } | |
| 2547 | #[derive(Deserialize)] | |
| 2548 | struct Size { | |
| 2549 | n: Option<f64>, | |
| 2550 | } | |
| 2551 | let size = self | |
| 2552 | .db | |
| A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so. | 2553 | .prepare("SELECT SUM(LENGTH(text)) AS n FROM logs WHERE job_id = ?") |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2554 | .bind(&[job.id.as_str().into()])? |
| 2555 | .first::<Size>(None) | |
| 2556 | .await?; | |
| A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so. | 2557 | let used = size.and_then(|s| s.n).unwrap_or(0.0) as usize; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2558 | if used < MAX_LOG_BYTES { |
| 2559 | if used + text.len() >= MAX_LOG_BYTES { | |
| 2560 | text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n"); | |
| 2561 | } | |
| A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so. | 2562 | // The next `seq` is taken in the insert itself, so two |
| 2563 | // batches at once (a report sent again while the first | |
| 2564 | // was still being written) never reach for the same one | |
| 2565 | // and lose a chunk to the primary key. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2566 | self.db |
| A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so. | 2567 | .prepare(LOG_INSERT) |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2568 | .bind(&[ |
| 2569 | job.id.as_str().into(), | |
| 2570 | (report["step"].as_u64().unwrap_or(0) as u32).into(), | |
| 2571 | text.into(), | |
| 2572 | report["chunk"].as_u64().map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()), | |
| 2573 | ])? | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2574 | .run() |
| 2575 | .await?; | |
| 2576 | } | |
| 2577 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2578 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2579 | "summary" => { |
| 2580 | // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the | |
| 2581 | // step's summary, up to 1 MiB a step and 20 steps a job. | |
| 2582 | let step = report["step"].as_u64().unwrap_or(0) as u32; | |
| 2583 | let markdown = report["markdown"].as_str().unwrap_or_default(); | |
| 2584 | #[derive(Deserialize)] | |
| 2585 | struct Held { | |
| 2586 | steps: u32, | |
| 2587 | mine: Option<f64>, | |
| 2588 | } | |
| 2589 | let held = self | |
| 2590 | .db | |
| 2591 | .prepare("SELECT COUNT(*) AS steps, MAX(CASE WHEN step = ? THEN LENGTH(markdown) END) AS mine FROM job_summaries WHERE job_id = ?") | |
| 2592 | .bind(&[step.into(), job.id.as_str().into()])? | |
| 2593 | .first::<Held>(None) | |
| 2594 | .await?; | |
| 2595 | let (steps, mine) = held.map_or((0, None), |held| (held.steps, held.mine.map(|n| n as usize))); | |
| 2596 | if summary_fits(steps, mine, markdown.len()) { | |
| 2597 | self.db | |
| 2598 | .prepare( | |
| 2599 | "INSERT INTO job_summaries (job_id, step, markdown) VALUES (?, ?, ?) | |
| 2600 | ON CONFLICT (job_id, step) DO UPDATE SET markdown = job_summaries.markdown || excluded.markdown", | |
| 2601 | ) | |
| 2602 | .bind(&[job.id.as_str().into(), step.into(), markdown.into()])? | |
| 2603 | .run() | |
| 2604 | .await?; | |
| 2605 | } | |
| 2606 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2607 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2608 | "annotation" => { |
| 2609 | let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default(); | |
| 2610 | if annotations.len() < MAX_ANNOTATIONS { | |
| 2611 | annotations.push(json!({ | |
| 2612 | "level": report["level"].as_str().unwrap_or("notice"), | |
| 2613 | "message": report["message"].as_str().unwrap_or_default().chars().take(4000).collect::<String>(), | |
| 2614 | "title": report["title"], | |
| 2615 | "file": report["file"], | |
| 2616 | "line": report["line"], | |
| 2617 | })); | |
| 2618 | self.db | |
| 2619 | .prepare("UPDATE jobs SET annotations = ?, seen_at = ? WHERE id = ?") | |
| 2620 | .bind(&[serde_json::to_string(&annotations)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2621 | .run() | |
| 2622 | .await?; | |
| 2623 | } | |
| 2624 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2625 | // Nothing to tell; the answer says whether to stop. |
| 2626 | "ping" => { | |
| 2627 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2628 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2629 | "done" => { |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2630 | // A job told to stop ends cancelled, however its cleanup went. |
| 2631 | let conclusion = if job.cancel_requested_at.is_some() { | |
| 2632 | "cancelled" | |
| 2633 | } else { | |
| 2634 | report["conclusion"] | |
| 2635 | .as_str() | |
| 2636 | .filter(|c| matches!(*c, "success" | "failure" | "cancelled")) | |
| 2637 | .unwrap_or("failure") | |
| 2638 | }; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2639 | let outputs = report["outputs"].as_object().cloned(); |
| 2640 | Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?; | |
| 2641 | } | |
| 2642 | other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))), | |
| 2643 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2644 | // `cancelled`: the run was cancelled, and the runner should stop |
| 2645 | // the step it is on and run only its cleanup steps. | |
| 2646 | Ok(Outcome::Ok(json!({ "ok": true, "cancelled": job.cancel_requested_at.is_some() }))) | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2647 | } |
| 2648 | ||
| 2649 | // --- Every minute --------------------------------------------------------------- | |
| 2650 | ||
| 2651 | pub async fn on_minute(&self, now_ms: u64) -> Result<()> { | |
| 2652 | let minute = now_ms / 60_000 * 60_000; | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 2653 | // Repositories a workspace's managed workflows changed in, beyond |
| 2654 | // those synced at once (managed.rs): their schedules wait for it. | |
| 2655 | if let Err(error) = self.catch_up_syncs().await { | |
| 2656 | worker::console_error!("actions: catching up syncs failed: {error}"); | |
| 2657 | } | |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 2658 | // Staff (or billing's usage watcher) paused scheduled runs across |
| 2659 | // g1t: this minute's schedules are skipped, not queued for later. | |
| 2660 | // Kept 30 seconds in the isolate (g1t_kit::pause). | |
| 2661 | if g1t_kit::pause::paused(&self.billing, g1t_contracts::billing::PauseLevel::Schedules).await { | |
| 2662 | worker::console_log!("actions: schedules are paused across g1t; skipped this minute's"); | |
| 2663 | } else if let Err(error) = self.run_schedules(minute).await { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2664 | worker::console_error!("actions: schedules failed: {error}"); |
| 2665 | } | |
| 2666 | // Jobs whose sandbox went quiet or ran past their time. | |
| 2667 | let running = self.db.prepare("SELECT * FROM jobs WHERE status = 'in_progress'").all().await?.results::<JobRow>()?; | |
| 2668 | for job in running { | |
| 2669 | // Times in g1t's format compare as text. | |
| 2670 | let before = |ms: u64| rfc3339(now_ms.saturating_sub(ms)); | |
| 2671 | let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str()); | |
| 2672 | let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000; | |
| 2673 | let over = job.started_at.as_deref().is_some_and(|started| started < before(limit).as_str()); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2674 | let cancel_overdue = job.cancel_requested_at.as_deref().is_some_and(|at| at < before(CANCEL_GRACE_MS).as_str()); |
| 2675 | if cancel_overdue { | |
| 2676 | // Cancelled, and still going after its grace period. | |
| 2677 | self.hard_stop(&job, "It was cancelled, and did not finish its cleanup steps within 5 minutes.").await?; | |
| 2678 | self.advance(&job.run_id).await?; | |
| 2679 | } else if over { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2680 | let reason = format!("It ran longer than its time limit of {} minutes.", job.timeout_minutes); |
| 2681 | // A self-hosted runner is told to stop on its next poll. | |
| 2682 | if job.runner_id.is_none() { | |
| 2683 | let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await; | |
| 2684 | } | |
| 2685 | self.finish_job(&job.id, "failure", Some(&reason), None).await?; | |
| 2686 | } else if silent { | |
| 2687 | let reason = match &job.runner_name { | |
| 2688 | Some(name) => format!("The self-hosted runner {name} stopped answering."), | |
| 2689 | None => "The runner stopped answering.".to_owned(), | |
| 2690 | }; | |
| 2691 | self.finish_job(&job.id, "failure", Some(&reason), None).await?; | |
| 2692 | } | |
| 2693 | } | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2694 | // Calls of reusable workflows waiting in a concurrency group, in |
| 2695 | // case the job they waited for was cancelled in another run. | |
| 2696 | #[derive(Deserialize)] | |
| 2697 | struct RunId { | |
| 2698 | run_id: String, | |
| 2699 | } | |
| 2700 | let held = self | |
| 2701 | .db | |
| 2702 | .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'waiting' AND concurrency_group IS NOT NULL LIMIT 20") | |
| 2703 | .all() | |
| 2704 | .await? | |
| 2705 | .results::<RunId>()?; | |
| 2706 | for run in held { | |
| 2707 | self.advance(&run.run_id).await?; | |
| 2708 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2709 | if let Err(error) = self.sweep_runners(now_ms).await { |
| 2710 | worker::console_error!("actions: the runners' sweep failed: {error}"); | |
| 2711 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2712 | // Jobs held at an environment whose wait timer has run out. |
| 2713 | if let Err(error) = self.release_gates().await { | |
| 2714 | worker::console_error!("actions: environments' gates failed: {error}"); | |
| 2715 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2716 | // Once an hour: the cache's expired entries, and its storage. |
| 2717 | if (now_ms / 60_000) % 60 == 7 | |
| 2718 | && let Err(error) = self.sweep_cache(now_ms).await | |
| 2719 | { | |
| 2720 | worker::console_error!("actions: the cache's sweep failed: {error}"); | |
| 2721 | } | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 2722 | // And the archives of GitHub's actions no job uses any more. |
| 2723 | if (now_ms / 60_000) % 60 == 47 | |
| 2724 | && let Err(error) = self.sweep_action_archives(now_ms).await | |
| 2725 | { | |
| 2726 | worker::console_error!("actions: the action archives' sweep failed: {error}"); | |
| 2727 | } | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2728 | // And artifacts past their time, and the toolkit's abandoned parts. |
| 2729 | if (now_ms / 60_000) % 60 == 37 { | |
| 2730 | if let Err(error) = self.sweep_artifacts(now_ms).await { | |
| 2731 | worker::console_error!("actions: the artifacts' sweep failed: {error}"); | |
| 2732 | } | |
| 2733 | if let Err(error) = self.sweep_blob_parts(now_ms).await { | |
| 2734 | worker::console_error!("actions: the blob parts' sweep failed: {error}"); | |
| 2735 | } | |
| 2736 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2737 | self.start_queued().await |
| 2738 | } | |
| 2739 | } | |
| 2740 | ||
| 2741 | ||
| 2742 | #[cfg(test)] | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2743 | mod log_chunks { |
| 2744 | use rusqlite::{Connection, params}; | |
| 2745 | ||
| 2746 | use super::LOG_INSERT; | |
| 2747 | ||
| 2748 | /// The logs table as the migrations leave it. | |
| 2749 | fn database() -> Connection { | |
| 2750 | let db = Connection::open_in_memory().unwrap(); | |
| 2751 | for name in ["0001_init.sql", "0016_log_chunks.sql"] { | |
| 2752 | let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR")); | |
| 2753 | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 2754 | } | |
| 2755 | db | |
| 2756 | } | |
| 2757 | ||
| 2758 | fn log(db: &Connection, job: &str) -> Vec<(i64, i64, String)> { | |
| 2759 | let mut rows = db.prepare("SELECT seq, step, text FROM logs WHERE job_id = ? ORDER BY seq").unwrap(); | |
| 2760 | rows.query_map([job], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect() | |
| 2761 | } | |
| 2762 | ||
| 2763 | #[test] | |
| 2764 | fn a_batch_sent_again_is_kept_once() { | |
| 2765 | let db = database(); | |
| 2766 | let insert = |job: &str, step: i64, text: &str, chunk: Option<i64>| db.execute(LOG_INSERT, params![job, step, text, chunk]).unwrap(); | |
| 2767 | assert_eq!(insert("job_1", 0, "one\n", Some(1)), 1); | |
| 2768 | // The runner did not hear the answer and sent the batch again. | |
| 2769 | assert_eq!(insert("job_1", 0, "one\n", Some(1)), 0); | |
| 2770 | assert_eq!(insert("job_1", 1, "two\n", Some(2)), 1); | |
| 2771 | // Another job numbers its batches from 1 too. | |
| 2772 | assert_eq!(insert("job_2", 0, "other\n", Some(1)), 1); | |
| 2773 | // A runner that numbers nothing has every batch kept, as before. | |
| 2774 | assert_eq!(insert("job_3", 0, "a\n", None), 1); | |
| 2775 | assert_eq!(insert("job_3", 0, "a\n", None), 1); | |
| 2776 | assert_eq!(log(&db, "job_1"), [(1, 0, "one\n".into()), (2, 1, "two\n".into())]); | |
| 2777 | assert_eq!(log(&db, "job_2"), [(1, 0, "other\n".into())]); | |
| 2778 | assert_eq!(log(&db, "job_3").len(), 2); | |
| 2779 | // A job run again moves its log aside (`{id}.{attempt}`), and the | |
| 2780 | // new attempt's runner numbers from 1 again. | |
| 2781 | db.execute("UPDATE logs SET job_id = job_id || '.1' WHERE job_id = 'job_1'", []).unwrap(); | |
| 2782 | assert_eq!(insert("job_1", 0, "again\n", Some(1)), 1); | |
| 2783 | assert_eq!(log(&db, "job_1"), [(1, 0, "again\n".into())]); | |
| 2784 | assert_eq!(log(&db, "job_1.1").len(), 2); | |
| 2785 | } | |
| 2786 | } | |
| 2787 | ||
| 2788 | #[cfg(test)] | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 2789 | mod stopping { |
| 2790 | use super::stops_runs; | |
| 2791 | use g1t_contracts::events::Event; | |
| 2792 | use serde_json::{Value, json}; | |
| 2793 | ||
| 2794 | fn event(kind: &str, data: Value) -> Event { | |
| 2795 | Event { | |
| 2796 | id: "evt_1".into(), | |
| 2797 | kind: kind.into(), | |
| 2798 | source: "repos".into(), | |
| 2799 | time: "2026-10-05T00:00:00Z".into(), | |
| 2800 | repo_id: Some("rep_1".into()), | |
| 2801 | actor: None, | |
| 2802 | data, | |
| 2803 | } | |
| 2804 | } | |
| 2805 | ||
| 2806 | #[test] | |
| 2807 | fn deleting_or_archiving_stops_runs() { | |
| 2808 | assert_eq!(stops_runs(&event("repo.deleted", json!({ "repoId": "rep_1" }))).as_deref(), Some("rep_1")); | |
| 2809 | assert_eq!(stops_runs(&event("repo.archived", json!({ "archived": true }))).as_deref(), Some("rep_1")); | |
| 2810 | assert_eq!(stops_runs(&event("repo.unarchived", json!({ "archived": false }))), None); | |
| 2811 | assert_eq!(stops_runs(&event("repo.restored", json!({}))), None); | |
| 2812 | assert_eq!(stops_runs(&event("git.push", json!({}))), None); | |
| 2813 | } | |
| 2814 | } | |
| 2815 | ||
| 2816 | #[cfg(test)] | |
| 2817 | mod status_of_needs { | |
| 2818 | use std::collections::HashMap; | |
| 2819 | ||
| 2820 | use super::ancestor_failed; | |
| 2821 | ||
| 2822 | /// check -> plan -> (migrate) -> core -> edge, as deploy.yml has them, | |
| 2823 | /// and a job that needs only the last. | |
| 2824 | fn graph() -> HashMap<&'static str, Vec<&'static str>> { | |
| 2825 | HashMap::from([ | |
| 2826 | ("check", vec![]), | |
| 2827 | ("plan", vec!["check"]), | |
| 2828 | ("migrate", vec!["plan"]), | |
| 2829 | ("core", vec!["plan", "migrate"]), | |
| 2830 | ("edge", vec!["plan", "migrate", "core"]), | |
| 2831 | ("notify", vec!["edge"]), | |
| 2832 | ]) | |
| 2833 | } | |
| 2834 | ||
| 2835 | #[test] | |
| 2836 | fn a_failure_is_seen_however_far_back() { | |
| 2837 | let needs = graph(); | |
| 2838 | let failed = |which: &'static str| move |key: &str| key == which; | |
| 2839 | // check failed; plan, and everything after, was skipped for it. | |
| 2840 | assert!(ancestor_failed(&needs, "notify", failed("check"))); | |
| 2841 | assert!(ancestor_failed(&needs, "core", failed("check"))); | |
| 2842 | assert!(ancestor_failed(&needs, "edge", failed("core"))); | |
| 2843 | // Nothing before a job failed: a skipped migrate is not a failure. | |
| 2844 | assert!(!ancestor_failed(&needs, "edge", |_| false)); | |
| 2845 | assert!(!ancestor_failed(&needs, "core", failed("edge"))); | |
| 2846 | assert!(!ancestor_failed(&needs, "check", failed("check"))); | |
| 2847 | } | |
| 2848 | ||
| 2849 | #[test] | |
| 2850 | fn cycles_and_unknown_keys_end() { | |
| 2851 | let needs = HashMap::from([("a", vec!["b"]), ("b", vec!["a"])]); | |
| 2852 | assert!(!ancestor_failed(&needs, "a", |_| false)); | |
| 2853 | assert!(!ancestor_failed(&needs, "missing", |_| true)); | |
| 2854 | } | |
| 2855 | } | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2856 | |
| 2857 | #[cfg(test)] | |
| 2858 | mod deployments { | |
| 2859 | use serde_json::{Map, Value, json}; | |
| 2860 | ||
| 2861 | use super::{JobEnvironment, deployment_outcome, environment_of}; | |
| 2862 | ||
| 2863 | #[test] | |
| 2864 | fn a_jobs_environment_is_read_for_deployments() { | |
| 2865 | let contexts: Map<String, Value> = serde_json::from_value(json!({ | |
| 2866 | "github": { "ref_name": "main", "repository": "acme/web" }, | |
| 2867 | "inputs": { "target": "staging" }, | |
| 2868 | "matrix": {}, | |
| 2869 | })) | |
| 2870 | .unwrap(); | |
| 2871 | let read = |raw: Value| environment_of(&raw, &contexts); | |
| 2872 | assert_eq!(read(json!({})), None); | |
| 2873 | assert_eq!( | |
| 2874 | read(json!({ "environment": "production" })), | |
| 2875 | Some(JobEnvironment { name: "production".into(), url: None, deploys: true }) | |
| 2876 | ); | |
| 2877 | assert_eq!( | |
| 2878 | read(json!({ "environment": { "name": "production", "url": "https://g1t.sh" } })), | |
| 2879 | Some(JobEnvironment { name: "production".into(), url: Some("https://g1t.sh".into()), deploys: true }) | |
| 2880 | ); | |
| 2881 | // Expressions are filled in from the run. | |
| 2882 | assert_eq!( | |
| 2883 | read(json!({ "environment": { "name": "${{ inputs.target }}", "url": "https://${{ github.ref_name }}.example.com" } })), | |
| 2884 | Some(JobEnvironment { name: "staging".into(), url: Some("https://main.example.com".into()), deploys: true }) | |
| 2885 | ); | |
| 2886 | // Secrets only: no deployment. | |
| 2887 | assert!(!read(json!({ "environment": { "name": "production", "deployment": false } })).unwrap().deploys); | |
| Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9 | 2888 | // The same, said in the job's g1t settings. |
| 2889 | assert!(!read(json!({ "environment": "production", "g1t": { "deployment": false } })).unwrap().deploys); | |
| 2890 | assert!(!read(json!({ "environment": { "name": "production" }, "g1t": { "deployment": false } })).unwrap().deploys); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2891 | // Only http(s) addresses. |
| 2892 | assert_eq!(read(json!({ "environment": { "name": "production", "url": "javascript:alert(1)" } })).unwrap().url, None); | |
| 2893 | } | |
| 2894 | ||
| 2895 | #[test] | |
| 2896 | fn a_runs_outcome_for_an_environment() { | |
| 2897 | let of = |list: &[&str]| deployment_outcome(&list.iter().map(|c| Some((*c).to_owned())).collect::<Vec<_>>()); | |
| 2898 | assert_eq!(of(&["success", "skipped"]), Some("success")); | |
| 2899 | assert_eq!(of(&["success", "failure"]), Some("failure")); | |
| 2900 | assert_eq!(of(&["success", "cancelled"]), Some("error")); | |
| 2901 | assert_eq!(of(&["skipped"]), None); | |
| 2902 | assert_eq!(deployment_outcome(&[None]), None); | |
| 2903 | } | |
| 2904 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2905 | |
| 2906 | #[cfg(test)] | |
| 2907 | mod reruns { | |
| 2908 | use serde_json::{Map, Value, json}; | |
| 2909 | ||
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2910 | use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, Scope, Status, call_allowed, call_prefix, debug_logging, rerun_keys, shown_name, summary_fits, top_key}; |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2911 | |
| 2912 | /// build ← test ← deploy, and lint on its own. | |
| 2913 | fn keys(which: Rerun, failed: &[&str]) -> Vec<String> { | |
| 2914 | let order = ["build", "lint", "test", "deploy"]; | |
| 2915 | let needs = |key: &str| -> Vec<String> { | |
| 2916 | match key { | |
| 2917 | "test" => vec!["build".into()], | |
| 2918 | "deploy" => vec!["test".into()], | |
| 2919 | _ => Vec::new(), | |
| 2920 | } | |
| 2921 | }; | |
| 2922 | rerun_keys(&order, needs, |key| !failed.contains(&key), which) | |
| 2923 | } | |
| 2924 | ||
| 2925 | #[test] | |
| 2926 | fn a_rerun_takes_the_jobs_it_names_and_those_that_need_them() { | |
| 2927 | assert_eq!(keys(Rerun::All, &[]), ["build", "lint", "test", "deploy"]); | |
| 2928 | assert_eq!(keys(Rerun::Failed, &["test"]), ["test", "deploy"]); | |
| 2929 | assert_eq!(keys(Rerun::Failed, &["lint"]), ["lint"]); | |
| 2930 | assert!(keys(Rerun::Failed, &[]).is_empty()); | |
| 2931 | // One job, whatever it came to, and what depends on it. | |
| 2932 | assert_eq!(keys(Rerun::Job("build"), &[]), ["build", "test", "deploy"]); | |
| 2933 | assert_eq!(keys(Rerun::Job("lint"), &["test"]), ["lint"]); | |
| 2934 | assert_eq!(keys(Rerun::Job("deploy"), &[]), ["deploy"]); | |
| 2935 | assert!(keys(Rerun::Job("missing"), &[]).is_empty()); | |
| 2936 | } | |
| 2937 | ||
| 2938 | #[test] | |
| 2939 | fn a_called_workflows_jobs_run_again_with_the_job_that_calls_it() { | |
| 2940 | assert_eq!(top_key("build/test"), "build"); | |
| 2941 | assert_eq!(top_key("build/inner/test"), "build"); | |
| 2942 | assert_eq!(top_key("lint"), "lint"); | |
| Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9 | 2943 | // A matrix's calls each have their own jobs, under `key[i]/`. |
| 2944 | assert_eq!(top_key("build[1]/test"), "build"); | |
| 2945 | assert_eq!(top_key("build/inner[0]/test"), "build"); | |
| 2946 | assert_eq!(call_prefix("build", Some(1)), "build[1]"); | |
| 2947 | assert_eq!(call_prefix("build/inner", None), "build/inner"); | |
| 2948 | } | |
| 2949 | ||
| 2950 | #[test] | |
| 2951 | fn a_run_connects_ten_levels_of_workflows_and_fifty_reusable_ones() { | |
| 2952 | assert!(call_allowed(1, &[], "a.yml").is_ok()); | |
| 2953 | assert!(call_allowed(9, &[], "a.yml").is_ok()); | |
| 2954 | let deep = call_allowed(10, &[], "a.yml").unwrap_err(); | |
| 2955 | assert!(deep.contains("at most ten") && deep.contains("nine levels"), "{deep}"); | |
| 2956 | let names: Vec<String> = (0..50).map(|n| format!("w{n}.yml")).collect(); | |
| 2957 | let fifty: Vec<&str> = names.iter().map(String::as_str).collect(); | |
| 2958 | // A workflow called again, as by each combination of a matrix, is one. | |
| 2959 | assert!(call_allowed(1, &fifty, "w3.yml").is_ok()); | |
| 2960 | assert!(call_allowed(1, &fifty, "another.yml").unwrap_err().contains("at most 50 different")); | |
| 2961 | } | |
| 2962 | ||
| 2963 | #[test] | |
| 2964 | fn a_matrixs_calls_are_named_by_the_caller_and_its_values() { | |
| 2965 | let workflow = super::workflow::parse( | |
| 2966 | "on: push\njobs:\n build:\n name: Build\n strategy:\n matrix: { os: [linux], node: [20] }\n uses: ./.g1t/workflows/b.yml\n plain:\n uses: ./.g1t/workflows/b.yml\n test:\n name: Test ${{ matrix.os }}\n strategy:\n matrix: { os: [linux] }\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]", | |
| 2967 | ) | |
| 2968 | .unwrap(); | |
| 2969 | let job = |id: &str| workflow.jobs.iter().find(|job| job.id == id).unwrap(); | |
| 2970 | let combination: Map<String, Value> = serde_json::from_value(json!({ "os": "linux", "node": 20 })).unwrap(); | |
| 2971 | let mut contexts = Map::new(); | |
| 2972 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 2973 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 2974 | assert_eq!(shown_name(job("build"), None, &combination, &scope), "Build (linux, 20)"); | |
| 2975 | assert_eq!(shown_name(job("plain"), None, &Map::new(), &scope), "plain"); | |
| 2976 | // A called workflow's job, under the call that made it. | |
| 2977 | assert_eq!(shown_name(job("plain"), Some("Build (linux, 20)"), &Map::new(), &scope), "Build (linux, 20) / plain"); | |
| 2978 | assert_eq!(shown_name(job("test"), Some("Build"), &combination, &scope), "Build / Test linux"); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2979 | } |
| 2980 | ||
| 2981 | #[test] | |
| 2982 | fn a_job_keeps_twenty_steps_summaries_of_a_mebibyte_each() { | |
| 2983 | assert!(summary_fits(0, None, 10)); | |
| 2984 | assert!(!summary_fits(0, None, 0), "nothing to keep"); | |
| 2985 | assert!(!summary_fits(MAX_SUMMARIES, None, 10), "a twenty-first step's summary is dropped"); | |
| 2986 | assert!(summary_fits(MAX_SUMMARIES, Some(10), 10), "a step that has one may add to it"); | |
| 2987 | assert!(summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 10)); | |
| 2988 | assert!(!summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 11)); | |
| 2989 | assert!(!summary_fits(0, None, MAX_SUMMARY_BYTES + 1)); | |
| 2990 | } | |
| 2991 | ||
| 2992 | #[test] | |
| 2993 | fn a_debug_rerun_sets_what_github_sets() { | |
| 2994 | let mut variables = Map::new(); | |
| 2995 | let mut runner = json!({ "name": "g1t", "debug": "" }); | |
| 2996 | debug_logging(&mut variables, &mut runner); | |
| 2997 | assert_eq!(variables["RUNNER_DEBUG"], "1"); | |
| 2998 | assert_eq!(variables["ACTIONS_STEP_DEBUG"], "true"); | |
| 2999 | assert_eq!(variables["ACTIONS_RUNNER_DEBUG"], "true"); | |
| 3000 | assert_eq!(runner["debug"], Value::String("1".into())); | |
| 3001 | assert_eq!(runner["name"], "g1t"); | |
| 3002 | } | |
| 3003 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.