Skip to content
646 linesCodeBlameRaw

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

The docs' Copy page menu keeps its own spacing, with an icon per item1//! 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};
The docs' Copy page menu keeps its own spacing, with an icon per item6
7use anyhow::Result;
Merge branch 'worktree-agent-a3abfcce648e87dca'8use g1t_actions::mask;
The docs' Copy page menu keeps its own spacing, with an icon per item9use 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);
The docs' Copy page menu keeps its own spacing, with an icon per item14/// 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 }
The docs' Copy page menu keeps its own spacing, with an icon per item46
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 remote-tracking branch 'origin/claude/cool-newton-7nm8r9'74#[derive(Clone)]
The docs' Copy page menu keeps its own spacing, with an icon per item75pub(crate) struct Api {
76 pub(crate) base: String,
77 pub(crate) job: String,
78 pub(crate) token: String,
79}
80
81impl 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-agents87 ///
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-agents96 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 item115 }
116
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts117 /// 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 timeouts121 /// 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 timeouts126 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 timeouts128 .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 item164 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 item169 // 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 item182 // 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 item192pub(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-7nm8r9200 /// 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 item210}
211
212impl 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 item216 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-7nm8r9223 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 item227 }
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 item237 /// 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 item255 }
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 item260 }
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 item265 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 item274 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 item282 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 item288 }
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 item297 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 item308 }
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 item345 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-7nm8r9374 /// 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 item378 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-7nm8r9379 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 item384 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-7nm8r9386 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 item388 "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 item392 "file": properties.get("file"),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9393 "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 item398 }
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 item402 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`).
428pub(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`).
444fn 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.459pub(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'469mod 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-7nm8r9620 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 item646}

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