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