Skip to content
597 linesCodeBlameRaw
1//! Runs: every sandbox g1t starts for checks, the merge queue and finding
2//! out whether a pull request merges cleanly, as a record people can watch
3//! and look back on. Runs of g1t's former agents are kept and listed too.
4//!
5//! The runner service opens a run as it starts a sandbox (`open_run`) and
6//! the sandbox reports its steps through the API with the run's one-time
7//! token (`report_run`), as checks do. The runner closes it when the
8//! sandbox stops.
9//!
10//! Sessions are the read side of what coding agents record on a pull
11//! request (`append_session`): `list_sessions` and `get_session`.
12
13use g1t_contracts::agents::*;
14use g1t_contracts::repos::{Repo, RepoPath};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::work::{PullStatus, SessionEntry};
17use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer, new_id};
18use g1t_kit::now_ms;
19use serde::Deserialize;
20use worker::Result;
21use worker::wasm_bindgen::JsValue;
22
23use crate::checks::{hash, new_token};
24use crate::rows::SessionRow;
25use crate::{Work, optional};
26
27/// How the runner starts a step that notes something about the sandbox
28/// rather than what the run is doing: a host it was refused (the runner's
29/// egress.ts `blockedStep`).
30const NOTE_PREFIXES: [&str; 1] = ["Blocked: "];
31
32/// Where a report leaves the run: its newest step that says what the run
33/// is doing. A note (a refused host) stays in the steps but never stands
34/// for the run, which carries on past it. `None` when there is only notes.
35fn current_step(steps: &[String]) -> Option<String> {
36 steps.iter().rev().find(|step| !NOTE_PREFIXES.iter().any(|prefix| step.starts_with(prefix))).cloned()
37}
38
39/// The most steps a run keeps; older ones fall off the start.
40const MAX_STEPS: u32 = 200;
41/// The most steps taken from one report.
42const MAX_STEPS_PER_REPORT: usize = 20;
43/// The longest a step is kept.
44const MAX_STEP_CHARS: usize = 240;
45/// A run that has said nothing for this long is taken to have died.
46const SILENT_HOURS: u64 = 3;
47const DEFAULT_LIST: u32 = 50;
48const MAX_LIST: u32 = 200;
49/// The most session entries one view returns.
50const SESSION_ENTRIES: u32 = 2000;
51
52/// Every column but the steps, which only a run's own page needs.
53const COLUMNS: &str = "id, workspace, repo_id, repo, number, pull_id, kind, agent, model, status,
54 step, step_count, cost_usd, turns, sandbox, token_hash, started_by, error, created_at,
55 started_at, finished_at, updated_at, budget_usd, time_cap_minutes, halted,
56 COALESCE((SELECT title FROM pulls WHERE pulls.id = agent_runs.pull_id), agent_runs.title) AS title";
57
58#[derive(Deserialize)]
59pub(crate) struct RunRow {
60 id: String,
61 #[allow(dead_code)]
62 workspace: String,
63 #[allow(dead_code)]
64 repo_id: String,
65 repo: String,
66 number: Option<u32>,
67 pub(crate) pull_id: Option<String>,
68 kind: String,
69 agent: String,
70 model: Option<String>,
71 status: String,
72 step: Option<String>,
73 #[serde(default)]
74 steps: Option<String>,
75 step_count: u32,
76 cost_usd: Option<f64>,
77 turns: Option<u32>,
78 token_hash: String,
79 started_by: Option<String>,
80 error: Option<String>,
81 created_at: String,
82 started_at: Option<String>,
83 finished_at: Option<String>,
84 updated_at: String,
85 title: Option<String>,
86 #[serde(default)]
87 budget_usd: Option<f64>,
88 #[serde(default)]
89 time_cap_minutes: Option<u32>,
90 #[serde(default)]
91 halted: Option<String>,
92}
93
94impl RunRow {
95 fn status(&self) -> RunStatus {
96 RunStatus::parse(&self.status).unwrap_or(RunStatus::Failed)
97 }
98
99 /// The run as `member` (or not) may see it: model and cost are the
100 /// workspace's business.
101 fn into_run(self, member: bool) -> AgentRun {
102 let (namespace, name) = self.repo.split_once('/').unwrap_or((&self.repo, ""));
103 AgentRun {
104 repo: RepoPath {
105 namespace: namespace.to_owned(),
106 name: name.to_owned(),
107 },
108 status: self.status(),
109 kind: RunKind::parse(&self.kind).unwrap_or(RunKind::Implement),
110 steps: self
111 .steps
112 .as_deref()
113 .and_then(|steps| serde_json::from_str(steps).ok())
114 .unwrap_or_default(),
115 id: self.id,
116 number: self.number.filter(|number| *number > 0),
117 title: self.title,
118 agent: self.agent,
119 model: self.model.filter(|_| member),
120 step: self.step,
121 step_count: self.step_count,
122 started_by: self.started_by,
123 error: self.error,
124 cost_usd: self.cost_usd.filter(|_| member),
125 turns: self.turns,
126 budget_usd: self.budget_usd.filter(|_| member),
127 time_cap_minutes: self.time_cap_minutes,
128 halted: self.halted,
129 created_at: self.created_at,
130 started_at: self.started_at,
131 finished_at: self.finished_at,
132 updated_at: self.updated_at,
133 }
134 }
135}
136
137/// One line, short enough to read at a glance.
138pub(crate) fn one_line(text: &str, limit: usize) -> String {
139 let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
140 if line.chars().count() <= limit {
141 return line;
142 }
143 let mut short: String = line.chars().take(limit.saturating_sub(1)).collect();
144 short.push('…');
145 short
146}
147
148/// A principal that can read any repository of `namespace`, for lookups a
149/// trusted service asks for. What it is given reads and nothing more.
150pub(crate) fn member_of(actor: &User, namespace: &str) -> Viewer {
151 let mut viewer = actor.clone();
152 let slug = namespace.to_lowercase();
153 if !viewer.is_member(&slug) {
154 viewer.workspaces.push(Membership {
155 base_permission: Some(g1t_contracts::access::BasePermission::Read),
156 ..Membership::member(slug)
157 });
158 }
159 Some(viewer)
160}
161
162/// Whether the viewer belongs to the workspace: what a run spent, its model
163/// and its budget are the workspace's business, not every reader's (an
164/// outside collaborator sees the work, not the bill).
165fn is_member(viewer: &Viewer, namespace: &str) -> bool {
166 viewer
167 .as_ref()
168 .is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase()))
169}
170
171#[derive(Deserialize)]
172struct SessionListRow {
173 pull_id: String,
174 number: u32,
175 title: String,
176 status: PullStatus,
177 agent: String,
178 entries: u32,
179 tools: u32,
180 started_at: String,
181 last_at: String,
182 prompt: Option<String>,
183}
184
185#[derive(Deserialize)]
186struct RunSumRow {
187 pull_id: String,
188 kind: String,
189 status: String,
190 cost_usd: Option<f64>,
191}
192
193impl Work {
194 /// The repository at `path`, if `viewer` may see it, and whether they
195 /// are a member of its workspace (which shows what runs cost).
196 async fn visible_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<(Repo, bool)>> {
197 Ok(match self.repo(path, viewer).await? {
198 Outcome::Ok(repo) => {
199 let member = is_member(viewer, &repo.namespace);
200 Outcome::Ok((repo, member))
201 }
202 Outcome::Fail(failure) => Outcome::Fail(failure),
203 })
204 }
205
206 async fn run_row(&self, id: &str) -> Result<Option<RunRow>> {
207 self.db
208 .prepare(format!("SELECT {COLUMNS}, steps FROM agent_runs WHERE id = ?"))
209 .bind(&[id.into()])?
210 .first::<RunRow>(None)
211 .await
212 }
213
214 pub(crate) async fn open_run(&self, a: OpenRunArgs) -> Result<Outcome<AgentRunTicket>> {
215 // Only sandboxes that are not agents are started now; earlier
216 // kinds stay readable on the runs already recorded.
217 if !matches!(a.kind, RunKind::Checks | RunKind::Queue | RunKind::Mergecheck) {
218 return Ok(Outcome::fail(
219 FailureCode::Invalid,
220 format!("A {} run is no longer started.", a.kind.as_str()),
221 ));
222 }
223 // The runner is trusted: it names the repository it is starting a
224 // sandbox in, whoever the sandbox acts as. The run records where
225 // the repository is now, looked up by its id, not the path it was
226 // named by, which may be from before a transfer or a rename.
227 let (repo_id, path) = match &a.pull_id {
228 Some(pull_id) => match self.pull_by_id(pull_id).await? {
229 Some(pull) => {
230 let now: Option<RepoPath> = g1t_kit::call(
231 &self.repos,
232 "path_by_id",
233 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
234 )
235 .await?;
236 (pull.repo_id, now.unwrap_or_else(|| a.repo.clone()))
237 }
238 None => return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found.")),
239 },
240 None => match self.repo(&a.repo, &member_of(&a.actor, &a.repo.namespace)).await? {
241 Outcome::Ok(repo) => (repo.id, RepoPath { namespace: repo.namespace, name: repo.name }),
242 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
243 },
244 };
245 let namespace = path.namespace.to_lowercase();
246 // Nothing new starts on an archived or deleted repository.
247 if !self.repo_active(&repo_id).await? {
248 return Ok(Outcome::fail(
249 FailureCode::Forbidden,
250 format!("{}/{} is archived or deleted, so nothing new starts on it.", path.namespace, path.name),
251 ));
252 }
253 let now = now_ms();
254 let id = new_id("arn", now);
255 let token = new_token();
256 let timestamp = rfc3339(now);
257 let agent = a
258 .agent
259 .clone()
260 .unwrap_or_else(|| g1t_contracts::identity::AGENT_NAME.to_owned());
261 self.db
262 .prepare(
263 "INSERT INTO agent_runs
264 (id, workspace, repo_id, repo, number, pull_id, title, kind, agent, model, status,
265 sandbox, token_hash, started_by, created_at, updated_at, budget_usd, time_cap_minutes)
266 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?)",
267 )
268 .bind(&[
269 id.as_str().into(),
270 namespace.into(),
271 repo_id.into(),
272 format!("{}/{}", path.namespace, path.name).into(),
273 a.number.filter(|n| *n > 0).map_or(JsValue::NULL, JsValue::from),
274 optional(&a.pull_id),
275 optional(&a.title.map(|title| one_line(&title, 200))),
276 a.kind.as_str().into(),
277 agent.into(),
278 optional(&a.model),
279 a.sandbox.into(),
280 hash(&token).into(),
281 optional(&a.started_by),
282 timestamp.as_str().into(),
283 timestamp.as_str().into(),
284 a.budget_usd.filter(|usd| usd.is_finite() && *usd > 0.0).map_or(JsValue::NULL, JsValue::from),
285 a.time_cap_minutes.map_or(JsValue::NULL, JsValue::from),
286 ])?
287 .run()
288 .await?;
289 Ok(Outcome::Ok(AgentRunTicket { run_id: id, token }))
290 }
291
292 /// Adds `text` to a run's steps, keeping the latest `MAX_STEPS`.
293 pub(crate) fn add_step(&self, run_id: &str, at: &str, text: &str) -> Result<worker::D1PreparedStatement> {
294 self.db
295 .prepare(
296 "UPDATE agent_runs SET
297 steps = CASE WHEN json_array_length(steps) >= ?4
298 THEN json_insert(json_remove(steps, '$[0]'), '$[#]', json_object('at', ?1, 'text', ?2))
299 ELSE json_insert(steps, '$[#]', json_object('at', ?1, 'text', ?2)) END,
300 step_count = step_count + 1
301 WHERE id = ?3",
302 )
303 .bind(&[at.into(), text.into(), run_id.into(), MAX_STEPS.into()])
304 }
305
306 pub(crate) async fn report_run(&self, a: ReportRunArgs) -> Result<Outcome<RunStatus>> {
307 let Some(run) = self
308 .run_row(&a.run_id)
309 .await?
310 .filter(|run| run.token_hash == hash(&a.token))
311 else {
312 return Ok(Outcome::fail(FailureCode::NotFound, "Run not found."));
313 };
314 // Finished, or stopped by a person: nothing more is taken, and the
315 // sandbox learns why.
316 if run.finished_at.is_some() {
317 return Ok(Outcome::Ok(run.status()));
318 }
319 // It reached a cap of its guardrails (guardrails.rs).
320 if let Some(halt) = a.halt {
321 let pull_id = run.pull_id.as_deref();
322 return self
323 .halt_run(&run.id, &run.repo_id, pull_id, run.number, &run.kind, halt, a.error, a.cost_usd)
324 .await;
325 }
326 let now = rfc3339(now_ms());
327 let steps: Vec<String> = a
328 .steps
329 .iter()
330 .map(|step| one_line(step, MAX_STEP_CHARS))
331 .filter(|step| !step.is_empty())
332 .collect();
333 // A burst keeps its end: that is where the run is now.
334 let skip = steps.len().saturating_sub(MAX_STEPS_PER_REPORT);
335 let mut statements = Vec::new();
336 for step in &steps[skip..] {
337 statements.push(self.add_step(&run.id, &now, step)?);
338 }
339 let current = a
340 .step
341 .as_deref()
342 .map(|step| one_line(step, MAX_STEP_CHARS))
343 .filter(|step| !step.is_empty())
344 .or_else(|| current_step(&steps));
345 let outcome = a
346 .outcome
347 .filter(|outcome| matches!(outcome, RunStatus::Succeeded | RunStatus::Failed));
348 let cost = a.cost_usd.filter(|cost| cost.is_finite() && *cost >= 0.0);
349 statements.push(
350 self.db
351 .prepare(
352 "UPDATE agent_runs SET
353 status = COALESCE(?1, CASE WHEN status = 'queued' THEN 'running' ELSE status END),
354 started_at = COALESCE(started_at, ?2),
355 step = COALESCE(?3, step),
356 cost_usd = COALESCE(?4, cost_usd),
357 turns = COALESCE(?5, turns),
358 error = COALESCE(?6, error),
359 finished_at = CASE WHEN ?1 IS NULL THEN finished_at ELSE ?2 END,
360 updated_at = ?2
361 WHERE id = ?7 AND finished_at IS NULL",
362 )
363 .bind(&[
364 outcome.map_or(JsValue::NULL, |outcome| outcome.as_str().into()),
365 now.as_str().into(),
366 optional(&current),
367 cost.map_or(JsValue::NULL, JsValue::from),
368 a.turns.map_or(JsValue::NULL, JsValue::from),
369 optional(&a.error.map(|error| one_line(&error, 1000))),
370 run.id.as_str().into(),
371 ])?,
372 );
373 self.db.batch(statements).await?;
374 Ok(Outcome::Ok(outcome.unwrap_or(RunStatus::Running)))
375 }
376
377 /// Runs whose sandbox died without anyone noticing, marked failed.
378 pub(crate) async fn sweep_silent(&self) -> Result<()> {
379 let now = now_ms();
380 let cutoff = rfc3339(now.saturating_sub(SILENT_HOURS * 3_600_000));
381 self.db
382 .prepare(
383 "UPDATE agent_runs
384 SET status = 'failed', error = COALESCE(error, 'It stopped reporting.'),
385 finished_at = ?, updated_at = ?
386 WHERE status IN ('queued', 'running') AND updated_at < ?",
387 )
388 .bind(&[rfc3339(now).into(), rfc3339(now).into(), cutoff.into()])?
389 .run()
390 .await?;
391 Ok(())
392 }
393
394 pub(crate) async fn list_runs(&self, a: ListRunsArgs) -> Result<Outcome<Vec<AgentRun>>> {
395 let (column, key, member) = if let Some(workspace) = &a.workspace {
396 let slug = workspace.to_lowercase();
397 if !is_member(&a.viewer, &slug) {
398 return Ok(Outcome::fail(
399 FailureCode::Forbidden,
400 "Only members of the workspace can see its fleet.",
401 ));
402 }
403 ("workspace", slug, true)
404 } else if let Some(path) = &a.repo {
405 match self.visible_repo(path, &a.viewer).await? {
406 Outcome::Ok((repo, member)) => ("repo_id", repo.id, member),
407 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
408 }
409 } else {
410 return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository or a workspace."));
411 };
412 self.sweep_silent().await?;
413 let limit = a.limit.unwrap_or(DEFAULT_LIST).clamp(1, MAX_LIST);
414 let rows = self
415 .db
416 .prepare(format!(
417 "SELECT {COLUMNS} FROM agent_runs
418 WHERE {column} = ?1
419 AND (?2 = 0 OR status IN ('queued', 'running'))
420 AND (?3 IS NULL OR kind = ?3)
421 AND (?4 IS NULL OR status = ?4)
422 AND (?5 IS NULL OR number = ?5)
423 ORDER BY created_at DESC LIMIT ?6"
424 ))
425 .bind(&[
426 key.into(),
427 u32::from(a.active).into(),
428 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
429 a.status.map_or(JsValue::NULL, |status| status.as_str().into()),
430 a.number.map_or(JsValue::NULL, JsValue::from),
431 limit.into(),
432 ])?
433 .all()
434 .await?
435 .results::<RunRow>()?;
436 Ok(Outcome::Ok(rows.into_iter().map(|row| row.into_run(member)).collect()))
437 }
438
439 pub(crate) async fn get_run(&self, a: GetRunArgs) -> Result<Outcome<AgentRun>> {
440 let (repo, member) = match self.visible_repo(&a.repo, &a.viewer).await? {
441 Outcome::Ok(found) => found,
442 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
443 };
444 Ok(match self.run_row(&a.id).await?.filter(|run| run.repo_id == repo.id) {
445 Some(row) => Outcome::Ok(row.into_run(member)),
446 None => Outcome::fail(FailureCode::NotFound, "Run not found."),
447 })
448 }
449
450 // --- Sessions ----------------------------------------------------------
451
452 pub(crate) async fn list_sessions(&self, a: ListSessionsArgs) -> Result<Outcome<Vec<SessionSummary>>> {
453 let Some(path) = &a.repo else {
454 return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository."));
455 };
456 let (repo, member) = match self.visible_repo(path, &a.viewer).await? {
457 Outcome::Ok(found) => found,
458 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
459 };
460 let rows = self
461 .db
462 .prepare(
463 "SELECT p.id AS pull_id, p.number, p.title, p.status, p.agent,
464 count(*) AS entries,
465 sum(CASE WHEN e.kind = 'tool_call' THEN 1 ELSE 0 END) AS tools,
466 min(e.at) AS started_at, max(e.at) AS last_at,
467 (SELECT substr(f.text, 1, 300) FROM session_entries f
468 WHERE f.pull_id = p.id AND f.kind = 'prompt' ORDER BY f.seq LIMIT 1) AS prompt
469 FROM pulls p JOIN session_entries e ON e.pull_id = p.id
470 WHERE p.repo_id = ?1
471 AND (?2 IS NULL OR p.status = ?2)
472 AND (?3 IS NULL OR p.number = ?3)
473 AND (?4 IS NULL OR EXISTS
474 (SELECT 1 FROM agent_runs r WHERE r.pull_id = p.id AND r.kind = ?4))
475 GROUP BY p.id ORDER BY last_at DESC LIMIT 100",
476 )
477 .bind(&[
478 repo.id.as_str().into(),
479 a.outcome.map_or(JsValue::NULL, |status| status.as_str().into()),
480 a.number.map_or(JsValue::NULL, JsValue::from),
481 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
482 ])?
483 .all()
484 .await?
485 .results::<SessionListRow>()?;
486 let ids: Vec<&str> = rows.iter().map(|row| row.pull_id.as_str()).collect();
487 let runs = if ids.is_empty() {
488 Vec::new()
489 } else {
490 self.db
491 .prepare(
492 "SELECT pull_id, kind, status, cost_usd FROM agent_runs
493 WHERE repo_id = ? AND pull_id IN (SELECT value FROM json_each(?))",
494 )
495 .bind(&[repo.id.as_str().into(), serde_json::to_string(&ids)?.into()])?
496 .all()
497 .await?
498 .results::<RunSumRow>()?
499 };
500 Ok(Outcome::Ok(
501 rows.into_iter()
502 .map(|row| {
503 let mine: Vec<&RunSumRow> = runs.iter().filter(|run| run.pull_id == row.pull_id).collect();
504 let mut kinds: Vec<RunKind> = Vec::new();
505 for run in &mine {
506 if let Some(kind) = RunKind::parse(&run.kind)
507 && !kinds.contains(&kind)
508 {
509 kinds.push(kind);
510 }
511 }
512 let spent: f64 = mine.iter().filter_map(|run| run.cost_usd).sum();
513 SessionSummary {
514 number: row.number,
515 title: row.title,
516 status: row.status,
517 agent: row.agent,
518 entries: row.entries,
519 tools: row.tools,
520 prompt: row.prompt.map(|prompt| one_line(&prompt, 200)),
521 runs: mine.len() as u32,
522 cost_usd: (member && mine.iter().any(|run| run.cost_usd.is_some())).then_some(spent),
523 active: mine
524 .iter()
525 .any(|run| RunStatus::parse(&run.status).is_some_and(RunStatus::is_active)),
526 kinds,
527 started_at: row.started_at,
528 last_at: row.last_at,
529 }
530 })
531 .collect(),
532 ))
533 }
534
535 pub(crate) async fn get_session(&self, a: GetSessionArgs) -> Result<Outcome<SessionView>> {
536 let (repo, pull) = match self.pull_at(&a.repo, a.number, &a.viewer).await? {
537 Outcome::Ok(found) => found,
538 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
539 };
540 let member = is_member(&a.viewer, &repo.namespace);
541 let entries: Vec<SessionEntry> = self
542 .db
543 .prepare(
544 "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries
545 WHERE pull_id = ? ORDER BY seq LIMIT ?",
546 )
547 .bind(&[pull.id.as_str().into(), SESSION_ENTRIES.into()])?
548 .all()
549 .await?
550 .results::<SessionRow>()?
551 .into_iter()
552 .map(SessionEntry::from)
553 .collect();
554 let runs: Vec<AgentRun> = self
555 .db
556 .prepare(format!(
557 "SELECT {COLUMNS}, steps FROM agent_runs WHERE pull_id = ? ORDER BY created_at DESC LIMIT 50"
558 ))
559 .bind(&[pull.id.as_str().into()])?
560 .all()
561 .await?
562 .results::<RunRow>()?
563 .into_iter()
564 .map(|row| row.into_run(member))
565 .collect();
566 let cost_usd = (member && runs.iter().any(|run| run.cost_usd.is_some()))
567 .then(|| runs.iter().filter_map(|run| run.cost_usd).sum());
568 Ok(Outcome::Ok(SessionView {
569 pull,
570 entries,
571 runs,
572 cost_usd,
573 }))
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn a_refused_host_is_noted_but_never_where_the_run_is() {
583 let steps = |list: &[&str]| list.iter().map(|step| (*step).to_owned()).collect::<Vec<String>>();
584 assert_eq!(current_step(&steps(&["Running npm ci", "Blocked: sparrow.cloudflare.com (not an allowed domain)"])).as_deref(), Some("Running npm ci"));
585 assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)"])), None);
586 assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)", "Typecheck"])).as_deref(), Some("Typecheck"));
587 assert_eq!(current_step(&[]), None);
588 }
589
590 #[test]
591 fn steps_are_one_short_line() {
592 assert_eq!(one_line(" Read\n src/lib.rs ", 40), "Read src/lib.rs");
593 let long = one_line(&"x".repeat(300), 10);
594 assert_eq!(long.chars().count(), 10);
595 assert!(long.ends_with('…'));
596 }
597}