Skip to content
603 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

What g1t can't do yet, and an open letter to Cloudflare1//! Telling g1t how a job is going: its steps, its log in batches, its
2//! annotations, and how it ended. Every report carries the job's token.
3
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)4use std::sync::atomic::{AtomicBool, Ordering};
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.5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
What g1t can't do yet, and an open letter to Cloudflare6
7use anyhow::Result;
Merge branch 'worktree-agent-a3abfcce648e87dca'8use g1t_actions::mask;
What g1t can't do yet, and an open letter to Cloudflare9use 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.
13const FLUSH_EVERY: Duration = Duration::from_millis(1000);
What g1t can't do yet, and an open letter to Cloudflare14/// How much log is sent at once.
15const 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.
17pub(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.
20const PING_EVERY: Duration = Duration::from_secs(10);
21
22/// Whether the job was cancelled, as g1t's answers to its reports say.
23struct 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
31impl 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 Cloudflare46
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).
57static CANCEL: Cancel = Cancel::new();
58
59/// Whether g1t said the job was cancelled.
60pub(crate) fn cancelled() -> bool {
61 CANCEL.cancelled()
62}
63
64/// Whether a running step should be stopped: cancelled, and not yet taken in.
65pub(crate) fn interrupt() -> bool {
66 CANCEL.interrupt()
67}
68
69/// The job has seen the cancellation: what runs from here is its cleanup.
70pub(crate) fn take_cancel() {
71 CANCEL.take();
72}
73
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r974#[derive(Clone)]
What g1t can't do yet, and an open letter to Cloudflare75pub(crate) struct Api {
76 pub(crate) base: String,
77 pub(crate) job: String,
78 pub(crate) token: String,
79}
80
81impl 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 timeouts89 /// 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 timeouts93 /// 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 timeouts98 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 timeouts100 .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 Cloudflare136 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 Cloudflare141 // 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 Cloudflare154 // 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 Cloudflare164pub(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-7nm8r9172 /// 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-7nm8r9174 /// 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 Cloudflare182}
183
184impl 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 Cloudflare188 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-7nm8r9195 matchers: super::matchers::Matchers::default(),
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9196 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 Cloudflare199 }
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 Cloudflare209 /// 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 Cloudflare227 }
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 Cloudflare232 }
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 Cloudflare237 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 Cloudflare246 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 Cloudflare254 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 Cloudflare260 }
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 Cloudflare269 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-7nm8r9272 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 Cloudflare280 }
281
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9282 /// 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 Cloudflare317 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-7nm8r9346 /// 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 Cloudflare350 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-7nm8r9351 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 Cloudflare356 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-7nm8r9358 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 Cloudflare360 "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 Cloudflare364 "file": properties.get("file"),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9365 "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 Cloudflare370 }
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 Cloudflare374 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`).
400pub(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`).
416fn 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.431pub(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'441mod 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-7nm8r9467 /// 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-7nm8r9577 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 Cloudflare603}

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