Skip to main content

artifactize_core/store/
requests.rs

1use std::path::Path;
2
3use serde::Serialize;
4
5use tokio_rusqlite::Connection;
6
7use super::{
8    DATABASE, Execution, HumanClaim, Request,
9    receipts::{Error, check_files, schema_initialized},
10};
11use crate::workspace::{canonical_target, outside_workspace};
12
13#[derive(Debug, Clone, Serialize)]
14pub struct RequestView {
15    #[serde(flatten)]
16    pub request: Request,
17    pub claim: Option<HumanClaim>,
18    pub execution: Option<Execution>,
19    pub definition: Option<RequestDefinition>,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct RequestDefinition {
24    pub eval: super::definitions::Eval,
25    pub artifact: super::definitions::Artifact,
26}
27
28#[derive(Default)]
29struct Filter<'a> {
30    run: Option<&'a str>,
31    id: Option<&'a str>,
32    repo: Option<&'a Path>,
33    waiting: bool,
34    session: Option<&'a str>,
35    statuses: &'a [crate::types::RequestStatus],
36    /// A bounded slice of the ordered requests: offset and count.
37    slice: Option<(u64, u32)>,
38}
39
40pub async fn read_requests(state: &Path, run: Option<&str>) -> Result<Vec<RequestView>, String> {
41    read(
42        state,
43        Filter {
44            run,
45            ..Filter::default()
46        },
47    )
48    .await
49}
50
51pub async fn read_requests_in(
52    state: &Path,
53    run: Option<&str>,
54    repo: Option<&Path>,
55) -> Result<Vec<RequestView>, String> {
56    read(
57        state,
58        Filter {
59            run,
60            repo,
61            ..Filter::default()
62        },
63    )
64    .await
65}
66
67pub async fn read_request(state: &Path, id: &str) -> Result<RequestView, String> {
68    read(
69        state,
70        Filter {
71            id: Some(id),
72            ..Filter::default()
73        },
74    )
75    .await?
76    .pop()
77    .ok_or_else(|| "Review request not found.".into())
78}
79
80/// The request whose Agent review ran as session `id`, if this state has it.
81pub async fn read_session_request(state: &Path, id: &str) -> Result<Option<RequestView>, String> {
82    Ok(read(
83        state,
84        Filter {
85            session: Some(id),
86            ..Filter::default()
87        },
88    )
89    .await?
90    .pop())
91}
92
93/// The request that owns `view`'s shared execution: a follower of another Run joins the
94/// original waiting request, which alone records the Human definition (tools and owner
95/// schemas), the claim and the result. `view` itself when it is the original.
96pub async fn read_original(state: &Path, view: &RequestView) -> Result<RequestView, String> {
97    let original = view
98        .execution
99        .as_ref()
100        .map(|execution| &execution.provenance.request_id)
101        .or_else(|| {
102            view.request
103                .provenance
104                .as_ref()
105                .map(|source| &source.request_id)
106        });
107    match original.filter(|id| *id != &view.request.id) {
108        Some(id) => read_request(state, id).await,
109        None => Ok(view.clone()),
110    }
111}
112
113/// One entry per waiting Human sign-off: followers collapse onto the original request that
114/// owns their shared execution, at the position of the first entry that names it.
115pub async fn read_waiting_originals(
116    state: &Path,
117    repo: Option<&Path>,
118) -> Result<Vec<RequestView>, String> {
119    let waiting = read_waiting(state, repo).await?;
120    let by_id: std::collections::BTreeMap<_, _> = waiting
121        .iter()
122        .map(|view| (&view.request.id, view))
123        .collect();
124    let mut seen = std::collections::BTreeSet::new();
125    let mut originals: Vec<RequestView> = Vec::with_capacity(waiting.len());
126    for view in &waiting {
127        let source = view
128            .execution
129            .as_ref()
130            .map_or(&view.request.id, |execution| {
131                &execution.provenance.request_id
132            });
133        if seen.contains(source) {
134            continue;
135        }
136        let original = match by_id.get(source) {
137            Some(original) => Some((*original).clone()),
138            None => read_original(state, view).await.ok(),
139        };
140        // A follower whose original is gone or no longer waits stays listed as itself.
141        let original = original
142            .filter(|original| original.request.status().is_human_wait())
143            .unwrap_or_else(|| view.clone());
144        if seen.insert(original.request.id.clone()) {
145            originals.push(original);
146        }
147    }
148    Ok(originals)
149}
150
151/// Up to `limit` requests with one of `statuses` (any status when empty) from position
152/// `offset` of every Run's requests, newest Run first, each with its Run's repository.
153pub async fn read_request_batch(
154    state: &Path,
155    statuses: &[crate::types::RequestStatus],
156    offset: u64,
157    limit: u32,
158) -> Result<Vec<(RequestView, std::path::PathBuf)>, String> {
159    read_with_repo(
160        state,
161        Filter {
162            statuses,
163            slice: Some((offset, limit)),
164            ..Filter::default()
165        },
166    )
167    .await
168}
169
170/// WAITING_HUMAN requests, newest Run first, from the canonical `repo`'s Runs or every Run.
171pub async fn read_waiting(state: &Path, repo: Option<&Path>) -> Result<Vec<RequestView>, String> {
172    read(
173        state,
174        Filter {
175            repo,
176            waiting: true,
177            ..Filter::default()
178        },
179    )
180    .await
181}
182
183async fn read(state: &Path, filter: Filter<'_>) -> Result<Vec<RequestView>, String> {
184    Ok(read_with_repo(state, filter)
185        .await?
186        .into_iter()
187        .map(|(view, _)| view)
188        .collect())
189}
190
191/// Human action setup needs only the owning repository, not another full Run/request audit.
192pub async fn read_request_repo(
193    state: &Path,
194    id: &str,
195) -> Result<(RequestView, std::path::PathBuf), String> {
196    read_with_repo(
197        state,
198        Filter {
199            id: Some(id),
200            ..Filter::default()
201        },
202    )
203    .await?
204    .pop()
205    .ok_or_else(|| "Review request not found.".into())
206}
207async fn read_with_repo(
208    state: &Path,
209    filter: Filter<'_>,
210) -> Result<Vec<(RequestView, std::path::PathBuf)>, String> {
211    let state = canonical_target(state).map_err(|e| e.to_string())?;
212    let repo = filter
213        .repo
214        .map(canonical_target)
215        .transpose()
216        .map_err(|e| e.to_string())?;
217    if let Some(repo) = &repo {
218        outside_workspace(repo, &state).map_err(|e| e.to_string())?;
219    }
220    check_files(&state)?;
221    if !state
222        .join(DATABASE)
223        .try_exists()
224        .map_err(|e| e.to_string())?
225    {
226        return Ok(Vec::new());
227    }
228    let run = filter
229        .run
230        .map(str::parse::<crate::types::RunId>)
231        .transpose()?;
232    let id = filter
233        .id
234        .map(str::parse::<crate::types::RequestId>)
235        .transpose()?;
236    let repo = repo.map(|repo| crate::platform::path_text(&repo));
237    let waiting = filter.waiting;
238    let statuses = filter.statuses.to_vec();
239    let slice = filter.slice;
240    let session = filter
241        .session
242        .map(str::parse::<crate::types::SessionId>)
243        .transpose()?;
244    let connection = Connection::open_with_flags(
245        state.join(DATABASE),
246        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
247    )
248    .await
249    .map_err(|e| e.to_string())?;
250    let result = connection
251        .call(move |db| -> Result<_, Error> {
252            db.busy_timeout(super::SQLITE_BUSY_TIMEOUT)?;
253            let transaction = db.transaction()?;
254            if !schema_initialized(&transaction)? {
255                return Ok(Vec::new());
256            }
257            let filter = Query {
258                run,
259                id,
260                repo,
261                waiting,
262                session,
263                statuses,
264                slice,
265            };
266            let rows = rows(&transaction, &filter)?;
267            let ids: std::collections::BTreeSet<_> = rows.iter().map(|row| &row.run).collect();
268            let mut statement = transaction.prepare(
269                "SELECT id,repo,data FROM runs WHERE id IN (SELECT value FROM json_each(?))",
270            )?;
271            let runs = statement
272                .query_map([serde_json::to_string(&ids)?], |row| {
273                    Ok((
274                        row.get::<_, crate::types::RunId>(0)?,
275                        row.get::<_, String>(1)?,
276                        row.get::<_, String>(2)?,
277                    ))
278                })?
279                .collect::<Result<Vec<_>, _>>()?;
280            let mut indexes = std::collections::BTreeMap::new();
281            let mut repos = Vec::new();
282            for (id, repo, data) in runs {
283                let Some(run) = super::unreadable::evidence::<super::Run>("run", &data) else {
284                    continue;
285                };
286                repos.push(std::path::PathBuf::from(repo));
287                indexes.insert(id, (DefinitionIndex::new(&run), run.repo_path));
288            }
289            crate::workspace::outside_workspaces(repos.iter().map(|repo| repo.as_path()), &state)
290                .map_err(|error| Error::Invalid(error.to_string()))?;
291            let views = rows
292                .into_iter()
293                .filter_map(|row| {
294                    indexes
295                        .get(&row.run)
296                        .and_then(|(index, repo)| row.view(index).map(|view| (view, repo.clone())))
297                })
298                .collect();
299            drop(statement);
300            transaction.commit()?;
301            Ok(views)
302        })
303        .await
304        .map_err(|e| e.to_string());
305    super::connection::finish(connection, result).await
306}
307
308/// Only present predicates are emitted, so Run and request lookups use their existing indexes.
309#[derive(Default)]
310pub(super) struct Query {
311    pub run: Option<crate::types::RunId>,
312    pub id: Option<crate::types::RequestId>,
313    pub repo: Option<String>,
314    pub waiting: bool,
315    pub session: Option<crate::types::SessionId>,
316    pub statuses: Vec<crate::types::RequestStatus>,
317    pub slice: Option<(u64, u32)>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub(super) struct Row {
322    pub id: crate::types::RequestId,
323    pub run: crate::types::RunId,
324    pub eval: crate::types::EvalId,
325    pub ordinal: i64,
326    pub data: String,
327    pub execution: Option<String>,
328    pub execution_id: Option<crate::types::ExecutionId>,
329    pub claim: Option<(
330        crate::types::RequestId,
331        crate::types::ReviewerId,
332        crate::types::Timestamp,
333    )>,
334}
335
336pub(super) fn rows(db: &rusqlite::Connection, filter: &Query) -> Result<Vec<Row>, Error> {
337    let mut predicates = Vec::new();
338    let mut values: Vec<rusqlite::types::Value> = Vec::new();
339    for (column, value) in [
340        ("q.run_id", filter.run.as_ref().map(ToString::to_string)),
341        ("q.id", filter.id.as_ref().map(ToString::to_string)),
342        ("r.repo", filter.repo.clone()),
343        (
344            "json_extract(q.data,'$.sessionId')",
345            filter.session.as_ref().map(ToString::to_string),
346        ),
347    ] {
348        if let Some(value) = value {
349            predicates.push(format!("{column}=?"));
350            values.push(value.into());
351        }
352    }
353    if filter.waiting {
354        predicates.push("q.status IN ('WAITING_HUMAN','DEFERRED')".into());
355    }
356    if !filter.statuses.is_empty() {
357        let marks = vec!["?"; filter.statuses.len()].join(",");
358        predicates.push(format!("q.status IN ({marks})"));
359        values.extend(
360            filter
361                .statuses
362                .iter()
363                .map(|status| rusqlite::types::Value::from(status.as_str().to_owned())),
364        );
365    }
366    let predicate = if predicates.is_empty() {
367        String::new()
368    } else {
369        format!("WHERE {}", predicates.join(" AND "))
370    };
371    let limit = match filter.slice {
372        Some((offset, count)) => {
373            values.push(i64::from(count).into());
374            values.push(i64::try_from(offset).unwrap_or(i64::MAX).into());
375            " LIMIT ? OFFSET ?"
376        }
377        None => "",
378    };
379    let order = if filter.run.is_some() {
380        "q.ordinal"
381    } else {
382        "r.rowid DESC,q.ordinal"
383    };
384    let mut statement = db.prepare_cached(&format!(
385        "SELECT q.id,q.run_id,q.data,e.data,h.id,h.claimed_by,h.claimed_at,q.eval_id,q.ordinal,e.id
386        FROM requests q JOIN runs r ON r.id=q.run_id
387        LEFT JOIN executions e ON e.id=q.execution_id
388        LEFT JOIN requests h ON h.id=json_extract(e.data,'$.provenance.requestId') AND h.claimed_by IS NOT NULL
389        {predicate} ORDER BY {order}{limit}"
390    ))?;
391    Ok(statement
392        .query_map(rusqlite::params_from_iter(values), |row| {
393            let id = row.get::<_, Option<crate::types::RequestId>>(4)?;
394            let reviewer = row.get::<_, Option<crate::types::ReviewerId>>(5)?;
395            let at = row.get::<_, Option<crate::types::Timestamp>>(6)?;
396            Ok(Row {
397                id: row.get(0)?,
398                run: row.get(1)?,
399                eval: row.get(7)?,
400                ordinal: row.get(8)?,
401                data: row.get(2)?,
402                execution: row.get(3)?,
403                execution_id: row.get(9)?,
404                claim: id
405                    .zip(reviewer)
406                    .zip(at)
407                    .map(|((id, reviewer), at)| (id, reviewer, at)),
408            })
409        })?
410        .collect::<Result<_, _>>()?)
411}
412
413/// Saved definitions are decoded/indexed once per Run, never once per request. First duplicate
414/// Eval wins, matching SQLite's former json_each lookup; missing/null definitions remain absent.
415pub(super) struct DefinitionIndex {
416    artifacts: std::collections::BTreeMap<crate::types::ArtifactName, super::definitions::Artifact>,
417    evals: std::collections::BTreeMap<crate::types::EvalId, super::definitions::Eval>,
418}
419impl DefinitionIndex {
420    pub fn new(run: &super::Run) -> Self {
421        let mut evals = std::collections::BTreeMap::new();
422        if let Some(graph) = run.definitions.graph() {
423            for eval in graph.evals() {
424                evals.entry(eval.id.clone()).or_insert_with(|| eval.clone());
425            }
426        }
427        Self {
428            artifacts: run
429                .definitions
430                .graph()
431                .map(|graph| {
432                    graph
433                        .artifacts()
434                        .map(|(id, artifact)| (id.clone(), artifact.clone()))
435                        .collect()
436                })
437                .unwrap_or_default(),
438            evals,
439        }
440    }
441}
442impl Row {
443    pub fn view(&self, index: &DefinitionIndex) -> Option<RequestView> {
444        let request = super::unreadable::evidence::<Request>("request", &self.data)?;
445        let execution = self
446            .execution
447            .as_deref()
448            .and_then(|data| super::unreadable::evidence("execution", data));
449        Some(self.with_evidence(index, request, execution))
450    }
451    pub fn view_cached(
452        &self,
453        index: &DefinitionIndex,
454        audit: &mut super::unreadable::Audit,
455    ) -> Option<RequestView> {
456        let request = audit
457            .take_request(&self.id, &self.data)
458            .or_else(|| super::unreadable::evidence("request", &self.data))?;
459        let execution = self.execution.as_deref().and_then(|data| {
460            self.execution_id
461                .as_ref()
462                .and_then(|id| audit.take_execution(id, data))
463                .or_else(|| super::unreadable::evidence("execution", data))
464        });
465        Some(self.with_evidence(index, request, execution))
466    }
467    fn with_evidence(
468        &self,
469        index: &DefinitionIndex,
470        request: Request,
471        execution: Option<Execution>,
472    ) -> RequestView {
473        let definition = index
474            .evals
475            .get(&self.eval)
476            .zip(index.artifacts.get(&request.target))
477            .map(|(eval, artifact)| RequestDefinition {
478                eval: eval.clone(),
479                artifact: artifact.clone(),
480            });
481        RequestView {
482            request,
483            definition,
484            execution,
485            claim: self.claim.as_ref().map(|(id, reviewer, at)| HumanClaim {
486                request_id: id.clone(),
487                reviewer: reviewer.clone(),
488                claimed_at: *at,
489            }),
490        }
491    }
492}
493
494/// Both selected-Run view shapes from the same short read transaction and decoded rows.
495pub async fn read_run_requests(
496    state: &Path,
497    id: &str,
498) -> Result<(super::RunView, Vec<RequestView>), String> {
499    let state = state.to_path_buf();
500    let id: crate::types::RunId = id.parse()?;
501    crate::task::joined(
502        tokio::task::spawn_blocking(move || {
503            let mut reader = super::reader::Reader::default();
504            reader
505                .snapshot(&state, |db, cache, state| {
506                    cache.selected(db, state, &id, None, true)
507                })?
508                .ok_or_else(|| "Run not found.".into())
509        })
510        .await,
511    )
512    .map_err(|error| error.to_string())?
513}