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.
| What g1t can't do yet, and an open letter to Cloudflare | 1 | //! 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) | 4 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 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. | 5 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| What g1t can't do yet, and an open letter to Cloudflare | 6 | |
| 7 | use anyhow::Result; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 8 | use g1t_actions::mask; |
| What g1t can't do yet, and an open letter to Cloudflare | 9 | use serde_json::{Value, json}; |
| 10 | ||
| 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. | 11 | /// How long log lines wait before they are sent: about a second, so a |
| 12 | /// running step's page follows it closely, at one write to g1t a second. | |
| 13 | const FLUSH_EVERY: Duration = Duration::from_millis(1000); | |
| What g1t can't do yet, and an open letter to Cloudflare | 14 | /// How much log is sent at once. |
| 15 | const FLUSH_BYTES: usize = 64 * 1024; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 16 | /// The most one step may add to the job's summary, as on GitHub. |
| 17 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| 18 | /// How long the runner goes without a report before it asks whether the | |
| 19 | /// job was cancelled. | |
| 20 | const PING_EVERY: Duration = Duration::from_secs(10); | |
| 21 | ||
| 22 | /// Whether the job was cancelled, as g1t's answers to its reports say. | |
| 23 | struct Cancel { | |
| 24 | /// g1t said so. | |
| 25 | said: AtomicBool, | |
| 26 | /// The job has taken it in: the step it was on was stopped, and the | |
| 27 | /// cleanup steps that follow are not stopped for it again. | |
| 28 | taken: AtomicBool, | |
| 29 | } | |
| 30 | ||
| 31 | impl Cancel { | |
| 32 | const fn new() -> Cancel { | |
| 33 | Cancel { said: AtomicBool::new(false), taken: AtomicBool::new(false) } | |
| 34 | } | |
| 35 | ||
| 36 | /// Reads an answer to a report: `cancelled` when the run was cancelled. | |
| 37 | fn hear(&self, answer: &Value) { | |
| 38 | if answer["cancelled"].as_bool() == Some(true) { | |
| 39 | self.said.store(true, Ordering::Relaxed); | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | fn cancelled(&self) -> bool { | |
| 44 | self.said.load(Ordering::Relaxed) | |
| 45 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 46 | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 47 | fn interrupt(&self) -> bool { |
| 48 | self.cancelled() && !self.taken.load(Ordering::Relaxed) | |
| 49 | } | |
| 50 | ||
| 51 | fn take(&self) { | |
| 52 | self.taken.store(true, Ordering::Relaxed); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /// This job's (one runs per process). | |
| 57 | static CANCEL: Cancel = Cancel::new(); | |
| 58 | ||
| 59 | /// Whether g1t said the job was cancelled. | |
| 60 | pub(crate) fn cancelled() -> bool { | |
| 61 | CANCEL.cancelled() | |
| 62 | } | |
| 63 | ||
| 64 | /// Whether a running step should be stopped: cancelled, and not yet taken in. | |
| 65 | pub(crate) fn interrupt() -> bool { | |
| 66 | CANCEL.interrupt() | |
| 67 | } | |
| 68 | ||
| 69 | /// The job has seen the cancellation: what runs from here is its cleanup. | |
| 70 | pub(crate) fn take_cancel() { | |
| 71 | CANCEL.take(); | |
| 72 | } | |
| 73 | ||
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 74 | #[derive(Clone)] |
| What g1t can't do yet, and an open letter to Cloudflare | 75 | pub(crate) struct Api { |
| 76 | pub(crate) base: String, | |
| 77 | pub(crate) job: String, | |
| 78 | pub(crate) token: String, | |
| 79 | } | |
| 80 | ||
| 81 | impl Api { | |
| 82 | pub(crate) fn spec(&self) -> Result<Value> { | |
| 83 | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)) | |
| 84 | .timeout(Duration::from_secs(60)) | |
| 85 | .send_json(json!({ "token": self.token }))?; | |
| 86 | Ok(response.into_json()?) | |
| 87 | } | |
| 88 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 89 | /// Where to fetch another repository's action from: `{"source": "g1t", |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 90 | /// "url", "ref", "token"}`, or `{"source": "github", "sha", "archive", |
| 91 | /// "note"}`: the commit GitHub's ref names, and whether g1t keeps its | |
| 92 | /// archive (`action_archive`). Err holds why g1t | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 93 | /// refused (`true`: the repository is private and may not be used |
| 94 | /// here), or that it could not be asked (`false`). | |
| 95 | 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' | 96 | // The first job to use an action at a commit waits while g1t |
| 97 | // fetches and keeps its archive. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 98 | let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job)) |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 99 | .timeout(Duration::from_secs(120)) |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 100 | .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } })); |
| 101 | match sent { | |
| 102 | Ok(response) => response.into_json().map_err(|error| (false, error.to_string())), | |
| 103 | Err(ureq::Error::Status(code, response)) => { | |
| 104 | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 105 | let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned(); | |
| 106 | Err((code == 403, message)) | |
| 107 | } | |
| 108 | Err(error) => Err((false, error.to_string())), | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 112 | /// Downloads the archive g1t keeps of `repository` at `sha` to `to`. |
| 113 | pub(crate) fn action_archive(&self, repository: &str, sha: &str, to: &std::path::Path) -> std::result::Result<u64, String> { | |
| 114 | let response = ureq::post(&format!("{}/actions/jobs/{}/archive", self.base, self.job)) | |
| 115 | .timeout(Duration::from_secs(300)) | |
| 116 | .send_json(json!({ "token": self.token, "report": { "repository": repository, "sha": sha } })) | |
| 117 | .map_err(|error| match error { | |
| 118 | ureq::Error::Status(code, response) => { | |
| 119 | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 120 | body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned) | |
| 121 | } | |
| 122 | other => other.to_string(), | |
| 123 | })?; | |
| 124 | let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?; | |
| 125 | std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string()) | |
| 126 | } | |
| 127 | ||
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 128 | /// An API with no address, which sends nothing: what the tests use, so |
| 129 | /// a step's run is not held up by reports that go nowhere (each one | |
| 130 | /// tried three times, with waits between, over a second and a half). | |
| 131 | #[cfg(test)] | |
| 132 | pub(crate) fn offline() -> Api { | |
| 133 | Api { base: String::new(), job: "job_1".into(), token: "t".into() } | |
| 134 | } | |
| 135 | ||
| What g1t can't do yet, and an open letter to Cloudflare | 136 | 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) | 137 | // No address: nowhere to send it (Api::offline). |
| 138 | if self.base.is_empty() { | |
| 139 | return; | |
| 140 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 141 | // A report that cannot be sent is tried a few times, then dropped: |
| 142 | // the job goes on, and g1t notices a silent job by itself. | |
| 143 | for attempt in 0..3 { | |
| 144 | let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job)) | |
| 145 | .timeout(Duration::from_secs(30)) | |
| 146 | .send_json(json!({ "token": self.token, "report": report })); | |
| 147 | match sent { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 148 | Ok(response) => { |
| 149 | if let Ok(answer) = response.into_json::<Value>() { | |
| 150 | CANCEL.hear(&answer); | |
| 151 | } | |
| 152 | return; | |
| 153 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 154 | // Refused: the job was cancelled or finished; nothing to retry. |
| 155 | Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return, | |
| 156 | Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))), | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 162 | /// The job's log, masked, sent in batches. `masks` holds every form of |
| 163 | /// every secret (`g1t_actions::mask`), longest first. | |
| What g1t can't do yet, and an open letter to Cloudflare | 164 | pub(crate) struct Log { |
| 165 | pub(crate) api: Api, | |
| 166 | pub(crate) masks: Vec<String>, | |
| 167 | step: u32, | |
| 168 | buffer: String, | |
| 169 | last: Instant, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 170 | /// When anything was last sent, for the ping. |
| 171 | sent: Instant, | |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 172 | /// The problem matchers steps added, which last the whole job. |
| 173 | pub(crate) matchers: super::matchers::Matchers, | |
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 174 | /// The number of the last batch sent, 1 for the job's first: each |
| 175 | /// batch carries its own, and keeps it when it is sent again, so g1t | |
| 176 | /// keeps a batch it already has once. | |
| 177 | chunk: u64, | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 178 | /// Everything sent so far, so a test reads the whole log however its |
| 179 | /// batches fell (`buffered`). | |
| 180 | #[cfg(test)] | |
| 181 | flushed: String, | |
| What g1t can't do yet, and an open letter to Cloudflare | 182 | } |
| 183 | ||
| 184 | impl Log { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 185 | pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log { |
| 186 | masks.retain(|mask| !mask.is_empty()); | |
| 187 | masks.sort_by_key(|mask| std::cmp::Reverse(mask.len())); | |
| What g1t can't do yet, and an open letter to Cloudflare | 188 | Log { |
| 189 | api, | |
| 190 | masks, | |
| 191 | step: 0, | |
| 192 | buffer: String::new(), | |
| 193 | last: Instant::now(), | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 194 | sent: Instant::now(), |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 195 | matchers: super::matchers::Matchers::default(), |
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 196 | chunk: 0, |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 197 | #[cfg(test)] |
| 198 | flushed: String::new(), | |
| What g1t can't do yet, and an open letter to Cloudflare | 199 | } |
| 200 | } | |
| 201 | ||
| 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. | 202 | /// The log so far, what was sent and what waits to be, without the |
| 203 | /// time each line is stamped with. | |
| 204 | #[cfg(test)] | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 205 | pub(crate) fn buffered(&self) -> String { |
| 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. | 206 | unstamped(&format!("{}{}", self.flushed, self.buffer)) |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 207 | } |
| 208 | ||
| What g1t can't do yet, and an open letter to Cloudflare | 209 | /// Starts writing to step `number` (0 for the job's setup). |
| 210 | pub(crate) fn step(&mut self, number: u32) { | |
| 211 | self.flush(); | |
| 212 | self.step = number; | |
| 213 | } | |
| 214 | ||
| 215 | pub(crate) fn mask(&self, text: &str) -> String { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 216 | mask::apply(text, &self.masks) |
| 217 | } | |
| 218 | ||
| 219 | /// `::add-mask::`: masks `value` from here on, in every form it can | |
| 220 | /// take (each line, base64, JSON-escaped), as a secret is. | |
| 221 | pub(crate) fn add_mask(&mut self, value: &str) { | |
| 222 | let mut added = false; | |
| 223 | for variant in mask::variants(value) { | |
| 224 | if !self.masks.contains(&variant) { | |
| 225 | self.masks.push(variant); | |
| 226 | added = true; | |
| What g1t can't do yet, and an open letter to Cloudflare | 227 | } |
| 228 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 229 | if added { |
| 230 | self.masks.sort_by_key(|mask| std::cmp::Reverse(mask.len())); | |
| 231 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 232 | } |
| 233 | ||
| 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. | 234 | /// Adds a line to the log, masked, each of its lines stamped with the |
| 235 | /// time, as GitHub's runner writes its logs | |
| 236 | /// (`2026-10-12T09:41:07.1234567Z text`). | |
| What g1t can't do yet, and an open letter to Cloudflare | 237 | pub(crate) fn line(&mut self, text: &str) { |
| 238 | let masked = self.mask(text); | |
| 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. | 239 | let at = stamp(SystemTime::now()); |
| 240 | for line in masked.split('\n') { | |
| 241 | self.buffer.push_str(&at); | |
| 242 | self.buffer.push(' '); | |
| 243 | self.buffer.push_str(line); | |
| 244 | self.buffer.push('\n'); | |
| 245 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 246 | if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY { |
| 247 | self.flush(); | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 251 | /// Sends what is waiting if it has waited long enough, and asks after |
| 252 | /// the job when nothing has been sent for a while, so a cancellation | |
| 253 | /// reaches a step that prints nothing. | |
| What g1t can't do yet, and an open letter to Cloudflare | 254 | pub(crate) fn tick(&mut self) { |
| 255 | if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY { | |
| 256 | self.flush(); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 257 | } else if self.sent.elapsed() >= PING_EVERY { |
| 258 | self.sent = Instant::now(); | |
| 259 | self.api.report(json!({ "kind": "ping" })); | |
| What g1t can't do yet, and an open letter to Cloudflare | 260 | } |
| 261 | } | |
| 262 | ||
| 263 | pub(crate) fn flush(&mut self) { | |
| 264 | self.last = Instant::now(); | |
| 265 | if self.buffer.is_empty() { | |
| 266 | return; | |
| 267 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 268 | self.sent = Instant::now(); |
| What g1t can't do yet, and an open letter to Cloudflare | 269 | 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) | 270 | #[cfg(test)] |
| 271 | self.flushed.push_str(&text); | |
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 272 | let report = self.log_report(text); |
| 273 | self.api.report(report); | |
| 274 | } | |
| 275 | ||
| 276 | /// The next batch of the log, numbered. | |
| 277 | fn log_report(&mut self, text: String) -> Value { | |
| 278 | self.chunk += 1; | |
| 279 | json!({ "kind": "log", "step": self.step, "chunk": self.chunk, "text": text }) | |
| What g1t can't do yet, and an open letter to Cloudflare | 280 | } |
| 281 | ||
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 282 | /// Does `work` with the job's API on a thread of its own, writing |
| 283 | /// `waiting(seconds)` to the log, and sending it, every `every` it is | |
| 284 | /// still going: a wait the job's page would otherwise show as a silent | |
| 285 | /// log, such as g1t fetching an action's archive from GitHub. | |
| 286 | pub(crate) fn waiting<T: Send + 'static>( | |
| 287 | &mut self, | |
| 288 | every: Duration, | |
| 289 | work: impl FnOnce(Api) -> T + Send + 'static, | |
| 290 | waiting: impl Fn(u64) -> String, | |
| 291 | ) -> T { | |
| 292 | self.flush(); | |
| 293 | let api = self.api.clone(); | |
| 294 | let (done, answer) = std::sync::mpsc::channel(); | |
| 295 | let worker = std::thread::spawn(move || { | |
| 296 | let _ = done.send(work(api)); | |
| 297 | }); | |
| 298 | let started = Instant::now(); | |
| 299 | let result = loop { | |
| 300 | match answer.recv_timeout(every) { | |
| 301 | Ok(result) => break result, | |
| 302 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { | |
| 303 | self.line(&waiting(started.elapsed().as_secs())); | |
| 304 | self.flush(); | |
| 305 | } | |
| 306 | // The work panicked: so does the job, as it would have. | |
| 307 | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => match worker.join() { | |
| 308 | Err(panic) => std::panic::resume_unwind(panic), | |
| 309 | Ok(()) => unreachable!("the work sends its result before it ends"), | |
| 310 | }, | |
| 311 | } | |
| 312 | }; | |
| 313 | let _ = worker.join(); | |
| 314 | result | |
| 315 | } | |
| 316 | ||
| What g1t can't do yet, and an open letter to Cloudflare | 317 | pub(crate) fn steps(&self, names: &[String]) { |
| 318 | self.api.report(json!({ "kind": "steps", "steps": names })); | |
| 319 | } | |
| 320 | ||
| 321 | pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) { | |
| 322 | self.flush(); | |
| 323 | let name = self.mask(name); | |
| 324 | self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion })); | |
| 325 | } | |
| 326 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 327 | /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's |
| 328 | /// page. More than 1 MiB is refused, as on GitHub, with an error in the | |
| 329 | /// log. | |
| 330 | pub(crate) fn summary(&mut self, markdown: &str) { | |
| 331 | if markdown.trim().is_empty() { | |
| 332 | return; | |
| 333 | } | |
| 334 | if markdown.len() > MAX_SUMMARY_BYTES { | |
| 335 | self.line(&format!( | |
| 336 | "##[error]$GITHUB_STEP_SUMMARY upload aborted: a step's summary may be up to 1024k, and this one is {}k.", | |
| 337 | markdown.len().div_ceil(1024) | |
| 338 | )); | |
| 339 | return; | |
| 340 | } | |
| 341 | let markdown = self.mask(markdown); | |
| 342 | self.flush(); | |
| 343 | self.api.report(json!({ "kind": "summary", "step": self.step, "markdown": markdown })); | |
| 344 | } | |
| 345 | ||
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 346 | /// An annotation, from `::error::` and its kin or a problem matcher: |
| 347 | /// its `file`, `line`, `col`, `endLine` and `endColumn` as GitHub's | |
| 348 | /// commands spell them, sent as `file`, `line`, `column`, `end_line` | |
| 349 | /// and `end_column`. | |
| What g1t can't do yet, and an open letter to Cloudflare | 350 | pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) { |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 351 | let report = self.annotation_report(level, message, properties); |
| 352 | self.api.report(report); | |
| 353 | } | |
| 354 | ||
| 355 | fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value { | |
| What g1t can't do yet, and an open letter to Cloudflare | 356 | let message = self.mask(message); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 357 | let title = properties.get("title").and_then(Value::as_str).map(|title| self.mask(title)); |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 358 | 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()); |
| 359 | json!({ | |
| What g1t can't do yet, and an open letter to Cloudflare | 360 | "kind": "annotation", |
| 361 | "level": level, | |
| 362 | "message": message, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 363 | "title": title, |
| What g1t can't do yet, and an open letter to Cloudflare | 364 | "file": properties.get("file"), |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 365 | "line": number(&["line"]), |
| 366 | "column": number(&["col"]), | |
| 367 | "end_line": number(&["endLine"]), | |
| 368 | "end_column": number(&["endColumn"]), | |
| 369 | }) | |
| What g1t can't do yet, and an open letter to Cloudflare | 370 | } |
| 371 | ||
| 372 | pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 373 | let outputs = self.withhold_secrets(outputs); |
| What g1t can't do yet, and an open letter to Cloudflare | 374 | self.flush(); |
| 375 | self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason })); | |
| 376 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 377 | |
| 378 | /// The job's outputs without any that hold a secret, as on GitHub: an | |
| 379 | /// output goes to other jobs and to the run's page, where no mask | |
| 380 | /// reaches. Each one left out is warned of in the log. | |
| 381 | pub(crate) fn withhold_secrets(&mut self, outputs: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> { | |
| 382 | let mut kept = serde_json::Map::new(); | |
| 383 | for (name, value) in outputs { | |
| 384 | let text = match value { | |
| 385 | Value::String(text) => text.clone(), | |
| 386 | other => other.to_string(), | |
| 387 | }; | |
| 388 | if mask::reveals(&text, &self.masks) { | |
| 389 | self.line(&format!("##[warning]The output `{name}` was left out: it holds a secret.")); | |
| 390 | continue; | |
| 391 | } | |
| 392 | kept.insert(name.clone(), value.clone()); | |
| 393 | } | |
| 394 | kept | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 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. | 398 | /// A time as GitHub's logs stamp each line: RFC 3339 in UTC, to a tenth |
| 399 | /// of a microsecond (`2026-10-12T09:41:07.1234567Z`). | |
| 400 | pub(crate) fn stamp(at: SystemTime) -> String { | |
| 401 | let since = at.duration_since(UNIX_EPOCH).unwrap_or_default(); | |
| 402 | let seconds = since.as_secs(); | |
| 403 | let (days, rest) = (seconds / 86_400, seconds % 86_400); | |
| 404 | let (year, month, day) = civil(days as i64); | |
| 405 | format!( | |
| 406 | "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:07}Z", | |
| 407 | rest / 3600, | |
| 408 | rest % 3600 / 60, | |
| 409 | rest % 60, | |
| 410 | since.subsec_nanos() / 100 | |
| 411 | ) | |
| 412 | } | |
| 413 | ||
| 414 | /// The calendar date `days` after 1970-01-01 (Howard Hinnant's | |
| 415 | /// `civil_from_days`). | |
| 416 | fn civil(days: i64) -> (i64, u32, u32) { | |
| 417 | let z = days + 719_468; | |
| 418 | let era = z.div_euclid(146_097); | |
| 419 | let doe = z.rem_euclid(146_097); | |
| 420 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 421 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 422 | let mp = (5 * doy + 2) / 153; | |
| 423 | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; | |
| 424 | let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; | |
| 425 | let year = yoe + era * 400 + i64::from(month <= 2); | |
| 426 | (year, month, day) | |
| 427 | } | |
| 428 | ||
| 429 | /// A log without the time stamped at the start of each line. | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 430 | #[cfg(test)] |
| 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. | 431 | pub(crate) fn unstamped(text: &str) -> String { |
| 432 | text.split_inclusive('\n') | |
| 433 | .map(|line| match line.split_once(' ') { | |
| 434 | Some((at, rest)) if at.len() == 28 && at.ends_with('Z') && at.as_bytes()[10] == b'T' => rest, | |
| 435 | _ => line, | |
| 436 | }) | |
| 437 | .collect() | |
| 438 | } | |
| 439 | ||
| 440 | #[cfg(test)] | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 441 | mod tests { |
| 442 | use super::*; | |
| 443 | ||
| 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. | 444 | #[test] |
| 445 | fn every_line_is_stamped_with_the_time_as_github_stamps_it() { | |
| 446 | assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00.0000000Z"); | |
| 447 | assert_eq!(stamp(UNIX_EPOCH + Duration::new(1_791_797_067, 123_456_789)), "2026-10-12T09:24:27.1234567Z"); | |
| 448 | // A leap day, and the last day of February in a century that has none. | |
| 449 | assert_eq!(stamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.0000000Z"); | |
| 450 | assert_eq!(stamp(UNIX_EPOCH + Duration::from_secs(4_107_542_399)), "2100-02-28T23:59:59.0000000Z"); | |
| 451 | let mut log = log(&["s3cr3t"]); | |
| 452 | log.line("one s3cr3t\ntwo"); | |
| 453 | let lines: Vec<&str> = log.buffer.lines().collect(); | |
| 454 | assert_eq!(lines.len(), 2, "each line of a message is stamped"); | |
| 455 | for (line, text) in lines.iter().zip(["one ***", "two"]) { | |
| 456 | let (at, rest) = line.split_once(' ').unwrap(); | |
| 457 | assert_eq!((at.len(), &at[10..11], &at[27..]), (28, "T", "Z"), "{line}"); | |
| 458 | assert_eq!(rest, text); | |
| 459 | } | |
| 460 | assert_eq!(log.buffered(), "one ***\ntwo\n"); | |
| 461 | } | |
| 462 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 463 | fn log(secrets: &[&str]) -> Log { |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 464 | Log::new(Api::offline(), mask::all_variants(secrets.iter().copied())) |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 465 | } |
| 466 | ||
| Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9 | 467 | /// A server that answers the first `fail` requests with a 500 and the |
| 468 | /// rest with `{}`, and hands back each request's body. | |
| 469 | fn flaky_server(fail: usize, requests: usize) -> (String, std::thread::JoinHandle<Vec<Value>>) { | |
| 470 | use std::io::{BufRead, BufReader, Read, Write}; | |
| 471 | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 472 | let base = format!("http://{}", listener.local_addr().unwrap()); | |
| 473 | let serving = std::thread::spawn(move || { | |
| 474 | let mut bodies = Vec::new(); | |
| 475 | for answered in 0..requests { | |
| 476 | let (stream, _) = listener.accept().unwrap(); | |
| 477 | let mut reader = BufReader::new(stream); | |
| 478 | let mut length = 0; | |
| 479 | loop { | |
| 480 | let mut header = String::new(); | |
| 481 | reader.read_line(&mut header).unwrap(); | |
| 482 | if header.trim().is_empty() { | |
| 483 | break; | |
| 484 | } | |
| 485 | if let Some((name, value)) = header.split_once(':') | |
| 486 | && name.eq_ignore_ascii_case("content-length") | |
| 487 | { | |
| 488 | length = value.trim().parse().unwrap(); | |
| 489 | } | |
| 490 | } | |
| 491 | let mut body = vec![0; length]; | |
| 492 | reader.read_exact(&mut body).unwrap(); | |
| 493 | bodies.push(serde_json::from_slice::<Value>(&body).unwrap()); | |
| 494 | let status = if answered < fail { "500 Internal Server Error" } else { "200 OK" }; | |
| 495 | write!(reader.get_mut(), "HTTP/1.1 {status}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap(); | |
| 496 | } | |
| 497 | bodies | |
| 498 | }); | |
| 499 | (base, serving) | |
| 500 | } | |
| 501 | ||
| 502 | #[test] | |
| 503 | fn each_batch_of_the_log_keeps_its_number_when_sent_again() { | |
| 504 | let (base, serving) = flaky_server(1, 3); | |
| 505 | let mut log = Log::new(Api { base, job: "job_1".into(), token: "t".into() }, Vec::new()); | |
| 506 | log.line("one"); | |
| 507 | log.flush(); | |
| 508 | log.line("two"); | |
| 509 | log.flush(); | |
| 510 | log.flush(); // Nothing waits: no batch, and no number used. | |
| 511 | let bodies = serving.join().unwrap(); | |
| 512 | let sent: Vec<(u64, String)> = bodies | |
| 513 | .iter() | |
| 514 | .map(|body| (body["report"]["chunk"].as_u64().unwrap(), unstamped(body["report"]["text"].as_str().unwrap()))) | |
| 515 | .collect(); | |
| 516 | // The first batch failed and was sent again with its number; the | |
| 517 | // next is the job's second. | |
| 518 | assert_eq!(sent, [(1, "one\n".into()), (1, "one\n".into()), (2, "two\n".into())]); | |
| 519 | assert_eq!(bodies[0], bodies[1], "a batch sent again is the same batch"); | |
| 520 | } | |
| 521 | ||
| 522 | #[test] | |
| 523 | fn a_long_wait_says_so_in_the_log_as_it_goes() { | |
| 524 | let mut log = log(&[]); | |
| 525 | let answer = log.waiting(Duration::from_millis(50), |_| { | |
| 526 | std::thread::sleep(Duration::from_millis(180)); | |
| 527 | 42 | |
| 528 | }, |seconds| format!("still waiting ({seconds}s)")); | |
| 529 | assert_eq!(answer, 42); | |
| 530 | let lines = log.buffered(); | |
| 531 | let told = lines.lines().filter(|line| line.starts_with("still waiting (")).count(); | |
| 532 | assert!((2..=4).contains(&told), "a line each time the wait goes on: {lines:?}"); | |
| 533 | // A wait that ends at once writes nothing. | |
| 534 | let mut quick = super::tests::log(&[]); | |
| 535 | assert_eq!(quick.waiting(Duration::from_secs(5), |api| api.job, |_| "never".into()), "job_1"); | |
| 536 | assert_eq!(quick.buffered(), ""); | |
| 537 | } | |
| 538 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 539 | #[test] |
| 540 | fn outputs_holding_a_secret_are_left_out() { | |
| 541 | let mut log = log(&["s3cr3t-token"]); | |
| 542 | let mut outputs = serde_json::Map::new(); | |
| 543 | outputs.insert("version".into(), json!("1.2.0")); | |
| 544 | outputs.insert("leak".into(), json!("token=s3cr3t-token")); | |
| 545 | outputs.insert("encoded".into(), json!("czNjcjN0LXRva2Vu")); | |
| 546 | let kept = log.withhold_secrets(&outputs); | |
| 547 | assert_eq!(kept.keys().collect::<Vec<_>>(), ["version"]); | |
| 548 | assert!(log.buffer.contains("The output `leak` was left out")); | |
| 549 | assert!(log.buffer.contains("The output `encoded` was left out")); | |
| 550 | } | |
| 551 | ||
| 552 | #[test] | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 553 | fn a_cancelled_answer_is_heard_once_and_taken_in() { |
| 554 | let cancel = Cancel::new(); | |
| 555 | cancel.hear(&json!({ "ok": true })); | |
| 556 | assert!(!cancel.cancelled()); | |
| 557 | cancel.hear(&json!({ "ok": true, "cancelled": true })); | |
| 558 | assert!(cancel.cancelled() && cancel.interrupt()); | |
| 559 | cancel.take(); | |
| 560 | assert!(cancel.cancelled() && !cancel.interrupt(), "cleanup steps are not stopped again"); | |
| 561 | cancel.hear(&json!({ "ok": true, "cancelled": false })); | |
| 562 | assert!(cancel.cancelled(), "a cancelled job stays cancelled"); | |
| 563 | } | |
| 564 | ||
| 565 | #[test] | |
| 566 | fn a_summary_past_its_limit_is_refused_in_the_log() { | |
| 567 | let mut log = log(&[]); | |
| 568 | log.summary(" | |
| 569 | "); | |
| 570 | assert!(log.buffer.is_empty(), "an empty summary is not sent"); | |
| 571 | log.summary(&"x".repeat(MAX_SUMMARY_BYTES + 1)); | |
| 572 | assert!(log.buffer.contains("$GITHUB_STEP_SUMMARY upload aborted")); | |
| 573 | assert!(log.buffer.contains("1025k")); | |
| 574 | } | |
| 575 | ||
| 576 | #[test] | |
| Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9 | 577 | fn annotations_carry_their_columns_and_end() { |
| 578 | let log = log(&["hidden"]); | |
| 579 | let properties: serde_json::Map<String, Value> = | |
| 580 | serde_json::from_value(json!({ "file": "a.rs", "line": "3", "col": "7", "endLine": "4", "endColumn": "2", "title": "t hidden" })).unwrap(); | |
| 581 | let report = log.annotation_report("error", "bad hidden", &properties); | |
| 582 | assert_eq!(report["line"], 3); | |
| 583 | assert_eq!(report["column"], 7); | |
| 584 | assert_eq!(report["end_line"], 4); | |
| 585 | assert_eq!(report["end_column"], 2); | |
| 586 | assert_eq!(report["title"], "t ***"); | |
| 587 | assert_eq!(report["message"], "bad ***"); | |
| 588 | let bare = log.annotation_report("notice", "x", &serde_json::Map::new()); | |
| 589 | assert!(bare["column"].is_null() && bare["file"].is_null()); | |
| 590 | } | |
| 591 | ||
| 592 | #[test] | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 593 | fn added_masks_cover_every_form() { |
| 594 | let mut log = log(&[]); | |
| 595 | log.add_mask("line one\nline two"); | |
| 596 | assert_eq!(log.mask("first: line one"), "first: ***"); | |
| 597 | assert_eq!(log.mask("then line two"), "then ***"); | |
| 598 | // Longest first: the whole value, not its pieces. | |
| 599 | log.add_mask("abc"); | |
| 600 | log.add_mask("abcdef"); | |
| 601 | assert_eq!(log.mask("abcdef"), "***"); | |
| 602 | } | |
| What g1t can't do yet, and an open letter to Cloudflare | 603 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.