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
1,931 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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! 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::{
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.18 self as model, ACTIVITY_LIMIT, CloudJob, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily19 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily44/// 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}
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily58
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>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily85}
86
87impl RunnerRow {
88 fn labels(&self) -> Vec<String> {
89 serde_json::from_str(&self.labels).unwrap_or_default()
90 }
91
92 fn online(&self, now: u64) -> bool {
93 self.last_seen_at.as_deref().is_some_and(|seen| seen >= rfc3339(now.saturating_sub(model::ONLINE_WITHIN_MS)).as_str())
94 }
95}
96
97#[derive(Clone, Debug, Deserialize)]
98struct GroupRow {
99 id: String,
100 name: String,
101 is_default: u32,
102 repositories: String,
103 updated_at: String,
104}
105
106impl GroupRow {
107 fn repositories(&self) -> Vec<String> {
108 serde_json::from_str(&self.repositories).unwrap_or_default()
109 }
110
111 /// Whether `repo` (`owner/name`, or a bare name) may use the group.
112 fn allows(&self, repo: &str) -> bool {
113 let name = repo.rsplit('/').next().unwrap_or(repo);
114 let listed = self.repositories();
115 listed.is_empty() || listed.iter().any(|r| r.eq_ignore_ascii_case(name))
116 }
117}
118
119#[derive(Clone, Debug, Deserialize)]
120struct RegistrationRow {
121 workspace: String,
122 repo_id: Option<String>,
123 repo: Option<String>,
124 group_id: Option<String>,
125 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.126 /// Who made the token: the runners it registers are theirs.
127 #[serde(default)]
128 created_by: Option<String>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily129}
130
131#[derive(Clone, Debug, Deserialize)]
132struct SettingsRow {
133 agents: u32,
134 agent_labels: String,
135 fork_pulls: u32,
136}
137
138#[derive(Clone, Debug, Deserialize)]
139pub struct TaskRow {
140 pub id: String,
141 pub sandbox: String,
142 pub repo_id: Option<String>,
143 pub repo: String,
144 pub kind: String,
145 pub title: String,
146 pub labels: String,
147 pub env: Option<String>,
148 pub timeout_minutes: u32,
149 pub status: String,
150 pub runner_id: Option<String>,
151 pub runner_name: Option<String>,
152 pub started_at: Option<String>,
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails153 #[serde(default)]
154 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.155 /// The agent run it belongs to (migration 0012); none for older tasks.
156 #[serde(default)]
157 pub run_id: Option<String>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily158}
159
160/// Who may do what with a set of runners: the workspace, and the
161/// repository when they are a repository's own.
162struct Place {
163 workspace: String,
164 repo: Option<Repo>,
165}
166
167fn now() -> String {
168 rfc3339(now_ms())
169}
170
171fn valid_name(name: &str) -> std::result::Result<String, String> {
172 let name = name.trim();
173 let ok = !name.is_empty()
174 && name.len() <= 64
175 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
176 if ok {
177 Ok(name.to_owned())
178 } else {
179 Err("A runner's name is 1 to 64 letters, digits, `-`, `_` and `.`.".to_owned())
180 }
181}
182
183fn valid_group_name(name: &str) -> std::result::Result<String, String> {
184 let name = name.trim();
185 if name.is_empty() || name.len() > 64 || name.chars().any(|c| c.is_control()) {
186 return Err("A group's name is 1 to 64 characters.".to_owned());
187 }
188 Ok(name.to_owned())
189}
190
191/// What a runner's credential is checked against, and what a registration
192/// token is found by.
193fn hash(secret: &str) -> String {
194 sha256_hex(secret)
195}
196
197/// The actor of a runner's own entries in the audit log.
198fn runner_actor(runner: &RunnerRow) -> AuditActor {
199 AuditActor {
200 actor_kind: Some(g1t_contracts::audit::ActorKind::Runner),
201 actor: runner.name.clone(),
202 actor_id: runner.id.clone(),
203 ..AuditActor::default()
204 }
205}
206
207fn entry(actor: AuditActor, action: &str, workspace: &str, repo: Option<String>, path: Option<String>, message: String) -> NewAuditEntry {
208 NewAuditEntry {
209 actor,
210 action: action.to_owned(),
211 surface: Surface::Rest,
212 target: AuditTarget {
213 workspace: workspace.to_owned(),
214 repo,
215 path,
216 ..AuditTarget::default()
217 },
218 outcome: AuditOutcome::Allowed,
219 rule: "runner".to_owned(),
220 result: Some("ok".to_owned()),
221 message: Some(message),
222 request_id: new_id("req", now_ms()),
223 }
224}
225
226impl Actions {
227 // --- Shared ------------------------------------------------------------
228
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.229 pub(crate) async fn audit(&self, entries: Vec<NewAuditEntry>) {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily230 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
231 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.232 worker::console_error!("actions: audit entries not recorded: {error}");
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily233 }
234 }
235
236 /// Where `owner` points, if `actor` may see it (`manage` false) or
237 /// change it (`manage` true): a workspace's runners are seen by its
238 /// members and changed by its owners; a repository's own, by its
239 /// admins. Agents and workspace tokens never change them, so a
240 /// workflow's `G1T_TOKEN` cannot add a machine to run its own jobs.
241 async fn runner_place(&self, actor: &User, owner: &RunnersOwner, manage: bool) -> Result<Outcome<Place>> {
242 if actor.kind == PrincipalKind::Agent {
243 return Ok(fail(FailureCode::Forbidden, "Agents cannot see or change self-hosted runners."));
244 }
245 if manage && actor.kind == PrincipalKind::Workspace {
246 return Ok(fail(
247 FailureCode::Forbidden,
248 "A workspace's tokens, G1T_TOKEN included, cannot change self-hosted runners. Use a person's token with the runners:admin scope.",
249 ));
250 }
251 match (&owner.repo, &owner.workspace) {
252 (Some(path), _) => {
253 let repo = check!(self.may(actor, path, Capability::ManageIntegrations).await?);
254 Ok(Outcome::Ok(Place { workspace: repo.namespace.to_lowercase(), repo: Some(repo) }))
255 }
256 (None, Some(slug)) => {
257 let slug = slug.to_lowercase();
258 let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
259 match role {
260 None => Ok(fail(FailureCode::NotFound, "There is no such workspace, or you are not a member of it.")),
A workspace's runners are its owners' to see, through the API too; docs for the new sidebar and Settings261 // A workspace's machines and their tokens are its owners' alone, to see as well as change.
262 Some(Role::Member) => Ok(fail(
263 FailureCode::Forbidden,
264 format!("Only owners of {slug} can {} its self-hosted runners.", if manage { "change" } else { "see" }),
265 )),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily266 Some(_) => Ok(Outcome::Ok(Place { workspace: slug, repo: None })),
267 }
268 }
269 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
270 }
271 }
272
273 async fn group_rows(&self, workspace: &str) -> Result<Vec<GroupRow>> {
274 self.db
275 .prepare("SELECT * FROM runner_groups WHERE workspace = ? ORDER BY is_default DESC, name")
276 .bind(&[workspace.into()])?
277 .all()
278 .await?
279 .results::<GroupRow>()
280 }
281
282 /// The workspace's default group, made the first time it is needed.
283 async fn default_group(&self, workspace: &str) -> Result<GroupRow> {
284 let at = now();
285 self.db
286 .prepare(
287 "INSERT OR IGNORE INTO runner_groups (id, workspace, name, is_default, repositories, created_at, updated_at)
288 VALUES (?, ?, 'Default', 1, '[]', ?, ?)",
289 )
290 .bind(&[new_id("rng", now_ms()).into(), workspace.into(), at.as_str().into(), at.as_str().into()])?
291 .run()
292 .await?;
293 let row = self
294 .db
295 .prepare("SELECT * FROM runner_groups WHERE workspace = ? AND is_default = 1 LIMIT 1")
296 .bind(&[workspace.into()])?
297 .first::<GroupRow>(None)
298 .await?;
299 row.ok_or_else(|| worker::Error::RustError("the default runner group could not be made".into()))
300 }
301
302 /// A group of the workspace by id or name.
303 async fn find_group(&self, workspace: &str, wanted: &str) -> Result<Option<GroupRow>> {
304 self.default_group(workspace).await?;
305 Ok(self
306 .group_rows(workspace)
307 .await?
308 .into_iter()
309 .find(|g| g.id == wanted || g.name.eq_ignore_ascii_case(wanted.trim())))
310 }
311
312 async fn runner_rows(&self, workspace: &str) -> Result<Vec<RunnerRow>> {
313 self.db
314 .prepare("SELECT * FROM runners WHERE workspace = ? ORDER BY name")
315 .bind(&[workspace.into()])?
316 .all()
317 .await?
318 .results::<RunnerRow>()
319 }
320
321 async fn runner_by_id(&self, id: &str) -> Result<Option<RunnerRow>> {
322 self.db.prepare("SELECT * FROM runners WHERE id = ?").bind(&[id.into()])?.first::<RunnerRow>(None).await
323 }
324
325 /// What a runner is doing, for people.
326 async fn work_of(&self, row: &RunnerRow) -> Result<Option<RunnerWork>> {
327 let Some(id) = &row.work_id else { return Ok(None) };
328 if row.work_kind.as_deref() == Some("agent") {
329 let task = self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[id.as_str().into()])?.first::<TaskRow>(None).await?;
330 return Ok(task.filter(|t| t.status == "in_progress").map(|t| RunnerWork {
331 kind: "agent".into(),
332 id: t.id,
333 name: t.title,
334 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.335 run_id: t.run_id,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily336 started_at: t.started_at,
337 }));
338 }
339 #[derive(Deserialize)]
340 struct Found {
341 id: String,
342 name: String,
343 run_id: String,
344 status: String,
345 started_at: Option<String>,
346 repo: String,
347 }
348 let job = self
349 .db
350 .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 = ?")
351 .bind(&[id.as_str().into()])?
352 .first::<Found>(None)
353 .await?;
354 Ok(job.filter(|j| j.status == "in_progress").map(|j| RunnerWork {
355 kind: "workflow".into(),
356 id: j.id,
357 name: j.name,
358 repo: Some(j.repo),
359 run_id: Some(j.run_id),
360 started_at: j.started_at,
361 }))
362 }
363
364 async fn runner_view(&self, row: &RunnerRow, groups: &[GroupRow], at: u64) -> Result<Runner> {
365 let online = row.online(at);
366 let work = if online { self.work_of(row).await? } else { None };
367 Ok(Runner {
368 id: row.id.clone(),
369 name: row.name.clone(),
370 workspace: row.workspace.clone(),
371 repo: row.repo.clone(),
372 group: row
373 .group_id
374 .as_ref()
375 .and_then(|id| groups.iter().find(|g| &g.id == id))
376 .map(|g| g.name.clone()),
377 labels: row.labels(),
378 os: row.os.clone(),
379 arch: row.arch.clone(),
380 version: row.version.clone(),
381 ephemeral: row.ephemeral != 0,
382 status: if !online {
383 "offline"
384 } else if work.is_some() {
385 "busy"
386 } else {
387 "online"
388 }
389 .to_owned(),
390 work,
391 last_seen_at: row.last_seen_at.clone(),
392 created_at: row.created_at.clone(),
393 created_by: row.created_by.clone(),
394 })
395 }
396
397 // --- People's side -----------------------------------------------------
398
399 /// `runners`: a workspace's runners, or a repository's: its own and
400 /// the workspace's that its group lets it use.
401 pub async fn runners(&self, a: ListRunnersArgs) -> Result<Outcome<Vec<Runner>>> {
402 let place = check!(self.runner_place(&a.actor, &a.owner, false).await?);
403 self.default_group(&place.workspace).await?;
404 let groups = self.group_rows(&place.workspace).await?;
405 let at = now_ms();
406 let mut out = Vec::new();
407 for row in self.runner_rows(&place.workspace).await? {
408 let shown = match &place.repo {
409 None => row.repo_id.is_none(),
410 Some(repo) => match &row.repo_id {
411 Some(id) => id == &repo.id,
412 None => groups.iter().find(|g| Some(&g.id) == row.group_id.as_ref()).is_none_or(|g| g.allows(&repo.name)),
413 },
414 };
415 if shown {
416 out.push(self.runner_view(&row, &groups, at).await?);
417 }
418 }
419 Ok(Outcome::Ok(out))
420 }
421
422 /// `create_registration_token`.
423 pub async fn create_registration_token(&self, a: CreateRegistrationTokenArgs) -> Result<Outcome<RegistrationToken>> {
424 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
425 let made = self
426 .db
427 .prepare("SELECT COUNT(*) AS n FROM runner_registrations WHERE workspace = ? AND created_at > ?")
428 .bind(&[place.workspace.as_str().into(), rfc3339(now_ms().saturating_sub(60 * 60 * 1000)).into()])?
429 .first::<Count>(None)
430 .await?
431 .map_or(0, |c| c.n);
432 if made >= model::MAX_TOKENS_PER_HOUR {
433 return Ok(fail(FailureCode::Conflict, "Too many registration tokens in the last hour. Each lasts an hour and registers any number of runners."));
434 }
435 let group = match (&place.repo, a.group.as_deref().map(str::trim).filter(|g| !g.is_empty())) {
436 (Some(_), Some(_)) => return Ok(fail(FailureCode::Invalid, "A repository's own runners are in no group.")),
437 (Some(_), None) => None,
438 (None, Some(wanted)) => match self.find_group(&place.workspace, wanted).await? {
439 Some(group) => Some(group),
440 None => return Ok(fail(FailureCode::NotFound, format!("There is no runner group called {wanted}."))),
441 },
442 (None, None) => Some(self.default_group(&place.workspace).await?),
443 };
444 let token = format!("{}{}", model::REGISTRATION_PREFIX, random_hex(24));
445 let at = now_ms();
446 let expires_at = rfc3339(at + model::REGISTRATION_TTL_SECONDS * 1000);
447 let repo_name = place.repo.as_ref().map(|r| format!("{}/{}", r.namespace, r.name));
448 self.db
449 .prepare(
450 "INSERT INTO runner_registrations (token_hash, workspace, repo_id, repo, group_id, created_by, created_at, expires_at)
451 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
452 )
453 .bind(&[
454 hash(&token).into(),
455 place.workspace.as_str().into(),
456 optional(place.repo.as_ref().map(|r| r.id.as_str())),
457 optional(repo_name.as_deref()),
458 optional(group.as_ref().map(|g| g.id.as_str())),
459 a.actor.username.as_str().into(),
460 rfc3339(at).into(),
461 expires_at.as_str().into(),
462 ])?
463 .run()
464 .await?;
465 self.audit(vec![entry(
466 AuditActor::of(&a.actor),
467 "create_runner_registration_token",
468 &place.workspace,
469 repo_name.clone(),
470 None,
471 format!("Made a runner registration token{}.", group.as_ref().map(|g| format!(" for group {}", g.name)).unwrap_or_default()),
472 )])
473 .await;
474 Ok(Outcome::Ok(RegistrationToken {
475 token,
476 expires_at,
477 workspace: place.workspace,
478 repo: repo_name,
479 group: group.map(|g| g.name),
480 url: crate::SITE.to_owned(),
481 }))
482 }
483
484 /// `remove_runner`: a person removing one.
485 pub async fn remove_runner(&self, a: RemoveRunnerArgs) -> Result<Outcome<bool>> {
486 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
487 let Some(row) = self.runner_by_id(&a.id).await? else {
488 return Ok(fail(FailureCode::NotFound, "There is no such runner."));
489 };
490 let ours = row.workspace == place.workspace
491 && match &place.repo {
492 Some(repo) => row.repo_id.as_deref() == Some(repo.id.as_str()),
493 None => row.repo_id.is_none(),
494 };
495 if !ours {
496 return Ok(fail(FailureCode::NotFound, "There is no such runner."));
497 }
498 self.forget_runner(&row, &format!("{} removed the runner {}.", a.actor.username, row.name)).await?;
499 self.audit(vec![entry(
500 AuditActor::of(&a.actor),
501 "remove_runner",
502 &row.workspace,
503 row.repo.clone(),
504 Some(row.name.clone()),
505 format!("Removed the self-hosted runner {}.", row.name),
506 )])
507 .await;
508 Ok(Outcome::Ok(true))
509 }
510
511 /// Removes a runner: what it was running fails, and its credential
512 /// stops working at once.
513 async fn forget_runner(&self, row: &RunnerRow, why: &str) -> Result<()> {
514 self.db.prepare("DELETE FROM runners WHERE id = ?").bind(&[row.id.as_str().into()])?.run().await?;
515 self.abandon_work(&row.id, why).await
516 }
517
518 /// Fails whatever a runner was running.
519 async fn abandon_work(&self, runner_id: &str, why: &str) -> Result<()> {
520 let jobs = self
521 .db
522 .prepare("SELECT id FROM jobs WHERE runner_id = ? AND status = 'in_progress'")
523 .bind(&[runner_id.into()])?
524 .all()
525 .await?
526 .results::<IdRow>()?;
527 for job in jobs {
528 self.finish_job(&job.id, "failure", Some(why), None).await?;
529 }
530 let tasks = self
531 .db
532 .prepare("SELECT * FROM runner_tasks WHERE runner_id = ? AND status = 'in_progress'")
533 .bind(&[runner_id.into()])?
534 .all()
535 .await?
536 .results::<TaskRow>()?;
537 for task in tasks {
538 self.end_task(&task, 1, why).await?;
539 }
540 Ok(())
541 }
542
543 /// `runner_groups`.
544 pub async fn runner_groups(&self, a: RunnerGroupsArgs) -> Result<Outcome<Vec<RunnerGroup>>> {
545 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
546 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
547 self.default_group(&place.workspace).await?;
548 let groups = self.group_rows(&place.workspace).await?;
549 let runners = self.runner_rows(&place.workspace).await?;
550 Ok(Outcome::Ok(groups.iter().map(|g| group_view(g, &runners)).collect()))
551 }
552
553 /// `set_runner_group`.
554 pub async fn set_runner_group(&self, a: SetRunnerGroupArgs) -> Result<Outcome<RunnerGroup>> {
555 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
556 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
557 self.default_group(&place.workspace).await?;
558 let groups = self.group_rows(&place.workspace).await?;
559 let name = match a.name.as_deref().map(valid_group_name).transpose() {
560 Ok(name) => name,
561 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
562 };
563 let repositories: Option<Vec<String>> = a.repositories.map(|list| {
564 let mut out: Vec<String> = Vec::new();
565 for repo in list {
566 let name = repo.trim().rsplit('/').next().unwrap_or_default().to_owned();
567 if !name.is_empty() && !out.iter().any(|r: &String| r.eq_ignore_ascii_case(&name)) {
568 out.push(name);
569 }
570 }
571 out
572 });
573 if let Some(name) = &name
574 && groups.iter().any(|g| g.name.eq_ignore_ascii_case(name) && Some(&g.id) != a.id.as_ref())
575 {
576 return Ok(fail(FailureCode::Conflict, format!("There is already a group called {name}.")));
577 }
578 let at = now();
579 let id = match &a.id {
580 Some(id) => {
581 let Some(group) = groups.iter().find(|g| &g.id == id) else {
582 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
583 };
584 self.db
585 .prepare("UPDATE runner_groups SET name = ?, repositories = ?, updated_at = ? WHERE id = ?")
586 .bind(&[
587 name.clone().unwrap_or(group.name.clone()).into(),
588 serde_json::to_string(&repositories.clone().unwrap_or(group.repositories()))?.into(),
589 at.as_str().into(),
590 id.as_str().into(),
591 ])?
592 .run()
593 .await?;
594 id.clone()
595 }
596 None => {
597 let Some(name) = name.clone() else {
598 return Ok(fail(FailureCode::Invalid, "A new group needs a name."));
599 };
600 if groups.len() >= 50 {
601 return Ok(fail(FailureCode::Conflict, "A workspace has at most 50 runner groups."));
602 }
603 let id = new_id("rng", now_ms());
604 self.db
605 .prepare(
606 "INSERT INTO runner_groups (id, workspace, name, is_default, repositories, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?)",
607 )
608 .bind(&[
609 id.as_str().into(),
610 place.workspace.as_str().into(),
611 name.into(),
612 serde_json::to_string(&repositories.clone().unwrap_or_default())?.into(),
613 at.as_str().into(),
614 at.as_str().into(),
615 ])?
616 .run()
617 .await?;
618 id
619 }
620 };
621 let groups = self.group_rows(&place.workspace).await?;
622 let runners = self.runner_rows(&place.workspace).await?;
623 let Some(group) = groups.iter().find(|g| g.id == id) else {
624 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
625 };
626 self.audit(vec![entry(
627 AuditActor::of(&a.actor),
628 if a.id.is_some() { "update_runner_group" } else { "create_runner_group" },
629 &place.workspace,
630 None,
631 Some(group.name.clone()),
632 format!(
633 "Runner group {}: {}.",
634 group.name,
635 if group.repositories().is_empty() { "every repository".to_owned() } else { group.repositories().join(", ") }
636 ),
637 )])
638 .await;
639 Ok(Outcome::Ok(group_view(group, &runners)))
640 }
641
642 /// `delete_runner_group`.
643 pub async fn delete_runner_group(&self, a: DeleteRunnerGroupArgs) -> Result<Outcome<bool>> {
644 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
645 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
646 let default = self.default_group(&place.workspace).await?;
647 let Some(group) = self.group_rows(&place.workspace).await?.into_iter().find(|g| g.id == a.id) else {
648 return Ok(fail(FailureCode::NotFound, "There is no such runner group."));
649 };
650 if group.is_default != 0 {
651 return Ok(fail(FailureCode::Conflict, "The default group cannot be deleted."));
652 }
653 self.db
654 .batch(vec![
655 self.db.prepare("UPDATE runners SET group_id = ? WHERE group_id = ?").bind(&[default.id.as_str().into(), group.id.as_str().into()])?,
656 self.db.prepare("DELETE FROM runner_groups WHERE id = ?").bind(&[group.id.as_str().into()])?,
657 ])
658 .await?;
659 self.audit(vec![entry(
660 AuditActor::of(&a.actor),
661 "delete_runner_group",
662 &place.workspace,
663 None,
664 Some(group.name.clone()),
665 format!("Deleted the runner group {}; its runners joined {}.", group.name, default.name),
666 )])
667 .await;
668 Ok(Outcome::Ok(true))
669 }
670
671 async fn settings_row(&self, owner: &str) -> Result<Option<SettingsRow>> {
672 self.db.prepare("SELECT * FROM runner_settings WHERE owner = ?").bind(&[owner.into()])?.first::<SettingsRow>(None).await
673 }
674
675 fn settings_of(row: Option<SettingsRow>, inherited: bool) -> RunnerSettings {
676 match row {
677 Some(row) => RunnerSettings {
678 agents_on_self_hosted: row.agents != 0,
679 agent_labels: serde_json::from_str(&row.agent_labels).unwrap_or_else(|_| vec![model::SELF_HOSTED.to_owned()]),
680 fork_pull_requests: row.fork_pulls != 0,
681 inherited,
682 },
683 None => RunnerSettings {
684 agent_labels: vec![model::SELF_HOSTED.to_owned()],
685 inherited,
686 ..RunnerSettings::default()
687 },
688 }
689 }
690
691 /// The settings that apply in a workspace, or in one of its
692 /// repositories: the repository's own if it has them.
693 pub async fn effective_runner_settings(&self, workspace: &str, repo_id: Option<&str>) -> Result<RunnerSettings> {
694 if let Some(repo_id) = repo_id
695 && let Some(own) = self.settings_row(repo_id).await?
696 {
697 return Ok(Self::settings_of(Some(own), false));
698 }
699 let inherited = repo_id.is_some();
700 Ok(Self::settings_of(self.settings_row(&workspace.to_lowercase()).await?, inherited))
701 }
702
703 /// `runner_settings`.
704 pub async fn runner_settings(&self, a: RunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> {
705 let place = check!(self.runner_place(&a.actor, &a.owner, false).await?);
706 Ok(Outcome::Ok(self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?))
707 }
708
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.709 /// `report_desktop`: what a person's desktop app says of its runner,
710 /// in a workspace they own. Kept per computer, replaced each time.
711 pub async fn report_desktop(&self, a: ReportDesktopArgs) -> Result<Outcome<bool>> {
712 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
713 let place = check!(self.runner_place(&a.actor, &owner, true).await?);
714 let machine = match valid_name(&a.machine) {
715 Ok(name) => name,
716 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
717 };
718 if !matches!(a.backend.as_str(), "sandboxed" | "docker" | "host") {
719 return Ok(fail(FailureCode::Invalid, "A desktop runs jobs sandboxed, in Docker or on the host."));
720 }
721 let clip = |text: &str, most: usize| text.chars().take(most).collect::<String>();
722 let tidy = |d: &DesktopDiagnosis| DesktopDiagnosis {
723 code: clip(&d.code, 40),
724 tone: clip(&d.tone, 20),
725 title: clip(&d.title, 200),
726 cause: d.cause.as_deref().map(|t| clip(t, 1000)),
727 fix: d.fix.as_deref().map(|t| clip(t, 1000)),
728 };
729 let at = now_ms();
730 let known = self
731 .db
732 .prepare("SELECT COUNT(*) AS n FROM desktop_runners WHERE workspace = ? AND lower(machine) <> lower(?)")
733 .bind(&[place.workspace.as_str().into(), machine.as_str().into()])?
734 .first::<Count>(None)
735 .await?
736 .map_or(0, |c| c.n);
737 if known >= model::MAX_DESKTOPS {
738 // The oldest report makes room: a computer long gone.
739 self.db
740 .prepare("DELETE FROM desktop_runners WHERE rowid IN (SELECT rowid FROM desktop_runners WHERE workspace = ? ORDER BY reported_at LIMIT 1)")
741 .bind(&[place.workspace.as_str().into()])?
742 .run()
743 .await?;
744 }
745 self.db
746 .prepare(
747 "INSERT INTO desktop_runners (workspace, machine, person, backend, version, diagnosis, closed, reported_at)
748 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
749 ON CONFLICT (workspace, machine) DO UPDATE SET person = ?3, backend = ?4, version = ?5, diagnosis = ?6, closed = ?7, reported_at = ?8",
750 )
751 .bind(&[
752 place.workspace.as_str().into(),
753 machine.as_str().into(),
754 a.actor.username.as_str().into(),
755 a.backend.as_str().into(),
756 optional(a.version.as_deref().map(|v| clip(v, 40)).as_deref()),
757 serde_json::to_string(&tidy(&a.diagnosis))?.into(),
758 serde_json::to_string(&tidy(&a.closed))?.into(),
759 rfc3339(at).into(),
760 ])?
761 .run()
762 .await?;
763 Ok(Outcome::Ok(true))
764 }
765
766 /// `desktop_runners`: the workspace's desktops, as each app last said.
767 pub async fn desktop_runners(&self, a: DesktopRunnersArgs) -> Result<Outcome<Vec<DesktopRunner>>> {
768 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
769 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
770 #[derive(Deserialize)]
771 struct Row {
772 workspace: String,
773 machine: String,
774 person: String,
775 backend: String,
776 version: Option<String>,
777 diagnosis: String,
778 closed: String,
779 reported_at: String,
780 }
781 let rows = self
782 .db
783 .prepare("SELECT * FROM desktop_runners WHERE workspace = ? ORDER BY lower(machine)")
784 .bind(&[place.workspace.as_str().into()])?
785 .all()
786 .await?
787 .results::<Row>()?;
788 let open_since = rfc3339(now_ms().saturating_sub(model::DESKTOP_OPEN_WITHIN_MS));
789 Ok(Outcome::Ok(
790 rows.into_iter()
791 .map(|row| DesktopRunner {
792 open: row.reported_at.as_str() > open_since.as_str(),
793 diagnosis: serde_json::from_str(&row.diagnosis).unwrap_or_default(),
794 closed: serde_json::from_str(&row.closed).unwrap_or_default(),
795 workspace: row.workspace,
796 machine: row.machine,
797 person: row.person,
798 backend: row.backend,
799 version: row.version,
800 reported_at: row.reported_at,
801 })
802 .collect(),
803 ))
804 }
805
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily806 /// `set_runner_settings`.
807 pub async fn set_runner_settings(&self, a: SetRunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> {
808 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
809 let (owner, scope) = match &place.repo {
810 Some(repo) => (repo.id.clone(), "repository"),
811 None => (place.workspace.clone(), "workspace"),
812 };
813 if a.inherit && place.repo.is_some() {
814 self.db.prepare("DELETE FROM runner_settings WHERE owner = ?").bind(&[owner.as_str().into()])?.run().await?;
815 } else {
816 let current = self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?;
817 let labels = match &a.agent_labels {
818 Some(given) => match model::runner_labels(given, model::SELF_HOSTED, model::SELF_HOSTED) {
819 Ok(labels) => labels,
820 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
821 },
822 None => current.agent_labels.clone(),
823 };
824 let agents = a.agents_on_self_hosted.unwrap_or(current.agents_on_self_hosted);
825 let forks = a.fork_pull_requests.unwrap_or(current.fork_pull_requests);
826 self.db
827 .prepare(
828 "INSERT INTO runner_settings (owner, scope, agents, agent_labels, fork_pulls, updated_at, updated_by)
829 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
830 ON CONFLICT (owner) DO UPDATE SET agents = ?3, agent_labels = ?4, fork_pulls = ?5, updated_at = ?6, updated_by = ?7",
831 )
832 .bind(&[
833 owner.as_str().into(),
834 scope.into(),
835 u32::from(agents).into(),
836 serde_json::to_string(&labels)?.into(),
837 u32::from(forks).into(),
838 now().into(),
839 a.actor.username.as_str().into(),
840 ])?
841 .run()
842 .await?;
843 }
844 let settings = self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?;
845 self.audit(vec![entry(
846 AuditActor::of(&a.actor),
847 "update_runner_settings",
848 &place.workspace,
849 place.repo.as_ref().map(|r| format!("{}/{}", r.namespace, r.name)),
850 None,
851 format!(
852 "Agents on self-hosted runners: {}; labels {}; pull requests from forks: {}.",
853 if settings.agents_on_self_hosted { "on" } else { "off" },
854 settings.agent_labels.join(", "),
855 if settings.fork_pull_requests { "allowed" } else { "not allowed" }
856 ),
857 )])
858 .await;
859 Ok(Outcome::Ok(settings))
860 }
861
862 // --- The runner's side ---------------------------------------------------
863
864 /// `runner_register`.
865 pub async fn runner_register(&self, a: RegisterArgs) -> Result<Outcome<Registered>> {
866 let refused = || fail(FailureCode::Unauthenticated, "That registration token is not valid, or it expired. Make a new one under Settings, Runners.");
867 if !a.token.starts_with(model::REGISTRATION_PREFIX) {
868 return Ok(refused());
869 }
870 let registration = self
871 .db
872 .prepare("SELECT * FROM runner_registrations WHERE token_hash = ?")
873 .bind(&[hash(&a.token).into()])?
874 .first::<RegistrationRow>(None)
875 .await?;
876 let at = now_ms();
877 let Some(registration) = registration.filter(|r| r.expires_at.as_str() > rfc3339(at).as_str()) else {
878 return Ok(refused());
879 };
880 let name = match valid_name(&a.name) {
881 Ok(name) => name,
882 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
883 };
884 let (Some(os), Some(arch)) = (model::os_label(&a.os), model::arch_label(&a.arch)) else {
885 return Ok(fail(FailureCode::Invalid, "A runner's os is linux, macos or windows, and its arch x64 or arm64."));
886 };
887 let labels = match model::runner_labels(&a.labels, os, arch) {
888 Ok(labels) => labels,
889 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
890 };
891 let workspace = registration.workspace.clone();
892 // Abuse: so many machines, so fast, are not a team's.
893 let recent = self
894 .db
895 .prepare("SELECT COUNT(*) AS n FROM runners WHERE workspace = ? AND created_at > ?")
896 .bind(&[workspace.as_str().into(), rfc3339(at.saturating_sub(60 * 1000)).into()])?
897 .first::<Count>(None)
898 .await?
899 .map_or(0, |c| c.n);
900 if recent >= model::MAX_REGISTRATIONS_PER_MINUTE {
901 return Ok(fail(FailureCode::Conflict, "Too many runners registered in the last minute. Wait a moment and try again."));
902 }
903 let total = self
904 .db
905 .prepare("SELECT COUNT(*) AS n FROM runners WHERE workspace = ?")
906 .bind(&[workspace.as_str().into()])?
907 .first::<Count>(None)
908 .await?
909 .map_or(0, |c| c.n);
910 // A group: the one asked for, the token's, or the default.
911 let group = if registration.repo_id.is_some() {
912 None
913 } else {
914 match a.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
915 Some(wanted) => match self.find_group(&workspace, wanted).await? {
916 Some(group) => Some(group.id),
917 None => return Ok(fail(FailureCode::NotFound, format!("There is no runner group called {wanted}."))),
918 },
919 None => match registration.group_id.clone() {
920 Some(id) => Some(id),
921 None => Some(self.default_group(&workspace).await?.id),
922 },
923 }
924 };
925 let existing = self
926 .db
927 .prepare("SELECT * FROM runners WHERE workspace = ? AND COALESCE(repo_id, '') = ? AND lower(name) = lower(?)")
928 .bind(&[workspace.as_str().into(), registration.repo_id.clone().unwrap_or_default().into(), name.as_str().into()])?
929 .first::<RunnerRow>(None)
930 .await?;
931 if let Some(existing) = &existing {
932 if !a.replace {
933 return Ok(fail(
934 FailureCode::Conflict,
935 format!("A runner called {name} is already registered here. Register with --replace to take its place, or choose another --name."),
936 ));
937 }
938 self.forget_runner(existing, &format!("The runner {name} was replaced by a new registration.")).await?;
939 } else if total >= model::MAX_RUNNERS {
940 return Ok(fail(FailureCode::Conflict, format!("A workspace has at most {} self-hosted runners.", model::MAX_RUNNERS)));
941 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9942 // How it keeps work from its machine, if it said (older runners do not).
943 let isolation = a.isolation.as_deref().filter(|level| model::ISOLATIONS.contains(level));
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily944 let id = new_id("rnr", at);
945 let credential = format!("{}{}", model::CREDENTIAL_PREFIX, random_hex(32));
946 let created = rfc3339(at);
947 self.db
948 .prepare(
949 "INSERT INTO runners (id, workspace, repo_id, repo, group_id, name, labels, os, arch, version, ephemeral, credential_hash,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9950 rotated_at, last_seen_at, created_at, created_by, isolation)
951 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily952 )
953 .bind(&[
954 id.as_str().into(),
955 workspace.as_str().into(),
956 optional(registration.repo_id.as_deref()),
957 optional(registration.repo.as_deref()),
958 optional(group.as_deref()),
959 name.as_str().into(),
960 serde_json::to_string(&labels)?.into(),
961 os.into(),
962 arch.into(),
963 a.version.chars().take(40).collect::<String>().into(),
964 u32::from(a.ephemeral).into(),
965 hash(&credential).into(),
966 created.as_str().into(),
967 created.as_str().into(),
968 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.969 // The person who made the token: a desktop's runner is theirs.
970 optional(registration.created_by.as_deref()),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9971 optional(isolation),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily972 ])?
973 .run()
974 .await?;
975 self.db
976 .prepare("UPDATE runner_registrations SET used = used + 1 WHERE token_hash = ?")
977 .bind(&[hash(&a.token).into()])?
978 .run()
979 .await?;
980 let row = self.runner_by_id(&id).await?.ok_or_else(|| worker::Error::RustError("the runner was not kept".into()))?;
981 self.audit(vec![entry(
982 runner_actor(&row),
983 "register_runner",
984 &workspace,
985 row.repo.clone(),
986 Some(name.clone()),
987 format!("Registered the self-hosted runner {name} ({os}, {arch}; labels {}).", labels.join(", ")),
988 )])
989 .await;
990 let groups = self.group_rows(&workspace).await?;
991 Ok(Outcome::Ok(Registered {
992 runner: self.runner_view(&row, &groups, at).await?,
993 credential,
994 }))
995 }
996
997 /// The runner a call is from, if its credential is that runner's (or
998 /// the one before a rotation, until the new one is used).
999 async fn authenticated(&self, auth: &RunnerAuth) -> Result<Option<(RunnerRow, bool)>> {
1000 if !auth.credential.starts_with(model::CREDENTIAL_PREFIX) {
1001 return Ok(None);
1002 }
1003 let Some(row) = self.runner_by_id(&auth.runner).await? else { return Ok(None) };
1004 let given = hash(&auth.credential);
1005 if g1t_secrets::same(&row.credential_hash, &given) {
1006 if row.previous_hash.is_some() {
1007 self.db.prepare("UPDATE runners SET previous_hash = NULL WHERE id = ?").bind(&[row.id.as_str().into()])?.run().await?;
1008 }
1009 return Ok(Some((row, true)));
1010 }
1011 if row.previous_hash.as_deref().is_some_and(|previous| g1t_secrets::same(previous, &given)) {
1012 return Ok(Some((row, false)));
1013 }
1014 Ok(None)
1015 }
1016
1017 fn unknown_runner<T>() -> Outcome<T> {
1018 fail(FailureCode::Unauthenticated, "This runner is not registered, or its credential is not valid. Register it again.")
1019 }
1020
1021 /// `runner_poll`.
1022 pub async fn runner_poll(&self, a: PollArgs) -> Result<Outcome<Poll>> {
1023 let Some((row, current)) = self.authenticated(&a.auth).await? else {
1024 return Ok(Self::unknown_runner());
1025 };
1026 let at = now_ms();
1027 let seen = rfc3339(at);
1028 // A new credential every day, handed over on a poll that used the
1029 // current one.
1030 let mut credential = None;
1031 let rotate_after = rfc3339(at.saturating_sub(model::ROTATE_AFTER_MS));
1032 if current && row.rotated_at.as_str() < rotate_after.as_str() {
1033 let fresh = format!("{}{}", model::CREDENTIAL_PREFIX, random_hex(32));
1034 self.db
1035 .prepare("UPDATE runners SET previous_hash = credential_hash, credential_hash = ?, rotated_at = ? WHERE id = ?")
1036 .bind(&[hash(&fresh).into(), seen.as_str().into(), row.id.as_str().into()])?
1037 .run()
1038 .await?;
1039 credential = Some(fresh);
1040 }
1041 // What it holds that it should stop, and the heartbeat for the rest.
1042 let mut cancel = Vec::new();
1043 let mut active: Option<(String, &'static str)> = None;
1044 for id in a.running.iter().take(10) {
1045 if let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[id.as_str().into()])?.first::<JobRow>(None).await? {
1046 if job.status == "in_progress" && job.runner_id.as_deref() == Some(row.id.as_str()) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1047 if seen_is_stale(job.seen_at.as_deref(), at) {
1048 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?;
1049 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1050 active = Some((id.clone(), "workflow"));
1051 } else {
1052 cancel.push(id.clone());
1053 }
1054 continue;
1055 }
1056 match self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[id.as_str().into()])?.first::<TaskRow>(None).await? {
1057 Some(task) if task.status == "in_progress" && task.runner_id.as_deref() == Some(row.id.as_str()) => {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1058 if seen_is_stale(task.seen_at.as_deref(), at) {
1059 self.db.prepare("UPDATE runner_tasks SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?;
1060 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1061 active = Some((id.clone(), "agent"));
1062 }
1063 _ => cancel.push(id.clone()),
1064 }
1065 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1066 // Written when something changed, or once a minute to keep it online.
1067 let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() };
1068 let work_id = active.as_ref().map(|(id, _)| id.as_str());
1069 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.1070 // How it is isolated, as it says now: a runner re-registered or
1071 // restarted with or without Docker says so on its next poll.
1072 let isolation = a
1073 .isolation
1074 .as_deref()
1075 .filter(|level| model::ISOLATIONS.contains(level))
1076 .map(str::to_owned)
1077 .or_else(|| row.isolation.clone());
1078 let changed = version != row.version
1079 || work_id != row.work_id.as_deref()
1080 || work_kind != row.work_kind.as_deref()
1081 || isolation != row.isolation;
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1082 if changed || seen_is_stale(row.last_seen_at.as_deref(), at) {
1083 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.1084 .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ?, isolation = ? WHERE id = ?")
1085 .bind(&[
1086 seen.as_str().into(),
1087 version.into(),
1088 optional(work_id),
1089 optional(work_kind),
1090 optional(isolation.as_deref()),
1091 row.id.as_str().into(),
1092 ])?
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1093 .run()
1094 .await?;
1095 }
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.1096 let row = RunnerRow { isolation, ..row };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1097 let mut poll = Poll { assignment: None, cancel, credential, removed: false };
1098 // Busy, or an ephemeral runner that has had its one job.
1099 if active.is_some() || !a.running.is_empty() {
1100 return Ok(Outcome::Ok(poll));
1101 }
1102 if row.ephemeral != 0 && row.spent != 0 {
1103 self.forget_runner(&row, "The ephemeral runner finished its job.").await?;
1104 poll.removed = true;
1105 return Ok(Outcome::Ok(poll));
1106 }
1107 let groups = self.group_rows(&row.workspace).await?;
1108 let group = row.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)).cloned();
1109 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.1110 let mut looked = Looked::default();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1111 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.1112 if let Some(assignment) = self.claim(&row, group.as_ref(), &mut looked).await? {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1113 poll.assignment = Some(assignment);
1114 return Ok(Outcome::Ok(poll));
1115 }
1116 if now_ms() + POLL_EVERY_MS > deadline {
1117 return Ok(Outcome::Ok(poll));
1118 }
1119 worker::Delay::from(std::time::Duration::from_millis(POLL_EVERY_MS)).await;
1120 }
1121 }
1122
1123 /// 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.1124 async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>, looked: &mut Looked) -> Result<Option<Assignment>> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1125 let labels = runner.labels();
1126 let group_name = group.map(|g| g.name.clone());
1127 // Workflow jobs.
1128 let mut sql = "SELECT jobs.*, runs.repo AS run_repo FROM jobs JOIN runs ON runs.id = jobs.run_id
1129 WHERE jobs.status = 'queued' AND jobs.labels IS NOT NULL AND lower(jobs.namespace) = ?"
1130 .to_owned();
1131 let mut binds: Vec<JsValue> = vec![runner.workspace.as_str().into()];
1132 if let Some(repo_id) = &runner.repo_id {
1133 sql.push_str(" AND jobs.repo_id = ?");
1134 binds.push(repo_id.as_str().into());
1135 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1136 // One job of a concurrency group runs at a time.
1137 sql.push_str(
1138 " 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-7nm8r91139 AND o.concurrency_group = jobs.concurrency_group AND o.status IN ('in_progress', 'calling')))",
Merge branch 'worktree-agent-a3abfcce648e87dca'1140 );
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1141 sql.push_str(" ORDER BY jobs.rowid LIMIT 50");
1142 #[derive(Deserialize)]
1143 struct Queued {
1144 #[serde(flatten)]
1145 job: JobRow,
1146 run_repo: String,
1147 }
1148 let queued = self.db.prepare(sql).bind(&binds)?.all().await?.results::<Queued>()?;
1149 for Queued { job, run_repo } in queued {
1150 let stored: Vec<String> = job.labels.as_deref().and_then(|l| serde_json::from_str(l).ok()).unwrap_or_default();
1151 let wanted = Wanted::from_stored(&stored);
1152 if !wanted.matches(&labels, group_name.as_deref()) {
1153 continue;
1154 }
1155 if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&run_repo)) {
1156 continue;
1157 }
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.1158 // A host-mode runner takes no public repository's job: it waits
1159 // for a sandboxed runner, and says so when none could take it.
1160 if runner.isolation.as_deref() == Some("host") {
1161 let visibility = self.visibility_once(looked, &job.repo_id).await;
1162 if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1163 if looked.noted.insert(job.id.clone()) {
1164 self.wait_for_sandbox(&job, &run_repo, &wanted).await?;
1165 }
1166 continue;
1167 }
1168 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1169 if let Some(max) = job.max_parallel {
1170 let siblings = self
1171 .db
1172 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'")
1173 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1174 .first::<Count>(None)
1175 .await?
1176 .map_or(0, |c| c.n);
1177 if siblings >= max {
1178 continue;
1179 }
1180 }
1181 let token = random_hex(24);
1182 let at = now();
1183 let claimed = self
1184 .db
1185 .prepare(
1186 "UPDATE jobs SET status = 'in_progress', token_hash = ?, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
1187 WHERE id = ? AND status = 'queued' RETURNING id",
1188 )
1189 .bind(&[
1190 sha256_hex(&token).into(),
1191 runner.id.as_str().into(),
1192 runner.name.as_str().into(),
1193 at.as_str().into(),
1194 at.as_str().into(),
1195 job.id.as_str().into(),
1196 ])?
1197 .first::<Value>(None)
1198 .await?;
1199 if claimed.is_none() {
1200 continue;
1201 }
1202 self.db
1203 .batch(vec![
1204 self.db
1205 .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'")
1206 .bind(&[at.as_str().into(), job.run_id.as_str().into()])?,
1207 self.db
1208 .prepare("UPDATE runners SET work_id = ?, work_kind = 'workflow', spent = ephemeral WHERE id = ?")
1209 .bind(&[job.id.as_str().into(), runner.id.as_str().into()])?,
1210 ])
1211 .await?;
1212 self.audit(vec![entry(
1213 runner_actor(runner),
1214 "runner.take_job",
1215 &runner.workspace,
1216 Some(run_repo.clone()),
1217 Some(job.name.clone()),
1218 format!("The self-hosted runner {} took the job {}.", runner.name, job.name),
1219 )])
1220 .await;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971221 // A job that deploys: its run's deployment is under way.
1222 self.job_started(&job.id).await?;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1223 let image = self.job_image(&job).await?;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91224 let visibility = self.visibility(&job.repo_id).await;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1225 return Ok(Some(Assignment {
1226 kind: "workflow".into(),
1227 id: job.id.clone(),
1228 name: job.name.clone(),
1229 repo: run_repo,
1230 timeout_minutes: job.timeout_minutes,
1231 image,
1232 token: Some(token),
1233 env: None,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91234 visibility,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1235 }));
1236 }
1237
1238 // Agent work.
1239 let tasks = self
1240 .db
1241 .prepare("SELECT * FROM runner_tasks WHERE status = 'queued' AND workspace = ? ORDER BY created_at LIMIT 20")
1242 .bind(&[runner.workspace.as_str().into()])?
1243 .all()
1244 .await?
1245 .results::<TaskRow>()?;
1246 for task in tasks {
1247 let wanted = Wanted::of(&serde_json::from_str::<Vec<String>>(&task.labels).unwrap_or_default(), None);
1248 if !wanted.matches(&labels, None) {
1249 continue;
1250 }
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.1251 // Nor a public repository's agent work.
1252 if runner.isolation.as_deref() == Some("host")
1253 && let Some(repo_id) = &task.repo_id
1254 {
1255 let visibility = self.visibility_once(looked, repo_id).await;
1256 if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1257 continue;
1258 }
1259 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1260 if let Some(repo_id) = &runner.repo_id
1261 && task.repo_id.as_deref() != Some(repo_id.as_str())
1262 {
1263 continue;
1264 }
1265 if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&task.repo)) {
1266 continue;
1267 }
1268 let Some(sealed) = task.env.clone() else { continue };
1269 let at = now();
1270 let claimed = self
1271 .db
1272 .prepare(
1273 "UPDATE runner_tasks SET status = 'in_progress', runner_id = ?, runner_name = ?, env = NULL, started_at = ?, seen_at = ?
1274 WHERE id = ? AND status = 'queued' RETURNING id",
1275 )
1276 .bind(&[runner.id.as_str().into(), runner.name.as_str().into(), at.as_str().into(), at.as_str().into(), task.id.as_str().into()])?
1277 .first::<Value>(None)
1278 .await?;
1279 if claimed.is_none() {
1280 continue;
1281 }
1282 self.db
1283 .prepare("UPDATE runners SET work_id = ?, work_kind = 'agent', spent = ephemeral WHERE id = ?")
1284 .bind(&[task.id.as_str().into(), runner.id.as_str().into()])?
1285 .run()
1286 .await?;
1287 let env: Map<String, Value> = self
1288 .sealer
1289 .as_ref()
1290 .and_then(|sealer| sealer.open(&sealed, &task.id))
1291 .and_then(|text| serde_json::from_str(&text).ok())
1292 .unwrap_or_default();
1293 if env.is_empty() {
1294 self.end_task(&TaskRow { status: "in_progress".into(), ..task.clone() }, 1, "The work's environment could not be opened.").await?;
1295 continue;
1296 }
1297 self.audit(vec![entry(
1298 runner_actor(runner),
1299 "runner.take_task",
1300 &runner.workspace,
1301 Some(task.repo.clone()),
1302 Some(task.kind.clone()),
1303 format!("The self-hosted runner {} took agent work: {}.", runner.name, task.title),
1304 )])
1305 .await;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91306 let visibility = match &task.repo_id {
1307 Some(repo_id) => self.visibility(repo_id).await,
1308 None => None,
1309 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1310 return Ok(Some(Assignment {
1311 kind: "agent".into(),
1312 id: task.id.clone(),
1313 name: task.title.clone(),
1314 repo: task.repo.clone(),
1315 timeout_minutes: task.timeout_minutes,
1316 image: None,
1317 token: None,
1318 env: Some(env),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91319 visibility,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1320 }));
1321 }
1322 Ok(None)
1323 }
1324
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.1325 /// [`Self::visibility`], asked once per repository in a poll.
1326 async fn visibility_once(&self, looked: &mut Looked, repo_id: &str) -> Option<String> {
1327 if let Some(known) = looked.visibilities.get(repo_id) {
1328 return known.clone();
1329 }
1330 let found = self.visibility(repo_id).await;
1331 looked.visibilities.insert(repo_id.to_owned(), found.clone());
1332 found
1333 }
1334
1335 /// A public repository's job that a host-mode runner passed over: when
1336 /// no online runner that may take it could (its labels, group,
1337 /// repository, and isolation), its page says it waits for a sandboxed
1338 /// one. Written only when that changes what it says.
1339 async fn wait_for_sandbox(&self, job: &JobRow, run_repo: &str, wanted: &Wanted) -> Result<()> {
1340 let at = now_ms();
1341 let groups = self.group_rows(&job.namespace.to_lowercase()).await?;
1342 let runners = self.runner_rows(&job.namespace.to_lowercase()).await?;
1343 let sandboxed = runners.iter().any(|other| takes_public_job(other, &groups, &job.repo_id, run_repo, wanted, at));
1344 if sandboxed {
1345 return Ok(());
1346 }
1347 self.db
1348 .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'queued' AND reason IS NOT ?")
1349 .bind(&[model::WAITING_FOR_SANDBOX.into(), job.id.as_str().into(), model::WAITING_FOR_SANDBOX.into()])?
1350 .run()
1351 .await?;
1352 Ok(())
1353 }
1354
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91355 /// A repository's visibility for a runner, `public` or `private`; none
1356 /// when it cannot be read, and the runner decides without it.
1357 async fn visibility(&self, repo_id: &str) -> Option<String> {
1358 match self.repo_by_id(repo_id).await {
1359 Ok(Some((repo, _))) => Some(if repo.is_private { "private" } else { "public" }.to_owned()),
1360 _ => None,
1361 }
1362 }
1363
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1364 /// The image a job's `container:` names, when it names one plainly.
1365 async fn job_image(&self, job: &JobRow) -> Result<Option<String>> {
1366 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(None) };
1367 let raw = match job.callee() {
1368 Some((_, spec, _)) => spec.raw,
1369 None => match g1t_actions::workflow::parse(&run.source) {
1370 Ok(workflow) => match workflow.jobs.into_iter().find(|j| j.id == job.key) {
1371 Some(spec) => spec.raw,
1372 None => return Ok(None),
1373 },
1374 Err(_) => return Ok(None),
1375 },
1376 };
1377 let image = match raw.get("container") {
1378 Some(Value::String(image)) => Some(image.clone()),
1379 Some(Value::Object(container)) => container.get("image").and_then(Value::as_str).map(str::to_owned),
1380 _ => None,
1381 };
1382 Ok(image.filter(|image| !image.contains("${{") && !image.trim().is_empty()))
1383 }
1384
1385 /// `runner_finished`.
1386 pub async fn runner_finished(&self, a: FinishedArgs) -> Result<Outcome<bool>> {
1387 let Some((row, _)) = self.authenticated(&a.auth).await? else {
1388 return Ok(Self::unknown_runner());
1389 };
1390 let reason = a.reason.clone().filter(|r| !r.trim().is_empty()).map(|r| r.chars().take(2000).collect::<String>());
1391 let mut changed = false;
1392 if let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.id.as_str().into()])?.first::<JobRow>(None).await? {
1393 if job.runner_id.as_deref() == Some(row.id.as_str()) && job.status == "in_progress" {
1394 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));
1395 self.finish_job(&job.id, "failure", Some(&why), None).await?;
1396 changed = true;
1397 }
1398 } else if let Some(task) = self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[a.id.as_str().into()])?.first::<TaskRow>(None).await?
1399 && task.runner_id.as_deref() == Some(row.id.as_str())
1400 && task.status == "in_progress"
1401 {
1402 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) });
1403 self.end_task(&task, a.exit_code, &why).await?;
1404 changed = true;
1405 }
1406 self.db
1407 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL, last_seen_at = ? WHERE id = ? AND work_id = ?")
1408 .bind(&[now().into(), row.id.as_str().into(), a.id.as_str().into()])?
1409 .run()
1410 .await?;
1411 Ok(Outcome::Ok(changed))
1412 }
1413
1414 /// `runner_remove_self`.
1415 pub async fn runner_remove_self(&self, a: RemoveSelfArgs) -> Result<Outcome<bool>> {
1416 let Some((row, _)) = self.authenticated(&a.auth).await? else {
1417 return Ok(Self::unknown_runner());
1418 };
1419 self.forget_runner(&row, &format!("The runner {} was removed from its machine.", row.name)).await?;
1420 self.audit(vec![entry(
1421 runner_actor(&row),
1422 "remove_runner",
1423 &row.workspace,
1424 row.repo.clone(),
1425 Some(row.name.clone()),
1426 format!("The self-hosted runner {} removed itself.", row.name),
1427 )])
1428 .await;
1429 Ok(Outcome::Ok(true))
1430 }
1431
1432 /// A self-hosted job finished, however it did: its runner is free, and
1433 /// its time goes on the workspace's usage as self-hosted, at $0.
1434 pub async fn released(&self, job: &JobRow) -> Result<()> {
1435 let Some(runner_id) = &job.runner_id else { return Ok(()) };
1436 self.db
1437 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL WHERE id = ? AND work_id = ?")
1438 .bind(&[runner_id.as_str().into(), job.id.as_str().into()])?
1439 .run()
1440 .await?;
1441 let (Some(started), Some(finished)) = (job.started_at.as_deref(), job.finished_at.as_deref().map(str::to_owned).or_else(|| Some(now()))) else {
1442 return Ok(());
1443 };
1444 let seconds = match (g1t_contracts::time::parse_rfc3339(started), g1t_contracts::time::parse_rfc3339(&finished)) {
1445 (Some(a), Some(b)) if b > a => ((b - a) / 1000).max(1),
1446 _ => 1,
1447 };
1448 let run = self.run_row(&job.run_id).await?;
1449 let repo = run.as_ref().map(|r| r.repo.clone());
1450 let recorded: Result<Outcome<bool>> = g1t_kit::call(
1451 &self.billing,
1452 "record_sandbox",
1453 &RecordSandboxArgs {
1454 workspace: job.namespace.to_lowercase(),
1455 seconds: u32::try_from(seconds).unwrap_or(u32::MAX),
1456 description: format!(
1457 "{} in {} on the self-hosted runner {}",
1458 job.name,
1459 repo.clone().unwrap_or_default(),
1460 job.runner_name.clone().unwrap_or_default()
1461 ),
1462 repo,
1463 reference: format!("selfhosted/{}/{}", job.id, started),
1464 kind: Some(ComputeKind::Workflow),
1465 cpu_seconds: None,
A workflow job on a self-hosted runner records its sandbox time with no agent and nobody who asked, so the actions service builds again now that every sandbox line can name them.1466 // A workflow job on a self-hosted runner: no agent ran it and nobody asked an agent for it.
1467 agent: None,
1468 asked_by: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1469 reservation_id: None,
1470 self_hosted: true,
1471 instance: None,
1472 },
1473 )
1474 .await;
1475 if let Err(error) = recorded {
1476 worker::console_error!("actions: self-hosted time not recorded for {}: {error}", job.id);
1477 }
1478 Ok(())
1479 }
1480
1481 /// `runner` and `RUNNER_*` for a job on a self-hosted runner.
1482 pub async fn runner_context_for(&self, runner_id: &str, variables: &mut Map<String, Value>) -> Result<Value> {
1483 let mut context = g1t_actions::events::runner_context();
1484 let Some(runner) = self.runner_by_id(runner_id).await? else { return Ok(context) };
1485 let os = match runner.os.as_str() {
1486 "macos" => "macOS",
1487 "windows" => "Windows",
1488 _ => "Linux",
1489 };
1490 let arch = if runner.arch == "arm64" { "ARM64" } else { "X64" };
1491 context["name"] = json!(runner.name);
1492 context["os"] = json!(os);
1493 context["arch"] = json!(arch);
1494 context["environment"] = json!("self-hosted");
1495 {
1496 let vars = variables;
1497 vars.insert("RUNNER_NAME".into(), json!(runner.name));
1498 vars.insert("RUNNER_OS".into(), json!(os));
1499 vars.insert("RUNNER_ARCH".into(), json!(arch));
1500 vars.insert("RUNNER_ENVIRONMENT".into(), json!("self-hosted"));
1501 }
1502 Ok(context)
1503 }
1504
1505 // --- Agent work, for the runner service ----------------------------------
1506
1507 /// `runner_route`: the labels g1t's own work in `repo` runs on, when
1508 /// its workspace (or the repository) sends it to self-hosted runners.
1509 pub async fn runner_route(&self, a: RouteArgs) -> Result<Option<Vec<String>>> {
1510 let repo_id = match self.repo_by_path(&a.repo).await? {
1511 Some(repo) => Some(repo.id),
1512 None => None,
1513 };
1514 let settings = self.effective_runner_settings(&a.workspace, repo_id.as_deref()).await?;
1515 Ok(settings.agents_on_self_hosted.then_some(settings.agent_labels))
1516 }
1517
1518 async fn repo_by_path(&self, path: &RepoPath) -> Result<Option<Repo>> {
1519 let Some(actor) = self.workspace_actor(&path.namespace).await? else { return Ok(None) };
1520 self.visible_repo(path, &Some(actor)).await
1521 }
1522
1523 /// `enqueue_task`.
1524 pub async fn enqueue_task(&self, a: EnqueueTaskArgs) -> Result<Outcome<String>> {
1525 let Some(sealer) = &self.sealer else {
1526 return Ok(fail(FailureCode::Conflict, "Agent work cannot be handed to self-hosted runners yet: g1t's key for it is not set."));
1527 };
1528 let id = new_id("rtk", now_ms());
1529 let sealed = sealer.seal(&serde_json::to_string(&a.env)?, &id);
1530 let repo_id = self.repo_by_path(&a.repo).await?.map(|r| r.id);
1531 let labels = match model::runner_labels(&a.labels, model::SELF_HOSTED, model::SELF_HOSTED) {
1532 Ok(labels) => labels,
1533 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
1534 };
1535 let at = now();
1536 let inserted = self
1537 .db
1538 .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.1539 "INSERT OR IGNORE INTO runner_tasks (id, sandbox, workspace, repo_id, repo, kind, title, labels, env, timeout_minutes, status, created_at, run_id)
1540 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?) RETURNING id",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1541 )
1542 .bind(&[
1543 id.as_str().into(),
1544 a.sandbox.as_str().into(),
1545 a.workspace.to_lowercase().into(),
1546 optional(repo_id.as_deref()),
1547 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
1548 a.kind.as_str().into(),
1549 a.title.chars().take(300).collect::<String>().into(),
1550 serde_json::to_string(&labels)?.into(),
1551 sealed.into(),
1552 a.timeout_minutes.max(1).into(),
1553 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.1554 optional(a.run_id.as_deref().filter(|id| !id.is_empty())),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1555 ])?
1556 .first::<Value>(None)
1557 .await?;
1558 if inserted.is_none() {
1559 return Ok(fail(FailureCode::Conflict, "That sandbox already handed work to a self-hosted runner."));
1560 }
1561 Ok(Outcome::Ok(id))
1562 }
1563
1564 /// `cancel_task`: the sandbox stopped it. Its runner hears on its next
1565 /// poll; the sandbox does its own ending.
1566 pub async fn cancel_task(&self, a: CancelTaskArgs) -> Result<Outcome<bool>> {
1567 let done = self
1568 .db
1569 .prepare(
1570 "UPDATE runner_tasks SET status = 'completed', exit_code = -1, reason = ?, env = NULL, finished_at = ?
1571 WHERE sandbox = ? AND status != 'completed' RETURNING id",
1572 )
1573 .bind(&[optional(a.reason.as_deref()), now().into(), a.sandbox.as_str().into()])?
1574 .first::<Value>(None)
1575 .await?;
1576 Ok(Outcome::Ok(done.is_some()))
1577 }
1578
1579 /// Ends a task and tells the sandbox waiting on it, once.
1580 async fn end_task(&self, task: &TaskRow, exit_code: i32, reason: &str) -> Result<()> {
1581 let ended = self
1582 .db
1583 .prepare(
1584 "UPDATE runner_tasks SET status = 'completed', exit_code = ?, reason = ?, env = NULL, finished_at = ?
1585 WHERE id = ? AND status != 'completed' RETURNING id",
1586 )
1587 .bind(&[exit_code.into(), optional(Some(reason).filter(|r| !r.is_empty())), now().into(), task.id.as_str().into()])?
1588 .first::<Value>(None)
1589 .await?;
1590 if ended.is_none() {
1591 return Ok(());
1592 }
1593 if let Some(runner_id) = &task.runner_id {
1594 self.db
1595 .prepare("UPDATE runners SET work_id = NULL, work_kind = NULL WHERE id = ? AND work_id = ?")
1596 .bind(&[runner_id.as_str().into(), task.id.as_str().into()])?
1597 .run()
1598 .await?;
1599 }
1600 let told: Result<Value> = g1t_kit::call(
1601 &self.runner,
1602 "task_ended",
1603 &json!({
1604 "sandbox": task.sandbox,
1605 "exitCode": exit_code,
1606 "reason": if reason.is_empty() { Value::Null } else { json!(reason) },
1607 "runner": task.runner_name,
1608 }),
1609 )
1610 .await;
1611 if let Err(error) = told {
1612 worker::console_error!("actions: the sandbox waiting on task {} was not told: {error}", task.id);
1613 }
1614 Ok(())
1615 }
1616
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.1617 /// `runner_activity`: what runs for the workspace now, in g1t's
1618 /// sandboxes and handed to its own runners. Its owners only, as its
1619 /// runners are.
1620 pub async fn runner_activity(&self, a: RunnerActivityArgs) -> Result<Outcome<RunnerActivity>> {
1621 let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
1622 let place = check!(self.runner_place(&a.actor, &owner, false).await?);
1623 let workspace = place.workspace;
1624 let cloud_jobs = self
1625 .db
1626 .prepare(
1627 "SELECT jobs.id, jobs.name, jobs.run_id, runs.repo, jobs.started_at FROM jobs JOIN runs ON runs.id = jobs.run_id
1628 WHERE lower(jobs.namespace) = ? AND jobs.status = 'in_progress' AND jobs.labels IS NULL
1629 ORDER BY jobs.started_at DESC LIMIT ?",
1630 )
1631 .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1632 .all()
1633 .await?
1634 .results::<CloudJob>()?;
1635 let cloud_jobs_queued = self.queued_jobs(&workspace, false).await?;
1636 let self_hosted_jobs_queued = self.queued_jobs(&workspace, true).await?;
1637 let handed_over = self
1638 .db
1639 .prepare(
1640 "SELECT id, run_id, kind, title, repo, status, runner_name, created_at, started_at FROM runner_tasks
1641 WHERE status IN ('queued', 'in_progress') AND workspace = ?
1642 ORDER BY created_at DESC LIMIT ?",
1643 )
1644 .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1645 .all()
1646 .await?
1647 .results::<HandedOverTask>()?;
1648 Ok(Outcome::Ok(RunnerActivity { cloud_jobs, cloud_jobs_queued, handed_over, self_hosted_jobs_queued }))
1649 }
1650
1651 /// The workspace's workflow jobs queued for its own runners
1652 /// (`labelled`), or for g1t's sandboxes.
1653 async fn queued_jobs(&self, workspace: &str, labelled: bool) -> Result<u32> {
1654 let sql = if labelled {
1655 "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NOT NULL"
1656 } else {
1657 "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NULL"
1658 };
1659 Ok(self.db.prepare(sql).bind(&[workspace.into()])?.first::<Count>(None).await?.map_or(0, |c| c.n))
1660 }
1661
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1662 /// `stuck_jobs`: jobs of the viewer's workspaces that have waited ten
1663 /// minutes or more for a self-hosted runner while none that could take
1664 /// them is online, for Mission control's Needs you.
1665 pub async fn stuck_jobs(&self, a: StuckJobsArgs) -> Result<Vec<StuckJob>> {
1666 let Some(viewer) = a.viewer else { return Ok(Vec::new()) };
1667 let at = now_ms();
1668 let mut out = Vec::new();
1669 for membership in viewer.workspaces.iter().take(20) {
1670 let workspace = membership.slug.to_lowercase();
1671 #[derive(Deserialize)]
1672 struct Waiting {
1673 id: String,
1674 name: String,
1675 run_id: String,
1676 labels: String,
1677 queued_at: Option<String>,
1678 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.1679 repo_id: String,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1680 }
1681 let waiting = self
1682 .db
1683 .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.1684 "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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1685 WHERE jobs.status = 'queued' AND jobs.labels IS NOT NULL AND lower(jobs.namespace) = ? AND jobs.queued_at < ?
1686 ORDER BY jobs.rowid LIMIT 20",
1687 )
1688 .bind(&[workspace.as_str().into(), rfc3339(at.saturating_sub(10 * 60 * 1000)).into()])?
1689 .all()
1690 .await?
1691 .results::<Waiting>()?;
1692 if waiting.is_empty() {
1693 continue;
1694 }
1695 let groups = self.group_rows(&workspace).await?;
1696 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.1697 // A repository's visibility is asked only when a host-mode runner
1698 // is online, since only those pass over a public repository's jobs.
1699 let any_host = online.iter().any(|runner| runner.isolation.as_deref() == Some("host"));
1700 let mut looked = Looked::default();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1701 for job in waiting {
1702 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.1703 let visibility = if any_host { self.visibility_once(&mut looked, &job.repo_id).await } else { None };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1704 let served = online.iter().any(|runner| {
1705 let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id));
1706 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.1707 && model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref())
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1708 });
1709 if !served {
1710 out.push(StuckJob {
1711 id: job.id,
1712 name: job.name,
1713 run_id: job.run_id,
1714 repo: job.repo,
1715 labels: wanted.describe(),
1716 queued_at: job.queued_at.unwrap_or_default(),
1717 });
1718 }
1719 }
1720 }
1721 Ok(out)
1722 }
1723
1724 // --- Every minute ----------------------------------------------------------
1725
1726 /// Jobs and tasks that waited too long, tasks whose runner went quiet,
1727 /// runners offline for weeks, and registration tokens long expired.
1728 pub async fn sweep_runners(&self, at: u64) -> Result<()> {
1729 let before = |ms: u64| rfc3339(at.saturating_sub(ms));
1730 let waited = model::MAX_WAIT_HOURS * 60 * 60 * 1000;
1731 let stale = self
1732 .db
1733 .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NOT NULL AND queued_at < ? LIMIT 50")
1734 .bind(&[before(waited).into()])?
1735 .all()
1736 .await?
1737 .results::<JobRow>()?;
1738 for job in stale {
1739 let stored: Vec<String> = job.labels.as_deref().and_then(|l| serde_json::from_str(l).ok()).unwrap_or_default();
1740 let why = format!(
1741 "No self-hosted runner with labels {} took it within {} hours.",
1742 Wanted::from_stored(&stored).describe(),
1743 model::MAX_WAIT_HOURS
1744 );
1745 self.finish_job(&job.id, "failure", Some(&why), None).await?;
1746 }
1747 let tasks = self
1748 .db
1749 .prepare(
1750 "SELECT * FROM runner_tasks WHERE (status = 'queued' AND created_at < ?) OR (status = 'in_progress' AND seen_at < ?) LIMIT 50",
1751 )
1752 .bind(&[before(waited).into(), before(SILENT_MS).into()])?
1753 .all()
1754 .await?
1755 .results::<TaskRow>()?;
1756 for task in tasks {
1757 let why = if task.status == "queued" {
1758 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)
1759 } else {
1760 format!("The self-hosted runner {} stopped answering.", task.runner_name.clone().unwrap_or_default())
1761 };
1762 self.end_task(&task, 1, &why).await?;
1763 }
1764 let forgotten = self
1765 .db
1766 .prepare("SELECT * FROM runners WHERE COALESCE(last_seen_at, created_at) < ? LIMIT 50")
1767 .bind(&[before(FORGET_OFFLINE_MS).into()])?
1768 .all()
1769 .await?
1770 .results::<RunnerRow>()?;
1771 for row in forgotten {
1772 self.forget_runner(&row, "The runner was offline for 14 days.").await?;
1773 }
1774 // An ephemeral runner that took its job and then went quiet.
1775 let spent = self
1776 .db
1777 .prepare("SELECT * FROM runners WHERE ephemeral = 1 AND spent = 1 AND work_id IS NULL AND COALESCE(last_seen_at, created_at) < ? LIMIT 50")
1778 .bind(&[before(60 * 60 * 1000).into()])?
1779 .all()
1780 .await?
1781 .results::<RunnerRow>()?;
1782 for row in spent {
1783 self.forget_runner(&row, "The ephemeral runner finished its job.").await?;
1784 }
1785 self.db
1786 .prepare("DELETE FROM runner_registrations WHERE expires_at < ?")
1787 .bind(&[before(24 * 60 * 60 * 1000).into()])?
1788 .run()
1789 .await?;
1790 Ok(())
1791 }
1792}
1793
1794#[derive(Deserialize)]
1795struct IdRow {
1796 id: String,
1797}
1798
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.1799/// Whether `runner` is online and could take a public repository's job:
1800/// not host-mode, and its repository, group and labels let it.
1801fn takes_public_job(runner: &RunnerRow, groups: &[GroupRow], repo_id: &str, run_repo: &str, wanted: &Wanted, at: u64) -> bool {
1802 let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id));
1803 runner.online(at)
1804 && model::isolation_allows(runner.isolation.as_deref(), Some("public"))
1805 && runner.repo_id.as_deref().is_none_or(|repo| repo == repo_id)
1806 && (runner.repo_id.is_some() || group.is_none_or(|g| g.allows(run_repo)))
1807 && wanted.matches(&runner.labels(), group.map(|g| g.name.as_str()))
1808}
1809
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1810fn group_view(group: &GroupRow, runners: &[RunnerRow]) -> RunnerGroup {
1811 RunnerGroup {
1812 id: group.id.clone(),
1813 name: group.name.clone(),
1814 default: group.is_default != 0,
1815 repositories: group.repositories(),
1816 runners: runners.iter().filter(|r| r.group_id.as_deref() == Some(group.id.as_str())).count() as u32,
1817 updated_at: group.updated_at.clone(),
1818 }
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823 use super::*;
1824
1825 fn group(repositories: &[&str]) -> GroupRow {
1826 GroupRow {
1827 id: "rng_1".into(),
1828 name: "Default".into(),
1829 is_default: 1,
1830 repositories: serde_json::to_string(&repositories).unwrap(),
1831 updated_at: "2026-10-06T00:00:00Z".into(),
1832 }
1833 }
1834
1835 #[test]
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1836 fn being_seen_is_written_once_a_minute() {
1837 let at = 2_000_000_000_000;
1838 assert!(seen_is_stale(None, at));
1839 assert!(!seen_is_stale(Some(&rfc3339(at - 59_000)), at));
1840 assert!(seen_is_stale(Some(&rfc3339(at - 61_000)), at));
1841 // A runner written down at the last moment is still online until the
1842 // next write, a poll later.
1843 assert!(SEEN_RESOLUTION_MS + model::MAX_POLL_WAIT_MS < model::ONLINE_WITHIN_MS);
1844 assert!(SEEN_RESOLUTION_MS < crate::SILENT_MS);
1845 }
1846
1847 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1848 fn a_group_lets_its_repositories_use_it() {
1849 assert!(group(&[]).allows("acme/web"));
1850 assert!(group(&["web", "api"]).allows("acme/WEB"));
1851 assert!(group(&["web"]).allows("web"));
1852 assert!(!group(&["web"]).allows("acme/docs"));
1853 }
1854
1855 #[test]
1856 fn names_are_checked() {
1857 assert_eq!(valid_name(" build-01 ").unwrap(), "build-01");
1858 assert!(valid_name("").is_err());
1859 assert!(valid_name("has space").is_err());
1860 assert!(valid_name(&"x".repeat(65)).is_err());
1861 assert!(valid_group_name("GPU machines").is_ok());
1862 assert!(valid_group_name("").is_err());
1863 }
1864
1865 #[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.1866 fn only_a_sandboxed_runner_that_could_take_it_serves_a_public_job() {
1867 let at = 1_000_000_000;
1868 let runner = |isolation: Option<&str>| RunnerRow {
1869 id: "rnr_1".into(),
1870 workspace: "acme".into(),
1871 repo_id: None,
1872 repo: None,
1873 group_id: None,
1874 name: "a".into(),
1875 labels: r#"["self-hosted","linux","x64"]"#.into(),
1876 os: "linux".into(),
1877 arch: "x64".into(),
1878 version: String::new(),
1879 ephemeral: 0,
1880 credential_hash: String::new(),
1881 previous_hash: None,
1882 rotated_at: String::new(),
1883 work_id: None,
1884 work_kind: None,
1885 spent: 0,
1886 last_seen_at: Some(rfc3339(at)),
1887 created_at: String::new(),
1888 created_by: None,
1889 isolation: isolation.map(str::to_owned),
1890 };
1891 let wanted = Wanted::of(&["self-hosted".to_owned(), "linux".to_owned()], None);
1892 let takes = |row: &RunnerRow| takes_public_job(row, &[], "rep_1", "acme/web", &wanted, at);
1893 assert!(!takes(&runner(Some("host"))));
1894 assert!(takes(&runner(Some("container"))));
1895 assert!(takes(&runner(Some("nested-container"))));
1896 assert!(takes(&runner(None)), "an older runner, which refuses for itself");
1897 assert!(!takes(&RunnerRow { repo_id: Some("rep_2".into()), ..runner(None) }), "another repository's");
1898 assert!(!takes(&RunnerRow { labels: r#"["self-hosted","macos"]"#.into(), ..runner(None) }));
1899 assert!(!takes(&RunnerRow { last_seen_at: None, ..runner(None) }), "offline");
1900 }
1901
1902 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1903 fn online_means_seen_in_the_last_ninety_seconds() {
1904 let row = RunnerRow {
1905 id: "rnr_1".into(),
1906 workspace: "acme".into(),
1907 repo_id: None,
1908 repo: None,
1909 group_id: None,
1910 name: "a".into(),
1911 labels: "[]".into(),
1912 os: "linux".into(),
1913 arch: "x64".into(),
1914 version: String::new(),
1915 ephemeral: 0,
1916 credential_hash: String::new(),
1917 previous_hash: None,
1918 rotated_at: String::new(),
1919 work_id: None,
1920 work_kind: None,
1921 spent: 0,
1922 last_seen_at: Some(rfc3339(1_000_000_000)),
1923 created_at: String::new(),
1924 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.1925 isolation: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1926 };
1927 assert!(row.online(1_000_000_000 + 60_000));
1928 assert!(!row.online(1_000_000_000 + 120_000));
1929 assert_eq!(row.workspace, "acme");
1930 }
1931}

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