Skip to content
412 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.

The balance shows what was spent this month and in total1//! Telling g1t how a job is going: its steps, its log in batches, its
2//! annotations, and how it ended. Every report carries the job's token.
3
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)4use std::sync::atomic::{AtomicBool, Ordering};
The balance shows what was spent this month and in total5use std::time::{Duration, Instant};
6
7use anyhow::Result;
Merge branch 'worktree-agent-a3abfcce648e87dca'8use g1t_actions::mask;
The balance shows what was spent this month and in total9use serde_json::{Value, json};
10
11/// How long log lines wait before they are sent.
12const FLUSH_EVERY: Duration = Duration::from_millis(1500);
13/// How much log is sent at once.
14const FLUSH_BYTES: usize = 64 * 1024;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)15/// The most one step may add to the job's summary, as on GitHub.
16pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
17/// How long the runner goes without a report before it asks whether the
18/// job was cancelled.
19const PING_EVERY: Duration = Duration::from_secs(10);
20
21/// Whether the job was cancelled, as g1t's answers to its reports say.
22struct Cancel {
23 /// g1t said so.
24 said: AtomicBool,
25 /// The job has taken it in: the step it was on was stopped, and the
26 /// cleanup steps that follow are not stopped for it again.
27 taken: AtomicBool,
28}
29
30impl Cancel {
31 const fn new() -> Cancel {
32 Cancel { said: AtomicBool::new(false), taken: AtomicBool::new(false) }
33 }
34
35 /// Reads an answer to a report: `cancelled` when the run was cancelled.
36 fn hear(&self, answer: &Value) {
37 if answer["cancelled"].as_bool() == Some(true) {
38 self.said.store(true, Ordering::Relaxed);
39 }
40 }
41
42 fn cancelled(&self) -> bool {
43 self.said.load(Ordering::Relaxed)
44 }
The balance shows what was spent this month and in total45
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)46 fn interrupt(&self) -> bool {
47 self.cancelled() && !self.taken.load(Ordering::Relaxed)
48 }
49
50 fn take(&self) {
51 self.taken.store(true, Ordering::Relaxed);
52 }
53}
54
55/// This job's (one runs per process).
56static CANCEL: Cancel = Cancel::new();
57
58/// Whether g1t said the job was cancelled.
59pub(crate) fn cancelled() -> bool {
60 CANCEL.cancelled()
61}
62
63/// Whether a running step should be stopped: cancelled, and not yet taken in.
64pub(crate) fn interrupt() -> bool {
65 CANCEL.interrupt()
66}
67
68/// The job has seen the cancellation: what runs from here is its cleanup.
69pub(crate) fn take_cancel() {
70 CANCEL.take();
71}
72
The balance shows what was spent this month and in total73pub(crate) struct Api {
74 pub(crate) base: String,
75 pub(crate) job: String,
76 pub(crate) token: String,
77}
78
79impl Api {
80 pub(crate) fn spec(&self) -> Result<Value> {
81 let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
82 .timeout(Duration::from_secs(60))
83 .send_json(json!({ "token": self.token }))?;
84 Ok(response.into_json()?)
85 }
86
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts87 /// Where to fetch another repository's action from: `{"source": "g1t",
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'88 /// "url", "ref", "token"}`, or `{"source": "github", "sha", "archive",
89 /// "note"}`: the commit GitHub's ref names, and whether g1t keeps its
90 /// archive (`action_archive`). Err holds why g1t
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts91 /// refused (`true`: the repository is private and may not be used
92 /// here), or that it could not be asked (`false`).
93 pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> {
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'94 // The first job to use an action at a commit waits while g1t
95 // fetches and keeps its archive.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts96 let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job))
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'97 .timeout(Duration::from_secs(120))
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts98 .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } }));
99 match sent {
100 Ok(response) => response.into_json().map_err(|error| (false, error.to_string())),
101 Err(ureq::Error::Status(code, response)) => {
102 let body: Value = response.into_json().unwrap_or(Value::Null);
103 let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned();
104 Err((code == 403, message))
105 }
106 Err(error) => Err((false, error.to_string())),
107 }
108 }
109
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'110 /// Downloads the archive g1t keeps of `repository` at `sha` to `to`.
111 pub(crate) fn action_archive(&self, repository: &str, sha: &str, to: &std::path::Path) -> std::result::Result<u64, String> {
112 let response = ureq::post(&format!("{}/actions/jobs/{}/archive", self.base, self.job))
113 .timeout(Duration::from_secs(300))
114 .send_json(json!({ "token": self.token, "report": { "repository": repository, "sha": sha } }))
115 .map_err(|error| match error {
116 ureq::Error::Status(code, response) => {
117 let body: Value = response.into_json().unwrap_or(Value::Null);
118 body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned)
119 }
120 other => other.to_string(),
121 })?;
122 let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?;
123 std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string())
124 }
125
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)126 /// An API with no address, which sends nothing: what the tests use, so
127 /// a step's run is not held up by reports that go nowhere (each one
128 /// tried three times, with waits between, over a second and a half).
129 #[cfg(test)]
130 pub(crate) fn offline() -> Api {
131 Api { base: String::new(), job: "job_1".into(), token: "t".into() }
132 }
133
The balance shows what was spent this month and in total134 pub(crate) fn report(&self, report: Value) {
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)135 // No address: nowhere to send it (Api::offline).
136 if self.base.is_empty() {
137 return;
138 }
The balance shows what was spent this month and in total139 // A report that cannot be sent is tried a few times, then dropped:
140 // the job goes on, and g1t notices a silent job by itself.
141 for attempt in 0..3 {
142 let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job))
143 .timeout(Duration::from_secs(30))
144 .send_json(json!({ "token": self.token, "report": report }));
145 match sent {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)146 Ok(response) => {
147 if let Ok(answer) = response.into_json::<Value>() {
148 CANCEL.hear(&answer);
149 }
150 return;
151 }
The balance shows what was spent this month and in total152 // Refused: the job was cancelled or finished; nothing to retry.
153 Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return,
154 Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))),
155 }
156 }
157 }
158}
159
Merge branch 'worktree-agent-a3abfcce648e87dca'160/// The job's log, masked, sent in batches. `masks` holds every form of
161/// every secret (`g1t_actions::mask`), longest first.
The balance shows what was spent this month and in total162pub(crate) struct Log {
163 pub(crate) api: Api,
164 pub(crate) masks: Vec<String>,
165 step: u32,
166 buffer: String,
167 last: Instant,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)168 /// When anything was last sent, for the ping.
169 sent: Instant,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.170 /// The problem matchers steps added, which last the whole job.
171 pub(crate) matchers: super::matchers::Matchers,
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)172 /// Everything sent so far, so a test reads the whole log however its
173 /// batches fell (`buffered`).
174 #[cfg(test)]
175 flushed: String,
The balance shows what was spent this month and in total176}
177
178impl Log {
Merge branch 'worktree-agent-a3abfcce648e87dca'179 pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log {
180 masks.retain(|mask| !mask.is_empty());
181 masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
The balance shows what was spent this month and in total182 Log {
183 api,
184 masks,
185 step: 0,
186 buffer: String::new(),
187 last: Instant::now(),
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)188 sent: Instant::now(),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.189 matchers: super::matchers::Matchers::default(),
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)190 #[cfg(test)]
191 flushed: String::new(),
The balance shows what was spent this month and in total192 }
193 }
194
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)195 /// The log so far: what was sent and what waits to be.
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)196 #[cfg(all(test, unix))]
197 pub(crate) fn buffered(&self) -> String {
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)198 format!("{}{}", self.flushed, self.buffer)
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)199 }
200
The balance shows what was spent this month and in total201 /// Starts writing to step `number` (0 for the job's setup).
202 pub(crate) fn step(&mut self, number: u32) {
203 self.flush();
204 self.step = number;
205 }
206
207 pub(crate) fn mask(&self, text: &str) -> String {
Merge branch 'worktree-agent-a3abfcce648e87dca'208 mask::apply(text, &self.masks)
209 }
210
211 /// `::add-mask::`: masks `value` from here on, in every form it can
212 /// take (each line, base64, JSON-escaped), as a secret is.
213 pub(crate) fn add_mask(&mut self, value: &str) {
214 let mut added = false;
215 for variant in mask::variants(value) {
216 if !self.masks.contains(&variant) {
217 self.masks.push(variant);
218 added = true;
The balance shows what was spent this month and in total219 }
220 }
Merge branch 'worktree-agent-a3abfcce648e87dca'221 if added {
222 self.masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
223 }
The balance shows what was spent this month and in total224 }
225
226 pub(crate) fn line(&mut self, text: &str) {
227 let masked = self.mask(text);
228 self.buffer.push_str(&masked);
229 self.buffer.push('\n');
230 if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY {
231 self.flush();
232 }
233 }
234
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)235 /// Sends what is waiting if it has waited long enough, and asks after
236 /// the job when nothing has been sent for a while, so a cancellation
237 /// reaches a step that prints nothing.
The balance shows what was spent this month and in total238 pub(crate) fn tick(&mut self) {
239 if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY {
240 self.flush();
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)241 } else if self.sent.elapsed() >= PING_EVERY {
242 self.sent = Instant::now();
243 self.api.report(json!({ "kind": "ping" }));
The balance shows what was spent this month and in total244 }
245 }
246
247 pub(crate) fn flush(&mut self) {
248 self.last = Instant::now();
249 if self.buffer.is_empty() {
250 return;
251 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)252 self.sent = Instant::now();
The balance shows what was spent this month and in total253 let text = std::mem::take(&mut self.buffer);
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)254 #[cfg(test)]
255 self.flushed.push_str(&text);
The balance shows what was spent this month and in total256 self.api.report(json!({ "kind": "log", "step": self.step, "text": text }));
257 }
258
259 pub(crate) fn steps(&self, names: &[String]) {
260 self.api.report(json!({ "kind": "steps", "steps": names }));
261 }
262
263 pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) {
264 self.flush();
265 let name = self.mask(name);
266 self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion }));
267 }
268
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)269 /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's
270 /// page. More than 1 MiB is refused, as on GitHub, with an error in the
271 /// log.
272 pub(crate) fn summary(&mut self, markdown: &str) {
273 if markdown.trim().is_empty() {
274 return;
275 }
276 if markdown.len() > MAX_SUMMARY_BYTES {
277 self.line(&format!(
278 "##[error]$GITHUB_STEP_SUMMARY upload aborted: a step's summary may be up to 1024k, and this one is {}k.",
279 markdown.len().div_ceil(1024)
280 ));
281 return;
282 }
283 let markdown = self.mask(markdown);
284 self.flush();
285 self.api.report(json!({ "kind": "summary", "step": self.step, "markdown": markdown }));
286 }
287
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.288 /// An annotation, from `::error::` and its kin or a problem matcher:
289 /// its `file`, `line`, `col`, `endLine` and `endColumn` as GitHub's
290 /// commands spell them, sent as `file`, `line`, `column`, `end_line`
291 /// and `end_column`.
The balance shows what was spent this month and in total292 pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.293 let report = self.annotation_report(level, message, properties);
294 self.api.report(report);
295 }
296
297 fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value {
The balance shows what was spent this month and in total298 let message = self.mask(message);
Merge branch 'worktree-agent-a3abfcce648e87dca'299 let title = properties.get("title").and_then(Value::as_str).map(|title| self.mask(title));
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.300 let number = |keys: &[&str]| keys.iter().find_map(|key| properties.get(*key).and_then(Value::as_str)).and_then(|n| n.trim().parse::<u32>().ok());
301 json!({
The balance shows what was spent this month and in total302 "kind": "annotation",
303 "level": level,
304 "message": message,
Merge branch 'worktree-agent-a3abfcce648e87dca'305 "title": title,
The balance shows what was spent this month and in total306 "file": properties.get("file"),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.307 "line": number(&["line"]),
308 "column": number(&["col"]),
309 "end_line": number(&["endLine"]),
310 "end_column": number(&["endColumn"]),
311 })
The balance shows what was spent this month and in total312 }
313
314 pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) {
Merge branch 'worktree-agent-a3abfcce648e87dca'315 let outputs = self.withhold_secrets(outputs);
The balance shows what was spent this month and in total316 self.flush();
317 self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason }));
318 }
Merge branch 'worktree-agent-a3abfcce648e87dca'319
320 /// The job's outputs without any that hold a secret, as on GitHub: an
321 /// output goes to other jobs and to the run's page, where no mask
322 /// reaches. Each one left out is warned of in the log.
323 pub(crate) fn withhold_secrets(&mut self, outputs: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
324 let mut kept = serde_json::Map::new();
325 for (name, value) in outputs {
326 let text = match value {
327 Value::String(text) => text.clone(),
328 other => other.to_string(),
329 };
330 if mask::reveals(&text, &self.masks) {
331 self.line(&format!("##[warning]The output `{name}` was left out: it holds a secret."));
332 continue;
333 }
334 kept.insert(name.clone(), value.clone());
335 }
336 kept
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn log(secrets: &[&str]) -> Log {
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)345 Log::new(Api::offline(), mask::all_variants(secrets.iter().copied()))
Merge branch 'worktree-agent-a3abfcce648e87dca'346 }
347
348 #[test]
349 fn outputs_holding_a_secret_are_left_out() {
350 let mut log = log(&["s3cr3t-token"]);
351 let mut outputs = serde_json::Map::new();
352 outputs.insert("version".into(), json!("1.2.0"));
353 outputs.insert("leak".into(), json!("token=s3cr3t-token"));
354 outputs.insert("encoded".into(), json!("czNjcjN0LXRva2Vu"));
355 let kept = log.withhold_secrets(&outputs);
356 assert_eq!(kept.keys().collect::<Vec<_>>(), ["version"]);
357 assert!(log.buffer.contains("The output `leak` was left out"));
358 assert!(log.buffer.contains("The output `encoded` was left out"));
359 }
360
361 #[test]
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)362 fn a_cancelled_answer_is_heard_once_and_taken_in() {
363 let cancel = Cancel::new();
364 cancel.hear(&json!({ "ok": true }));
365 assert!(!cancel.cancelled());
366 cancel.hear(&json!({ "ok": true, "cancelled": true }));
367 assert!(cancel.cancelled() && cancel.interrupt());
368 cancel.take();
369 assert!(cancel.cancelled() && !cancel.interrupt(), "cleanup steps are not stopped again");
370 cancel.hear(&json!({ "ok": true, "cancelled": false }));
371 assert!(cancel.cancelled(), "a cancelled job stays cancelled");
372 }
373
374 #[test]
375 fn a_summary_past_its_limit_is_refused_in_the_log() {
376 let mut log = log(&[]);
377 log.summary("
378");
379 assert!(log.buffer.is_empty(), "an empty summary is not sent");
380 log.summary(&"x".repeat(MAX_SUMMARY_BYTES + 1));
381 assert!(log.buffer.contains("$GITHUB_STEP_SUMMARY upload aborted"));
382 assert!(log.buffer.contains("1025k"));
383 }
384
385 #[test]
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.386 fn annotations_carry_their_columns_and_end() {
387 let log = log(&["hidden"]);
388 let properties: serde_json::Map<String, Value> =
389 serde_json::from_value(json!({ "file": "a.rs", "line": "3", "col": "7", "endLine": "4", "endColumn": "2", "title": "t hidden" })).unwrap();
390 let report = log.annotation_report("error", "bad hidden", &properties);
391 assert_eq!(report["line"], 3);
392 assert_eq!(report["column"], 7);
393 assert_eq!(report["end_line"], 4);
394 assert_eq!(report["end_column"], 2);
395 assert_eq!(report["title"], "t ***");
396 assert_eq!(report["message"], "bad ***");
397 let bare = log.annotation_report("notice", "x", &serde_json::Map::new());
398 assert!(bare["column"].is_null() && bare["file"].is_null());
399 }
400
401 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'402 fn added_masks_cover_every_form() {
403 let mut log = log(&[]);
404 log.add_mask("line one\nline two");
405 assert_eq!(log.mask("first: line one"), "first: ***");
406 assert_eq!(log.mask("then line two"), "then ***");
407 // Longest first: the whole value, not its pieces.
408 log.add_mask("abc");
409 log.add_mask("abcdef");
410 assert_eq!(log.mask("abcdef"), "***");
411 }
The balance shows what was spent this month and in total412}

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