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/services/work/src/runs.rs

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