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