1use std::collections::HashMap;
4
5use utoipa::ToSchema;
6
7use crate::{
8 agent_changeset_entities::AgentChangesetEntityOperation,
9 agent_changesets::AgentChangesetStatus,
10 library::entity_snapshots::{ContentEntityType, EntityKey, EntityRef, EntitySnapshot},
11 prelude::*,
12};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct NewEntityChange {
17 pub key: EntityKey,
18 pub label: String,
20 pub before_json: Option<String>,
22 pub after_digest: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct EntityChange {
30 pub entity_type: ContentEntityType,
31 pub entity_id: Uuid,
32 pub label: String,
33 pub before: Option<EntitySnapshot>,
35 pub after_digest: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct AgentChangesetCall {
43 pub id: Uuid,
44 pub changeset_id: Uuid,
45 pub tool_name: String,
46 pub entity_changes: Vec<EntityChange>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CallActor {
52 pub user_id: Uuid,
53 pub oauth_client_id: Uuid,
54 pub oauth_grant_id: Uuid,
55 pub client_name: String,
56}
57
58#[derive(Debug, Clone)]
60pub struct NewAgentChangesetCall<'a> {
61 pub changeset_id: Uuid,
62 pub actor: &'a CallActor,
63 pub tool_name: &'a str,
64 pub ops: &'a serde_json::Value,
65 pub summary: Option<&'a str>,
66 pub entity_changes: &'a [NewEntityChange],
67 pub revert_of_call_id: Option<Uuid>,
68}
69
70pub async fn insert(conn: &mut PgConnection, call: NewAgentChangesetCall<'_>) -> ModelResult<Uuid> {
72 let id = sqlx::query_scalar!(
73 r#"
74INSERT INTO agent_changeset_calls (
75 changeset_id,
76 oauth_client_id,
77 oauth_grant_id,
78 client_name,
79 tool_name,
80 ops,
81 summary,
82 revert_of_call_id
83 )
84VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
85RETURNING id
86 "#,
87 call.changeset_id,
88 call.actor.oauth_client_id,
89 call.actor.oauth_grant_id,
90 call.actor.client_name,
91 call.tool_name,
92 call.ops,
93 call.summary,
94 call.revert_of_call_id,
95 )
96 .fetch_one(&mut *conn)
97 .await?;
98 let mut entity_types = Vec::with_capacity(call.entity_changes.len());
99 let mut entity_ids = Vec::with_capacity(call.entity_changes.len());
100 let mut labels = Vec::with_capacity(call.entity_changes.len());
101 let mut befores = Vec::with_capacity(call.entity_changes.len());
102 let mut after_digests = Vec::with_capacity(call.entity_changes.len());
103 for change in call.entity_changes {
104 entity_types.push(change.key.entity_type);
105 entity_ids.push(change.key.entity_id);
106 labels.push(change.label.as_str());
107 befores.push(change.before_json.as_deref());
108 after_digests.push(change.after_digest.as_deref());
109 }
110 sqlx::query!(
111 r#"
112INSERT INTO agent_changeset_call_entities (
113 call_id,
114 entity_type,
115 entity_id,
116 label,
117 before_snapshot,
118 after_digest
119 )
120SELECT $1,
121 k.entity_type,
122 k.entity_id,
123 k.label,
124 k.before::jsonb,
125 k.after_digest
126FROM UNNEST(
127 $2::content_entity_type [],
128 $3::uuid [],
129 $4::text [],
130 $5::text [],
131 $6::text []
132 ) AS k(entity_type, entity_id, label, before, after_digest)
133 "#,
134 id,
135 entity_types as Vec<ContentEntityType>,
136 &entity_ids,
137 &labels as &[&str],
138 &befores as &[Option<&str>],
139 &after_digests as &[Option<&str>],
140 )
141 .execute(conn)
142 .await?;
143 Ok(id)
144}
145
146pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<AgentChangesetCall> {
148 let call = sqlx::query!(
149 r#"
150SELECT id,
151 changeset_id,
152 tool_name
153FROM agent_changeset_calls
154WHERE id = $1
155 "#,
156 id,
157 )
158 .fetch_one(&mut *conn)
159 .await?;
160 let entity_changes = sqlx::query!(
161 r#"
162SELECT entity_type,
163 entity_id,
164 label,
165 before_snapshot::text AS "before_snapshot?",
166 after_digest
167FROM agent_changeset_call_entities
168WHERE call_id = $1
169 "#,
170 id,
171 )
172 .fetch_all(conn)
173 .await?
174 .into_iter()
175 .map(|row| {
176 Ok(EntityChange {
177 entity_type: row.entity_type,
178 entity_id: row.entity_id,
179 label: row.label,
180 before: row
181 .before_snapshot
182 .map(|json| EntitySnapshot::from_json(row.entity_type, &json))
183 .transpose()?,
184 after_digest: row.after_digest,
185 })
186 })
187 .collect::<ModelResult<_>>()?;
188 Ok(AgentChangesetCall {
189 id: call.id,
190 changeset_id: call.changeset_id,
191 tool_name: call.tool_name,
192 entity_changes,
193 })
194}
195
196pub async fn get_latest_by_entity(
198 conn: &mut PgConnection,
199 changeset_id: Uuid,
200) -> ModelResult<HashMap<EntityKey, Uuid>> {
201 let rows = sqlx::query!(
202 r#"
203SELECT DISTINCT ON (ce.entity_type, ce.entity_id) ce.entity_type,
204 ce.entity_id,
205 ce.call_id
206FROM agent_changeset_call_entities ce
207 JOIN agent_changeset_calls c ON c.id = ce.call_id
208WHERE c.changeset_id = $1
209ORDER BY ce.entity_type,
210 ce.entity_id,
211 c.created_at DESC,
212 c.id DESC
213 "#,
214 changeset_id,
215 )
216 .fetch_all(conn)
217 .await?;
218 Ok(rows
219 .into_iter()
220 .map(|row| (EntityKey::new(row.entity_type, row.entity_id), row.call_id))
221 .collect())
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
226pub struct AgentChangesetCallSummary {
227 pub id: Uuid,
228 pub created_at: DateTime<Utc>,
229 pub client_name: String,
230 pub tool_name: String,
231 pub summary: Option<String>,
232 pub revert_of_call_id: Option<Uuid>,
233}
234
235pub async fn get_summaries_by_changeset_id(
237 conn: &mut PgConnection,
238 changeset_id: Uuid,
239) -> ModelResult<Vec<AgentChangesetCallSummary>> {
240 let res = sqlx::query_as!(
241 AgentChangesetCallSummary,
242 r#"
243SELECT id,
244 created_at,
245 client_name,
246 tool_name,
247 summary,
248 revert_of_call_id
249FROM agent_changeset_calls
250WHERE changeset_id = $1
251ORDER BY created_at,
252 id
253 "#,
254 changeset_id,
255 )
256 .fetch_all(conn)
257 .await?;
258 Ok(res)
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct CallHistoryEntity {
264 #[serde(flatten)]
265 pub entity: EntityRef,
266 pub operation: AgentChangesetEntityOperation,
267}
268
269#[derive(Debug, Clone, PartialEq)]
271pub struct CallHistoryEntry {
272 pub id: Uuid,
273 pub created_at: DateTime<Utc>,
274 pub changeset_id: Uuid,
275 pub changeset_status: AgentChangesetStatus,
276 pub client_name: String,
277 pub tool_name: String,
278 pub summary: Option<String>,
279 pub revert_of_call_id: Option<Uuid>,
280 pub entities: Vec<CallHistoryEntity>,
281}
282
283pub async fn get_history(
286 conn: &mut PgConnection,
287 user_id: Uuid,
288 course_id: Uuid,
289 entity_id: Option<Uuid>,
290 limit: i64,
291) -> ModelResult<Vec<CallHistoryEntry>> {
292 let calls = sqlx::query!(
293 r#"
294SELECT c.id,
295 c.created_at,
296 c.changeset_id,
297 cs.status AS "changeset_status: AgentChangesetStatus",
298 cs.expires_at AS changeset_expires_at,
299 c.client_name,
300 c.tool_name,
301 c.summary,
302 c.revert_of_call_id
303FROM agent_changeset_calls c
304 JOIN agent_changesets cs ON cs.id = c.changeset_id
305WHERE cs.user_id = $1
306 AND cs.course_id = $2
307 AND (
308 $3::uuid IS NULL
309 OR EXISTS (
310 SELECT 1
311 FROM agent_changeset_call_entities ce
312 WHERE ce.call_id = c.id
313 AND ce.entity_id = $3
314 )
315 )
316ORDER BY c.created_at DESC,
317 c.id
318LIMIT $4
319 "#,
320 user_id,
321 course_id,
322 entity_id,
323 limit,
324 )
325 .fetch_all(&mut *conn)
326 .await?;
327 let call_ids: Vec<Uuid> = calls.iter().map(|call| call.id).collect();
328 let mut entities_by_call: HashMap<Uuid, Vec<CallHistoryEntity>> = HashMap::new();
329 for row in sqlx::query!(
330 r#"
331SELECT call_id,
332 entity_type,
333 entity_id,
334 label,
335 before_snapshot IS NOT NULL AS "has_before!",
336 after_digest IS NOT NULL AS "has_after!"
337FROM agent_changeset_call_entities
338WHERE call_id = ANY($1)
339ORDER BY entity_type,
340 label,
341 entity_id
342 "#,
343 &call_ids,
344 )
345 .fetch_all(conn)
346 .await?
347 {
348 entities_by_call
349 .entry(row.call_id)
350 .or_default()
351 .push(CallHistoryEntity {
352 entity: EntityRef {
353 entity_type: row.entity_type,
354 entity_id: row.entity_id,
355 label: row.label,
356 },
357 operation: AgentChangesetEntityOperation::between(row.has_before, row.has_after),
358 });
359 }
360 let now = Utc::now();
361 Ok(calls
362 .into_iter()
363 .map(|call| CallHistoryEntry {
364 entities: entities_by_call.remove(&call.id).unwrap_or_default(),
365 id: call.id,
366 created_at: call.created_at,
367 changeset_id: call.changeset_id,
368 changeset_status: call.changeset_status.at(call.changeset_expires_at, now),
369 client_name: call.client_name,
370 tool_name: call.tool_name,
371 summary: call.summary,
372 revert_of_call_id: call.revert_of_call_id,
373 })
374 .collect())
375}