Skip to content
3,003 linesCodeBlameRaw

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 pays1//! 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
6use g1t_actions::events::{RunInfo, runner_context};
7use g1t_actions::expr::{self, Scope, Status};
8use g1t_actions::matrix;
9use g1t_actions::workflow::{self, Workflow};
10use g1t_contracts::access::Capability;
Merge branch 'worktree-agent-a3abfcce648e87dca'11use g1t_contracts::actions::{JobCallArgs, RunActionArgs, RunApproval, StartJobArgs, WorkflowRun};
12use g1t_contracts::identity::{CreateJobTokenArgs, CreatedAccessToken, RevokeJobTokensArgs};
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays13use g1t_contracts::repos::{Repo, RepoPath};
14use g1t_contracts::time::rfc3339;
15use g1t_contracts::{FailureCode, Outcome, new_id};
16use g1t_kit::now_ms;
17use g1t_secrets::{random_hex, same, sha256_hex};
18use serde::Deserialize;
19use serde_json::{Map, Value, json};
20use worker::Result;
21
Merge branch 'worktree-agent-a3abfcce648e87dca'22use crate::protection::Gate;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays23use crate::sync::WorkflowRow;
24use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path};
25use g1t_contracts::runners::{Wanted, waiting_reason};
26
27/// The most log one job keeps, in bytes; past it, the log says so and stops.
28const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
29/// The most a single log report may add.
30const MAX_CHUNK_BYTES: usize = 256 * 1024;
31const 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).
37const 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.
42pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
43pub(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.
48pub(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 pays57
58/// The repository whose unfinished runs `event` stops, by id: one deleted,
59/// or archived (not unarchived).
60pub 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
72pub 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 pays93}
94
95#[derive(Clone, Deserialize)]
96pub 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 pays136}
137
138impl 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 pays143 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)]
184pub 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.
238pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000;
239
240/// Which jobs a re-run runs again.
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub 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.
253pub 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-7nm8r9269/// (`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)271pub fn top_key(key: &str) -> &str {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9272 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.
280fn 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.
292fn 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-7nm8r9309/// 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.
312fn 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).
331pub 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 pays336}
337
338impl 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-7nm8r9347 /// 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 pays361 /// 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).
373type Unit = (String, workflow::Job, Vec<(String, String)>);
374
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9375/// 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).
377const MAX_CALL_DEPTH: u64 = 9;
378/// How many different reusable workflows a run may call, as on GitHub.
379const MAX_CALLED_WORKFLOWS: usize = 50;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays380
381/// What the jobs of one key came to, for `needs.<key>`.
382fn 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.
399fn 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-a69aeabc4b0deeb97414/// 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)]
419pub(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.
428pub(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-7nm8r9440 name @ Value::String(_) => Some(JobEnvironment { name: plain(name)?, url: None, deploys: workflow::records_deployment(raw) }),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97441 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-7nm8r9447 Some(JobEnvironment { name, url, deploys: workflow::records_deployment(raw) })
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97448 }
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.
455fn 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.
464fn 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-a69aeabc4b0deeb97474pub(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-7nm8r9480 (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-a69aeabc4b0deeb97484}
485
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays486/// 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)]
490struct StartDetails {
491 environment: Option<String>,
492 instance: Option<String>,
493}
494
495fn 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 pays505 // `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-a69aeabc4b0deeb97507 let contexts = start_contexts(run, job, &spec.id);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays508 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
526fn now() -> String {
527 rfc3339(now_ms())
528}
529
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97530/// 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).
533pub(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 pays547impl 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 pays610 /// 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 pays632 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 pays666 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 pays673 )
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 pays687 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 pays689 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 pays700 ])?
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 pays749 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 pays755 .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 pays762 }
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 pays768 .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 pays771 }
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 pays774 }
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 pays833 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-7nm8r9868 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 pays869 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-7nm8r9882 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 pays883 let children: Vec<&JobRow> = jobs
884 .iter()
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9885 .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 pays886 .collect();
887 if !children.is_empty() && children.iter().all(|child| child.status == "completed") {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9888 self.finish_call(&run, row, &children).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays889 changed = true;
890 }
891 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9892 // 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 pays908 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-7nm8r9924 // 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 pays928 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-7nm8r9974 // 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 pays978 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 pays985 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-7nm8r9998 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 pays999 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 pays1053 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 pays1058 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 pays1105 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 pays1120 ];
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 pays1130 )
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 pays1152 )
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-7nm8r91187 // 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-7nm8r91194 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 pays1205 Ok(())
1206 }
1207
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1208 /// A job that calls a reusable workflow, in the repository or another
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91209 /// (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 pays1225 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-7nm8r91227 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 pays1228 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1229 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 pays1232 };
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91233 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 pays1245 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 timeouts1252 // 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-7nm8r91255 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 timeouts1395 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1396 let mut statements = Vec::new();
1397 for called_job in &called.jobs {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91398 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 pays1402 });
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-7nm8r91411 format!("{prefix}/{}", called_job.id).into(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1412 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-7nm8r91414 serde_json::to_string(&callee)?.into(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1415 ])?,
1416 );
1417 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91418 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 pays1441 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-7nm8r91445 /// 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 pays1448 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-7nm8r91483 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 pays1486 .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-7nm8r91487 .first::<Value>(None)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1488 .await?;
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91489 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 pays1507 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 timeouts1511 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 pays1512 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-7nm8r91592 .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 pays1601 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-guardrails1644 // 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 pays1655 Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971656 } else {
1657 self.job_started(&job.id).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1658 }
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-a69aeabc4b0deeb971689 // 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 pays1696 // 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-7nm8r91722 // 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 pays1724 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-7nm8r91737 // 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 pays1741 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 pays1748 async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91749 // 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 pays1771 // 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 pays1785 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 pays1801 /// 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 pays1805 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-a69aeabc4b0deeb971836 self.settle_deployments(run, &jobs, conclusion == "cancelled").await;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1837 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-a69aeabc4b0deeb971893 /// 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-a69aeabc4b0deeb971932 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-a69aeabc4b0deeb971959 /// 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 pays1989 /// 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 merge2011 "source": "actions",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2012 }),
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 pays2033 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2034 "source": "actions",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2035 }),
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 pays2044 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 pays2055 }
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 pays2057 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 pays2074 }
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 pays2095 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 pays2102 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 pays2122 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 pays2157 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 pays2197 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-7nm8r92199 // 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 pays2201 statements.push(
2202 self.db
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92203 .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 pays2205 );
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 pays2213 )
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 pays2224 );
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 pays2235 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-7nm8r92301 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 pays2304 }
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 pays2332 };
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 timeouts2335 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 pays2356 };
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 pays2361 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 R22362 // 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 pays2374
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 R22398 github["retention_days"] = json!(retention_days);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2399 // 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 R22402 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 pays2404 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 pays2411
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 pays2415 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 pays2417 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 pays2470 "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 R22477 // 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 pays2483 })))
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 pays2554 .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 pays2558 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 pays2566 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 pays2574 .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 pays2608 "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 pays2629 "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 pays2639 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 pays2647 }
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 pays2664 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 pays2680 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-7nm8r92694 // 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 pays2709 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 pays2716 // 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 R22728 // 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 pays2737 self.start_queued().await
2738 }
2739}
2740
2741
2742#[cfg(test)]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'2743mod 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 pays2789mod 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)]
2817mod 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-a69aeabc4b0deeb972856
2857#[cfg(test)]
2858mod 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-7nm8r92888 // 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-a69aeabc4b0deeb972891 // 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)]
2907mod reruns {
2908 use serde_json::{Map, Value, json};
2909
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92910 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-7nm8r92943 // 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.