1use anyhow::{anyhow, bail, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::{Deserialize, Serialize};
4
5use super::{ObjectRef, ScopeObjectKind};
6
7const DEFAULT_ROUTING_CONFIDENCE: f64 = 1.0;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum TargetProjectUpdate {
11 Preserve,
12 Clear,
13 Set(String),
14}
15
16#[derive(Debug, Clone)]
17pub struct RerouteRequest<'a> {
18 pub refs: &'a [ObjectRef],
19 pub owner_scope: &'a str,
20 pub owner_key: &'a str,
21 pub target_project: TargetProjectUpdate,
22 pub topic_domain: Option<&'a str>,
23 pub context_class: Option<&'a str>,
24 pub routing_confidence: Option<f64>,
25 pub reason: Option<&'a str>,
26 pub dry_run: bool,
27 pub confirm: bool,
28}
29
30#[derive(Debug, Clone)]
31pub struct ArchiveRequest<'a> {
32 pub refs: &'a [ObjectRef],
33 pub reason: Option<&'a str>,
34 pub dry_run: bool,
35 pub confirm: bool,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct ScopeMutationResult {
40 pub dry_run: bool,
41 pub action: String,
42 pub affected: Vec<ObjectMutation>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ObjectMutation {
47 pub object_ref: String,
48 pub title: String,
49 pub previous_status: String,
50 pub new_status: String,
51 pub previous_owner: OwnerSnapshot,
52 pub new_owner: OwnerSnapshot,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct OwnerSnapshot {
57 pub source_project: Option<String>,
58 pub target_project: Option<String>,
59 pub owner_scope: Option<String>,
60 pub owner_key: Option<String>,
61 pub topic_domain: Option<String>,
62 pub routing_confidence: Option<f64>,
63 pub routing_reason: Option<String>,
64 pub context_class: Option<String>,
65}
66
67pub fn reroute_objects(conn: &Connection, req: &RerouteRequest<'_>) -> Result<ScopeMutationResult> {
68 ensure_refs(req.refs)?;
69 let (owner_scope, owner_key) = normalize_owner(req.owner_scope, req.owner_key)?;
70 let reason = normalized_reason(req.reason);
71 let dry_run = req.dry_run || !req.confirm;
72 let tx = conn.unchecked_transaction()?;
73 let targets = load_targets(&tx, req.refs)?;
74 let preference_ids = preference_memory_ids(&targets);
75 if !dry_run {
76 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &preference_ids)?;
77 }
78 let now = chrono::Utc::now().timestamp();
79 let mut affected = Vec::with_capacity(targets.len());
80
81 for target in targets {
82 let new_owner = OwnerSnapshot {
83 source_project: target
84 .owner
85 .source_project
86 .clone()
87 .or_else(|| target.project.clone()),
88 target_project: target_project_after(
89 &target.owner.target_project,
90 &req.target_project,
91 )?,
92 owner_scope: Some(owner_scope.clone()),
93 owner_key: Some(owner_key.clone()),
94 topic_domain: req
95 .topic_domain
96 .map(str::to_string)
97 .or_else(|| target.owner.topic_domain.clone()),
98 routing_confidence: Some(req.routing_confidence.unwrap_or(DEFAULT_ROUTING_CONFIDENCE)),
99 routing_reason: reason
100 .clone()
101 .or_else(|| Some("manual scope cleanup reroute".to_string())),
102 context_class: req
103 .context_class
104 .map(str::to_string)
105 .or_else(|| default_context_class(&owner_scope).map(str::to_string))
106 .or_else(|| target.owner.context_class.clone()),
107 };
108 affected.push(ObjectMutation {
109 object_ref: target.object_ref.to_string(),
110 title: target.title.clone(),
111 previous_status: target.status.clone(),
112 new_status: target.status.clone(),
113 previous_owner: target.owner.clone(),
114 new_owner: new_owner.clone(),
115 });
116 if dry_run {
117 continue;
118 }
119 let previous_authority_project = target.preference_authority_project(&target.owner);
120 update_owner(&tx, target.object_ref, &new_owner, now)?;
121 let new_authority_project = target.preference_authority_project(&new_owner);
122 if let (Some(previous_project), Some(new_project)) =
123 (previous_authority_project, new_authority_project)
124 {
125 crate::memory::preference::reinforcement::reconcile_preference_project_change(
126 &tx,
127 target.object_ref.id,
128 &previous_project,
129 &new_project,
130 )?;
131 }
132 insert_scope_cleanup_event(
133 &tx,
134 "reroute",
135 &target,
136 &target.status,
137 &new_owner,
138 reason.as_deref(),
139 now,
140 )?;
141 }
142 if !dry_run {
143 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &preference_ids)?;
144 }
145 tx.commit()?;
146 Ok(ScopeMutationResult {
147 dry_run,
148 action: "reroute".to_string(),
149 affected,
150 })
151}
152
153pub fn archive_objects(conn: &Connection, req: &ArchiveRequest<'_>) -> Result<ScopeMutationResult> {
154 ensure_refs(req.refs)?;
155 let reason = normalized_reason(req.reason);
156 let dry_run = req.dry_run || !req.confirm;
157 let tx = conn.unchecked_transaction()?;
158 let targets = load_targets(&tx, req.refs)?;
159 let preference_ids = preference_memory_ids(&targets);
160 if !dry_run {
161 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &preference_ids)?;
162 }
163 let now = chrono::Utc::now().timestamp();
164 let mut affected = Vec::with_capacity(targets.len());
165
166 for target in targets {
167 let new_status = archive_status(target.object_ref.kind);
168 affected.push(ObjectMutation {
169 object_ref: target.object_ref.to_string(),
170 title: target.title.clone(),
171 previous_status: target.status.clone(),
172 new_status: new_status.to_string(),
173 previous_owner: target.owner.clone(),
174 new_owner: target.owner.clone(),
175 });
176 if dry_run {
177 continue;
178 }
179 update_status(&tx, target.object_ref, new_status, now)?;
180 insert_scope_cleanup_event(
181 &tx,
182 "archive",
183 &target,
184 new_status,
185 &target.owner,
186 reason.as_deref(),
187 now,
188 )?;
189 }
190 if !dry_run {
191 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &preference_ids)?;
192 }
193 tx.commit()?;
194 Ok(ScopeMutationResult {
195 dry_run,
196 action: "archive".to_string(),
197 affected,
198 })
199}
200
201fn ensure_refs(refs: &[ObjectRef]) -> Result<()> {
202 if refs.is_empty() {
203 bail!("at least one object ref is required");
204 }
205 Ok(())
206}
207
208fn normalize_owner(owner_scope: &str, owner_key: &str) -> Result<(String, String)> {
209 let owner_scope = owner_scope.trim();
210 let owner_key = owner_key.trim();
211 if owner_key.is_empty() {
212 bail!("owner-key must not be empty");
213 }
214 if !matches!(
215 owner_scope,
216 "user" | "workspace" | "repo" | "tool" | "domain" | "workstream" | "session"
217 ) {
218 bail!("unsupported owner-scope: {owner_scope}");
219 }
220 Ok((owner_scope.to_string(), owner_key.to_string()))
221}
222
223pub(super) fn normalized_reason(reason: Option<&str>) -> Option<String> {
224 reason
225 .map(str::trim)
226 .filter(|value| !value.is_empty())
227 .map(str::to_string)
228}
229
230fn default_context_class(owner_scope: &str) -> Option<&'static str> {
231 match owner_scope {
232 "repo" | "user" | "workspace" => Some("startup_core"),
233 "tool" | "domain" => Some("search_only"),
234 "workstream" => Some("task_relevant"),
235 "session" => Some("never_inject"),
236 _ => None,
237 }
238}
239
240fn archive_status(kind: ScopeObjectKind) -> &'static str {
241 match kind {
242 ScopeObjectKind::Memory => "archived",
243 ScopeObjectKind::Candidate => "discarded",
244 ScopeObjectKind::Workstream => "paused",
245 ScopeObjectKind::SessionSummary => "never_inject",
246 }
247}
248
249fn target_project_after(
250 previous: &Option<String>,
251 update: &TargetProjectUpdate,
252) -> Result<Option<String>> {
253 match update {
254 TargetProjectUpdate::Preserve => Ok(previous.clone()),
255 TargetProjectUpdate::Clear => Ok(None),
256 TargetProjectUpdate::Set(value) => {
257 let value = value.trim();
258 if value.is_empty() {
259 bail!("target-project must not be empty");
260 }
261 Ok(Some(value.to_string()))
262 }
263 }
264}
265
266#[derive(Debug, Clone)]
267pub(super) struct MutationTarget {
268 pub object_ref: ObjectRef,
269 pub project: Option<String>,
270 pub memory_type: Option<String>,
271 pub scope: Option<String>,
272 pub title: String,
273 pub status: String,
274 pub owner: OwnerSnapshot,
275}
276
277impl MutationTarget {
278 fn preference_authority_project(&self, owner: &OwnerSnapshot) -> Option<String> {
279 if self.memory_type.as_deref() != Some("preference")
280 || self.scope.as_deref().unwrap_or("project") == "global"
281 {
282 return None;
283 }
284 owner
285 .target_project
286 .as_deref()
287 .map(str::trim)
288 .filter(|project| !project.is_empty())
289 .map(str::to_string)
290 .or_else(|| {
291 (owner.owner_scope.as_deref() == Some("repo"))
292 .then(|| owner.owner_key.clone())
293 .flatten()
294 })
295 .or_else(|| self.project.clone())
296 }
297}
298
299fn preference_memory_ids(targets: &[MutationTarget]) -> Vec<i64> {
300 targets
301 .iter()
302 .filter(|target| target.memory_type.as_deref() == Some("preference"))
303 .map(|target| target.object_ref.id)
304 .collect()
305}
306
307fn load_targets(conn: &Connection, refs: &[ObjectRef]) -> Result<Vec<MutationTarget>> {
308 refs.iter()
309 .copied()
310 .map(|object_ref| load_target(conn, object_ref))
311 .collect()
312}
313
314pub(super) fn load_target(conn: &Connection, object_ref: ObjectRef) -> Result<MutationTarget> {
315 match object_ref.kind {
316 ScopeObjectKind::Memory => conn
317 .query_row(
318 "SELECT project, title, status, source_project, target_project,
319 owner_scope, owner_key, topic_domain, routing_confidence,
320 routing_reason, context_class, memory_type, scope
321 FROM memories WHERE id = ?1",
322 params![object_ref.id],
323 |row| {
324 Ok(MutationTarget {
325 object_ref,
326 project: row.get(0)?,
327 memory_type: row.get(11)?,
328 scope: row.get(12)?,
329 title: row.get(1)?,
330 status: row.get(2)?,
331 owner: owner_snapshot_from_row(row, 3)?,
332 })
333 },
334 )
335 .optional()?,
336 ScopeObjectKind::Workstream => conn
337 .query_row(
338 "SELECT project, title, status, source_project, target_project,
339 owner_scope, owner_key, topic_domain, routing_confidence,
340 routing_reason, context_class
341 FROM workstreams WHERE id = ?1",
342 params![object_ref.id],
343 |row| {
344 Ok(MutationTarget {
345 object_ref,
346 project: row.get(0)?,
347 memory_type: None,
348 scope: None,
349 title: row.get(1)?,
350 status: row.get(2)?,
351 owner: owner_snapshot_from_row(row, 3)?,
352 })
353 },
354 )
355 .optional()?,
356 ScopeObjectKind::Candidate => conn
357 .query_row(
358 "SELECT p.project_path, c.topic_key, c.review_status, c.source_project,
359 c.target_project, c.owner_scope, c.owner_key, c.topic_domain,
360 c.routing_confidence, c.routing_reason, c.context_class
361 FROM memory_candidates c
362 LEFT JOIN projects p ON p.id = c.project_id
363 WHERE c.id = ?1",
364 params![object_ref.id],
365 |row| {
366 Ok(MutationTarget {
367 object_ref,
368 project: row.get(0)?,
369 memory_type: None,
370 scope: None,
371 title: row.get(1)?,
372 status: row.get(2)?,
373 owner: owner_snapshot_from_row(row, 3)?,
374 })
375 },
376 )
377 .optional()?,
378 ScopeObjectKind::SessionSummary => conn
379 .query_row(
380 "SELECT project, COALESCE(request, memory_session_id, 'session summary'),
381 COALESCE(context_class, 'search_only'), source_project,
382 target_project, owner_scope, owner_key, topic_domain,
383 routing_confidence, routing_reason, context_class
384 FROM session_summaries WHERE id = ?1",
385 params![object_ref.id],
386 |row| {
387 Ok(MutationTarget {
388 object_ref,
389 project: row.get(0)?,
390 memory_type: None,
391 scope: None,
392 title: row.get(1)?,
393 status: row.get(2)?,
394 owner: owner_snapshot_from_row(row, 3)?,
395 })
396 },
397 )
398 .optional()?,
399 }
400 .ok_or_else(|| anyhow!("{} not found", object_ref))
401}
402
403fn owner_snapshot_from_row(
404 row: &rusqlite::Row<'_>,
405 offset: usize,
406) -> rusqlite::Result<OwnerSnapshot> {
407 Ok(OwnerSnapshot {
408 source_project: row.get(offset)?,
409 target_project: row.get(offset + 1)?,
410 owner_scope: row.get(offset + 2)?,
411 owner_key: row.get(offset + 3)?,
412 topic_domain: row.get(offset + 4)?,
413 routing_confidence: row.get(offset + 5)?,
414 routing_reason: row.get(offset + 6)?,
415 context_class: row.get(offset + 7)?,
416 })
417}
418
419fn update_owner(
420 conn: &Connection,
421 object_ref: ObjectRef,
422 owner: &OwnerSnapshot,
423 now: i64,
424) -> Result<()> {
425 let updated = match object_ref.kind {
426 ScopeObjectKind::Memory => conn.execute(
427 "UPDATE memories
428 SET source_project = ?1, target_project = ?2, owner_scope = ?3,
429 owner_key = ?4, topic_domain = ?5, routing_confidence = ?6,
430 routing_reason = ?7, context_class = ?8, updated_at_epoch = ?9
431 WHERE id = ?10",
432 params![
433 owner.source_project.as_deref(),
434 owner.target_project.as_deref(),
435 owner.owner_scope.as_deref(),
436 owner.owner_key.as_deref(),
437 owner.topic_domain.as_deref(),
438 owner.routing_confidence,
439 owner.routing_reason.as_deref(),
440 owner.context_class.as_deref(),
441 now,
442 object_ref.id
443 ],
444 )?,
445 ScopeObjectKind::Workstream => conn.execute(
446 "UPDATE workstreams
447 SET source_project = ?1, target_project = ?2, owner_scope = ?3,
448 owner_key = ?4, topic_domain = ?5, routing_confidence = ?6,
449 routing_reason = ?7, context_class = ?8, updated_at_epoch = ?9
450 WHERE id = ?10",
451 params![
452 owner.source_project.as_deref(),
453 owner.target_project.as_deref(),
454 owner.owner_scope.as_deref(),
455 owner.owner_key.as_deref(),
456 owner.topic_domain.as_deref(),
457 owner.routing_confidence,
458 owner.routing_reason.as_deref(),
459 owner.context_class.as_deref(),
460 now,
461 object_ref.id
462 ],
463 )?,
464 ScopeObjectKind::Candidate => conn.execute(
465 "UPDATE memory_candidates
466 SET source_project = ?1, target_project = ?2, owner_scope = ?3,
467 owner_key = ?4, topic_domain = ?5, routing_confidence = ?6,
468 routing_reason = ?7, context_class = ?8, updated_at_epoch = ?9
469 WHERE id = ?10",
470 params![
471 owner.source_project.as_deref(),
472 owner.target_project.as_deref(),
473 owner.owner_scope.as_deref(),
474 owner.owner_key.as_deref(),
475 owner.topic_domain.as_deref(),
476 owner.routing_confidence,
477 owner.routing_reason.as_deref(),
478 owner.context_class.as_deref(),
479 now,
480 object_ref.id
481 ],
482 )?,
483 ScopeObjectKind::SessionSummary => conn.execute(
484 "UPDATE session_summaries
485 SET source_project = ?1, target_project = ?2, owner_scope = ?3,
486 owner_key = ?4, topic_domain = ?5, routing_confidence = ?6,
487 routing_reason = ?7, context_class = ?8
488 WHERE id = ?9",
489 params![
490 owner.source_project.as_deref(),
491 owner.target_project.as_deref(),
492 owner.owner_scope.as_deref(),
493 owner.owner_key.as_deref(),
494 owner.topic_domain.as_deref(),
495 owner.routing_confidence,
496 owner.routing_reason.as_deref(),
497 owner.context_class.as_deref(),
498 object_ref.id
499 ],
500 )?,
501 };
502 if updated != 1 {
503 bail!("failed to update owner for {}", object_ref);
504 }
505 Ok(())
506}
507
508fn update_status(
509 conn: &Connection,
510 object_ref: ObjectRef,
511 new_status: &str,
512 now: i64,
513) -> Result<()> {
514 let updated = match object_ref.kind {
515 ScopeObjectKind::Memory => conn.execute(
516 "UPDATE memories SET status = ?1, updated_at_epoch = ?2 WHERE id = ?3",
517 params![new_status, now, object_ref.id],
518 )?,
519 ScopeObjectKind::Workstream => conn.execute(
520 "UPDATE workstreams SET status = ?1, updated_at_epoch = ?2 WHERE id = ?3",
521 params![new_status, now, object_ref.id],
522 )?,
523 ScopeObjectKind::Candidate => conn.execute(
524 "UPDATE memory_candidates SET review_status = ?1, updated_at_epoch = ?2 WHERE id = ?3",
525 params![new_status, now, object_ref.id],
526 )?,
527 ScopeObjectKind::SessionSummary => conn.execute(
528 "UPDATE session_summaries SET context_class = ?1 WHERE id = ?2",
529 params![new_status, object_ref.id],
530 )?,
531 };
532 if updated != 1 {
533 bail!("failed to update status for {}", object_ref);
534 }
535 Ok(())
536}
537
538pub(super) fn insert_scope_cleanup_event(
539 conn: &Connection,
540 action: &str,
541 target: &MutationTarget,
542 new_status: &str,
543 new_owner: &OwnerSnapshot,
544 reason: Option<&str>,
545 now: i64,
546) -> Result<()> {
547 let project = target
548 .owner
549 .source_project
550 .as_deref()
551 .or(target.project.as_deref())
552 .unwrap_or("<unknown>");
553 let detail = serde_json::json!({
554 "action": action,
555 "object_ref": target.object_ref.to_string(),
556 "title": target.title,
557 "previous_status": target.status,
558 "new_status": new_status,
559 "previous_owner": &target.owner,
560 "new_owner": new_owner,
561 "reason": reason,
562 })
563 .to_string();
564 let summary = format!(
565 "{} {}: {} -> {}{}",
566 action,
567 target.object_ref,
568 target.status,
569 new_status,
570 reason
571 .map(|value| format!(" ({value})"))
572 .unwrap_or_default()
573 );
574 conn.execute(
575 "INSERT INTO events
576 (session_id, project, event_type, summary, detail, files, exit_code, created_at_epoch)
577 VALUES ('scope-cleanup', ?1, 'scope_cleanup', ?2, ?3, NULL, NULL, ?4)",
578 params![project, summary, detail, now],
579 )?;
580 Ok(())
581}