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

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

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