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

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1//! The runner's calls to g1t: outbound HTTPS only, JSON in `snake_case`.
2//! See `g1t_contracts::runners` for the shapes.
3
4use std::time::Duration;
5
6use anyhow::{Result, anyhow};
7use serde::Deserialize;
8use serde_json::{Map, Value, json};
9
10use super::VERSION;
11
12#[derive(Clone, Debug, Deserialize)]
13pub struct Assignment {
14 pub kind: String,
15 pub id: String,
16 pub name: String,
17 pub repo: String,
18 pub timeout_minutes: u32,
19 #[serde(default)]
20 pub image: Option<String>,
21 #[serde(default)]
22 pub token: Option<String>,
23 #[serde(default)]
24 pub env: Option<Map<String, Value>>,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r925 /// `public` or `private`; none from an older g1t.
26 #[serde(default)]
27 pub visibility: Option<String>,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays28}
29
30#[derive(Debug, Default, Deserialize)]
31pub struct Poll {
32 #[serde(default)]
33 pub assignment: Option<Assignment>,
34 #[serde(default)]
35 pub cancel: Vec<String>,
36 #[serde(default)]
37 pub credential: Option<String>,
38 #[serde(default)]
39 pub removed: bool,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct RegisteredRunner {
44 pub id: String,
45 pub name: String,
46 pub workspace: String,
47 #[serde(default)]
48 pub repo: Option<String>,
49 #[serde(default)]
50 pub group: Option<String>,
51 pub labels: Vec<String>,
52}
53
54#[derive(Debug, Deserialize)]
55pub struct Registered {
56 pub runner: RegisteredRunner,
57 pub credential: String,
58}
59
60/// How a call failed: g1t refused it (with its status and message), or it
61/// did not get through.
62#[derive(Debug)]
63pub enum Failure {
64 Refused(u16, String),
65 Unreachable(String),
66}
67
68impl std::fmt::Display for Failure {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Failure::Refused(status, message) => write!(f, "{message} ({status})"),
72 Failure::Unreachable(why) => write!(f, "could not reach g1t: {why}"),
73 }
74 }
75}
76
77fn agent() -> ureq::Agent {
78 ureq::AgentBuilder::new()
79 .timeout_connect(Duration::from_secs(15))
80 .timeout_read(Duration::from_secs(60))
81 .user_agent(&format!("g1t-runner/{VERSION} ({}; {})", std::env::consts::OS, std::env::consts::ARCH))
82 .build()
83}
84
85fn post(url: &str, credential: Option<&str>, body: &Value) -> std::result::Result<Value, Failure> {
86 let mut request = agent().post(url);
87 if let Some(credential) = credential {
88 request = request.set("authorization", &format!("Bearer {credential}"));
89 }
90 match request.send_json(body.clone()) {
91 Ok(response) => response.into_json::<Value>().map_err(|e| Failure::Unreachable(e.to_string())),
92 Err(ureq::Error::Status(status, response)) => {
93 let text = response.into_string().unwrap_or_default();
94 let message = serde_json::from_str::<Value>(&text)
95 .ok()
96 .and_then(|v| v["error"]["message"].as_str().map(str::to_owned))
97 .unwrap_or(text);
98 Err(Failure::Refused(status, message))
99 }
100 Err(error) => Err(Failure::Unreachable(error.to_string())),
101 }
102}
103
104pub struct Api {
105 pub base: String,
106 pub runner: String,
107 pub credential: String,
108}
109
110impl Api {
111 pub fn register(base: &str, body: &Value) -> Result<Registered> {
112 let answer = post(&format!("{base}/runners/register"), None, body).map_err(|failure| anyhow!("{failure}"))?;
113 Ok(serde_json::from_value(answer)?)
114 }
115
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.116 /// Asks for work. `isolation` is how this runner keeps work from its
117 /// machine now, which g1t keeps up to date from each poll (it gives a
118 /// host-mode runner no public repository's work); an older g1t ignores it.
119 pub fn poll(&self, running: &[String], wait_ms: u64, isolation: &str) -> std::result::Result<Poll, Failure> {
120 let body = poll_body(running, wait_ms, isolation);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays121 let answer = post(&format!("{}/runners/{}/poll", self.base, self.runner), Some(&self.credential), &body)?;
122 serde_json::from_value(answer).map_err(|e| Failure::Unreachable(e.to_string()))
123 }
124
125 pub fn finished(&self, id: &str, exit_code: i32, reason: Option<&str>) -> std::result::Result<(), Failure> {
126 let body = json!({ "id": id, "exit_code": exit_code, "reason": reason });
127 // Tried a few times: a job that crashed should not wait for the
128 // silence check to be noticed.
129 let mut last = None;
130 for attempt in 0..3 {
131 match post(&format!("{}/runners/{}/finished", self.base, self.runner), Some(&self.credential), &body) {
132 Ok(_) => return Ok(()),
133 Err(Failure::Refused(status, message)) => return Err(Failure::Refused(status, message)),
134 Err(failure) => {
135 last = Some(failure);
136 std::thread::sleep(Duration::from_secs(2 * (attempt + 1)));
137 }
138 }
139 }
140 Err(last.unwrap_or(Failure::Unreachable("no answer".into())))
141 }
142
143 pub fn remove(&self) -> std::result::Result<(), Failure> {
144 post(&format!("{}/runners/{}/remove", self.base, self.runner), Some(&self.credential), &json!({})).map(|_| ())
145 }
146}
147
148/// Fetches a file's bytes.
149pub fn download(url: &str) -> Result<Vec<u8>> {
150 let response = agent().get(url).timeout(Duration::from_secs(300)).call().map_err(|e| anyhow!("could not download {url}: {e}"))?;
151 let mut bytes = Vec::new();
152 std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)?;
153 Ok(bytes)
154}
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.155
156fn poll_body(running: &[String], wait_ms: u64, isolation: &str) -> Value {
157 json!({ "version": VERSION, "running": running, "wait_ms": wait_ms, "isolation": isolation })
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn a_poll_says_how_the_runner_is_isolated() {
166 let body = poll_body(&["job_1".to_owned()], 0, "host");
167 assert_eq!(body["isolation"], "host");
168 assert_eq!(body["running"], json!(["job_1"]));
169 assert_eq!(body["version"], VERSION);
170 }
171}

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