Skip to main content

remem/memory/scope_cleanup/
mutate.rs

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}