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

704 lines23,011 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 /// RFC 3339.
170 pub created_at: String,
171 pub started_at: Option<String>,
172 pub finished_at: Option<String>,
173 pub updated_at: String,
174}
175
176/// What lets a sandbox, and nothing else, report on its run.
177#[derive(Clone, Debug, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct AgentRunTicket {
180 pub run_id: String,
181 pub token: String,
182}
183
184/// `open_run`: records a sandbox the runner is starting. Called by the
185/// runner service only. Returns `Outcome<AgentRunTicket>`.
186#[derive(Debug, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct OpenRunArgs {
189 /// Who the sandbox acts as.
190 pub actor: User,
191 pub repo: RepoPath,
192 #[serde(default)]
193 pub number: Option<u32>,
194 #[serde(default)]
195 pub pull_id: Option<String>,
196 /// For a run with no pull request, such as a plan: what it is about.
197 #[serde(default)]
198 pub title: Option<String>,
199 pub kind: RunKind,
200 #[serde(default)]
201 pub agent: Option<String>,
202 #[serde(default)]
203 pub model: Option<String>,
204 /// The sandbox's name, which stopping it needs.
205 pub sandbox: String,
206 #[serde(default)]
207 pub started_by: Option<String>,
208}
209
210/// `report_run`: a sandbox telling how its run goes, with the run's token.
211/// Steps are added, the current step replaced, and an outcome ends the run.
212/// A finished run accepts nothing more. Returns `Outcome<RunStatus>`: the
213/// run's status after the report, so a sandbox can tell it was stopped.
214#[derive(Debug, Default, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase", default)]
216pub struct ReportRunArgs {
217 pub run_id: String,
218 pub token: String,
219 pub steps: Vec<String>,
220 pub step: Option<String>,
221 pub cost_usd: Option<f64>,
222 pub turns: Option<u32>,
223 /// `succeeded` or `failed`, to end the run.
224 pub outcome: Option<RunStatus>,
225 pub error: Option<String>,
226}
227
228/// `stop_run`: a member stops a run. Marks it stopped and returns where its
229/// sandbox is, for the runner to destroy. A pull request it worked on waits
230/// for a person. Returns `Outcome<StoppedRun>`.
231#[derive(Debug, Serialize, Deserialize)]
232pub struct StopRunArgs {
233 pub actor: User,
234 pub repo: RepoPath,
235 pub id: String,
236}
237
238#[derive(Debug, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct StoppedRun {
241 pub run: AgentRun,
242 pub sandbox: String,
243}
244
245/// `list_runs`: runs in a repository, or with `workspace` instead, in every
246/// repository of a workspace (members only), newest first. Returns
247/// `Outcome<Vec<AgentRun>>`.
248#[derive(Debug, Default, Serialize, Deserialize)]
249#[serde(rename_all = "camelCase", default)]
250pub struct ListRunsArgs {
251 pub viewer: Viewer,
252 pub repo: Option<RepoPath>,
253 pub workspace: Option<String>,
254 /// Only those queued or running.
255 pub active: bool,
256 pub kind: Option<RunKind>,
257 pub status: Option<RunStatus>,
258 /// Only those on this pull request.
259 pub number: Option<u32>,
260 /// At most 200; 50 if not given.
261 pub limit: Option<u32>,
262}
263
264/// `get_run`: one run with all its steps. Returns `Outcome<AgentRun>`.
265#[derive(Debug, Serialize, Deserialize)]
266pub struct GetRunArgs {
267 pub viewer: Viewer,
268 pub repo: RepoPath,
269 pub id: String,
270}
271
272/// A pull request's recorded session, summed up for a list.
273#[derive(Clone, Debug, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct SessionSummary {
276 pub number: u32,
277 pub title: String,
278 pub status: PullStatus,
279 /// The agent label on the pull request, such as `g1t-agent`.
280 pub agent: String,
281 pub entries: u32,
282 /// How many tools it called.
283 pub tools: u32,
284 /// The start of the first prompt.
285 pub prompt: Option<String>,
286 /// The kinds of the runs g1t made for it.
287 pub kinds: Vec<RunKind>,
288 pub runs: u32,
289 /// What its runs cost together. Members only.
290 pub cost_usd: Option<f64>,
291 /// Whether one of its runs is under way.
292 pub active: bool,
293 /// RFC 3339.
294 pub started_at: String,
295 pub last_at: String,
296}
297
298/// `list_sessions`: the pull requests of a repository that have a session,
299/// most recently active first. Returns `Outcome<Vec<SessionSummary>>`.
300#[derive(Debug, Default, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase", default)]
302pub struct ListSessionsArgs {
303 pub viewer: Viewer,
304 pub repo: Option<RepoPath>,
305 /// Only those with a run of this kind.
306 pub kind: Option<RunKind>,
307 /// The pull request's status: `draft`, `open`, `merged` or `closed`.
308 pub outcome: Option<PullStatus>,
309 pub number: Option<u32>,
310}
311
312/// `get_session`: one pull request's session in full, with its runs.
313/// Returns `Outcome<SessionView>`.
314#[derive(Debug, Serialize, Deserialize)]
315pub struct GetSessionArgs {
316 pub viewer: Viewer,
317 pub repo: RepoPath,
318 pub number: u32,
319}
320
321#[derive(Clone, Debug, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct SessionView {
324 pub pull: Pull,
325 /// Oldest first; the first 2000.
326 pub entries: Vec<SessionEntry>,
327 /// Newest first, with their steps.
328 pub runs: Vec<AgentRun>,
329 /// Members only.
330 pub cost_usd: Option<f64>,
331}
332
333// --- Memory -----------------------------------------------------------------
334
335/// Whose memory: one project's, or the whole workspace's.
336#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum MemoryScope {
339 /// About one project's codebase.
340 Project,
341 /// True across the workspace's projects.
342 Workspace,
343}
344
345impl MemoryScope {
346 pub fn as_str(self) -> &'static str {
347 match self {
348 MemoryScope::Project => "project",
349 MemoryScope::Workspace => "workspace",
350 }
351 }
352}
353
354#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum MemoryKind {
357 /// How something is: "staging lives at staging.example.com".
358 #[default]
359 Fact,
360 /// How things are done here: "we use pnpm everywhere".
361 Convention,
362 /// Something chosen, and why: "we keep Postgres, not MySQL, for jsonb".
363 Decision,
364 /// A trap: "the tests need TZ=UTC or the date tests fail".
365 Gotcha,
366}
367
368impl MemoryKind {
369 pub fn as_str(self) -> &'static str {
370 match self {
371 MemoryKind::Fact => "fact",
372 MemoryKind::Convention => "convention",
373 MemoryKind::Decision => "decision",
374 MemoryKind::Gotcha => "gotcha",
375 }
376 }
377
378 pub fn parse(value: &str) -> Option<MemoryKind> {
379 [
380 MemoryKind::Fact,
381 MemoryKind::Convention,
382 MemoryKind::Decision,
383 MemoryKind::Gotcha,
384 ]
385 .into_iter()
386 .find(|kind| kind.as_str() == value)
387 }
388}
389
390/// Where a memory came from.
391#[derive(Clone, Debug, Default, Serialize, Deserialize)]
392#[serde(rename_all = "camelCase")]
393pub struct MemorySource {
394 /// `person`, `agent` or `run`.
395 pub kind: String,
396 /// The agent run that learned it.
397 #[serde(default)]
398 pub run_id: Option<String>,
399 /// The project and pull request it was learned on.
400 #[serde(default)]
401 pub repo: Option<RepoPath>,
402 #[serde(default)]
403 pub number: Option<u32>,
404}
405
406#[derive(Clone, Debug, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct Memory {
409 pub id: String,
410 pub scope: MemoryScope,
411 /// The workspace's slug.
412 pub workspace: String,
413 /// For a project's memory, the project's repository.
414 pub repo: Option<RepoPath>,
415 pub text: String,
416 pub kind: MemoryKind,
417 pub source: MemorySource,
418 /// Who wrote it: a username, or `g1t-agent`.
419 pub created_by: String,
420 /// Pinned memories are given to every agent first.
421 pub pinned: bool,
422 /// RFC 3339.
423 pub created_at: String,
424 pub updated_at: String,
425 /// When it was last given to an agent.
426 pub last_used_at: Option<String>,
427}
428
429#[derive(Clone, Debug, Default, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct Memories {
432 /// The project's own; empty when no project was named.
433 pub project: Vec<Memory>,
434 pub workspace: Vec<Memory>,
435}
436
437/// `list_memories`: a workspace's memory and, with `repo`, that project's.
438/// Members only. Returns `Outcome<Memories>`.
439#[derive(Debug, Serialize, Deserialize)]
440pub struct ListMemoriesArgs {
441 pub viewer: Viewer,
442 pub workspace: String,
443 #[serde(default)]
444 pub repo: Option<RepoPath>,
445}
446
447/// `add_memory`: a member or an agent adds to memory. A project's memory
448/// needs `repo`. Text that looks like a key or a token is refused.
449/// Returns `Outcome<Memory>`.
450#[derive(Debug, Serialize, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct AddMemoryArgs {
453 pub actor: User,
454 pub workspace: String,
455 #[serde(default)]
456 pub repo: Option<RepoPath>,
457 pub scope: MemoryScope,
458 pub text: String,
459 #[serde(default)]
460 pub kind: MemoryKind,
461 #[serde(default)]
462 pub pinned: bool,
463 /// For an agent: the pull request it is working on.
464 #[serde(default)]
465 pub from_number: Option<u32>,
466}
467
468/// `update_memory`: changes a memory's text, kind or pin. Members only.
469/// Returns `Outcome<Memory>`.
470#[derive(Debug, Serialize, Deserialize)]
471pub struct UpdateMemoryArgs {
472 pub actor: User,
473 pub workspace: String,
474 pub id: String,
475 #[serde(default)]
476 pub text: Option<String>,
477 #[serde(default)]
478 pub kind: Option<MemoryKind>,
479 #[serde(default)]
480 pub pinned: Option<bool>,
481}
482
483/// `delete_memory`: forgets a memory. Members only. Returns `Outcome<bool>`.
484#[derive(Debug, Serialize, Deserialize)]
485pub struct DeleteMemoryArgs {
486 pub actor: User,
487 pub workspace: String,
488 pub id: String,
489}
490
491/// `recall`: what a project and its workspace remember, matching `query`
492/// when given (every word, in any order), pinned first. Members and g1t's
493/// agents. Returns `Outcome<Memories>`.
494#[derive(Debug, Serialize, Deserialize)]
495pub struct RecallArgs {
496 pub viewer: Viewer,
497 pub repo: RepoPath,
498 #[serde(default)]
499 pub query: Option<String>,
500 #[serde(default)]
501 pub limit: Option<u32>,
502}
503
504/// `memory_context`: what to tell an agent starting work in `repo`, within
505/// `budget` characters: pinned first, then the most recently used, the
506/// workspace's and the project's labelled apart. Marks what it gives as
507/// used. Called by the runner service only. Returns `MemoryContext`.
508#[derive(Debug, Serialize, Deserialize)]
509pub struct MemoryContextArgs {
510 pub repo: RepoPath,
511 #[serde(default)]
512 pub budget: Option<u32>,
513}
514
515#[derive(Clone, Debug, Default, Serialize, Deserialize)]
516pub struct MemoryContext {
517 /// None when there is nothing to tell.
518 pub text: Option<String>,
519 pub count: u32,
520}
521
522/// The longest a memory may be.
523pub const MAX_MEMORY_CHARS: usize = 1000;
524
525/// Prefixes of credentials that say what they are.
526const SECRET_PREFIXES: [(&str, &str); 20] = [
527 ("sk-ant-", "an Anthropic key"),
528 ("sk-proj-", "an OpenAI key"),
529 ("sk_live_", "a Stripe key"),
530 ("sk_test_", "a Stripe key"),
531 ("rk_live_", "a Stripe key"),
532 ("whsec_", "a webhook signing secret"),
533 ("ghp_", "a GitHub token"),
534 ("gho_", "a GitHub token"),
535 ("ghs_", "a GitHub token"),
536 ("ghu_", "a GitHub token"),
537 ("github_pat_", "a GitHub token"),
538 ("glpat-", "a GitLab token"),
539 ("xoxb-", "a Slack token"),
540 ("xoxp-", "a Slack token"),
541 ("AKIA", "an AWS access key"),
542 ("ASIA", "an AWS access key"),
543 ("AIza", "a Google API key"),
544 ("g1t_", "a g1t token"),
545 ("npm_", "an npm token"),
546 ("SG.", "a SendGrid key"),
547];
548
549/// Words that, followed by `=` or `:` and a value, set a credential.
550const SECRET_WORDS: [&str; 8] = [
551 "password",
552 "passwd",
553 "secret",
554 "api_key",
555 "apikey",
556 "api-key",
557 "token",
558 "private_key",
559];
560
561/// Bits of entropy per character of `word`.
562fn entropy(word: &str) -> f64 {
563 let mut counts = std::collections::HashMap::new();
564 for c in word.chars() {
565 *counts.entry(c).or_insert(0usize) += 1;
566 }
567 let length = word.chars().count() as f64;
568 counts
569 .values()
570 .map(|&count| {
571 let p = count as f64 / length;
572 -p * p.log2()
573 })
574 .sum()
575}
576
577/// What `text` seems to hold that must never be stored in memory, which
578/// every agent in the workspace reads: a key, a token, a private key or a
579/// password. None when it looks clean. Errs on the side of refusing; a
580/// person can always say where a secret lives instead of what it is.
581pub fn secret_in(text: &str) -> Option<&'static str> {
582 if text.contains("-----BEGIN") && text.contains("PRIVATE KEY") {
583 return Some("a private key");
584 }
585 let lower = text.to_lowercase();
586 for word in SECRET_WORDS {
587 let mut rest = lower.as_str();
588 while let Some(at) = rest.find(word) {
589 let after = rest[at + word.len()..].trim_start_matches(['"', '\'', ' ']);
590 if let Some(value) = after.strip_prefix(['=', ':']) {
591 let value: String = value
592 .trim_start_matches([' ', '"', '\''])
593 .chars()
594 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'' && *c != ',')
595 .collect();
596 // "token: see 1Password" names where it is, which is fine.
597 if value.chars().count() >= 8 && value.chars().any(|c| c.is_ascii_digit()) {
598 return Some("a password or token");
599 }
600 }
601 rest = &rest[at + word.len()..];
602 }
603 }
604 let words = text.split(|c: char| {
605 c.is_whitespace() || matches!(c, '"' | '\'' | '`' | ',' | ';' | '(' | ')' | '<' | '>' | '[' | ']' | '{' | '}')
606 });
607 for word in words {
608 let word = word.trim_end_matches(['.', ':']);
609 // A key=value pair is judged by its value.
610 let word = word.rsplit_once('=').map_or(word, |(_, value)| value);
611 for (prefix, what) in SECRET_PREFIXES {
612 if let Some(rest) = word.strip_prefix(prefix) {
613 let tail = rest.chars().filter(|c| c.is_ascii_alphanumeric()).count();
614 if tail >= 12 {
615 return Some(what);
616 }
617 }
618 }
619 // A URL with a password in it.
620 if let Some((_, rest)) = word.split_once("://")
621 && let Some((credentials, _)) = rest.split_once('@')
622 && credentials.contains(':')
623 {
624 return Some("a URL with a password in it");
625 }
626 let length = word.chars().count();
627 if length < 32 || word.contains("://") || word.contains('/') && !word.contains('+') {
628 continue;
629 }
630 let token_chars = word
631 .chars()
632 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '/' | '='));
633 if !token_chars {
634 continue;
635 }
636 let hex = word.chars().all(|c| c.is_ascii_hexdigit());
637 // A commit's id is 40 hex characters and fine; longer runs of hex
638 // are keys and digests of keys.
639 if hex {
640 if length >= 48 {
641 return Some("a key or token");
642 }
643 continue;
644 }
645 let upper = word.chars().any(|c| c.is_ascii_uppercase());
646 let lower = word.chars().any(|c| c.is_ascii_lowercase());
647 let digit = word.chars().any(|c| c.is_ascii_digit());
648 if upper && lower && digit && entropy(word) > 4.0 {
649 return Some("a key or token");
650 }
651 }
652 None
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn plain_knowledge_is_kept() {
661 for text in [
662 "We use pnpm everywhere, never npm or yarn.",
663 "Staging lives at https://staging.example.com and deploys from main.",
664 "The tests need TZ=UTC or the date tests fail.",
665 "Commit 9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13 introduced the flaky retry.",
666 "The API token is in 1Password under 'Deploy'.",
667 "token: ask Ana for one",
668 "src/components/ui/select.tsx wraps Radix; import from there, not radix-ui.",
669 "Run cargo test -p g1t-work before pushing changes to services/work.",
670 ] {
671 assert_eq!(secret_in(text), None, "{text}");
672 }
673 }
674
675 #[test]
676 fn credentials_are_refused() {
677 for text in [
678 "The key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
679 "use ghp_abcdefghijklmnopqrstuvwxyz0123456789 to clone",
680 "AWS: AKIAIOSFODNN7EXAMPLE",
681 "STRIPE_KEY=sk_live_51HabcdefghijklmnopQRSTUV",
682 "password = hunter2hunter2",
683 "db: postgres://admin:s3cret@db.internal:5432/app",
684 "token is 3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a",
685 "-----BEGIN RSA PRIVATE KEY-----\nMIIE...",
686 "secret: Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lX",
687 "it is xK9mQ2vL8nR4tP7wZ3yB6cF1dG5hJ0sA",
688 ] {
689 assert!(secret_in(text).is_some(), "{text}");
690 }
691 }
692
693 #[test]
694 fn kinds_and_statuses_round_trip() {
695 for kind in RunKind::ALL {
696 assert_eq!(RunKind::parse(kind.as_str()), Some(kind));
697 }
698 assert!(RunKind::Implement.takes_messages() && !RunKind::Review.takes_messages());
699 assert!(!RunKind::Checks.is_agent());
700 assert_eq!(RunStatus::parse("stopped"), Some(RunStatus::Stopped));
701 assert!(RunStatus::Queued.is_active() && !RunStatus::Failed.is_active());
702 assert_eq!(MemoryKind::parse("gotcha"), Some(MemoryKind::Gotcha));
703 }
704}