refactor: massive codebase restructuring — naming, splitting, DRY
Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
This commit is contained in:
@@ -1,70 +1,23 @@
|
||||
#![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.
|
||||
|
||||
pub mod pending;
|
||||
pub mod probe;
|
||||
pub mod prompt;
|
||||
pub mod staleness;
|
||||
pub mod types;
|
||||
|
||||
pub use pending::{load_pending_lessons, process_pending_lessons, resolve_pending_lesson};
|
||||
pub use staleness::maybe_run_staleness_sweep;
|
||||
pub use types::{Confidence, LessonScope};
|
||||
|
||||
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};
|
||||
use std::process::Command;
|
||||
use zesdex_cms::domain::memory::Memory;
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
use crate::app::subagent::event::SubagentEvent;
|
||||
use crate::app::subagent::spawn::{spawn_subagent_with_drain, AgentDefinition};
|
||||
|
||||
/// Decide whether an adaptive quality review should fire for this turn.
|
||||
///
|
||||
@@ -102,308 +55,6 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
}
|
||||
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.
|
||||
///
|
||||
@@ -457,7 +108,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
||||
ctx.session_dir.clone_from(&state.session_dir);
|
||||
ctx.workspaces.clone_from(&state.workspace_roots);
|
||||
|
||||
let probe_result = probe_build_test(
|
||||
let probe_result = probe::probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
state.settings.verify_timeout_ms,
|
||||
@@ -479,38 +130,32 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
||||
None => "No build/test probe matched.".to_string(),
|
||||
};
|
||||
|
||||
ctx.system_prompt = compose_review_prompt(state, &probe_note);
|
||||
ctx.system_prompt = 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 (tx, _drain_thread) = spawn_subagent_with_drain(move |event| {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,198 +185,3 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
||||
"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 = MarkdownMemoryRepository::new()
|
||||
.list(memory_dir)
|
||||
.unwrap_or_default();
|
||||
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) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
|
||||
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
||||
mem.lifecycle = "stale".to_string();
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
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 = 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![],
|
||||
};
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
|
||||
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 = 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![],
|
||||
};
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
} else {
|
||||
remaining.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
save_pending_lessons(session_dir, &remaining)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user