Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
2,111 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.

Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1//! Self-hosted runners: registering them, handing them work, hearing back,
2//! and the people's side (listing, removing, groups, settings). See
3//! `g1t_contracts::runners` for the model and the wire.
4//!
5//! Work reaches a runner only when it asks for it (`runner_poll`), so a
6//! runner never needs an open port. A workflow job is taken by flipping its
7//! row from `queued` to `in_progress` in one statement, exactly as g1t's
8//! own sandboxes take one, and the harness on the machine then fetches it
9//! and reports with the job's own token through the same API. Agent work
10//! from the runner service is kept as a task with its environment sealed,
11//! and handed over once.
12
13use g1t_contracts::access::Capability;
14use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
15use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs};
16use g1t_contracts::repos::{Repo, RepoPath};
17use g1t_contracts::runners::{
The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.18 self as model, ACTIVITY_LIMIT, CloudJob, DesktopCapability, DesktopDiagnosis, DesktopJob, DesktopRunner, DesktopUpdate, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running19 ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner,
20 RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs,
21 SetRunnerSettingsArgs, Wanted,
22};
23use g1t_contracts::time::rfc3339;
24use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
25use g1t_kit::now_ms;
26use g1t_secrets::{random_hex, sha256_hex};
27use serde::Deserialize;
28use serde_json::{Map, Value, json};
29use worker::Result;
30use worker::wasm_bindgen::JsValue;
31
32use crate::plan::JobRow;
33use crate::{Actions, Count, SILENT_MS, check, fail, optional};
34
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.35/// What one poll has learned while it looks for work, so its looks again
36/// do not ask again: each repository's visibility, and the jobs it already
37/// said wait for a sandboxed runner.
38#[derive(Default)]
39struct Looked {
40 visibilities: std::collections::HashMap<String, Option<String>>,
41 noted: std::collections::HashSet<String>,
42}
43
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running44/// How often a poll looks again for work while it waits.
45const POLL_EVERY_MS: u64 = 2_500;
46/// A runner that has been offline this long is removed, as on GitHub.
47const FORGET_OFFLINE_MS: u64 = 14 * 24 * 60 * 60 * 1000;
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails48/// How often a poll writes down that a runner, or the work it holds, is
49/// still there. Polls come every twenty seconds or so; a write each time
50/// would be most of the service's writes for nothing. Well inside
51/// [`model::ONLINE_WITHIN_MS`] and [`crate::SILENT_MS`].
52const SEEN_RESOLUTION_MS: u64 = 60 * 1000;
53
54/// Whether `seen` is old enough, at `at`, to be written down again.
55fn seen_is_stale(seen: Option<&str>, at: u64) -> bool {
56 seen.is_none_or(|seen| seen < rfc3339(at.saturating_sub(SEEN_RESOLUTION_MS)).as_str())
57}
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running58
59#[derive(Clone, Debug, Deserialize)]
60pub struct RunnerRow {
61 pub id: String,
62 pub workspace: String,
63 pub repo_id: Option<String>,
64 pub repo: Option<String>,
65 pub group_id: Option<String>,
66 pub name: String,
67 pub labels: String,
68 pub os: String,
69 pub arch: String,
70 pub version: String,
71 pub ephemeral: u32,
72 pub credential_hash: String,
73 pub previous_hash: Option<String>,
74 pub rotated_at: String,
75 pub work_id: Option<String>,
76 pub work_kind: Option<String>,
77 pub spent: u32,
78 pub last_seen_at: Option<String>,
79 pub created_at: String,
80 pub created_by: Option<String>,
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.81 /// `nested-container`, `container` or `host` (migration 0015); none
82 /// for a runner that never said.
83 #[serde(default)]
84 pub isolation: Option<String>,
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.85 /// Which computer it registered from (migration 0022): a hash of the
86 /// fingerprint it sent, with its workspace; none for a runner that
87 /// never said.
88 #[serde(default)]
89 pub machine_hash: Option<String>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running90}
91
92impl RunnerRow {
93 fn labels(&self) -> Vec<String> {
94 serde_json::from_str(&self.labels).unwrap_or_default()
95 }
96
97 fn online(&self, now: u64) -> bool {
98 self.last_seen_at.as_deref().is_some_and(|seen| seen >= rfc3339(now.saturating_sub(model::ONLINE_WITHIN_MS)).as_str())
99 }
100}
101
102#[derive(Clone, Debug, Deserialize)]
103struct GroupRow {
104 id: String,
105 name: String,
106 is_default: u32,
107 repositories: String,
108 updated_at: String,
109}
110
111impl GroupRow {
112 fn repositories(&self) -> Vec<String> {
113 serde_json::from_str(&self.repositories).unwrap_or_default()
114 }
115
116 /// Whether `repo` (`owner/name`, or a bare name) may use the group.
117 fn allows(&self, repo: &str) -> bool {
118 let name = repo.rsplit('/').next().unwrap_or(repo);
119 let listed = self.repositories();
120 listed.is_empty() || listed.iter().any(|r| r.eq_ignore_ascii_case(name))
121 }
122}
123
124#[derive(Clone, Debug, Deserialize)]
125struct RegistrationRow {
126 workspace: String,
127 repo_id: Option<String>,
128 repo: Option<String>,
129 group_id: Option<String>,
130 expires_at: String,
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.131 /// Who made the token: the runners it registers are theirs.
132 #[serde(default)]
133 created_by: Option<String>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running134}
135
136#[derive(Clone, Debug, Deserialize)]
137struct SettingsRow {
138 agents: u32,
139 agent_labels: String,
140 fork_pulls: u32,
141}
142
143#[derive(Clone, Debug, Deserialize)]
144pub struct TaskRow {
145 pub id: String,
146 pub sandbox: String,
147 pub repo_id: Option<String>,
148 pub repo: String,
149 pub kind: String,
150 pub title: String,
151 pub labels: String,
152 pub env: Option<String>,
153 pub timeout_minutes: u32,
154 pub status: String,
155 pub runner_id: Option<String>,
156 pub runner_name: Option<String>,
157 pub started_at: Option<String>,
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails158 #[serde(default)]
159 pub seen_at: Option<String>,
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.160 /// The agent run it belongs to (migration 0012); none for older tasks.
161 #[serde(default)]
162 pub run_id: Option<String>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running163}
164
165/// Who may do what with a set of runners: the workspace, and the
166/// repository when they are a repository's own.
167struct Place {
168 workspace: String,
169 repo: Option<Repo>,
170}
171
172fn now() -> String {
173 rfc3339(now_ms())
174}
175
176fn valid_name(name: &str) -> std::result::Result<String, String> {
177 let name = name.trim();
178 let ok = !name.is_empty()
179 && name.len() <= 64
180 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
181 if ok {
182 Ok(name.to_owned())
183 } else {
184 Err("A runner's name is 1 to 64 letters, digits, `-`, `_` and `.`.".to_owned())
185 }
186}
187
188fn valid_group_name(name: &str) -> std::result::Result<String, String> {
189 let name = name.trim();
190 if name.is_empty() || name.len() > 64 || name.chars().any(|c| c.is_control()) {
191 return Err("A group's name is 1 to 64 characters.".to_owned());
192 }
193 Ok(name.to_owned())
194}
195
196/// What a runner's credential is checked against, and what a registration
197/// token is found by.
198fn hash(secret: &str) -> String {
199 sha256_hex(secret)
200}
201
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.202/// What a runner's machine fingerprint is kept as: hashed with its
203/// workspace, so the same computer is not recognisable across workspaces.
204/// A fingerprint is 32 to 128 hex digits; anything else counts as none.
205fn machine_hash(workspace: &str, machine: Option<&str>) -> Option<String> {
206 let machine = machine.map(str::trim).filter(|m| (32..=128).contains(&m.len()) && m.chars().all(|c| c.is_ascii_hexdigit()))?;
207 Some(hash(&format!("{workspace}\n{}", machine.to_ascii_lowercase())))
208}
209
210/// Whether a registration takes the place of `existing`, a runner of the
211/// same name: when asked to (`--replace`); when it is the same computer
212/// registering again (its sandbox made again, its state lost); or, for a
213/// runner from before runners said which computer they are, when the same
214/// person registered it and it is no longer heard from.
215fn takes_over(existing: &RunnerRow, replace: bool, machine_hash: Option<&str>, created_by: Option<&str>, now: u64) -> bool {
216 let same_machine = machine_hash.is_some() && existing.machine_hash.as_deref() == machine_hash;
217 let left_behind = existing.machine_hash.is_none() && created_by.is_some() && existing.created_by.as_deref() == created_by && !existing.online(now);
218 replace || same_machine || left_behind
219}
220
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running221/// The actor of a runner's own entries in the audit log.
222fn runner_actor(runner: &RunnerRow) -> AuditActor {
223 AuditActor {
224 actor_kind: Some(g1t_contracts::audit::ActorKind::Runner),
225 actor: runner.name.clone(),
226 actor_id: runner.id.clone(),
227 ..AuditActor::default()
228 }
229}
230
231fn entry(actor: AuditActor, action: &str, workspace: &str, repo: Option<String>, path: Option<String>, message: String) -> NewAuditEntry {
232 NewAuditEntry {
233 actor,
234 action: action.to_owned(),
235 surface: Surface::Rest,
236 target: AuditTarget {
237 workspace: workspace.to_owned(),
238 repo,
239 path,
240 ..AuditTarget::default()
241 },
242 outcome: AuditOutcome::Allowed,
243 rule: "runner".to_owned(),
244 result: Some("ok".to_owned()),
245 message: Some(message),
246 request_id: new_id("req", now_ms()),
247 }
248}
249
250impl Actions {
251 // --- Shared ------------------------------------------------------------
252
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.253 pub(crate) async fn audit(&self, entries: Vec<NewAuditEntry>) {
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running254 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
255 if let Err(error) = recorded {
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.256 worker::console_error!("actions: audit entries not recorded: {error}");
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running257 }
258 }
259
260 /// Where `owner` points, if `actor` may see it (`manage` false) or
261 /// change it (`manage` true): a workspace's runners are seen by its
262 /// members and changed by its owners; a repository's own, by its
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.263 /// admins. Workspace tokens never change them, so a workflow's
264 /// `G1T_TOKEN` cannot add a machine to run its own jobs.
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running265 async fn runner_place(&self, actor: &User, owner: &RunnersOwner, manage: bool) -> Result<Outcome<Place>> {
266 if manage && actor.kind == PrincipalKind::Workspace {
267 return Ok(fail(
268 FailureCode::Forbidden,
269 "A workspace's tokens, G1T_TOKEN included, cannot change self-hosted runners. Use a person's token with the runners:admin scope.",
270 ));
271 }
272 match (&owner.repo, &owner.workspace) {
273 (Some(path), _) => {
274 let repo = check!(self.may(actor, path, Capability::ManageIntegrations).await?);
275 Ok(Outcome::Ok(Place { workspace: repo.namespace.to_lowercase(), repo: Some(repo) }))
276 }
277 (None, Some(slug)) => {
278 let slug = slug.to_lowercase();
279 let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
280 match role {
281 None => Ok(fail(FailureCode::NotFound, "There is no such workspace, or you are not a member of it.")),
282 // A workspace's machines and their tokens are its owners' alone, to see as well as change.
283 Some(Role::Member) => Ok(fail(
284 FailureCode::Forbidden,
285 format!("Only owners of {slug} can {} its self-hosted runners.", if manage { "change" } else { "see" }),
286 )),
287 Some(_) => Ok(Outcome::Ok(Place { workspace: slug, repo: None })),
288 }
289 }
290 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
291 }
292 }
293
294 async fn group_rows(&self, workspace: &str) -> Result<Vec<GroupRow>> {
295 self.db
296 .prepare("SELECT * FROM runner_groups WHERE workspace = ? ORDER BY is_default DESC, name")
297 .bind(&[workspace.into()])?
298 .all()
299 .await?
300 .results::<GroupRow>()
301 }
302
303 /// The workspace's default group, made the first time it is needed.
304 async fn default_group(&self, workspace: &str) -> Result<GroupRow> {
305 let at = now();
306 self.db
307 .prepare(
308 "INSERT OR IGNORE INTO runner_groups (id, workspace, name, is_default, repositories, created_at, updated_at)
309 VALUES (?, ?, 'Default', 1, '[]', ?, ?)",
310 )
311 .bind(&[new_id("rng", now_ms()).into(), workspace.into(), at.as_str().into(), at.as_str().into()])?
312 .run()
313 .await?;
314 let row = self
315 .db
316 .prepare("SELECT * FROM runner_groups WHERE workspace = ? AND is_default = 1 LIMIT 1")
317 .bind(&[workspace.into()])?
318 .first::<GroupRow>(None)
319 .await?;
320 row.ok_or_else(|| worker::Error::RustError("the default runner group could not be made".into()))
321 }
322
323 /// A group of the workspace by id or name.
324 async fn find_group(&self, workspace: &str, wanted: &str) -> Result<Option<GroupRow>> {
325 self.default_group(workspace).await?;
326 Ok(self
327 .group_rows(workspace)
328 .await?
329 .into_iter()
330 .find(|g| g.id == wanted || g.name.eq_ignore_ascii_case(wanted.trim())))
331 }
332
333 async fn runner_rows(&self, workspace: &str) -> Result<Vec<RunnerRow>> {
334 self.db
335 .prepare("SELECT * FROM runners WHERE workspace = ? ORDER BY name")
336 .bind(&[workspace.into()])?
337 .all()
338 .await?
339 .results::<RunnerRow>()
340 }
341
342 async fn runner_by_id(&self, id: &str) -> Result<Option<RunnerRow>> {
343 self.db.prepare("SELECT * FROM runners WHERE id = ?").bind(&[id.into()])?.first::<RunnerRow>(None).await
344 }
345
346 /// What a runner is doing, for people.
347 async fn work_of(&self, row: &RunnerRow) -> Result<Option<RunnerWork>> {
348 let Some(id) = &row.work_id else { return Ok(None) };
349 if row.work_kind.as_deref() == Some("agent") {
350 let task = self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[id.as_str().into()])?.first::<TaskRow>(None).await?;
351 return Ok(task.filter(|t| t.status == "in_progress").map(|t| RunnerWork {
352 kind: "agent".into(),
353 id: t.id,
354 name: t.title,
355 repo: Some(t.repo),
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.356 run_id: t.run_id,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running357 started_at: t.started_at,
358 }));
359 }
360 #[derive(Deserialize)]
361 struct Found {
362 id: String,
363 name: String,
364 run_id: String,
365 status: String,
366 started_at: Option<String>,
367 repo: String,
368 }
369 let job = self
370 .db
371 .prepare("SELECT jobs.id, jobs.name, jobs.run_id, jobs.status, jobs.started_at, runs.repo FROM jobs JOIN runs ON runs.id = jobs.run_id WHERE jobs.id = ?")
372 .bind(&[id.as_str().into()])?
373 .first::<Found>(None)
374 .await?;
375 Ok(job.filter(|j| j.status == "in_progress").map(|j| RunnerWork {
376 kind: "workflow".into(),
377 id: j.id,
378 name: j.name,
379 repo: Some(j.repo),
380 run_id: Some(j.run_id),
381 started_at: j.started_at,
382 }))
383 }
384
385 async fn runner_view(&self, row: &RunnerRow, groups: &[GroupRow], at: u64) -> Result<Runner> {
386 let online = row.online(at);
387 let work = if online { self.work_of(row).await? } else { None };
388 Ok(Runner {
389 id: row.id.clone(),
390 name: row.name.clone(),
391 workspace: row.workspace.clone(),
392 repo: row.repo.clone(),
393 group: row
394 .group_id
395 .as_ref()
396 .and_then(|id| groups.iter().find(|g| &g.id == id))
397 .map(|g| g.name.clone()),
398 labels: row.labels(),
399 os: row.os.clone(),
400 arch: row.arch.clone(),
401 version: row.version.clone(),
402 ephemeral: row.ephemeral != 0,
403 status: if !online {
404 "offline"
405 } else if work.is_some() {
406 "busy"
407 } else {
408 "online"
409 }
410 .to_owned(),
411 work,
412 last_seen_at: row.last_seen_at.clone(),
413 created_at: row.created_at.clone(),
414 created_by: row.created_by.clone(),
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.415 isolation: row.isolation.clone(),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running416 })
417 }
418
419 // --- People's side -----------------------------------------------------
420
421 /// `runners`: a workspace's runners, or a repository's: its own and
422 /// the workspace's that its group lets it use.
423 pub async fn runners(&self, a: ListRunnersArgs) -> Result<Outcome<Vec<Runner>>> {
424 let place = check!(self.runner_place(&a.actor, &a.owner, false).await?);
425 self.default_group(&place.workspace).await?;
426 let groups = self.group_rows(&place.workspace).await?;
427 let at = now_ms();
428 let mut out = Vec::new();
429 for row in self.runner_rows(&place.workspace).await? {
430 let shown = match &place.repo {
431 None => row.repo_id.is_none(),
432 Some(repo) => match &row.repo_id {
433 Some(id) => id == &repo.id,
434 None => groups.iter().find(|g| Some(&g.id) == row.group_id.as_ref()).is_none_or(|g| g.allows(&repo.name)),
435 },
436 };
437 if shown {
438 out.push(self.runner_view(&row, &groups, at).await?);
439 }
440 }
441 Ok(Outcome::Ok(out))
442 }
443
444 /// `create_registration_token`.
445 pub async fn create_registration_token(&self, a: CreateRegistrationTokenArgs) -> Result<Outcome<RegistrationToken>> {
446 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
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.447 // A workspace g1t staff put a hold on registers no new runners.
448 if self.standing(&place.workspace).await.is_some_and(|standing| standing.held) {
449 return Ok(fail(
450 FailureCode::Paused,
451 "g1t staff put a hold on this workspace, so it cannot register new runners. Write to hey@flagon.io if you think this is a mistake.",
452 ));
453 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running454 let made = self
455 .db
456 .prepare("SELECT COUNT(*) AS n FROM runner_registrations WHERE workspace = ? AND created_at > ?")
457 .bind(&[place.workspace.as_str().into(), rfc3339(now_ms().saturating_sub(60 * 60 * 1000)).into()])?
458 .first::<Count>(None)
459 .await?
460 .map_or(0, |c| c.n);
461 if made >= model::MAX_TOKENS_PER_HOUR {
462 return Ok(fail(FailureCode::Conflict, "Too many registration tokens in the last hour. Each lasts an hour and registers any number of runners."));
463 }
464 let group = match (&place.repo, a.group.as_deref().map(str::trim).filter(|g| !g.is_empty())) {
465 (Some(_), Some(_)) => return Ok(fail(FailureCode::Invalid, "A repository's own runners are in no group.")),
466 (Some(_), None) => None,
467 (None, Some(wanted)) => match self.find_group(&place.workspace, wanted).await? {
468 Some(group) => Some(group),
469 None => return Ok(fail(FailureCode::NotFound, format!("There is no runner group called {wanted}."))),
470 },
471 (None, None) => Some(self.default_group(&place.workspace).await?),
472 };
473 let token = format!("{}{}", model::REGISTRATION_PREFIX, random_hex(24));
474 let at = now_ms();
475 let expires_at = rfc3339(at + model::REGISTRATION_TTL_SECONDS * 1000);
476 let repo_name = place.repo.as_ref().map(|r| format!("{}/{}", r.namespace, r.name));
477 self.db
478 .prepare(
479 "INSERT INTO runner_registrations (token_hash, workspace, repo_id, repo, group_id, created_by, created_at, expires_at)
480 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
481 )
482 .bind(&[
483 hash(&token).into(),
484 place.workspace.as_str().into(),
485 optional(place.repo.as_ref().map(|r| r.id.as_str())),
486 optional(repo_name.as_deref()),
487 optional(group.as_ref().map(|g| g.id.as_str())),
488 a.actor.username.as_str().into(),
489 rfc3339(at).into(),
490 expires_at.as_str().into(),
491 ])?
492 .run()
493 .await?;
494 self.audit(vec![entry(
495 AuditActor::of(&a.actor),
496 "create_runner_registration_token",
497 &place.workspace,
498 repo_name.clone(),
499 None,
500 format!("Made a runner registration token{}.", group.as_ref().map(|g| format!(" for group {}", g.name)).unwrap_or_default()),
501 )])
502 .await;
503 Ok(Outcome::Ok(RegistrationToken {
504 token,
505 expires_at,
506 workspace: place.workspace,
507 repo: repo_name,
508 group: group.map(|g| g.name),
509 url: crate::SITE.to_owned(),
510 }))
511 }
512
513 /// `remove_runner`: a person removing one.
514 pub async fn remove_runner(&self, a: RemoveRunnerArgs) -> Result<Outcome<bool>> {
515 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
516 let Some(row) = self.runner_by_id(&a.id).await? else {
517 return Ok(fail(FailureCode::NotFound, "There is no such runner."));
518 };
519 let ours = row.workspace == place.workspace
520 && match &place.repo {
521 Some(repo) => row.repo_id.as_deref() == Some(repo.id.as_str()),
522 None => row.repo_id.is_none(),
523 };
524 if !ours {
525 return Ok(fail(FailureCode::NotFound, "There is no such runner."));
526 }
527 self.forget_runner(&row, &format!("{} removed the runner {}.", a.actor.username, row.name)).await?;
528 self.audit(vec![entry(
529 AuditActor::of(&a.actor),
530 "remove_runner",
531 &row.workspace,
532 row.repo.clone(),
533 Some(row.name.clone()),
534 format!("Removed the self-hosted runner {}.", row.name),
535 )])
536 .await;
537 Ok(Outcome::Ok(true))
538 }
539
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.540 /// `admin_runners`: staff see a workspace's runners.
541 pub async fn admin_runners(&self, a: g1t_contracts::runners::AdminRunnersArgs) -> Result<Vec<g1t_contracts::runners::AdminRunner>> {
542 let workspace = a.workspace.trim().to_lowercase();
543 Ok(self
544 .runner_rows(&workspace)
545 .await?
546 .into_iter()
547 .map(|row| g1t_contracts::runners::AdminRunner {
548 busy: row.work_id.is_some(),
549 id: row.id,
550 name: row.name,
551 repo: row.repo,
552 os: row.os,
553 version: row.version,
554 last_seen_at: row.last_seen_at,
555 created_at: row.created_at,
556 created_by: row.created_by,
557 })
558 .collect())
559 }
560
561 /// `admin_remove_runners`: staff remove one runner or all of a
562 /// workspace's, and end its registration tokens.
563 pub async fn admin_remove_runners(&self, a: g1t_contracts::runners::AdminRemoveRunnersArgs) -> Result<Outcome<u32>> {
564 let staff = a.staff.trim();
565 if staff.is_empty() {
566 return Ok(fail(FailureCode::Forbidden, "Say which staff member is doing it."));
567 }
568 let workspace = a.workspace.trim().to_lowercase();
569 let rows: Vec<RunnerRow> = self
570 .runner_rows(&workspace)
571 .await?
572 .into_iter()
573 .filter(|row| a.id.as_deref().is_none_or(|id| id == row.id))
574 .collect();
575 if a.id.is_some() && rows.is_empty() {
576 return Ok(fail(FailureCode::NotFound, "There is no such runner."));
577 }
578 let why = format!("g1t staff removed the runner from {workspace}.");
579 for row in &rows {
580 self.forget_runner(row, &why).await?;
581 }
582 if a.id.is_none() {
583 self.db.prepare("DELETE FROM runner_registrations WHERE workspace = ?").bind(&[workspace.as_str().into()])?.run().await?;
584 }
585 worker::console_log!("actions: staff {staff} removed {} of {workspace}'s runners", rows.len());
586 Ok(Outcome::Ok(u32::try_from(rows.len()).unwrap_or(u32::MAX)))
587 }
588
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running589 /// Removes a runner: what it was running fails, and its credential
590 /// stops working at once.
591 async fn forget_runner(&self, row: &RunnerRow, why: &str) -> Result<()> {
592 self.db.prepare("DELETE FROM runners WHERE id = ?").bind(&[row.id.as_str().into()])?.run().await?;
593 self.abandon_work(&row.id, why).await
594 }
595
596 /// Fails whatever a runner was running.
597 async fn abandon_work(&self, runner_id: &str, why: &str) -> Result<()> {
598 let jobs = self
599 .db
600 .prepare("SELECT id FROM jobs WHERE runner_id = ? AND status = 'in_progress'")
601 .bind(&[runner_id.into()])?
602 .all()
603 .await?
604 .results::<IdRow>()?;
605 for job in jobs {
606 self.finish_job(&job.id, "failure", Some(why), None).await?;
607 }
608 let tasks = self
609 .db
610 .prepare("SELECT * FROM runner_tasks WHERE runner_id = ? AND status = 'in_progress'")
611 .bind(&[runner_id.into()])?
612 .all()
613 .await?
614 .results::<TaskRow>()?;
615 for task in tasks {
616 self.end_task(&task, 1, why).await?;
617 }
618 Ok(())
619 }
620
621 /// `runner_groups`.
622 pub async fn runner_groups(&self, a: RunnerGroupsArgs) -> Result<Outcome<Vec<RunnerGroup>>> {
623 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
624 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
625 self.default_group(&place.workspace).await?;
626 let groups = self.group_rows(&place.workspace).await?;
627 let runners = self.runner_rows(&place.workspace).await?;
628 Ok(Outcome::Ok(groups.iter().map(|g| group_view(g, &runners)).collect()))
629 }
630
631 /// `set_runner_group`.
632 pub async fn set_runner_group(&self, a: SetRunnerGroupArgs) -> Result<Outcome<RunnerGroup>> {
633 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
634 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
635 self.default_group(&place.workspace).await?;
636 let groups = self.group_rows(&place.workspace).await?;
637 let name = match a.name.as_deref().map(valid_group_name).transpose() {
638 Ok(name) => name,
639 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
640 };
641 let repositories: Option<Vec<String>> = a.repositories.map(|list| {
642 let mut out: Vec<String> = Vec::new();
643 for repo in list {
644 let name = repo.trim().rsplit('/').next().unwrap_or_default().to_owned();
645 if !name.is_empty() && !out.iter().any(|r: &String| r.eq_ignore_ascii_case(&name)) {
646 out.push(name);
647 }
648 }
649 out
650 });
651 if let Some(name) = &name
652 && groups.iter().any(|g| g.name.eq_ignore_ascii_case(name) && Some(&g.id) != a.id.as_ref())
653 {
654 return Ok(fail(FailureCode::Conflict, format!("There is already a group called {name}.")));
655 }
656 let at = now();
657 let id = match &a.id {
658 Some(id) => {
659 let Some(group) = groups.iter().find(|g| &g.id == id) else {
660 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
661 };
662 self.db
663 .prepare("UPDATE runner_groups SET name = ?, repositories = ?, updated_at = ? WHERE id = ?")
664 .bind(&[
665 name.clone().unwrap_or(group.name.clone()).into(),
666 serde_json::to_string(&repositories.clone().unwrap_or(group.repositories()))?.into(),
667 at.as_str().into(),
668 id.as_str().into(),
669 ])?
670 .run()
671 .await?;
672 id.clone()
673 }
674 None => {
675 let Some(name) = name.clone() else {
676 return Ok(fail(FailureCode::Invalid, "A new group needs a name."));
677 };
678 if groups.len() >= 50 {
679 return Ok(fail(FailureCode::Conflict, "A workspace has at most 50 runner groups."));
680 }
681 let id = new_id("rng", now_ms());
682 self.db
683 .prepare(
684 "INSERT INTO runner_groups (id, workspace, name, is_default, repositories, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?)",
685 )
686 .bind(&[
687 id.as_str().into(),
688 place.workspace.as_str().into(),
689 name.into(),
690 serde_json::to_string(&repositories.clone().unwrap_or_default())?.into(),
691 at.as_str().into(),
692 at.as_str().into(),
693 ])?
694 .run()
695 .await?;
696 id
697 }
698 };
699 let groups = self.group_rows(&place.workspace).await?;
700 let runners = self.runner_rows(&place.workspace).await?;
701 let Some(group) = groups.iter().find(|g| g.id == id) else {
702 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
703 };
704 self.audit(vec![entry(
705 AuditActor::of(&a.actor),
706 if a.id.is_some() { "update_runner_group" } else { "create_runner_group" },
707 &place.workspace,
708 None,
709 Some(group.name.clone()),
710 format!(
711 "Runner group {}: {}.",
712 group.name,
713 if group.repositories().is_empty() { "every repository".to_owned() } else { group.repositories().join(", ") }
714 ),
715 )])
716 .await;
717 Ok(Outcome::Ok(group_view(group, &runners)))
718 }
719
720 /// `delete_runner_group`.
721 pub async fn delete_runner_group(&self, a: DeleteRunnerGroupArgs) -> Result<Outcome<bool>> {
722 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
723 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
724 let default = self.default_group(&place.workspace).await?;
725 let Some(group) = self.group_rows(&place.workspace).await?.into_iter().find(|g| g.id == a.id) else {
726 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
727 };
728 if group.is_default != 0 {
729 return Ok(fail(FailureCode::Conflict, "The default group cannot be deleted."));
730 }
731 self.db
732 .batch(vec![
733 self.db.prepare("UPDATE runners SET group_id = ? WHERE group_id = ?").bind(&[default.id.as_str().into(), group.id.as_str().into()])?,
734 self.db.prepare("DELETE FROM runner_groups WHERE id = ?").bind(&[group.id.as_str().into()])?,
735 ])
736 .await?;
737 self.audit(vec![entry(
738 AuditActor::of(&a.actor),
739 "delete_runner_group",
740 &place.workspace,
741 None,
742 Some(group.name.clone()),
743 format!("Deleted the runner group {}; its runners joined {}.", group.name, default.name),
744 )])
745 .await;
746 Ok(Outcome::Ok(true))
747 }
748
749 async fn settings_row(&self, owner: &str) -> Result<Option<SettingsRow>> {
750 self.db.prepare("SELECT * FROM runner_settings WHERE owner = ?").bind(&[owner.into()])?.first::<SettingsRow>(None).await
751 }
752
753 fn settings_of(row: Option<SettingsRow>, inherited: bool) -> RunnerSettings {
754 match row {
755 Some(row) => RunnerSettings {
756 agents_on_self_hosted: row.agents != 0,
757 agent_labels: serde_json::from_str(&row.agent_labels).unwrap_or_else(|_| vec![model::SELF_HOSTED.to_owned()]),
758 fork_pull_requests: row.fork_pulls != 0,
759 inherited,
760 },
761 None => RunnerSettings {
762 agent_labels: vec![model::SELF_HOSTED.to_owned()],
763 inherited,
764 ..RunnerSettings::default()
765 },
766 }
767 }
768
769 /// The settings that apply in a workspace, or in one of its
770 /// repositories: the repository's own if it has them.
771 pub async fn effective_runner_settings(&self, workspace: &str, repo_id: Option<&str>) -> Result<RunnerSettings> {
772 if let Some(repo_id) = repo_id
773 && let Some(own) = self.settings_row(repo_id).await?
774 {
775 return Ok(Self::settings_of(Some(own), false));
776 }
777 let inherited = repo_id.is_some();
778 Ok(Self::settings_of(self.settings_row(&workspace.to_lowercase()).await?, inherited))
779 }
780
781 /// `runner_settings`.
782 pub async fn runner_settings(&self, a: RunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> {
783 let place = check!(self.runner_place(&a.actor, &a.owner, false).await?);
784 Ok(Outcome::Ok(self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?))
785 }
786
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.787 /// `report_desktop`: what a person's desktop app says of its runner,
788 /// in a workspace they own. Kept per computer, replaced each time.
789 pub async fn report_desktop(&self, a: ReportDesktopArgs) -> Result<Outcome<bool>> {
790 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
791 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
792 let machine = match valid_name(&a.machine) {
793 Ok(name) => name,
794 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
795 };
796 if !matches!(a.backend.as_str(), "sandboxed" | "docker" | "host") {
797 return Ok(fail(FailureCode::Invalid, "A desktop runs jobs sandboxed, in Docker or on the host."));
798 }
799 let clip = |text: &str, most: usize| text.chars().take(most).collect::<String>();
800 let tidy = |d: &DesktopDiagnosis| DesktopDiagnosis {
801 code: clip(&d.code, 40),
802 tone: clip(&d.tone, 20),
803 title: clip(&d.title, 200),
804 cause: d.cause.as_deref().map(|t| clip(t, 1000)),
805 fix: d.fix.as_deref().map(|t| clip(t, 1000)),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.806 capabilities: d
807 .capabilities
808 .iter()
809 .take(model::MAX_DESKTOP_CAPABILITIES)
810 .map(|c| DesktopCapability {
811 id: clip(&c.id, 20),
812 label: clip(&c.label, 60),
813 tone: clip(&c.tone, 20),
814 title: clip(&c.title, 200),
815 cause: c.cause.as_deref().map(|t| clip(t, 1000)),
816 fix: c.fix.as_deref().map(|t| clip(t, 1000)),
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.817 state: c.state.as_deref().map(|t| clip(t, 20)),
818 headline: c.headline.as_deref().map(|t| clip(t, 200)),
819 about: c.about.as_deref().map(|t| clip(t, 200)),
820 enabled: c.enabled,
821 job: c.job.as_ref().map(|j| DesktopJob { kind: clip(&j.kind, 20), name: clip(&j.name, 200), repo: clip(&j.repo, 200) }),
822 runner: c.runner.as_deref().map(|t| clip(t, 80)),
823 isolation: c.isolation.as_deref().map(|t| clip(t, 40)),
824 version: c.version.as_deref().map(|t| clip(t, 40)),
The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.825 update: c.update.as_deref().map(|t| clip(t, 200)),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.826 })
827 .collect(),
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.828 in_capabilities: d.in_capabilities && !d.capabilities.is_empty(),
The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.829 update: d.update.as_ref().map(|u| DesktopUpdate {
830 checked_at: u.checked_at.as_deref().map(|t| clip(t, 40)),
831 ready: u.ready.as_deref().map(|t| clip(t, 40)),
832 note: u.note.as_deref().map(|t| clip(t, 200)),
833 }),
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.834 };
835 let at = now_ms();
836 let known = self
837 .db
838 .prepare("SELECT COUNT(*) AS n FROM desktop_runners WHERE workspace = ? AND lower(machine) <> lower(?)")
839 .bind(&[place.workspace.as_str().into(), machine.as_str().into()])?
840 .first::<Count>(None)
841 .await?
842 .map_or(0, |c| c.n);
843 if known >= model::MAX_DESKTOPS {
844 // The oldest report makes room: a computer long gone.
845 self.db
846 .prepare("DELETE FROM desktop_runners WHERE rowid IN (SELECT rowid FROM desktop_runners WHERE workspace = ? ORDER BY reported_at LIMIT 1)")
847 .bind(&[place.workspace.as_str().into()])?
848 .run()
849 .await?;
850 }
851 self.db
852 .prepare(
853 "INSERT INTO desktop_runners (workspace, machine, person, backend, version, diagnosis, closed, reported_at)
854 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
855 ON CONFLICT (workspace, machine) DO UPDATE SET person = ?3, backend = ?4, version = ?5, diagnosis = ?6, closed = ?7, reported_at = ?8",
856 )
857 .bind(&[
858 place.workspace.as_str().into(),
859 machine.as_str().into(),
860 a.actor.username.as_str().into(),
861 a.backend.as_str().into(),
862 optional(a.version.as_deref().map(|v| clip(v, 40)).as_deref()),
863 serde_json::to_string(&tidy(&a.diagnosis))?.into(),
864 serde_json::to_string(&tidy(&a.closed))?.into(),
865 rfc3339(at).into(),
866 ])?
867 .run()
868 .await?;
869 Ok(Outcome::Ok(true))
870 }
871
872 /// `desktop_runners`: the workspace's desktops, as each app last said.
873 pub async fn desktop_runners(&self, a: DesktopRunnersArgs) -> Result<Outcome<Vec<DesktopRunner>>> {
874 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
875 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
876 #[derive(Deserialize)]
877 struct Row {
878 workspace: String,
879 machine: String,
880 person: String,
881 backend: String,
882 version: Option<String>,
883 diagnosis: String,
884 closed: String,
885 reported_at: String,
886 }
887 let rows = self
888 .db
889 .prepare("SELECT * FROM desktop_runners WHERE workspace = ? ORDER BY lower(machine)")
890 .bind(&[place.workspace.as_str().into()])?
891 .all()
892 .await?
893 .results::<Row>()?;
894 let open_since = rfc3339(now_ms().saturating_sub(model::DESKTOP_OPEN_WITHIN_MS));
895 Ok(Outcome::Ok(
896 rows.into_iter()
897 .map(|row| DesktopRunner {
898 open: row.reported_at.as_str() > open_since.as_str(),
899 diagnosis: serde_json::from_str(&row.diagnosis).unwrap_or_default(),
900 closed: serde_json::from_str(&row.closed).unwrap_or_default(),
901 workspace: row.workspace,
902 machine: row.machine,
903 person: row.person,
904 backend: row.backend,
905 version: row.version,
906 reported_at: row.reported_at,
907 })
908 .collect(),
909 ))
910 }
911
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running912 /// `set_runner_settings`.
913 pub async fn set_runner_settings(&self, a: SetRunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> {
914 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
915 let (owner, scope) = match &place.repo {
916 Some(repo) => (repo.id.clone(), "repository"),
917 None => (place.workspace.clone(), "workspace"),
918 };
919 if a.inherit && place.repo.is_some() {
920 self.db.prepare("DELETE FROM runner_settings WHERE owner = ?").bind(&[owner.as_str().into()])?.run().await?;
921 } else {
922 let current = self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?;
923 let labels = match &a.agent_labels {
924 Some(given) => match model::runner_labels(given, model::SELF_HOSTED, model::SELF_HOSTED) {
925 Ok(labels) => labels,
926 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
927 },
928 None => current.agent_labels.clone(),
929 };
930 let agents = a.agents_on_self_hosted.unwrap_or(current.agents_on_self_hosted);
931 let forks = a.fork_pull_requests.unwrap_or(current.fork_pull_requests);
932 self.db
933 .prepare(
934 "INSERT INTO runner_settings (owner, scope, agents, agent_labels, fork_pulls, updated_at, updated_by)
935 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
936 ON CONFLICT (owner) DO UPDATE SET agents = ?3, agent_labels = ?4, fork_pulls = ?5, updated_at = ?6, updated_by = ?7",
937 )
938 .bind(&[
939 owner.as_str().into(),
940 scope.into(),
941 u32::from(agents).into(),
942 serde_json::to_string(&labels)?.into(),
943 u32::from(forks).into(),
944 now().into(),
945 a.actor.username.as_str().into(),
946 ])?
947 .run()
948 .await?;
949 }
950 let settings = self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?;
951 self.audit(vec![entry(
952 AuditActor::of(&a.actor),
953 "update_runner_settings",
954 &place.workspace,
955 place.repo.as_ref().map(|r| format!("{}/{}", r.namespace, r.name)),
956 None,
957 format!(
958 "Agents on self-hosted runners: {}; labels {}; pull requests from forks: {}.",
959 if settings.agents_on_self_hosted { "on" } else { "off" },
960 settings.agent_labels.join(", "),
961 if settings.fork_pull_requests { "allowed" } else { "not allowed" }
962 ),
963 )])
964 .await;
965 Ok(Outcome::Ok(settings))
966 }
967
968 // --- The runner's side ---------------------------------------------------
969
970 /// `runner_register`.
971 pub async fn runner_register(&self, a: RegisterArgs) -> Result<Outcome<Registered>> {
972 let refused = || fail(FailureCode::Unauthenticated, "That registration token is not valid, or it expired. Make a new one under Settings, Runners.");
973 if !a.token.starts_with(model::REGISTRATION_PREFIX) {
974 return Ok(refused());
975 }
976 let registration = self
977 .db
978 .prepare("SELECT * FROM runner_registrations WHERE token_hash = ?")
979 .bind(&[hash(&a.token).into()])?
980 .first::<RegistrationRow>(None)
981 .await?;
982 let at = now_ms();
983 let Some(registration) = registration.filter(|r| r.expires_at.as_str() > rfc3339(at).as_str()) else {
984 return Ok(refused());
985 };
986 let name = match valid_name(&a.name) {
987 Ok(name) => name,
988 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
989 };
990 let (Some(os), Some(arch)) = (model::os_label(&a.os), model::arch_label(&a.arch)) else {
991 return Ok(fail(FailureCode::Invalid, "A runner's os is linux, macos or windows, and its arch x64 or arm64."));
992 };
993 let labels = match model::runner_labels(&a.labels, os, arch) {
994 Ok(labels) => labels,
995 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
996 };
997 let workspace = registration.workspace.clone();
998 // Abuse: so many machines, so fast, are not a team's.
999 let recent = self
1000 .db
1001 .prepare("SELECT COUNT(*) AS n FROM runners WHERE workspace = ? AND created_at > ?")
1002 .bind(&[workspace.as_str().into(), rfc3339(at.saturating_sub(60 * 1000)).into()])?
1003 .first::<Count>(None)
1004 .await?
1005 .map_or(0, |c| c.n);
1006 if recent >= model::MAX_REGISTRATIONS_PER_MINUTE {
1007 return Ok(fail(FailureCode::Conflict, "Too many runners registered in the last minute. Wait a moment and try again."));
1008 }
1009 let total = self
1010 .db
1011 .prepare("SELECT COUNT(*) AS n FROM runners WHERE workspace = ?")
1012 .bind(&[workspace.as_str().into()])?
1013 .first::<Count>(None)
1014 .await?
1015 .map_or(0, |c| c.n);
1016 // A group: the one asked for, the token's, or the default.
1017 let group = if registration.repo_id.is_some() {
1018 None
1019 } else {
1020 match a.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
1021 Some(wanted) => match self.find_group(&workspace, wanted).await? {
1022 Some(group) => Some(group.id),
1023 None => return Ok(fail(FailureCode::NotFound, format!("There is no runner group called {wanted}."))),
1024 },
1025 None => match registration.group_id.clone() {
1026 Some(id) => Some(id),
1027 None => Some(self.default_group(&workspace).await?.id),
1028 },
1029 }
1030 };
1031 let existing = self
1032 .db
1033 .prepare("SELECT * FROM runners WHERE workspace = ? AND COALESCE(repo_id, '') = ? AND lower(name) = lower(?)")
1034 .bind(&[workspace.as_str().into(), registration.repo_id.clone().unwrap_or_default().into(), name.as_str().into()])?
1035 .first::<RunnerRow>(None)
1036 .await?;
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.1037 let machine_hash = machine_hash(&workspace, a.machine.as_deref());
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1038 if let Some(existing) = &existing {
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.1039 if !takes_over(existing, a.replace, machine_hash.as_deref(), registration.created_by.as_deref(), at) {
1040 let elsewhere = if existing.machine_hash.is_some() && machine_hash.is_some() { " from another computer" } else { "" };
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1041 return Ok(fail(
1042 FailureCode::Conflict,
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.1043 format!("A runner called {name} is already registered here{elsewhere}. Register with --replace to take its place, or choose another --name."),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1044 ));
1045 }
1046 self.forget_runner(existing, &format!("The runner {name} was replaced by a new registration.")).await?;
1047 } else if total >= model::MAX_RUNNERS {
1048 return Ok(fail(FailureCode::Conflict, format!("A workspace has at most {} self-hosted runners.", model::MAX_RUNNERS)));
1049 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91050 // How it keeps work from its machine, if it said (older runners do not).
1051 let isolation = a.isolation.as_deref().filter(|level| model::ISOLATIONS.contains(level));
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1052 let id = new_id("rnr", at);
1053 let credential = format!("{}{}", model::CREDENTIAL_PREFIX, random_hex(32));
1054 let created = rfc3339(at);
1055 self.db
1056 .prepare(
1057 "INSERT INTO runners (id, workspace, repo_id, repo, group_id, name, labels, os, arch, version, ephemeral, credential_hash,
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.1058 rotated_at, last_seen_at, created_at, created_by, isolation, machine_hash)
1059 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1060 )
1061 .bind(&[
1062 id.as_str().into(),
1063 workspace.as_str().into(),
1064 optional(registration.repo_id.as_deref()),
1065 optional(registration.repo.as_deref()),
1066 optional(group.as_deref()),
1067 name.as_str().into(),
1068 serde_json::to_string(&labels)?.into(),
1069 os.into(),
1070 arch.into(),
1071 a.version.chars().take(40).collect::<String>().into(),
1072 u32::from(a.ephemeral).into(),
1073 hash(&credential).into(),
1074 created.as_str().into(),
1075 created.as_str().into(),
1076 created.as_str().into(),
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.1077 // The person who made the token: a desktop's runner is theirs.
1078 optional(registration.created_by.as_deref()),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91079 optional(isolation),
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.1080 optional(machine_hash.as_deref()),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1081 ])?
1082 .run()
1083 .await?;
1084 self.db
1085 .prepare("UPDATE runner_registrations SET used = used + 1 WHERE token_hash = ?")
1086 .bind(&[hash(&a.token).into()])?
1087 .run()
1088 .await?;
1089 let row = self.runner_by_id(&id).await?.ok_or_else(|| worker::Error::RustError("the runner was not kept".into()))?;
1090 self.audit(vec![entry(
1091 runner_actor(&row),
1092 "register_runner",
1093 &workspace,
1094 row.repo.clone(),
1095 Some(name.clone()),
1096 format!("Registered the self-hosted runner {name} ({os}, {arch}; labels {}).", labels.join(", ")),
1097 )])
1098 .await;
1099 let groups = self.group_rows(&workspace).await?;
1100 Ok(Outcome::Ok(Registered {
1101 runner: self.runner_view(&row, &groups, at).await?,
1102 credential,
1103 }))
1104 }
1105
1106 /// The runner a call is from, if its credential is that runner's (or
1107 /// the one before a rotation, until the new one is used).
1108 async fn authenticated(&self, auth: &RunnerAuth) -> Result<Option<(RunnerRow, bool)>> {
1109 if !auth.credential.starts_with(model::CREDENTIAL_PREFIX) {
1110 return Ok(None);
1111 }
1112 let Some(row) = self.runner_by_id(&auth.runner).await? else { return Ok(None) };
1113 let given = hash(&auth.credential);
1114 if g1t_secrets::same(&row.credential_hash, &given) {
1115 if row.previous_hash.is_some() {
1116 self.db.prepare("UPDATE runners SET previous_hash = NULL WHERE id = ?").bind(&[row.id.as_str().into()])?.run().await?;
1117 }
1118 return Ok(Some((row, true)));
1119 }
1120 if row.previous_hash.as_deref().is_some_and(|previous| g1t_secrets::same(previous, &given)) {
1121 return Ok(Some((row, false)));
1122 }
1123 Ok(None)
1124 }
1125
1126 fn unknown_runner<T>() -> Outcome<T> {
1127 fail(FailureCode::Unauthenticated, "This runner is not registered, or its credential is not valid. Register it again.")
1128 }
1129
1130 /// `runner_poll`.
1131 pub async fn runner_poll(&self, a: PollArgs) -> Result<Outcome<Poll>> {
1132 let Some((row, current)) = self.authenticated(&a.auth).await? else {
1133 return Ok(Self::unknown_runner());
1134 };
1135 let at = now_ms();
1136 let seen = rfc3339(at);
1137 // A new credential every day, handed over on a poll that used the
1138 // current one.
1139 let mut credential = None;
1140 let rotate_after = rfc3339(at.saturating_sub(model::ROTATE_AFTER_MS));
1141 if current && row.rotated_at.as_str() < rotate_after.as_str() {
1142 let fresh = format!("{}{}", model::CREDENTIAL_PREFIX, random_hex(32));
1143 self.db
1144 .prepare("UPDATE runners SET previous_hash = credential_hash, credential_hash = ?, rotated_at = ? WHERE id = ?")
1145 .bind(&[hash(&fresh).into(), seen.as_str().into(), row.id.as_str().into()])?
1146 .run()
1147 .await?;
1148 credential = Some(fresh);
1149 }
1150 // What it holds that it should stop, and the heartbeat for the rest.
1151 let mut cancel = Vec::new();
1152 let mut active: Option<(String, &'static str)> = None;
1153 for id in a.running.iter().take(10) {
1154 if let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[id.as_str().into()])?.first::<JobRow>(None).await? {
1155 if job.status == "in_progress" && job.runner_id.as_deref() == Some(row.id.as_str()) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1156 if seen_is_stale(job.seen_at.as_deref(), at) {
1157 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?;
1158 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1159 active = Some((id.clone(), "workflow"));
1160 } else {
1161 cancel.push(id.clone());
1162 }
1163 continue;
1164 }
1165 match self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[id.as_str().into()])?.first::<TaskRow>(None).await? {
1166 Some(task) if task.status == "in_progress" && task.runner_id.as_deref() == Some(row.id.as_str()) => {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1167 if seen_is_stale(task.seen_at.as_deref(), at) {
1168 self.db.prepare("UPDATE runner_tasks SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?;
1169 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1170 active = Some((id.clone(), "agent"));
1171 }
1172 _ => cancel.push(id.clone()),
1173 }
1174 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1175 // Written when something changed, or once a minute to keep it online.
1176 let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() };
1177 let work_id = active.as_ref().map(|(id, _)| id.as_str());
1178 let work_kind = active.as_ref().map(|(_, kind)| *kind);
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.1179 // How it is isolated, as it says now: a runner re-registered or
1180 // restarted with or without Docker says so on its next poll.
1181 let isolation = a
1182 .isolation
1183 .as_deref()
1184 .filter(|level| model::ISOLATIONS.contains(level))
1185 .map(str::to_owned)
1186 .or_else(|| row.isolation.clone());
1187 let changed = version != row.version
1188 || work_id != row.work_id.as_deref()
1189 || work_kind != row.work_kind.as_deref()
1190 || isolation != row.isolation;
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1191 if changed || seen_is_stale(row.last_seen_at.as_deref(), at) {
1192 self.db
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.1193 .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ?, isolation = ? WHERE id = ?")
1194 .bind(&[
1195 seen.as_str().into(),
1196 version.into(),
1197 optional(work_id),
1198 optional(work_kind),
1199 optional(isolation.as_deref()),
1200 row.id.as_str().into(),
1201 ])?
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1202 .run()
1203 .await?;
1204 }
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.1205 let row = RunnerRow { isolation, ..row };
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1206 let mut poll = Poll { assignment: None, cancel, credential, removed: false };
1207 // Busy, or an ephemeral runner that has had its one job.
1208 if active.is_some() || !a.running.is_empty() {
1209 return Ok(Outcome::Ok(poll));
1210 }
1211 if row.ephemeral != 0 && row.spent != 0 {
1212 self.forget_runner(&row, "The ephemeral runner finished its job.").await?;
1213 poll.removed = true;
1214 return Ok(Outcome::Ok(poll));
1215 }
1216 let groups = self.group_rows(&row.workspace).await?;
1217 let group = row.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)).cloned();
1218 let deadline = at + a.wait_ms.min(model::MAX_POLL_WAIT_MS);
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.1219 let mut looked = Looked::default();
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1220 loop {
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.1221 if let Some(assignment) = self.claim(&row, group.as_ref(), &mut looked).await? {
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1222 poll.assignment = Some(assignment);
1223 return Ok(Outcome::Ok(poll));
1224 }
1225 if now_ms() + POLL_EVERY_MS > deadline {
1226 return Ok(Outcome::Ok(poll));
1227 }
1228 worker::Delay::from(std::time::Duration::from_millis(POLL_EVERY_MS)).await;
1229 }
1230 }
1231
1232 /// Takes the oldest work this runner can do, if there is any.
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.1233 async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>, looked: &mut Looked) -> Result<Option<Assignment>> {
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1234 let labels = runner.labels();
1235 let group_name = group.map(|g| g.name.clone());
1236 // Workflow jobs.
1237 let mut sql = "SELECT jobs.*, runs.repo AS run_repo FROM jobs JOIN runs ON runs.id = jobs.run_id
1238 WHERE jobs.status = 'queued' AND jobs.labels IS NOT NULL AND lower(jobs.namespace) = ?"
1239 .to_owned();
1240 let mut binds: Vec<JsValue> = vec![runner.workspace.as_str().into()];
1241 if let Some(repo_id) = &runner.repo_id {
1242 sql.push_str(" AND jobs.repo_id = ?");
1243 binds.push(repo_id.as_str().into());
1244 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1245 // One job of a concurrency group runs at a time.
1246 sql.push_str(
1247 " AND (jobs.concurrency_group IS NULL OR NOT EXISTS (SELECT 1 FROM jobs o WHERE o.repo_id = jobs.repo_id
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91248 AND o.concurrency_group = jobs.concurrency_group AND o.status IN ('in_progress', 'calling')))",
Merge branch 'worktree-agent-a3abfcce648e87dca'1249 );
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1250 sql.push_str(" ORDER BY jobs.rowid LIMIT 50");
1251 #[derive(Deserialize)]
1252 struct Queued {
1253 #[serde(flatten)]
1254 job: JobRow,
1255 run_repo: String,
1256 }
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.1257 let mut queued = self.db.prepare(sql).bind(&binds)?.all().await?.results::<Queued>()?;
1258 // A workspace without the plan runs FREE_SELF_HOSTED_JOBS of its jobs
1259 // on its own runners at once (limits.rs). Billing is asked only once
1260 // that many are going, so a runner's poll stays cheap.
1261 if !queued.is_empty() && self.limits.free_self_hosted_jobs > 0 {
1262 let running = self
1263 .db
1264 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND runner_id IS NOT NULL AND lower(namespace) = ?")
1265 .bind(&[runner.workspace.as_str().into()])?
1266 .first::<Count>(None)
1267 .await?
1268 .map_or(0, |c| c.n);
1269 if !crate::limits::self_hosted_room(running as u64, self.limits.free_self_hosted_jobs)
1270 && self.standing(&runner.workspace).await.is_some_and(|standing| !standing.has_plan)
1271 {
1272 // Its workflow jobs wait for one to finish; anything else the
1273 // runner takes (below) is not held back.
1274 queued.clear();
1275 }
1276 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1277 for Queued { job, run_repo } in queued {
1278 let stored: Vec<String> = job.labels.as_deref().and_then(|l| serde_json::from_str(l).ok()).unwrap_or_default();
1279 let wanted = Wanted::from_stored(&stored);
1280 if !wanted.matches(&labels, group_name.as_deref()) {
1281 continue;
1282 }
1283 if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&run_repo)) {
1284 continue;
1285 }
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.1286 // A host-mode runner takes no public repository's job: it waits
1287 // for a sandboxed runner, and says so when none could take it.
1288 if runner.isolation.as_deref() == Some("host") {
1289 let visibility = self.visibility_once(looked, &job.repo_id).await;
1290 if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1291 if looked.noted.insert(job.id.clone()) {
1292 self.wait_for_sandbox(&job, &run_repo, &wanted).await?;
1293 }
1294 continue;
1295 }
1296 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1297 if let Some(max) = job.max_parallel {
1298 let siblings = self
1299 .db
1300 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'")
1301 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1302 .first::<Count>(None)
1303 .await?
1304 .map_or(0, |c| c.n);
1305 if siblings >= max {
1306 continue;
1307 }
1308 }
1309 let token = random_hex(24);
1310 let at = now();
1311 let claimed = self
1312 .db
1313 .prepare(
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.1314 "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1315 WHERE id = ? AND status = 'queued' RETURNING id",
1316 )
1317 .bind(&[
1318 sha256_hex(&token).into(),
1319 runner.id.as_str().into(),
1320 runner.name.as_str().into(),
1321 at.as_str().into(),
1322 at.as_str().into(),
1323 job.id.as_str().into(),
1324 ])?
1325 .first::<Value>(None)
1326 .await?;
1327 if claimed.is_none() {
1328 continue;
1329 }
1330 self.db
1331 .batch(vec![
1332 self.db
1333 .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'")
1334 .bind(&[at.as_str().into(), job.run_id.as_str().into()])?,
1335 self.db
1336 .prepare("UPDATE runners SET work_id = ?, work_kind = 'workflow', spent = ephemeral WHERE id = ?")
1337 .bind(&[job.id.as_str().into(), runner.id.as_str().into()])?,
1338 ])
1339 .await?;
1340 self.audit(vec![entry(
1341 runner_actor(runner),
1342 "runner.take_job",
1343 &runner.workspace,
1344 Some(run_repo.clone()),
1345 Some(job.name.clone()),
1346 format!("The self-hosted runner {} took the job {}.", runner.name, job.name),
1347 )])
1348 .await;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971349 // A job that deploys: its run's deployment is under way.
1350 self.job_started(&job.id).await?;
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1351 let image = self.job_image(&job).await?;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91352 let visibility = self.visibility(&job.repo_id).await;
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1353 return Ok(Some(Assignment {
1354 kind: "workflow".into(),
1355 id: job.id.clone(),
1356 name: job.name.clone(),
1357 repo: run_repo,
1358 timeout_minutes: job.timeout_minutes,
1359 image,
1360 token: Some(token),
1361 env: None,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91362 visibility,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1363 }));
1364 }
1365
1366 // Agent work.
1367 let tasks = self
1368 .db
1369 .prepare("SELECT * FROM runner_tasks WHERE status = 'queued' AND workspace = ? ORDER BY created_at LIMIT 20")
1370 .bind(&[runner.workspace.as_str().into()])?
1371 .all()
1372 .await?
1373 .results::<TaskRow>()?;
1374 for task in tasks {
1375 let wanted = Wanted::of(&serde_json::from_str::<Vec<String>>(&task.labels).unwrap_or_default(), None);
1376 if !wanted.matches(&labels, None) {
1377 continue;
1378 }
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.1379 // Nor a public repository's agent work.
1380 if runner.isolation.as_deref() == Some("host")
1381 && let Some(repo_id) = &task.repo_id
1382 {
1383 let visibility = self.visibility_once(looked, repo_id).await;
1384 if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1385 continue;
1386 }
1387 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1388 if let Some(repo_id) = &runner.repo_id
1389 && task.repo_id.as_deref() != Some(repo_id.as_str())
1390 {
1391 continue;
1392 }
1393 if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&task.repo)) {
1394 continue;
1395 }
1396 let Some(sealed) = task.env.clone() else { continue };
1397 let at = now();
1398 let claimed = self
1399 .db
1400 .prepare(
1401 "UPDATE runner_tasks SET status = 'in_progress', runner_id = ?, runner_name = ?, env = NULL, started_at = ?, seen_at = ?
1402 WHERE id = ? AND status = 'queued' RETURNING id",
1403 )
1404 .bind(&[runner.id.as_str().into(), runner.name.as_str().into(), at.as_str().into(), at.as_str().into(), task.id.as_str().into()])?
1405 .first::<Value>(None)
1406 .await?;
1407 if claimed.is_none() {
1408 continue;
1409 }
1410 self.db
1411 .prepare("UPDATE runners SET work_id = ?, work_kind = 'agent', spent = ephemeral WHERE id = ?")
1412 .bind(&[task.id.as_str().into(), runner.id.as_str().into()])?
1413 .run()
1414 .await?;
1415 let env: Map<String, Value> = self
1416 .sealer
1417 .as_ref()
1418 .and_then(|sealer| sealer.open(&sealed, &task.id))
1419 .and_then(|text| serde_json::from_str(&text).ok())
1420 .unwrap_or_default();
1421 if env.is_empty() {
1422 self.end_task(&TaskRow { status: "in_progress".into(), ..task.clone() }, 1, "The work's environment could not be opened.").await?;
1423 continue;
1424 }
1425 self.audit(vec![entry(
1426 runner_actor(runner),
1427 "runner.take_task",
1428 &runner.workspace,
1429 Some(task.repo.clone()),
1430 Some(task.kind.clone()),
1431 format!("The self-hosted runner {} took agent work: {}.", runner.name, task.title),
1432 )])
1433 .await;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91434 let visibility = match &task.repo_id {
1435 Some(repo_id) => self.visibility(repo_id).await,
1436 None => None,
1437 };
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1438 return Ok(Some(Assignment {
1439 kind: "agent".into(),
1440 id: task.id.clone(),
1441 name: task.title.clone(),
1442 repo: task.repo.clone(),
1443 timeout_minutes: task.timeout_minutes,
1444 image: None,
1445 token: None,
1446 env: Some(env),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91447 visibility,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1448 }));
1449 }
1450 Ok(None)
1451 }
1452
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.1453 /// [`Self::visibility`], asked once per repository in a poll.
1454 async fn visibility_once(&self, looked: &mut Looked, repo_id: &str) -> Option<String> {
1455 if let Some(known) = looked.visibilities.get(repo_id) {
1456 return known.clone();
1457 }
1458 let found = self.visibility(repo_id).await;
1459 looked.visibilities.insert(repo_id.to_owned(), found.clone());
1460 found
1461 }
1462
1463 /// A public repository's job that a host-mode runner passed over: when
1464 /// no online runner that may take it could (its labels, group,
1465 /// repository, and isolation), its page says it waits for a sandboxed
1466 /// one. Written only when that changes what it says.
1467 async fn wait_for_sandbox(&self, job: &JobRow, run_repo: &str, wanted: &Wanted) -> Result<()> {
1468 let at = now_ms();
1469 let groups = self.group_rows(&job.namespace.to_lowercase()).await?;
1470 let runners = self.runner_rows(&job.namespace.to_lowercase()).await?;
1471 let sandboxed = runners.iter().any(|other| takes_public_job(other, &groups, &job.repo_id, run_repo, wanted, at));
1472 if sandboxed {
1473 return Ok(());
1474 }
1475 self.db
1476 .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'queued' AND reason IS NOT ?")
1477 .bind(&[model::WAITING_FOR_SANDBOX.into(), job.id.as_str().into(), model::WAITING_FOR_SANDBOX.into()])?
1478 .run()
1479 .await?;
1480 Ok(())
1481 }
1482
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91483 /// A repository's visibility for a runner, `public` or `private`; none
1484 /// when it cannot be read, and the runner decides without it.
1485 async fn visibility(&self, repo_id: &str) -> Option<String> {
1486 match self.repo_by_id(repo_id).await {
1487 Ok(Some((repo, _))) => Some(if repo.is_private { "private" } else { "public" }.to_owned()),
1488 _ => None,
1489 }
1490 }
1491
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1492 /// The image a job's `container:` names, when it names one plainly.
1493 async fn job_image(&self, job: &JobRow) -> Result<Option<String>> {
1494 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(None) };
1495 let raw = match job.callee() {
1496 Some((_, spec, _)) => spec.raw,
1497 None => match g1t_actions::workflow::parse(&run.source) {
1498 Ok(workflow) => match workflow.jobs.into_iter().find(|j| j.id == job.key) {
1499 Some(spec) => spec.raw,
1500 None => return Ok(None),
1501 },
1502 Err(_) => return Ok(None),
1503 },
1504 };
1505 let image = match raw.get("container") {
1506 Some(Value::String(image)) => Some(image.clone()),
1507 Some(Value::Object(container)) => container.get("image").and_then(Value::as_str).map(str::to_owned),
1508 _ => None,
1509 };
1510 Ok(image.filter(|image| !image.contains("${{") && !image.trim().is_empty()))
1511 }
1512
1513 /// `runner_finished`.
1514 pub async fn runner_finished(&self, a: FinishedArgs) -> Result<Outcome<bool>> {
1515 let Some((row, _)) = self.authenticated(&a.auth).await? else {
1516 return Ok(Self::unknown_runner());
1517 };
1518 let reason = a.reason.clone().filter(|r| !r.trim().is_empty()).map(|r| r.chars().take(2000).collect::<String>());
1519 let mut changed = false;
1520 if let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.id.as_str().into()])?.first::<JobRow>(None).await? {
1521 if job.runner_id.as_deref() == Some(row.id.as_str()) && job.status == "in_progress" {
1522 let why = reason.unwrap_or_else(|| format!("The job's process on {} exited with code {} before it reported how the job went.", row.name, a.exit_code));
1523 self.finish_job(&job.id, "failure", Some(&why), None).await?;
1524 changed = true;
1525 }
1526 } else if let Some(task) = self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[a.id.as_str().into()])?.first::<TaskRow>(None).await?
1527 && task.runner_id.as_deref() == Some(row.id.as_str())
1528 && task.status == "in_progress"
1529 {
1530 let why = reason.unwrap_or_else(|| if a.exit_code == 0 { String::new() } else { format!("The work exited with code {} on {}.", a.exit_code, row.name) });
1531 self.end_task(&task, a.exit_code, &why).await?;
1532 changed = true;
1533 }
1534 self.db
1535 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL, last_seen_at = ? WHERE id = ? AND work_id = ?")
1536 .bind(&[now().into(), row.id.as_str().into(), a.id.as_str().into()])?
1537 .run()
1538 .await?;
1539 Ok(Outcome::Ok(changed))
1540 }
1541
1542 /// `runner_remove_self`.
1543 pub async fn runner_remove_self(&self, a: RemoveSelfArgs) -> Result<Outcome<bool>> {
1544 let Some((row, _)) = self.authenticated(&a.auth).await? else {
1545 return Ok(Self::unknown_runner());
1546 };
1547 self.forget_runner(&row, &format!("The runner {} was removed from its machine.", row.name)).await?;
1548 self.audit(vec![entry(
1549 runner_actor(&row),
1550 "remove_runner",
1551 &row.workspace,
1552 row.repo.clone(),
1553 Some(row.name.clone()),
1554 format!("The self-hosted runner {} removed itself.", row.name),
1555 )])
1556 .await;
1557 Ok(Outcome::Ok(true))
1558 }
1559
1560 /// A self-hosted job finished, however it did: its runner is free, and
1561 /// its time goes on the workspace's usage as self-hosted, at $0.
1562 pub async fn released(&self, job: &JobRow) -> Result<()> {
1563 let Some(runner_id) = &job.runner_id else { return Ok(()) };
1564 self.db
1565 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL WHERE id = ? AND work_id = ?")
1566 .bind(&[runner_id.as_str().into(), job.id.as_str().into()])?
1567 .run()
1568 .await?;
1569 let (Some(started), Some(finished)) = (job.started_at.as_deref(), job.finished_at.as_deref().map(str::to_owned).or_else(|| Some(now()))) else {
1570 return Ok(());
1571 };
1572 let seconds = match (g1t_contracts::time::parse_rfc3339(started), g1t_contracts::time::parse_rfc3339(&finished)) {
1573 (Some(a), Some(b)) if b > a => ((b - a) / 1000).max(1),
1574 _ => 1,
1575 };
1576 let run = self.run_row(&job.run_id).await?;
1577 let repo = run.as_ref().map(|r| r.repo.clone());
1578 let recorded: Result<Outcome<bool>> = g1t_kit::call(
1579 &self.billing,
1580 "record_sandbox",
1581 &RecordSandboxArgs {
1582 workspace: job.namespace.to_lowercase(),
1583 seconds: u32::try_from(seconds).unwrap_or(u32::MAX),
1584 description: format!(
1585 "{} in {} on the self-hosted runner {}",
1586 job.name,
1587 repo.clone().unwrap_or_default(),
1588 job.runner_name.clone().unwrap_or_default()
1589 ),
1590 repo,
1591 reference: format!("selfhosted/{}/{}", job.id, started),
1592 kind: Some(ComputeKind::Workflow),
1593 cpu_seconds: None,
1594 reservation_id: None,
1595 self_hosted: true,
1596 instance: None,
1597 },
1598 )
1599 .await;
1600 if let Err(error) = recorded {
1601 worker::console_error!("actions: self-hosted time not recorded for {}: {error}", job.id);
1602 }
1603 Ok(())
1604 }
1605
1606 /// `runner` and `RUNNER_*` for a job on a self-hosted runner.
1607 pub async fn runner_context_for(&self, runner_id: &str, variables: &mut Map<String, Value>) -> Result<Value> {
1608 let mut context = g1t_actions::events::runner_context();
1609 let Some(runner) = self.runner_by_id(runner_id).await? else { return Ok(context) };
1610 let os = match runner.os.as_str() {
1611 "macos" => "macOS",
1612 "windows" => "Windows",
1613 _ => "Linux",
1614 };
1615 let arch = if runner.arch == "arm64" { "ARM64" } else { "X64" };
1616 context["name"] = json!(runner.name);
1617 context["os"] = json!(os);
1618 context["arch"] = json!(arch);
1619 context["environment"] = json!("self-hosted");
1620 {
1621 let vars = variables;
1622 vars.insert("RUNNER_NAME".into(), json!(runner.name));
1623 vars.insert("RUNNER_OS".into(), json!(os));
1624 vars.insert("RUNNER_ARCH".into(), json!(arch));
1625 vars.insert("RUNNER_ENVIRONMENT".into(), json!("self-hosted"));
1626 }
1627 Ok(context)
1628 }
1629
1630 // --- Agent work, for the runner service ----------------------------------
1631
1632 /// `runner_route`: the labels g1t's own work in `repo` runs on, when
1633 /// its workspace (or the repository) sends it to self-hosted runners.
1634 pub async fn runner_route(&self, a: RouteArgs) -> Result<Option<Vec<String>>> {
1635 let repo_id = match self.repo_by_path(&a.repo).await? {
1636 Some(repo) => Some(repo.id),
1637 None => None,
1638 };
1639 let settings = self.effective_runner_settings(&a.workspace, repo_id.as_deref()).await?;
1640 Ok(settings.agents_on_self_hosted.then_some(settings.agent_labels))
1641 }
1642
1643 async fn repo_by_path(&self, path: &RepoPath) -> Result<Option<Repo>> {
1644 let Some(actor) = self.workspace_actor(&path.namespace).await? else { return Ok(None) };
1645 self.visible_repo(path, &Some(actor)).await
1646 }
1647
1648 /// `enqueue_task`.
1649 pub async fn enqueue_task(&self, a: EnqueueTaskArgs) -> Result<Outcome<String>> {
1650 let Some(sealer) = &self.sealer else {
1651 return Ok(fail(FailureCode::Conflict, "Agent work cannot be handed to self-hosted runners yet: g1t's key for it is not set."));
1652 };
1653 let id = new_id("rtk", now_ms());
1654 let sealed = sealer.seal(&serde_json::to_string(&a.env)?, &id);
1655 let repo_id = self.repo_by_path(&a.repo).await?.map(|r| r.id);
1656 let labels = match model::runner_labels(&a.labels, model::SELF_HOSTED, model::SELF_HOSTED) {
1657 Ok(labels) => labels,
1658 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
1659 };
1660 let at = now();
1661 let inserted = self
1662 .db
1663 .prepare(
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.1664 "INSERT OR IGNORE INTO runner_tasks (id, sandbox, workspace, repo_id, repo, kind, title, labels, env, timeout_minutes, status, created_at, run_id)
1665 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?) RETURNING id",
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1666 )
1667 .bind(&[
1668 id.as_str().into(),
1669 a.sandbox.as_str().into(),
1670 a.workspace.to_lowercase().into(),
1671 optional(repo_id.as_deref()),
1672 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
1673 a.kind.as_str().into(),
1674 a.title.chars().take(300).collect::<String>().into(),
1675 serde_json::to_string(&labels)?.into(),
1676 sealed.into(),
1677 a.timeout_minutes.max(1).into(),
1678 at.into(),
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.1679 optional(a.run_id.as_deref().filter(|id| !id.is_empty())),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1680 ])?
1681 .first::<Value>(None)
1682 .await?;
1683 if inserted.is_none() {
1684 return Ok(fail(FailureCode::Conflict, "That sandbox already handed work to a self-hosted runner."));
1685 }
1686 Ok(Outcome::Ok(id))
1687 }
1688
1689 /// `cancel_task`: the sandbox stopped it. Its runner hears on its next
1690 /// poll; the sandbox does its own ending.
1691 pub async fn cancel_task(&self, a: CancelTaskArgs) -> Result<Outcome<bool>> {
1692 let done = self
1693 .db
1694 .prepare(
1695 "UPDATE runner_tasks SET status = 'completed', exit_code = -1, reason = ?, env = NULL, finished_at = ?
1696 WHERE sandbox = ? AND status != 'completed' RETURNING id",
1697 )
1698 .bind(&[optional(a.reason.as_deref()), now().into(), a.sandbox.as_str().into()])?
1699 .first::<Value>(None)
1700 .await?;
1701 Ok(Outcome::Ok(done.is_some()))
1702 }
1703
1704 /// Ends a task and tells the sandbox waiting on it, once.
1705 async fn end_task(&self, task: &TaskRow, exit_code: i32, reason: &str) -> Result<()> {
1706 let ended = self
1707 .db
1708 .prepare(
1709 "UPDATE runner_tasks SET status = 'completed', exit_code = ?, reason = ?, env = NULL, finished_at = ?
1710 WHERE id = ? AND status != 'completed' RETURNING id",
1711 )
1712 .bind(&[exit_code.into(), optional(Some(reason).filter(|r| !r.is_empty())), now().into(), task.id.as_str().into()])?
1713 .first::<Value>(None)
1714 .await?;
1715 if ended.is_none() {
1716 return Ok(());
1717 }
1718 if let Some(runner_id) = &task.runner_id {
1719 self.db
1720 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL WHERE id = ? AND work_id = ?")
1721 .bind(&[runner_id.as_str().into(), task.id.as_str().into()])?
1722 .run()
1723 .await?;
1724 }
1725 let told: Result<Value> = g1t_kit::call(
1726 &self.runner,
1727 "task_ended",
1728 &json!({
1729 "sandbox": task.sandbox,
1730 "exitCode": exit_code,
1731 "reason": if reason.is_empty() { Value::Null } else { json!(reason) },
1732 "runner": task.runner_name,
1733 }),
1734 )
1735 .await;
1736 if let Err(error) = told {
1737 worker::console_error!("actions: the sandbox waiting on task {} was not told: {error}", task.id);
1738 }
1739 Ok(())
1740 }
1741
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.1742 /// `runner_activity`: what runs for the workspace now, in g1t's
1743 /// sandboxes and handed to its own runners. Its owners only, as its
1744 /// runners are.
1745 pub async fn runner_activity(&self, a: RunnerActivityArgs) -> Result<Outcome<RunnerActivity>> {
1746 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
1747 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
1748 let workspace = place.workspace;
1749 let cloud_jobs = self
1750 .db
1751 .prepare(
1752 "SELECT jobs.id, jobs.name, jobs.run_id, runs.repo, jobs.started_at FROM jobs JOIN runs ON runs.id = jobs.run_id
1753 WHERE lower(jobs.namespace) = ? AND jobs.status = 'in_progress' AND jobs.labels IS NULL
1754 ORDER BY jobs.started_at DESC LIMIT ?",
1755 )
1756 .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1757 .all()
1758 .await?
1759 .results::<CloudJob>()?;
1760 let cloud_jobs_queued = self.queued_jobs(&workspace, false).await?;
1761 let self_hosted_jobs_queued = self.queued_jobs(&workspace, true).await?;
1762 let handed_over = self
1763 .db
1764 .prepare(
1765 "SELECT id, run_id, kind, title, repo, status, runner_name, created_at, started_at FROM runner_tasks
1766 WHERE status IN ('queued', 'in_progress') AND workspace = ?
1767 ORDER BY created_at DESC LIMIT ?",
1768 )
1769 .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1770 .all()
1771 .await?
1772 .results::<HandedOverTask>()?;
1773 Ok(Outcome::Ok(RunnerActivity { cloud_jobs, cloud_jobs_queued, handed_over, self_hosted_jobs_queued }))
1774 }
1775
1776 /// The workspace's workflow jobs queued for its own runners
1777 /// (`labelled`), or for g1t's sandboxes.
1778 async fn queued_jobs(&self, workspace: &str, labelled: bool) -> Result<u32> {
1779 let sql = if labelled {
1780 "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NOT NULL"
1781 } else {
1782 "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NULL"
1783 };
1784 Ok(self.db.prepare(sql).bind(&[workspace.into()])?.first::<Count>(None).await?.map_or(0, |c| c.n))
1785 }
1786
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1787 /// `stuck_jobs`: jobs of the viewer's workspaces that have waited ten
1788 /// minutes or more for a self-hosted runner while none that could take
1789 /// them is online, for Mission control's Needs you.
1790 pub async fn stuck_jobs(&self, a: StuckJobsArgs) -> Result<Vec<StuckJob>> {
1791 let Some(viewer) = a.viewer else { return Ok(Vec::new()) };
1792 let at = now_ms();
1793 let mut out = Vec::new();
1794 for membership in viewer.workspaces.iter().take(20) {
1795 let workspace = membership.slug.to_lowercase();
1796 #[derive(Deserialize)]
1797 struct Waiting {
1798 id: String,
1799 name: String,
1800 run_id: String,
1801 labels: String,
1802 queued_at: Option<String>,
1803 repo: String,
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.1804 repo_id: String,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1805 }
1806 let waiting = self
1807 .db
1808 .prepare(
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.1809 "SELECT jobs.id, jobs.name, jobs.run_id, jobs.labels, jobs.queued_at, runs.repo, jobs.repo_id FROM jobs JOIN runs ON runs.id = jobs.run_id
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1810 WHERE jobs.status = 'queued' AND jobs.labels IS NOT NULL AND lower(jobs.namespace) = ? AND jobs.queued_at < ?
1811 ORDER BY jobs.rowid LIMIT 20",
1812 )
1813 .bind(&[workspace.as_str().into(), rfc3339(at.saturating_sub(10 * 60 * 1000)).into()])?
1814 .all()
1815 .await?
1816 .results::<Waiting>()?;
1817 if waiting.is_empty() {
1818 continue;
1819 }
1820 let groups = self.group_rows(&workspace).await?;
1821 let online: Vec<RunnerRow> = self.runner_rows(&workspace).await?.into_iter().filter(|r| r.online(at)).collect();
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.1822 // A repository's visibility is asked only when a host-mode runner
1823 // is online, since only those pass over a public repository's jobs.
1824 let any_host = online.iter().any(|runner| runner.isolation.as_deref() == Some("host"));
1825 let mut looked = Looked::default();
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1826 for job in waiting {
1827 let wanted = Wanted::from_stored(&serde_json::from_str::<Vec<String>>(&job.labels).unwrap_or_default());
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.1828 let visibility = if any_host { self.visibility_once(&mut looked, &job.repo_id).await } else { None };
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1829 let served = online.iter().any(|runner| {
1830 let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id));
1831 wanted.matches(&runner.labels(), group.map(|g| g.name.as_str()))
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.1832 && model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref())
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1833 });
1834 if !served {
1835 out.push(StuckJob {
1836 id: job.id,
1837 name: job.name,
1838 run_id: job.run_id,
1839 repo: job.repo,
1840 labels: wanted.describe(),
1841 queued_at: job.queued_at.unwrap_or_default(),
1842 });
1843 }
1844 }
1845 }
1846 Ok(out)
1847 }
1848
1849 // --- Every minute ----------------------------------------------------------
1850
1851 /// Jobs and tasks that waited too long, tasks whose runner went quiet,
1852 /// runners offline for weeks, and registration tokens long expired.
1853 pub async fn sweep_runners(&self, at: u64) -> Result<()> {
1854 let before = |ms: u64| rfc3339(at.saturating_sub(ms));
1855 let waited = model::MAX_WAIT_HOURS * 60 * 60 * 1000;
1856 let stale = self
1857 .db
1858 .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NOT NULL AND queued_at < ? LIMIT 50")
1859 .bind(&[before(waited).into()])?
1860 .all()
1861 .await?
1862 .results::<JobRow>()?;
1863 for job in stale {
1864 let stored: Vec<String> = job.labels.as_deref().and_then(|l| serde_json::from_str(l).ok()).unwrap_or_default();
1865 let why = format!(
1866 "No self-hosted runner with labels {} took it within {} hours.",
1867 Wanted::from_stored(&stored).describe(),
1868 model::MAX_WAIT_HOURS
1869 );
1870 self.finish_job(&job.id, "failure", Some(&why), None).await?;
1871 }
1872 let tasks = self
1873 .db
1874 .prepare(
1875 "SELECT * FROM runner_tasks WHERE (status = 'queued' AND created_at < ?) OR (status = 'in_progress' AND seen_at < ?) LIMIT 50",
1876 )
1877 .bind(&[before(waited).into(), before(SILENT_MS).into()])?
1878 .all()
1879 .await?
1880 .results::<TaskRow>()?;
1881 for task in tasks {
1882 let why = if task.status == "queued" {
1883 format!("No self-hosted runner with labels {} took it within {} hours.", Wanted::of(&serde_json::from_str::<Vec<String>>(&task.labels).unwrap_or_default(), None).describe(), model::MAX_WAIT_HOURS)
1884 } else {
1885 format!("The self-hosted runner {} stopped answering.", task.runner_name.clone().unwrap_or_default())
1886 };
1887 self.end_task(&task, 1, &why).await?;
1888 }
1889 let forgotten = self
1890 .db
1891 .prepare("SELECT * FROM runners WHERE COALESCE(last_seen_at, created_at) < ? LIMIT 50")
1892 .bind(&[before(FORGET_OFFLINE_MS).into()])?
1893 .all()
1894 .await?
1895 .results::<RunnerRow>()?;
1896 for row in forgotten {
1897 self.forget_runner(&row, "The runner was offline for 14 days.").await?;
1898 }
1899 // An ephemeral runner that took its job and then went quiet.
1900 let spent = self
1901 .db
1902 .prepare("SELECT * FROM runners WHERE ephemeral = 1 AND spent = 1 AND work_id IS NULL AND COALESCE(last_seen_at, created_at) < ? LIMIT 50")
1903 .bind(&[before(60 * 60 * 1000).into()])?
1904 .all()
1905 .await?
1906 .results::<RunnerRow>()?;
1907 for row in spent {
1908 self.forget_runner(&row, "The ephemeral runner finished its job.").await?;
1909 }
1910 self.db
1911 .prepare("DELETE FROM runner_registrations WHERE expires_at < ?")
1912 .bind(&[before(24 * 60 * 60 * 1000).into()])?
1913 .run()
1914 .await?;
1915 Ok(())
1916 }
1917}
1918
1919#[derive(Deserialize)]
1920struct IdRow {
1921 id: String,
1922}
1923
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.1924/// Whether `runner` is online and could take a public repository's job:
1925/// not host-mode, and its repository, group and labels let it.
1926fn takes_public_job(runner: &RunnerRow, groups: &[GroupRow], repo_id: &str, run_repo: &str, wanted: &Wanted, at: u64) -> bool {
1927 let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id));
1928 runner.online(at)
1929 && model::isolation_allows(runner.isolation.as_deref(), Some("public"))
1930 && runner.repo_id.as_deref().is_none_or(|repo| repo == repo_id)
1931 && (runner.repo_id.is_some() || group.is_none_or(|g| g.allows(run_repo)))
1932 && wanted.matches(&runner.labels(), group.map(|g| g.name.as_str()))
1933}
1934
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1935fn group_view(group: &GroupRow, runners: &[RunnerRow]) -> RunnerGroup {
1936 RunnerGroup {
1937 id: group.id.clone(),
1938 name: group.name.clone(),
1939 default: group.is_default != 0,
1940 repositories: group.repositories(),
1941 runners: runners.iter().filter(|r| r.group_id.as_deref() == Some(group.id.as_str())).count() as u32,
1942 updated_at: group.updated_at.clone(),
1943 }
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948 use super::*;
1949
1950 fn group(repositories: &[&str]) -> GroupRow {
1951 GroupRow {
1952 id: "rng_1".into(),
1953 name: "Default".into(),
1954 is_default: 1,
1955 repositories: serde_json::to_string(&repositories).unwrap(),
1956 updated_at: "2026-10-06T00:00:00Z".into(),
1957 }
1958 }
1959
1960 #[test]
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1961 fn being_seen_is_written_once_a_minute() {
1962 let at = 2_000_000_000_000;
1963 assert!(seen_is_stale(None, at));
1964 assert!(!seen_is_stale(Some(&rfc3339(at - 59_000)), at));
1965 assert!(seen_is_stale(Some(&rfc3339(at - 61_000)), at));
1966 // A runner written down at the last moment is still online until the
1967 // next write, a poll later.
1968 assert!(SEEN_RESOLUTION_MS + model::MAX_POLL_WAIT_MS < model::ONLINE_WITHIN_MS);
1969 assert!(SEEN_RESOLUTION_MS < crate::SILENT_MS);
1970 }
1971
1972 #[test]
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1973 fn a_group_lets_its_repositories_use_it() {
1974 assert!(group(&[]).allows("acme/web"));
1975 assert!(group(&["web", "api"]).allows("acme/WEB"));
1976 assert!(group(&["web"]).allows("web"));
1977 assert!(!group(&["web"]).allows("acme/docs"));
1978 }
1979
1980 #[test]
1981 fn names_are_checked() {
1982 assert_eq!(valid_name(" build-01 ").unwrap(), "build-01");
1983 assert!(valid_name("").is_err());
1984 assert!(valid_name("has space").is_err());
1985 assert!(valid_name(&"x".repeat(65)).is_err());
1986 assert!(valid_group_name("GPU machines").is_ok());
1987 assert!(valid_group_name("").is_err());
1988 }
1989
1990 #[test]
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.1991 fn only_a_sandboxed_runner_that_could_take_it_serves_a_public_job() {
1992 let at = 1_000_000_000;
1993 let runner = |isolation: Option<&str>| RunnerRow {
1994 id: "rnr_1".into(),
1995 workspace: "acme".into(),
1996 repo_id: None,
1997 repo: None,
1998 group_id: None,
1999 name: "a".into(),
2000 labels: r#"["self-hosted","linux","x64"]"#.into(),
2001 os: "linux".into(),
2002 arch: "x64".into(),
2003 version: String::new(),
2004 ephemeral: 0,
2005 credential_hash: String::new(),
2006 previous_hash: None,
2007 rotated_at: String::new(),
2008 work_id: None,
2009 work_kind: None,
2010 spent: 0,
2011 last_seen_at: Some(rfc3339(at)),
2012 created_at: String::new(),
2013 created_by: None,
2014 isolation: isolation.map(str::to_owned),
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.2015 machine_hash: None,
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.2016 };
2017 let wanted = Wanted::of(&["self-hosted".to_owned(), "linux".to_owned()], None);
2018 let takes = |row: &RunnerRow| takes_public_job(row, &[], "rep_1", "acme/web", &wanted, at);
2019 assert!(!takes(&runner(Some("host"))));
2020 assert!(takes(&runner(Some("container"))));
2021 assert!(takes(&runner(Some("nested-container"))));
2022 assert!(takes(&runner(None)), "an older runner, which refuses for itself");
2023 assert!(!takes(&RunnerRow { repo_id: Some("rep_2".into()), ..runner(None) }), "another repository's");
2024 assert!(!takes(&RunnerRow { labels: r#"["self-hosted","macos"]"#.into(), ..runner(None) }));
2025 assert!(!takes(&RunnerRow { last_seen_at: None, ..runner(None) }), "offline");
2026 }
2027
2028 #[test]
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running2029 fn online_means_seen_in_the_last_ninety_seconds() {
2030 let row = RunnerRow {
2031 id: "rnr_1".into(),
2032 workspace: "acme".into(),
2033 repo_id: None,
2034 repo: None,
2035 group_id: None,
2036 name: "a".into(),
2037 labels: "[]".into(),
2038 os: "linux".into(),
2039 arch: "x64".into(),
2040 version: String::new(),
2041 ephemeral: 0,
2042 credential_hash: String::new(),
2043 previous_hash: None,
2044 rotated_at: String::new(),
2045 work_id: None,
2046 work_kind: None,
2047 spent: 0,
2048 last_seen_at: Some(rfc3339(1_000_000_000)),
2049 created_at: String::new(),
2050 created_by: None,
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.2051 isolation: None,
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.2052 machine_hash: None,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running2053 };
2054 assert!(row.online(1_000_000_000 + 60_000));
2055 assert!(!row.online(1_000_000_000 + 120_000));
2056 assert_eq!(row.workspace, "acme");
2057 }
A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.2058
2059 #[test]
2060 fn a_machine_fingerprint_is_kept_per_workspace_and_only_when_it_is_one() {
2061 let print = "ab".repeat(32);
2062 assert_eq!(machine_hash("acme", Some(&print)), machine_hash("acme", Some(&print.to_ascii_uppercase())));
2063 assert_ne!(machine_hash("acme", Some(&print)), machine_hash("other", Some(&print)));
2064 assert_eq!(machine_hash("acme", Some("SYNTAQX")), None);
2065 assert_eq!(machine_hash("acme", Some("abc")), None);
2066 assert_eq!(machine_hash("acme", None), None);
2067 }
2068
2069 #[test]
2070 fn the_same_computer_takes_its_runners_name_back() {
2071 let at = 1_000_000_000;
2072 let ours = machine_hash("acme", Some(&"ab".repeat(32)));
2073 let theirs = machine_hash("acme", Some(&"cd".repeat(32)));
2074 let row = |machine_hash: Option<String>, created_by: Option<&str>, seen: u64| RunnerRow {
2075 id: "rnr_1".into(),
2076 workspace: "acme".into(),
2077 repo_id: None,
2078 repo: None,
2079 group_id: None,
2080 name: "box-linux".into(),
2081 labels: "[]".into(),
2082 os: "linux".into(),
2083 arch: "x64".into(),
2084 version: String::new(),
2085 ephemeral: 0,
2086 credential_hash: String::new(),
2087 previous_hash: None,
2088 rotated_at: String::new(),
2089 work_id: None,
2090 work_kind: None,
2091 spent: 0,
2092 last_seen_at: Some(rfc3339(seen)),
2093 created_at: String::new(),
2094 created_by: created_by.map(str::to_owned),
2095 isolation: None,
2096 machine_hash,
2097 };
2098 // The same computer, even while the old one still looks online.
2099 assert!(takes_over(&row(ours.clone(), Some("u1"), at), false, ours.as_deref(), Some("u1"), at));
2100 // Another computer of the same name: only when asked to.
2101 assert!(!takes_over(&row(theirs.clone(), Some("u1"), 0), false, ours.as_deref(), Some("u1"), at));
2102 assert!(takes_over(&row(theirs, Some("u1"), at), true, ours.as_deref(), Some("u1"), at));
2103 // One from before fingerprints: the same person's, gone quiet.
2104 assert!(takes_over(&row(None, Some("u1"), 0), false, ours.as_deref(), Some("u1"), at));
2105 assert!(!takes_over(&row(None, Some("u1"), at), false, ours.as_deref(), Some("u1"), at));
2106 assert!(!takes_over(&row(None, Some("u2"), 0), false, ours.as_deref(), Some("u1"), at));
2107 assert!(!takes_over(&row(None, None, 0), false, ours.as_deref(), None, at));
2108 // A runner without a fingerprint never matches one without.
2109 assert!(!takes_over(&row(None, Some("u1"), at), false, None, Some("u1"), at));
2110 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running2111}

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