Skip to content
3,194 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.

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

This file's history is long; its oldest lines are credited to the oldest commit read.