1use std::{
2 collections::BTreeMap,
3 path::{Path, PathBuf},
4};
5
6use rusqlite::params;
7use serde::Serialize;
8use tokio_rusqlite::Connection;
9
10use super::{
11 DATABASE,
12 receipts::{Error, check_files, schema_initialized},
13};
14use crate::workspace::{canonical_target, outside_workspace};
15
16#[derive(Debug, Clone, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct RunSummary {
19 pub id: crate::types::RunId,
20 #[serde(serialize_with = "crate::platform::path_serde::serialize")]
21 pub repo_path: PathBuf,
22 pub created_at: crate::types::Timestamp,
23 pub completed_at: Option<crate::types::Timestamp>,
24 pub status: crate::types::RunStatus,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub comment: Option<crate::types::RunComment>,
28 pub counts: BTreeMap<crate::types::RequestStatus, u64>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
30 pub unreadable: Vec<super::Unreadable>,
31}
32
33impl RunSummary {
34 pub fn readable(&self) -> bool {
36 !self.unreadable.iter().any(|record| record.kind == "run")
37 }
38}
39
40pub async fn read_runs(
42 state: &Path,
43 repo: Option<&Path>,
44 limit: u32,
45 offset: u32,
46) -> Result<Vec<RunSummary>, String> {
47 let repos = repo.map(|path| vec![path.to_path_buf()]);
48 read_scoped_runs(state, repos.as_deref(), limit, offset).await
49}
50
51pub async fn read_scoped_runs(
53 state: &Path,
54 repos: Option<&[PathBuf]>,
55 limit: u32,
56 offset: u32,
57) -> Result<Vec<RunSummary>, String> {
58 let state = canonical_target(state).map_err(|e| e.to_string())?;
59 let repos = repos
60 .map(|paths| {
61 paths
62 .iter()
63 .map(|path| {
64 let path = canonical_target(path).map_err(|error| error.to_string())?;
65 outside_workspace(&path, &state).map_err(|error| error.to_string())?;
66 Ok(crate::platform::path_text(&path))
67 })
68 .collect::<Result<Vec<_>, String>>()
69 })
70 .transpose()?;
71 let repos = repos
72 .map(|paths| serde_json::to_string(&paths))
73 .transpose()
74 .map_err(|error| error.to_string())?;
75 check_files(&state)?;
76 if !state
77 .join(DATABASE)
78 .try_exists()
79 .map_err(|e| e.to_string())?
80 {
81 return Ok(Vec::new());
82 }
83 let connection = Connection::open_with_flags(
84 state.join(DATABASE),
85 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
86 )
87 .await
88 .map_err(|e| e.to_string())?;
89 let result = connection
90 .call(move |db| -> Result<_, Error> {
91 super::wait_when_busy(db)?;
92 let transaction = db.transaction()?;
93 if !schema_initialized(&transaction)? {
94 return Ok(Vec::new());
95 }
96 let runs = in_snapshot(&transaction, &state, repos.as_deref(), limit, offset)?;
97 transaction.commit()?;
98 Ok(runs)
99 })
100 .await
101 .map_err(|e| e.to_string());
102 super::connection::finish(connection, result).await
103}
104
105pub(super) fn in_snapshot(
108 db: &rusqlite::Connection,
109 state: &Path,
110 repos: Option<&str>,
111 limit: u32,
112 offset: u32,
113) -> Result<Vec<RunSummary>, Error> {
114 let mut runs = listed(db, state, repos, limit, offset, None)?;
115 let ids: Vec<_> = runs.iter().map(|run| run.id.clone()).collect();
116 let mut unreadable = super::unreadable::in_runs(db, &ids)?;
117 let invalid: Vec<_> = unreadable
118 .values()
119 .flatten()
120 .filter(|record| record.kind == "request")
121 .map(|record| record.id.clone())
122 .collect();
123 count(db, &mut runs, &invalid)?;
124 for run in &mut runs {
125 run.unreadable = unreadable.remove(&run.id).unwrap_or_default();
126 }
127 Ok(runs)
128}
129
130pub(super) fn page(
133 db: &rusqlite::Connection,
134 state: &Path,
135 repos: Option<&str>,
136 limit: u32,
137 offset: u32,
138 pinned: Option<&crate::types::RunId>,
139) -> Result<Vec<RunSummary>, Error> {
140 let mut runs = listed(db, state, repos, limit, offset, pinned)?;
141 count(db, &mut runs, &[])?;
142 Ok(runs)
143}
144
145fn listed(
146 db: &rusqlite::Connection,
147 state: &Path,
148 repos: Option<&str>,
149 limit: u32,
150 offset: u32,
151 pinned: Option<&crate::types::RunId>,
152) -> Result<Vec<RunSummary>, Error> {
153 let mut statement = db.prepare_cached(
154 "WITH page AS (
155 SELECT id,repo,json_extract(data,'$.createdAt') created,json_extract(data,'$.completedAt') completed,status,rowid serial,json_extract(data,'$.comment') comment
156 FROM runs WHERE (?1 IS NULL OR repo IN (SELECT value FROM json_each(?1)))
157 ORDER BY json_extract(data,'$.createdAt') DESC,rowid DESC LIMIT ?2 OFFSET ?3)
158 SELECT id,repo,created,completed,status,0,serial,comment FROM page UNION ALL
159 SELECT id,repo,json_extract(data,'$.createdAt'),json_extract(data,'$.completedAt'),status,1,rowid,json_extract(data,'$.comment') FROM runs
160 WHERE id=?4 AND id NOT IN (SELECT id FROM page)
161 AND (?1 IS NULL OR repo IN (SELECT value FROM json_each(?1))) ORDER BY 3 DESC,7 DESC",
162 )?;
163 let runs = statement
164 .query_map(params![repos, limit, offset, pinned], |row| {
165 Ok(RunSummary {
166 id: row.get(0)?,
167 repo_path: PathBuf::from(row.get::<_, String>(1)?),
168 created_at: row.get(2)?,
169 completed_at: row.get(3)?,
170 status: row.get(4)?,
171 comment: row
174 .get::<_, Option<String>>(7)?
175 .and_then(|comment| comment.parse().ok()),
176 counts: BTreeMap::new(),
177 unreadable: Vec::new(),
178 })
179 })?
180 .collect::<Result<Vec<_>, _>>()?;
181 crate::workspace::outside_workspaces(runs.iter().map(|run| run.repo_path.as_path()), state)
182 .map_err(|error| Error::Invalid(error.to_string()))?;
183 Ok(runs)
184}
185
186fn count(
188 db: &rusqlite::Connection,
189 runs: &mut [RunSummary],
190 invalid: &[String],
191) -> Result<(), Error> {
192 let ids: Vec<_> = runs.iter().map(|run| run.id.clone()).collect();
193 let mut counts: BTreeMap<crate::types::RunId, BTreeMap<crate::types::RequestStatus, u64>> =
194 BTreeMap::new();
195 let mut statement = db.prepare_cached(
196 "SELECT run_id,status,count(*) FROM requests WHERE run_id IN (SELECT value FROM json_each(?1)) AND id NOT IN (SELECT value FROM json_each(?2)) GROUP BY run_id,status",
197 )?;
198 let rows = statement.query_map(
199 params![
200 serde_json::to_string(&ids)?,
201 serde_json::to_string(invalid)?
202 ],
203 |row| {
204 Ok((
205 row.get::<_, crate::types::RunId>(0)?,
206 row.get::<_, crate::types::RequestStatus>(1)?,
207 row.get::<_, i64>(2)?,
208 ))
209 },
210 )?;
211 for row in rows {
212 let (run, status, count) = row?;
213 let count = u64::try_from(count).expect("COUNT(*) is nonnegative");
214 counts.entry(run).or_default().insert(status, count);
215 }
216 for run in runs {
217 run.counts = counts.remove(&run.id).unwrap_or_default();
218 }
219 Ok(())
220}