flagon-io/g1t

public

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/crates/contracts/src/agents.rs

889 lines29,786 bytesCodeBlame
1//! Agents at work and what they remember. Kept by the work service.
2//!
3//! Every sandbox g1t starts for an agent is an [`AgentRun`]: what it is
4//! doing, on which pull request or issue, step by step, what it cost, and
5//! how it ended. People watch runs live, stop them and message them.
6//!
7//! Memory is what agents and people have learned that the next agent
8//! should know: about one project (its codebase), or about the whole
9//! workspace (true across its projects). It is members-only, is given to
10//! every g1t agent run, and never holds a secret.
11//!
12//! Each `*Args` struct is the argument of the method of the same name,
13//! served at `POST /rpc/<method>`.
14
15use serde::{Deserialize, Serialize};
16
17use crate::repos::RepoPath;
18use crate::work::{Pull, PullStatus, SessionEntry};
19use crate::{User, Viewer};
20
21/// The work an agent run does.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum RunKind {
25 /// Making the change an issue asks for.
26 Implement,
27 /// Addressing failed checks or a review on its own pull request.
28 Revise,
29 /// Reviewing someone's pull request.
30 Review,
31 /// Answering another agent's question or handoff.
32 Answer,
33 /// Merging in the branch a pull request will land on.
34 Update,
35 /// Turning an outcome into a plan of issues.
36 Plan,
37 /// Running an issue's acceptance checks. Not an agent: a sandbox.
38 Checks,
39 /// Building and checking a state of the merge queue. Not an agent.
40 Queue,
41 /// Finding out whether a pull request merges cleanly. Not an agent.
42 Mergecheck,
43}
44
45impl RunKind {
46 pub const ALL: [RunKind; 9] = [
47 RunKind::Implement,
48 RunKind::Revise,
49 RunKind::Review,
50 RunKind::Answer,
51 RunKind::Update,
52 RunKind::Plan,
53 RunKind::Checks,
54 RunKind::Queue,
55 RunKind::Mergecheck,
56 ];
57
58 pub fn as_str(self) -> &'static str {
59 match self {
60 RunKind::Implement => "implement",
61 RunKind::Revise => "revise",
62 RunKind::Review => "review",
63 RunKind::Answer => "answer",
64 RunKind::Update => "update",
65 RunKind::Plan => "plan",
66 RunKind::Checks => "checks",
67 RunKind::Queue => "queue",
68 RunKind::Mergecheck => "mergecheck",
69 }
70 }
71
72 pub fn parse(value: &str) -> Option<RunKind> {
73 RunKind::ALL.into_iter().find(|kind| kind.as_str() == value)
74 }
75
76 /// Whether a model does the work, rather than commands g1t runs.
77 pub fn is_agent(self) -> bool {
78 !matches!(self, RunKind::Checks | RunKind::Queue | RunKind::Mergecheck)
79 }
80
81 /// Whether a message reaches the agent while it runs: the harness asks
82 /// for messages after each tool call in these. Others read nothing new
83 /// until the next run on the same pull request.
84 pub fn takes_messages(self) -> bool {
85 matches!(self, RunKind::Implement | RunKind::Revise | RunKind::Answer)
86 }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum RunStatus {
92 /// Its sandbox is starting.
93 Queued,
94 Running,
95 Succeeded,
96 Failed,
97 /// A person stopped it.
98 Stopped,
99}
100
101impl RunStatus {
102 pub fn as_str(self) -> &'static str {
103 match self {
104 RunStatus::Queued => "queued",
105 RunStatus::Running => "running",
106 RunStatus::Succeeded => "succeeded",
107 RunStatus::Failed => "failed",
108 RunStatus::Stopped => "stopped",
109 }
110 }
111
112 pub fn parse(value: &str) -> Option<RunStatus> {
113 [
114 RunStatus::Queued,
115 RunStatus::Running,
116 RunStatus::Succeeded,
117 RunStatus::Failed,
118 RunStatus::Stopped,
119 ]
120 .into_iter()
121 .find(|status| status.as_str() == value)
122 }
123
124 pub fn is_active(self) -> bool {
125 matches!(self, RunStatus::Queued | RunStatus::Running)
126 }
127}
128
129/// One step of a run, as one short line.
130#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct RunStep {
132 /// RFC 3339.
133 pub at: String,
134 pub text: String,
135}
136
137/// One sandbox g1t started: an agent at work, or a run of checks.
138#[derive(Clone, Debug, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct AgentRun {
141 pub id: String,
142 pub repo: RepoPath,
143 /// The pull request it works on; for a plan, none.
144 pub number: Option<u32>,
145 /// That pull request's title, or the plan's brief.
146 pub title: Option<String>,
147 pub kind: RunKind,
148 /// Who is working: `g1t-agent` for g1t's own agent, `g1t` for a sandbox
149 /// that runs commands.
150 pub agent: String,
151 /// The model, by its public name. Members only.
152 pub model: Option<String>,
153 pub status: RunStatus,
154 /// What it is doing now, in one line.
155 pub step: Option<String>,
156 /// Every step so far, oldest first. Empty in lists.
157 #[serde(default)]
158 pub steps: Vec<RunStep>,
159 /// How many steps there are.
160 #[serde(default)]
161 pub step_count: u32,
162 /// Who put it to work, when a person did.
163 pub started_by: Option<String>,
164 /// Why it failed.
165 pub error: Option<String>,
166 /// What it cost, in US dollars, as the harness reported it. Members only.
167 pub cost_usd: Option<f64>,
168 pub turns: Option<u32>,
169 /// The most it may cost, in US dollars, from its guardrails. None: no
170 /// cap. Members only.
171 #[serde(default)]
172 pub budget_usd: Option<f64>,
173 /// The longest it may take, in minutes, from its guardrails.
174 #[serde(default)]
175 pub time_cap_minutes: Option<u32>,
176 /// `budget` or `time` when g1t stopped it for reaching a cap.
177 #[serde(default)]
178 pub halted: Option<String>,
179 /// How sure g1t was of the change as this run left it, for a run that
180 /// made or revised one.
181 #[serde(default)]
182 pub confidence: Option<crate::work::Confidence>,
183 /// RFC 3339.
184 pub created_at: String,
185 pub started_at: Option<String>,
186 pub finished_at: Option<String>,
187 pub updated_at: String,
188}
189
190/// What lets a sandbox, and nothing else, report on its run.
191#[derive(Clone, Debug, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct AgentRunTicket {
194 pub run_id: String,
195 pub token: String,
196}
197
198/// `open_run`: records a sandbox the runner is starting. Called by the
199/// runner service only. Returns `Outcome<AgentRunTicket>`.
200#[derive(Debug, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct OpenRunArgs {
203 /// Who the sandbox acts as.
204 pub actor: User,
205 pub repo: RepoPath,
206 #[serde(default)]
207 pub number: Option<u32>,
208 #[serde(default)]
209 pub pull_id: Option<String>,
210 /// For a run with no pull request, such as a plan: what it is about.
211 #[serde(default)]
212 pub title: Option<String>,
213 pub kind: RunKind,
214 #[serde(default)]
215 pub agent: Option<String>,
216 #[serde(default)]
217 pub model: Option<String>,
218 /// The sandbox's name, which stopping it needs.
219 pub sandbox: String,
220 #[serde(default)]
221 pub started_by: Option<String>,
222 /// Its cost cap, from its guardrails, in US dollars.
223 #[serde(default)]
224 pub budget_usd: Option<f64>,
225 /// Its time cap, from its guardrails, in minutes.
226 #[serde(default)]
227 pub time_cap_minutes: Option<u32>,
228}
229
230/// `report_run`: a sandbox telling how its run goes, with the run's token.
231/// Steps are added, the current step replaced, and an outcome ends the run.
232/// A finished run accepts nothing more. Returns `Outcome<RunStatus>`: the
233/// run's status after the report, so a sandbox can tell it was stopped.
234#[derive(Debug, Default, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase", default)]
236pub struct ReportRunArgs {
237 pub run_id: String,
238 pub token: String,
239 pub steps: Vec<String>,
240 pub step: Option<String>,
241 pub cost_usd: Option<f64>,
242 pub turns: Option<u32>,
243 /// `succeeded` or `failed`, to end the run.
244 pub outcome: Option<RunStatus>,
245 pub error: Option<String>,
246 /// `budget` or `time`: the run reached a cap of its guardrails and
247 /// stopped. Ends it as stopped, like a person's stop.
248 pub halt: Option<crate::guardrails::Halt>,
249}
250
251/// `stop_run`: a member stops a run. Marks it stopped and returns where its
252/// sandbox is, for the runner to destroy. A pull request it worked on waits
253/// for a person. Returns `Outcome<StoppedRun>`.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct StopRunArgs {
256 pub actor: User,
257 pub repo: RepoPath,
258 pub id: String,
259}
260
261#[derive(Debug, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct StoppedRun {
264 pub run: AgentRun,
265 pub sandbox: String,
266}
267
268/// `list_runs`: runs in a repository, or with `workspace` instead, in every
269/// repository of a workspace (members only), newest first. Returns
270/// `Outcome<Vec<AgentRun>>`.
271#[derive(Debug, Default, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase", default)]
273pub struct ListRunsArgs {
274 pub viewer: Viewer,
275 pub repo: Option<RepoPath>,
276 pub workspace: Option<String>,
277 /// Only those queued or running.
278 pub active: bool,
279 pub kind: Option<RunKind>,
280 pub status: Option<RunStatus>,
281 /// Only those on this pull request.
282 pub number: Option<u32>,
283 /// At most 200; 50 if not given.
284 pub limit: Option<u32>,
285}
286
287/// `get_run`: one run with all its steps. Returns `Outcome<AgentRun>`.
288#[derive(Debug, Serialize, Deserialize)]
289pub struct GetRunArgs {
290 pub viewer: Viewer,
291 pub repo: RepoPath,
292 pub id: String,
293}
294
295/// A pull request's recorded session, summed up for a list.
296#[derive(Clone, Debug, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct SessionSummary {
299 pub number: u32,
300 pub title: String,
301 pub status: PullStatus,
302 /// The agent label on the pull request, such as `g1t-agent`.
303 pub agent: String,
304 pub entries: u32,
305 /// How many tools it called.
306 pub tools: u32,
307 /// The start of the first prompt.
308 pub prompt: Option<String>,
309 /// The kinds of the runs g1t made for it.
310 pub kinds: Vec<RunKind>,
311 pub runs: u32,
312 /// What its runs cost together. Members only.
313 pub cost_usd: Option<f64>,
314 /// Whether one of its runs is under way.
315 pub active: bool,
316 /// RFC 3339.
317 pub started_at: String,
318 pub last_at: String,
319}
320
321/// `list_sessions`: the pull requests of a repository that have a session,
322/// most recently active first. Returns `Outcome<Vec<SessionSummary>>`.
323#[derive(Debug, Default, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase", default)]
325pub struct ListSessionsArgs {
326 pub viewer: Viewer,
327 pub repo: Option<RepoPath>,
328 /// Only those with a run of this kind.
329 pub kind: Option<RunKind>,
330 /// The pull request's status: `draft`, `open`, `merged` or `closed`.
331 pub outcome: Option<PullStatus>,
332 pub number: Option<u32>,
333}
334
335/// `get_session`: one pull request's session in full, with its runs.
336/// Returns `Outcome<SessionView>`.
337#[derive(Debug, Serialize, Deserialize)]
338pub struct GetSessionArgs {
339 pub viewer: Viewer,
340 pub repo: RepoPath,
341 pub number: u32,
342}
343
344#[derive(Clone, Debug, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SessionView {
347 pub pull: Pull,
348 /// Oldest first; the first 2000.
349 pub entries: Vec<SessionEntry>,
350 /// Newest first, with their steps.
351 pub runs: Vec<AgentRun>,
352 /// Members only.
353 pub cost_usd: Option<f64>,
354}
355
356// --- Memory -----------------------------------------------------------------
357
358/// Whose memory: one project's, or the whole workspace's.
359#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub enum MemoryScope {
362 /// About one project's codebase.
363 Project,
364 /// True across the workspace's projects.
365 Workspace,
366}
367
368impl MemoryScope {
369 pub fn as_str(self) -> &'static str {
370 match self {
371 MemoryScope::Project => "project",
372 MemoryScope::Workspace => "workspace",
373 }
374 }
375}
376
377#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum MemoryKind {
380 /// How something is: "staging lives at staging.example.com".
381 #[default]
382 Fact,
383 /// How things are done here: "we use pnpm everywhere".
384 Convention,
385 /// Something chosen, and why: "we keep Postgres, not MySQL, for jsonb".
386 Decision,
387 /// A trap: "the tests need TZ=UTC or the date tests fail".
388 Gotcha,
389}
390
391impl MemoryKind {
392 pub fn as_str(self) -> &'static str {
393 match self {
394 MemoryKind::Fact => "fact",
395 MemoryKind::Convention => "convention",
396 MemoryKind::Decision => "decision",
397 MemoryKind::Gotcha => "gotcha",
398 }
399 }
400
401 pub fn parse(value: &str) -> Option<MemoryKind> {
402 [
403 MemoryKind::Fact,
404 MemoryKind::Convention,
405 MemoryKind::Decision,
406 MemoryKind::Gotcha,
407 ]
408 .into_iter()
409 .find(|kind| kind.as_str() == value)
410 }
411}
412
413/// Where a memory came from.
414#[derive(Clone, Debug, Default, Serialize, Deserialize)]
415#[serde(rename_all = "camelCase")]
416pub struct MemorySource {
417 /// `person`, `agent` or `run`.
418 pub kind: String,
419 /// The agent run that learned it.
420 #[serde(default)]
421 pub run_id: Option<String>,
422 /// The project and pull request it was learned on.
423 #[serde(default)]
424 pub repo: Option<RepoPath>,
425 #[serde(default)]
426 pub number: Option<u32>,
427 /// What it was captured from: `run:<id>`, `comment:<id>`,
428 /// `pull:<repo id>#<n>`, `doc:<repo id>:<path>`. See `crate::capture`.
429 #[serde(default)]
430 pub reference: Option<String>,
431 /// What it was learned from, quoted.
432 #[serde(default)]
433 pub evidence: Option<String>,
434}
435
436/// Whether agents are given a memory: `kept` ones are; a `candidate` waits
437/// for review (see `crate::capture`); a `dismissed` one is never suggested
438/// again from the same wording.
439#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "snake_case")]
441pub enum MemoryStatus {
442 Candidate,
443 #[default]
444 Kept,
445 Dismissed,
446}
447
448impl MemoryStatus {
449 pub fn as_str(self) -> &'static str {
450 match self {
451 MemoryStatus::Candidate => "candidate",
452 MemoryStatus::Kept => "kept",
453 MemoryStatus::Dismissed => "dismissed",
454 }
455 }
456
457 pub fn parse(value: &str) -> Option<MemoryStatus> {
458 [MemoryStatus::Candidate, MemoryStatus::Kept, MemoryStatus::Dismissed]
459 .into_iter()
460 .find(|status| status.as_str() == value)
461 }
462}
463
464#[derive(Clone, Debug, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct Memory {
467 pub id: String,
468 pub scope: MemoryScope,
469 /// The workspace's slug.
470 pub workspace: String,
471 /// For a project's memory, the project's repository.
472 pub repo: Option<RepoPath>,
473 pub text: String,
474 pub kind: MemoryKind,
475 pub source: MemorySource,
476 /// Who wrote it: a username, or `g1t-agent`.
477 pub created_by: String,
478 /// Pinned memories are given to every agent first.
479 pub pinned: bool,
480 /// RFC 3339.
481 pub created_at: String,
482 pub updated_at: String,
483 /// When it was last given to an agent.
484 pub last_used_at: Option<String>,
485 /// Kept memories are given to agents; candidates wait for review.
486 #[serde(default)]
487 pub status: MemoryStatus,
488 /// 0 to 1: how sure its source was. None for what people wrote.
489 #[serde(default)]
490 pub confidence: Option<f64>,
491 /// How many independent sources said it.
492 #[serde(default)]
493 pub seen: u32,
494}
495
496#[derive(Clone, Debug, Default, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct Memories {
499 /// The project's own; empty when no project was named.
500 pub project: Vec<Memory>,
501 pub workspace: Vec<Memory>,
502}
503
504/// `list_memories`: a workspace's memory and, with `repo`, that project's.
505/// Members only. Returns `Outcome<Memories>`.
506#[derive(Debug, Serialize, Deserialize)]
507pub struct ListMemoriesArgs {
508 pub viewer: Viewer,
509 pub workspace: String,
510 #[serde(default)]
511 pub repo: Option<RepoPath>,
512}
513
514/// `add_memory`: a member or an agent adds to memory. A project's memory
515/// needs `repo`. Text that looks like a key or a token is refused.
516/// Returns `Outcome<Memory>`.
517#[derive(Debug, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct AddMemoryArgs {
520 pub actor: User,
521 pub workspace: String,
522 #[serde(default)]
523 pub repo: Option<RepoPath>,
524 pub scope: MemoryScope,
525 pub text: String,
526 #[serde(default)]
527 pub kind: MemoryKind,
528 #[serde(default)]
529 pub pinned: bool,
530 /// For an agent: the pull request it is working on.
531 #[serde(default)]
532 pub from_number: Option<u32>,
533}
534
535/// `update_memory`: changes a memory's text, kind or pin. Members only.
536/// Returns `Outcome<Memory>`.
537#[derive(Debug, Serialize, Deserialize)]
538pub struct UpdateMemoryArgs {
539 pub actor: User,
540 pub workspace: String,
541 pub id: String,
542 #[serde(default)]
543 pub text: Option<String>,
544 #[serde(default)]
545 pub kind: Option<MemoryKind>,
546 #[serde(default)]
547 pub pinned: Option<bool>,
548}
549
550/// `delete_memory`: forgets a memory. Members only. Returns `Outcome<bool>`.
551#[derive(Debug, Serialize, Deserialize)]
552pub struct DeleteMemoryArgs {
553 pub actor: User,
554 pub workspace: String,
555 pub id: String,
556}
557
558/// `recall`: what a project and its workspace remember, matching `query`
559/// when given (every word, in any order), pinned first. Members and g1t's
560/// agents. Returns `Outcome<Memories>`.
561#[derive(Debug, Serialize, Deserialize)]
562pub struct RecallArgs {
563 pub viewer: Viewer,
564 pub repo: RepoPath,
565 #[serde(default)]
566 pub query: Option<String>,
567 #[serde(default)]
568 pub limit: Option<u32>,
569}
570
571/// `memory_context`: what to tell an agent starting work in `repo`, within
572/// `budget` characters: pinned first, then the most recently used, the
573/// workspace's and the project's labelled apart. Marks what it gives as
574/// used. Called by the runner service only. Returns `MemoryContext`.
575///
576/// `requester` is the person the run acts for. The workspace's memory is
577/// its members': for a requester who is not one (an outside collaborator),
578/// only the project's memory is given. None, as from a runner that does not
579/// say, is the workspace's own step and gets both.
580#[derive(Debug, Serialize, Deserialize)]
581pub struct MemoryContextArgs {
582 pub repo: RepoPath,
583 #[serde(default)]
584 pub budget: Option<u32>,
585 #[serde(default)]
586 pub requester: Option<User>,
587}
588
589#[derive(Clone, Debug, Default, Serialize, Deserialize)]
590pub struct MemoryContext {
591 /// None when there is nothing to tell.
592 pub text: Option<String>,
593 pub count: u32,
594}
595
596/// The longest a memory may be.
597pub const MAX_MEMORY_CHARS: usize = 1000;
598
599/// Prefixes of credentials that say what they are.
600const SECRET_PREFIXES: [(&str, &str); 20] = [
601 ("sk-ant-", "an Anthropic key"),
602 ("sk-proj-", "an OpenAI key"),
603 ("sk_live_", "a Stripe key"),
604 ("sk_test_", "a Stripe key"),
605 ("rk_live_", "a Stripe key"),
606 ("whsec_", "a webhook signing secret"),
607 ("ghp_", "a GitHub token"),
608 ("gho_", "a GitHub token"),
609 ("ghs_", "a GitHub token"),
610 ("ghu_", "a GitHub token"),
611 ("github_pat_", "a GitHub token"),
612 ("glpat-", "a GitLab token"),
613 ("xoxb-", "a Slack token"),
614 ("xoxp-", "a Slack token"),
615 ("AKIA", "an AWS access key"),
616 ("ASIA", "an AWS access key"),
617 ("AIza", "a Google API key"),
618 ("g1t_", "a g1t token"),
619 ("npm_", "an npm token"),
620 ("SG.", "a SendGrid key"),
621];
622
623/// Words that, followed by `=` or `:` and a value, set a credential.
624const SECRET_WORDS: [&str; 8] = [
625 "password",
626 "passwd",
627 "secret",
628 "api_key",
629 "apikey",
630 "api-key",
631 "token",
632 "private_key",
633];
634
635/// Bits of entropy per character of `word`.
636fn entropy(word: &str) -> f64 {
637 let mut counts = std::collections::HashMap::new();
638 for c in word.chars() {
639 *counts.entry(c).or_insert(0usize) += 1;
640 }
641 let length = word.chars().count() as f64;
642 counts
643 .values()
644 .map(|&count| {
645 let p = count as f64 / length;
646 -p * p.log2()
647 })
648 .sum()
649}
650
651/// What `text` seems to hold that must never be stored in memory, which
652/// every agent in the workspace reads: a key, a token, a private key or a
653/// password. None when it looks clean. Errs on the side of refusing; a
654/// person can always say where a secret lives instead of what it is.
655pub fn secret_in(text: &str) -> Option<&'static str> {
656 if text.contains("-----BEGIN") && text.contains("PRIVATE KEY") {
657 return Some("a private key");
658 }
659 let lower = text.to_lowercase();
660 for word in SECRET_WORDS {
661 let mut rest = lower.as_str();
662 while let Some(at) = rest.find(word) {
663 let after = rest[at + word.len()..].trim_start_matches(['"', '\'', ' ']);
664 if let Some(value) = after.strip_prefix(['=', ':']) {
665 let value: String = value
666 .trim_start_matches([' ', '"', '\''])
667 .chars()
668 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'' && *c != ',')
669 .collect();
670 // "token: see 1Password" names where it is, which is fine.
671 if value.chars().count() >= 8 && value.chars().any(|c| c.is_ascii_digit()) {
672 return Some("a password or token");
673 }
674 }
675 rest = &rest[at + word.len()..];
676 }
677 }
678 let words = text.split(|c: char| {
679 c.is_whitespace() || matches!(c, '"' | '\'' | '`' | ',' | ';' | '(' | ')' | '<' | '>' | '[' | ']' | '{' | '}')
680 });
681 for word in words {
682 let word = word.trim_end_matches(['.', ':']);
683 // A key=value pair is judged by its value.
684 let word = word.rsplit_once('=').map_or(word, |(_, value)| value);
685 for (prefix, what) in SECRET_PREFIXES {
686 if let Some(rest) = word.strip_prefix(prefix) {
687 let tail = rest.chars().filter(|c| c.is_ascii_alphanumeric()).count();
688 if tail >= 12 {
689 return Some(what);
690 }
691 }
692 }
693 // A URL with a password in it.
694 if let Some((_, rest)) = word.split_once("://")
695 && let Some((credentials, _)) = rest.split_once('@')
696 && credentials.contains(':')
697 {
698 return Some("a URL with a password in it");
699 }
700 let length = word.chars().count();
701 if length < 32 || word.contains("://") || word.contains('/') && !word.contains('+') {
702 continue;
703 }
704 let token_chars = word
705 .chars()
706 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '/' | '='));
707 if !token_chars {
708 continue;
709 }
710 let hex = word.chars().all(|c| c.is_ascii_hexdigit());
711 // A commit's id is 40 hex characters and fine; longer runs of hex
712 // are keys and digests of keys.
713 if hex {
714 if length >= 48 {
715 return Some("a key or token");
716 }
717 continue;
718 }
719 let upper = word.chars().any(|c| c.is_ascii_uppercase());
720 let lower = word.chars().any(|c| c.is_ascii_lowercase());
721 let digit = word.chars().any(|c| c.is_ascii_digit());
722 if upper && lower && digit && entropy(word) > 4.0 {
723 return Some("a key or token");
724 }
725 }
726 None
727}
728
729// --- Compute gating ---------------------------------------------------------
730//
731// What the runner needs from the work service to apply a workspace's plan
732// caps: how many agents it has at work, what an issue's agents have spent,
733// and somewhere for runs to wait for a free slot.
734
735/// The kinds of run that count against a workspace's agents-at-once cap.
736pub const AGENT_KINDS: [RunKind; 6] = [
737 RunKind::Implement,
738 RunKind::Revise,
739 RunKind::Review,
740 RunKind::Answer,
741 RunKind::Update,
742 RunKind::Plan,
743];
744
745/// `active_agents`: how many agent runs a workspace has queued or running
746/// now. Called by the runner. Returns `u32`.
747#[derive(Debug, Serialize, Deserialize)]
748pub struct ActiveAgentsArgs {
749 pub workspace: String,
750}
751
752/// `issue_spend`: what the agents on an issue have cost so far, in all its
753/// pull requests' runs and the runs on the issue itself. `number` may name
754/// the issue or one of its pull requests. Called by the runner. Returns
755/// `Outcome<IssueSpend>`.
756#[derive(Debug, Serialize, Deserialize)]
757pub struct IssueSpendArgs {
758 pub repo: RepoPath,
759 pub number: u32,
760}
761
762#[derive(Clone, Debug, Serialize, Deserialize)]
763#[serde(rename_all = "camelCase")]
764pub struct IssueSpend {
765 /// The issue counted: the one `number` names, or its pull request's.
766 /// A pull request with no issue counts as its own.
767 pub issue: u32,
768 /// What the model providers charged for its runs, in millionths of a
769 /// dollar.
770 pub spent_micros: i64,
771}
772
773/// `wait_for_slot`: gives back a lifecycle step the runner claimed but
774/// could not start because the workspace's agents were all busy, so the
775/// next sweep takes it again, and says so on the pull request. Returns
776/// `bool`.
777#[derive(Debug, Serialize, Deserialize)]
778#[serde(rename_all = "camelCase")]
779pub struct WaitForSlotArgs {
780 pub pull_id: String,
781 pub reason: String,
782}
783
784/// `agent_comment`: a comment from g1t-agent on an issue or pull request,
785/// for what it could not do there. Called by the runner. Returns `bool`.
786#[derive(Debug, Serialize, Deserialize)]
787pub struct AgentCommentArgs {
788 pub repo: RepoPath,
789 pub number: u32,
790 pub body: String,
791}
792
793/// `add_wait`: a run a person asked for, waiting for one of the
794/// workspace's agent slots. `payload` is what the runner needs to start it
795/// later; the work service only keeps it. Returns `bool`.
796#[derive(Debug, Serialize, Deserialize)]
797pub struct AddWaitArgs {
798 pub workspace: String,
799 pub kind: String,
800 pub payload: serde_json::Value,
801}
802
803/// `waiting_workspaces`: the workspaces with runs waiting, oldest first.
804/// Returns `Vec<String>`.
805#[derive(Debug, Default, Serialize, Deserialize)]
806pub struct WaitingWorkspacesArgs {}
807
808/// `take_wait`: the oldest run waiting in a workspace, taken out of the
809/// queue in one statement. Returns `Option<AgentWait>`.
810#[derive(Debug, Serialize, Deserialize)]
811pub struct TakeWaitArgs {
812 pub workspace: String,
813}
814
815#[derive(Clone, Debug, Serialize, Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct AgentWait {
818 pub id: String,
819 pub workspace: String,
820 pub kind: String,
821 pub payload: serde_json::Value,
822 pub created_at: String,
823}
824
825/// `run_cost`: what a run's model cost, as its sandbox reported it, for
826/// the runner settling the run's reservation. Needs the run's token.
827/// Returns `Option<f64>`, in US dollars.
828#[derive(Debug, Serialize, Deserialize)]
829#[serde(rename_all = "camelCase")]
830pub struct RunCostArgs {
831 pub run_id: String,
832 pub token: String,
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn plain_knowledge_is_kept() {
841 for text in [
842 "We use pnpm everywhere, never npm or yarn.",
843 "Staging lives at https://staging.example.com and deploys from main.",
844 "The tests need TZ=UTC or the date tests fail.",
845 "Commit 9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13 introduced the flaky retry.",
846 "The API token is in 1Password under 'Deploy'.",
847 "token: ask Ana for one",
848 "src/components/ui/select.tsx wraps Radix; import from there, not radix-ui.",
849 "Run cargo test -p g1t-work before pushing changes to services/work.",
850 ] {
851 assert_eq!(secret_in(text), None, "{text}");
852 }
853 }
854
855 #[test]
856 fn credentials_are_refused() {
857 // Key-shaped values are joined at run time, so no whole key sits in
858 // the source, and each is an issuer's documented example or says
859 // EXAMPLE: were one ever found, it would be listed as a likely test
860 // value, never as a leaked key.
861 let join = |a: &str, b: &str| format!("{a}{b}");
862 for text in [
863 join("The key is sk-", "ant-api03-EXAMPLEKEY0EXAMPLEKEY0EXAMPLEKEY0EXAMPLE"),
864 join("use gh", "p_EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMP to clone"),
865 join("AWS: AK", "IAIOSFODNN7EXAMPLE"),
866 join("STRIPE_KEY=sk_l", "ive_EXAMPLE0EXAMPLE0EXAMPLE0"),
867 "password = hunter2hunter2".to_owned(),
868 "db: postgres://admin:s3cret@db.internal:5432/app".to_owned(),
869 "token is 3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a".to_owned(),
870 "-----BEGIN RSA PRIVATE KEY-----\nMIIE...".to_owned(),
871 "secret: Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lX".to_owned(),
872 "it is xK9mQ2vL8nR4tP7wZ3yB6cF1dG5hJ0sA".to_owned(),
873 ] {
874 assert!(secret_in(&text).is_some(), "{text}");
875 }
876 }
877
878 #[test]
879 fn kinds_and_statuses_round_trip() {
880 for kind in RunKind::ALL {
881 assert_eq!(RunKind::parse(kind.as_str()), Some(kind));
882 }
883 assert!(RunKind::Implement.takes_messages() && !RunKind::Review.takes_messages());
884 assert!(!RunKind::Checks.is_agent());
885 assert_eq!(RunStatus::parse("stopped"), Some(RunStatus::Stopped));
886 assert!(RunStatus::Queued.is_active() && !RunStatus::Failed.is_active());
887 assert_eq!(MemoryKind::parse("gotcha"), Some(MemoryKind::Gotcha));
888 }
889}