chore: fix all 702 clippy warnings across codebase - auto-fix 475 via cargo clippy --fix - fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms, underscore_binding, format_push_string, items_after_statements, needless_pass_by_value, clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline, and other clippy lints
578 lines
23 KiB
Rust
578 lines
23 KiB
Rust
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
|
//! Adaptive quality-review triggering, build/test probing, staleness
|
|
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
|
use std::process::Command;
|
|
use crate::app::state::rest::AppStateRest;
|
|
use crate::app::state::runtime::TurnEvent;
|
|
use crate::app::state::types::{Origin, Toast, ToastKind};
|
|
use crate::app::subagent::context::build_subagent_context;
|
|
use crate::app::subagent::engine::run_subagent;
|
|
use crate::app::subagent::spawn::AgentDefinition;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// How much trust a lesson's origin/verification warrants.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum Confidence {
|
|
Human,
|
|
Verified,
|
|
Unverified,
|
|
Auto,
|
|
}
|
|
|
|
/// Where a lesson sits in its life cycle, from freshly written to superseded.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum LessonLifecycle {
|
|
New,
|
|
Active,
|
|
Stale,
|
|
Contradicted,
|
|
Superseded,
|
|
}
|
|
|
|
/// Whether a lesson applies to the current project only or globally.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum LessonScope {
|
|
Project,
|
|
Global,
|
|
}
|
|
|
|
/// Records who/what produced a lesson and in which session/turn.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Provenance {
|
|
pub session_turn: String,
|
|
pub session_id: String,
|
|
pub reviewer: Origin,
|
|
}
|
|
|
|
/// A single learned fact/pattern surfaced by a review, prior to being
|
|
/// written to persistent memory.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Lesson {
|
|
pub name: String,
|
|
pub content: String,
|
|
pub confidence: Confidence,
|
|
pub outcome: Option<String>,
|
|
pub lifecycle: LessonLifecycle,
|
|
pub scope: LessonScope,
|
|
pub contradiction_with: Option<String>,
|
|
pub provenance: Provenance,
|
|
}
|
|
|
|
/// Decide whether an adaptive quality review should fire for this turn.
|
|
///
|
|
/// Flow: only `Origin::Main` turns are eligible → require review enabled
|
|
/// in settings → fire every 5th edit unconditionally → otherwise, once
|
|
/// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2),
|
|
/// fire on an exponentially growing skip interval (2^n, capped at 2^10)
|
|
/// to avoid reviewing every single edit once reviews keep coming back empty.
|
|
///
|
|
/// Why: balances review usefulness against wasted subagent calls when
|
|
/// reviews consistently find nothing.
|
|
///
|
|
/// Return: `true` if a review should be triggered this turn.
|
|
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|
|
|
if origin != Origin::Main {
|
|
return false;
|
|
}
|
|
let Some(runtime) = &state.session_runtime else { return false };
|
|
if !state.settings.review_enabled {
|
|
return false;
|
|
}
|
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
|
return true;
|
|
}
|
|
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
|
let consecutive = runtime.consecutive_empty_reviews;
|
|
if consecutive >= base {
|
|
let skip = 1u32 << (consecutive - base).min(10);
|
|
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
false
|
|
}
|
|
/// Outcome of running a build/test probe command against a workspace.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProbeResult {
|
|
pub command: String,
|
|
pub passed: bool,
|
|
pub output: String,
|
|
pub timed_out: bool,
|
|
}
|
|
|
|
/// Run a build/test verification command in the first workspace root and
|
|
/// capture its outcome, to back a review with a real pass/fail signal.
|
|
///
|
|
/// Flow: pick the first workspace → resolve the verify command (explicit
|
|
/// override or auto-detected via `resolve_verify_command`) → spawn it →
|
|
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
|
|
/// capture combined stdout+stderr (truncated) on completion.
|
|
///
|
|
/// Why: polling instead of a blocking wait lets the timeout be enforced
|
|
/// without spawning a watcher thread.
|
|
///
|
|
/// Return: `None` if no workspace exists, no command could be resolved,
|
|
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
|
|
/// describing pass/fail/timeout and truncated output.
|
|
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
|
let probe_dir = workspaces.first()?;
|
|
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
|
|
|
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
|
|
|
|
let Ok(mut child) = Command::new(&cmd_prog)
|
|
.args(cmd_args.split_whitespace())
|
|
.current_dir(probe_dir)
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn() else { return None };
|
|
|
|
let start = std::time::Instant::now();
|
|
let timed_out = loop {
|
|
if start.elapsed().as_millis() as u64 >= timeout_ms {
|
|
let _ = child.kill();
|
|
break true;
|
|
}
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
let output = child.wait_with_output().ok();
|
|
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
|
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
|
|
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
|
|
return Some(ProbeResult {
|
|
command: cmd.clone(),
|
|
passed: status.success(),
|
|
output: truncate_output(&combined, 2048),
|
|
timed_out: false,
|
|
});
|
|
}
|
|
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
|
|
Err(_) => return None,
|
|
}
|
|
};
|
|
if timed_out {
|
|
Some(ProbeResult {
|
|
command: cmd.clone(),
|
|
passed: false,
|
|
output: "timed out".to_string(),
|
|
timed_out: true,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Determine the shell command to build/test a workspace, auto-detecting
|
|
/// the project type from marker files when no override is given.
|
|
///
|
|
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
|
|
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
|
|
/// in priority order and return that ecosystem's conventional test/build
|
|
/// command.
|
|
///
|
|
/// Why: covers a broad set of ecosystems so review probing works without
|
|
/// per-project configuration in the common case.
|
|
///
|
|
/// Return: `Some(command)` if a command could be determined, `None` if
|
|
/// no marker files matched (e.g. plain Python project with no test dir).
|
|
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
|
|
if let Some(cmd) = override_cmd {
|
|
if !cmd.trim().is_empty() {
|
|
return Some(cmd.trim().to_string());
|
|
}
|
|
}
|
|
let has_file = |name: &str| probe_dir.join(name).exists();
|
|
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
|
if has_file("Cargo.toml") {
|
|
if has_dir("src") || has_dir("tests") {
|
|
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
|
}
|
|
return Some("cargo build 2>&1".to_string());
|
|
}
|
|
if has_file("go.mod") {
|
|
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
|
|
}
|
|
if has_file("package.json") {
|
|
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
|
let scripts = v.get("scripts")?;
|
|
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
|
return Some("npm test 2>&1".to_string());
|
|
}
|
|
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
|
return Some("npm run build 2>&1".to_string());
|
|
}
|
|
}
|
|
return Some("npm test 2>&1".to_string());
|
|
}
|
|
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
|
if has_file("pyproject.toml") {
|
|
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
|
if content.contains("[tool.pytest") {
|
|
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
|
}
|
|
}
|
|
if has_dir("tests") || has_dir("test") {
|
|
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
|
}
|
|
return None;
|
|
}
|
|
if has_file("Cargo.lock") {
|
|
return Some("cargo build 2>&1".to_string());
|
|
}
|
|
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
|
|
return Some("bundle exec rake 2>&1".to_string());
|
|
}
|
|
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
|
|
return Some("make test 2>&1 || make build 2>&1".to_string());
|
|
}
|
|
if has_file("justfile") || has_file("justfile") {
|
|
return Some("just test 2>&1 || just build 2>&1".to_string());
|
|
}
|
|
if has_file("deno.json") || has_file("deno.jsonc") {
|
|
return Some("deno test 2>&1".to_string());
|
|
}
|
|
if has_file("bun.lock") || has_file("bun.lockb") {
|
|
return Some("bun test 2>&1".to_string());
|
|
}
|
|
if has_file("pnpm-lock.yaml") {
|
|
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
|
|
}
|
|
if has_file("yarn.lock") {
|
|
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
|
|
}
|
|
if has_file("composer.json") {
|
|
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
|
|
}
|
|
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
|
|
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
|
|
}
|
|
if has_file("pom.xml") || has_file("mvnw") {
|
|
return Some("mvn test 2>&1".to_string());
|
|
}
|
|
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
|
|
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
|
|
}
|
|
if has_file("mix.exs") {
|
|
return Some("mix test 2>&1".to_string());
|
|
}
|
|
if has_file("rebar.config") || has_file("rebar.lock") {
|
|
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
|
|
}
|
|
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
|
|
return Some("dune runtest 2>&1".to_string());
|
|
}
|
|
if has_file("shard.yml") {
|
|
return Some("crystal spec 2>&1".to_string());
|
|
}
|
|
if has_file("Project.toml") || has_file("JuliaProject.toml") {
|
|
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Truncate a string to at most `max` characters, appending a marker if cut.
|
|
///
|
|
/// Return: the original string if short enough, otherwise the first `max`
|
|
/// characters plus `"... (truncated)"`.
|
|
fn truncate_output(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
s.to_string()
|
|
} else {
|
|
let mut t: String = s.chars().take(max).collect();
|
|
t.push_str("... (truncated)");
|
|
t
|
|
}
|
|
}
|
|
|
|
/// Spawn a background quality-review subagent for the current session.
|
|
///
|
|
/// Flow: build a "quality-reviewer" subagent context → probe build/test
|
|
/// status via `probe_build_test` to give the reviewer a real pass/fail
|
|
/// signal → compose a system prompt embedding the probe result and lesson
|
|
/// tagging instructions → spawn a thread running `run_subagent` → on
|
|
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
|
|
/// line (or error) → push an "in progress" toast immediately.
|
|
///
|
|
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
|
|
/// async event loop; communicates its result back via `turn_events`
|
|
/// rather than a channel receiver (the `_rx` half is intentionally unused).
|
|
///
|
|
/// Return: `Ok(())` once the review has been kicked off; errors only
|
|
/// propagate from constructing the subagent context, not from the review
|
|
/// itself (that failure is reported via a `SystemNote` instead).
|
|
#[allow(clippy::unnecessary_debug_formatting)]
|
|
pub fn trigger_review(state: &mut AppStateRest) {
|
|
let def = AgentDefinition::new(
|
|
"quality-reviewer".to_string(),
|
|
"reviewer".to_string(),
|
|
);
|
|
let mut ctx = build_subagent_context(&def);
|
|
ctx.session_dir.clone_from(&state.session_dir);
|
|
ctx.workspaces.clone_from(&state.workspace_roots);
|
|
let probe_result = probe_build_test(
|
|
&state.workspace_roots,
|
|
state.settings.verify_command.as_deref(),
|
|
state.settings.verify_timeout_ms,
|
|
);
|
|
|
|
let probe_note = match &probe_result {
|
|
Some(r) => {
|
|
if r.passed {
|
|
format!("Build/test verification passed ({}). Confidence: verified.", r.command)
|
|
} else if r.timed_out {
|
|
format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command)
|
|
} else {
|
|
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
|
|
}
|
|
}
|
|
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
|
|
};
|
|
|
|
let session_dir = &state.session_dir;
|
|
ctx.system_prompt = format!(
|
|
"You are a code quality reviewer. Review the recent code changes \
|
|
for correctness, and adherence to best practices. \
|
|
Use read-only tools (read, grep, glob, recall, remember) to \
|
|
inspect the session files and provide a concise review verdict. \
|
|
Session directory: {session_dir:?}\n\n\
|
|
Build/Test Probe:\n{probe_note}\n\n\
|
|
When writing a lesson via remember(), set tags appropriately:\n\
|
|
- If build/test verification printed any FAILED/ERROR lines, tag\n\
|
|
the lesson as \"confidence: verified\" (backed by a real failure).\n\
|
|
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
|
|
(reviewer judgment only).\n\
|
|
Check for duplicate lessons via recall before writing a new one.",
|
|
);
|
|
|
|
// Use a drain thread for subagent events (so blocking_send never
|
|
// fails on a closed channel) and log events at debug level for
|
|
// observability during review runs.
|
|
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
|
let _drain_thread = std::thread::spawn(move || {
|
|
use crate::app::subagent::event::SubagentEvent;
|
|
let mut rx = rx;
|
|
while let Some(event) = rx.blocking_recv() {
|
|
match &event {
|
|
SubagentEvent::ToolCall { tool, .. } => {
|
|
tracing::debug!("[review] tool call: {}", tool);
|
|
}
|
|
SubagentEvent::ToolResult { tool, .. } => {
|
|
tracing::debug!("[review] tool result: {}", tool);
|
|
}
|
|
SubagentEvent::StepCompleted { .. } => {
|
|
tracing::trace!("[review] step completed");
|
|
}
|
|
SubagentEvent::StepFailed { step, error } => {
|
|
tracing::warn!("[review] step {} failed: {}", step, error);
|
|
}
|
|
SubagentEvent::Completed { .. } => {
|
|
tracing::debug!("[review] completed");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let turn_events = state.turn_events.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
let result = run_subagent(&ctx, &tx);
|
|
let message = match result {
|
|
Ok(verdict) => {
|
|
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
|
format!("Quality review: {first_line}")
|
|
}
|
|
Err(e) => format!("Quality review failed: {e}"),
|
|
};
|
|
if let Ok(mut q) = turn_events.lock() {
|
|
q.push_back(TurnEvent::SystemNote {
|
|
kind: "review".to_string(),
|
|
message,
|
|
});
|
|
}
|
|
});
|
|
|
|
state.push_toast(Toast::new(
|
|
ToastKind::Info,
|
|
"Quality review triggered".to_string(),
|
|
));
|
|
}
|
|
|
|
const STALE_AFTER_DAYS: i64 = 60;
|
|
|
|
/// Flag memory entries as stale if they haven't been updated recently.
|
|
///
|
|
/// Flow: list all memory files → for each, read it → if `updated_at` is
|
|
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
|
|
/// `lifecycle = "stale"` and write it back → collect flagged names.
|
|
///
|
|
/// Return: names of newly-flagged memories, or an I/O error from
|
|
/// `mem.write`.
|
|
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
|
let mut flagged = Vec::new();
|
|
let names = crate::model::memory::Memory::list(memory_dir);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
|
|
for name in names {
|
|
if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) {
|
|
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
|
mem.lifecycle = "stale".to_string();
|
|
mem.write(memory_dir)?;
|
|
flagged.push(name);
|
|
}
|
|
}
|
|
}
|
|
Ok(flagged)
|
|
}
|
|
|
|
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
|
|
///
|
|
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
|
|
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
|
|
/// an info toast listing flagged lessons if any were found.
|
|
///
|
|
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
|
|
/// run on every event-loop tick.
|
|
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
|
return;
|
|
}
|
|
state.misc.last_staleness_sweep_ms = now;
|
|
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
|
|
if !flagged.is_empty() {
|
|
state.push_toast(Toast::new(
|
|
ToastKind::Info,
|
|
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A lesson awaiting confirmation before being committed to memory,
|
|
/// optionally auto-resolving after a grace period.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PendingLesson {
|
|
pub lesson: Lesson,
|
|
pub created_at: i64,
|
|
pub auto_resolve: bool,
|
|
}
|
|
|
|
/// Load the session's pending-lessons queue from disk.
|
|
///
|
|
/// Return: the parsed list, or an empty `Vec` if the file is missing or
|
|
/// fails to parse.
|
|
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
|
|
let path = session_dir.join("pending_lessons.json");
|
|
std::fs::read_to_string(&path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Write the session's pending-lessons queue to disk as pretty JSON.
|
|
///
|
|
/// Return: `Ok(())`, or an I/O error from writing the file.
|
|
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
|
|
let path = session_dir.join("pending_lessons.json");
|
|
let data = serde_json::to_string_pretty(pending)?;
|
|
std::fs::write(&path, data)
|
|
}
|
|
|
|
/// Commit any auto-resolvable pending lessons whose grace period has
|
|
/// elapsed, and persist the remaining queue.
|
|
///
|
|
/// Flow: load pending lessons → partition into those eligible to commit
|
|
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
|
|
/// → write eligible lessons as new `Memory` entries with `lifecycle:
|
|
/// "active"` → save the remaining (unresolved) queue back to disk.
|
|
///
|
|
/// Why: the grace window gives the user a brief window to reject an
|
|
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
|
|
///
|
|
/// Return: the still-pending lessons (post-commit), or an I/O error from
|
|
/// writing memory files or the queue.
|
|
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
|
let pending = load_pending_lessons(session_dir);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
let grace_window = 5_000;
|
|
let mut remaining = Vec::new();
|
|
let mut to_keep = Vec::new();
|
|
|
|
for p in &pending {
|
|
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
|
|
to_keep.push(p.lesson.clone());
|
|
} else {
|
|
remaining.push(p.clone());
|
|
}
|
|
}
|
|
for lesson in &to_keep {
|
|
let mem = crate::model::memory::Memory {
|
|
name: lesson.name.clone(),
|
|
description: lesson.content.chars().take(80).collect(),
|
|
content: lesson.content.clone(),
|
|
kind: "lesson".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
outcome: None,
|
|
lifecycle: "active".to_string(),
|
|
scope: Some("project".to_string()),
|
|
before_snippet: None,
|
|
after_snippet: None,
|
|
provenances: vec![],
|
|
};
|
|
mem.write(memory_dir)?;
|
|
}
|
|
|
|
save_pending_lessons(session_dir, &remaining)?;
|
|
Ok(remaining)
|
|
}
|
|
/// Manually resolve a single pending lesson by name: commit it to memory
|
|
/// or discard it.
|
|
///
|
|
/// Flow: load the queue → find the lesson matching `lesson_name` →
|
|
/// if `keep` is true, write it as an active `Memory` entry; either way
|
|
/// remove it from the queue → save the remaining queue.
|
|
///
|
|
/// Why: lets the user (or UI action) override a pending lesson's fate
|
|
/// before/without waiting for the auto-resolve grace window.
|
|
///
|
|
/// Return: `Ok(())`, or an I/O error from writing the memory file or queue.
|
|
pub fn resolve_pending_lesson(
|
|
session_dir: &std::path::Path,
|
|
memory_dir: &std::path::Path,
|
|
lesson_name: &str,
|
|
keep: bool,
|
|
) -> std::io::Result<()> {
|
|
let pending = load_pending_lessons(session_dir);
|
|
let mut remaining = Vec::new();
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
for p in pending {
|
|
if p.lesson.name == lesson_name {
|
|
if keep {
|
|
let mem = crate::model::memory::Memory {
|
|
name: p.lesson.name.clone(),
|
|
description: p.lesson.content.chars().take(80).collect(),
|
|
content: p.lesson.content.clone(),
|
|
kind: "lesson".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
outcome: None,
|
|
lifecycle: "active".to_string(),
|
|
scope: Some("project".to_string()),
|
|
before_snippet: None,
|
|
after_snippet: None,
|
|
provenances: vec![],
|
|
};
|
|
mem.write(memory_dir)?;
|
|
}
|
|
} else {
|
|
remaining.push(p);
|
|
}
|
|
}
|
|
|
|
save_pending_lessons(session_dir, &remaining)
|
|
}
|