Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
651 lines
26 KiB
Rust
651 lines
26 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.flags.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).
|
|
/// Compose the system prompt for the quality-review subagent.
|
|
fn compose_review_prompt(
|
|
state: &AppStateRest,
|
|
probe_note: &str,
|
|
) -> String {
|
|
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
|
|
std::process::Command::new("git")
|
|
.arg("diff")
|
|
.arg("HEAD")
|
|
.current_dir(workspace)
|
|
.output()
|
|
.ok()
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
|
.unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let history_output = if let Some(rt) = &state.session_runtime {
|
|
let msgs: Vec<String> = rt.messages.iter()
|
|
.filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User)
|
|
.rev()
|
|
.take(10)
|
|
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
|
|
.collect();
|
|
let mut rev_msgs = msgs;
|
|
rev_msgs.reverse();
|
|
rev_msgs.join("\n\n")
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let session_dir_disp = state.session_dir.display();
|
|
format!(
|
|
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
|
|
Session directory: {session_dir_disp}\n\n\
|
|
--- Build/Test Probe ---\n{probe_note}\n\n\
|
|
--- Recent Chat History (Last 10 messages) ---\n{history_output}\n\n\
|
|
--- Recent Code Diffs (git diff HEAD) ---\n{diff_output}\n\n\
|
|
INSTRUCTIONS:\n\
|
|
1. Compare the 'Recent Chat History' (what the AI promised or discussed) with the 'Recent Code Diffs' (what was actually changed).\n\
|
|
2. Ensure that the AI's promises match the actual code changes.\n\
|
|
3. Evaluate the code quality in the diff (check for best practices, clean code).\n\
|
|
4. Write your findings and learning points as a lesson to a file in `docs/lesson/` (e.g., docs/lesson/lesson_01.md).\n\
|
|
5. Use the `write` tool to save this markdown file.\n\
|
|
6. Your verdict should briefly summarize what lesson was created.",
|
|
)
|
|
}
|
|
|
|
/// 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) {
|
|
state.misc.lesson_running = true;
|
|
|
|
if let Some(workspace) = state.workspace_roots.first() {
|
|
let gitignore_path = workspace.join(".gitignore");
|
|
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
|
|
if !content.contains("docs/lesson") {
|
|
use std::io::Write;
|
|
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) {
|
|
let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
|
|
let _ = writeln!(file, "{prefix}docs/lesson/");
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut def = AgentDefinition::new(
|
|
"lesson-generator".to_string(),
|
|
"reviewer".to_string(),
|
|
);
|
|
// Explicitly allow write_file for docs/lesson
|
|
def.allowed_tools = Some(vec![
|
|
"read".to_string(),
|
|
"write".to_string(),
|
|
"grep".to_string(),
|
|
"glob".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 ({}).", r.command)
|
|
} else if r.timed_out {
|
|
format!("Build/test verification timed out ({}).", r.command)
|
|
} else {
|
|
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
|
|
}
|
|
}
|
|
None => "No build/test probe matched.".to_string(),
|
|
};
|
|
|
|
ctx.system_prompt = compose_review_prompt(state, &probe_note);
|
|
|
|
let turn_events_for_drain = state.turn_events.clone();
|
|
// Use a drain thread for subagent events
|
|
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::Progress(_) => {}
|
|
SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"),
|
|
SubagentEvent::Usage { tokens_in, tokens_out } => {
|
|
if let Ok(mut q) = turn_events_for_drain.lock() {
|
|
q.push_back(TurnEvent::ReviewUsage {
|
|
tokens_in: *tokens_in,
|
|
tokens_out: *tokens_out,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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!("Lesson created: {first_line}")
|
|
}
|
|
Err(e) => format!("Lesson generation 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,
|
|
"Generating lesson...".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)
|
|
}
|