Files
Lumotia/src-tauri/src/commands/tasks.rs
Jake 184214b60a agent: engine slop residuals B — eprintln → tracing sweep
Replaces 22 production eprintln! sites with structured tracing events
across 8 files. Closes Area B of the post-prognosis residuals plan
(docs/superpowers/plans/2026-05-12-engine-slop-residuals.md).

Files touched (22 sites):
- crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings
- crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning
- src-tauri/src/commands/transcription.rs (1) — chunking dispatch info
- src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning
- src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning
- src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail
- src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle
- src-tauri/src/commands/live.rs (8) — session start, chunk dispatch,
  per-chunk delivery, inference errors, worker disconnects, listener
  loss, status-channel cascade

Levels: error for unrecoverable failures (inference disconnect, panic,
status cascade), warn for recoverable degradation (LLM fallback,
malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle
(session start, chunk processed, App Nap acquire/release, warmup
complete, chunking dispatch), debug for per-chunk noise (speech-gate
skip, chunk dispatch).

Two new dependencies and two new filter targets:
- tracing = "0.1" added to crates/hotkey and crates/ai-formatting
- Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with
  magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets
  emit at the default level

Out of scope (intentional, left as-is):
- crates/mcp/src/main.rs — CLI binary, stderr is the log contract
  (module docstring) so the JSON-RPC stdout stream stays clean
- crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs —
  test/example diagnostic output relies on --nocapture stdio semantics

Discovery during sweep (not fixed — separate follow-up): hotkey crate
has 6 existing log:: calls (log::error/warn/info/debug) but the
workspace builds tracing-subscriber without the tracing-log feature, so
those events are currently silent. Worth a follow-up to either add the
tracing-log bridge or migrate hotkey's existing log:: calls to
tracing::.

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test --workspace — 330+ tests, zero failures
- rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits

Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged per Jake's instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:27:06 +01:00

410 lines
13 KiB
Rust

// Tauri commands wrapping magnotia_storage task CRUD.
// Pattern mirrors transcripts.rs — TaskDto is the camelCase frontend shape,
// storage functions are aliased with db_ prefix to avoid name collisions.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use magnotia_llm::prompts::FeedbackExample as LlmFeedbackExample;
use magnotia_storage::{
complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task,
delete_task as db_delete_task, get_task_by_id as db_get_task,
insert_subtask as db_insert_subtask, insert_task as db_insert_task,
list_feedback_examples as db_list_feedback_examples,
list_recent_completions as db_list_recent_completions, list_subtasks as db_list_subtasks,
list_tasks as db_list_tasks, set_task_energy as db_set_task_energy,
uncomplete_task as db_uncomplete_task, update_task as db_update_task, DailyCompletionCount,
FeedbackRow, FeedbackTargetType, TaskRow,
};
use crate::commands::power::PowerAssertion;
use crate::AppState;
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskDto {
pub id: String,
pub text: String,
pub bucket: String,
pub list_id: Option<String>,
pub effort: Option<String>,
pub notes: String,
pub done: bool,
pub done_at: Option<String>,
pub created_at: String,
pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
pub energy: Option<String>,
}
impl From<TaskRow> for TaskDto {
fn from(r: TaskRow) -> Self {
Self {
id: r.id,
text: r.text,
bucket: r.bucket,
list_id: r.list_id,
effort: r.effort,
notes: r.notes,
done: r.done,
done_at: r.done_at,
created_at: r.created_at,
source_transcript_id: r.source_transcript_id,
parent_task_id: r.parent_task_id,
energy: r.energy,
}
}
}
/// Accepted energy tag values. Kept as a const so frontend and storage
/// validate against the same list. Migration v11 enforces the same
/// set via a CHECK constraint.
const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"];
fn validate_energy(raw: Option<&str>) -> Result<Option<&str>, String> {
match raw {
None => Ok(None),
Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)),
Some(other) => Err(format!(
"energy must be one of {:?} or null, got {:?}",
ENERGY_LEVELS, other
)),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskRequest {
pub id: String,
pub text: String,
pub bucket: String,
#[serde(default)]
pub source_transcript_id: Option<String>,
#[serde(default)]
pub list_id: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub energy: Option<String>,
}
#[tauri::command]
pub async fn create_task_cmd(
state: tauri::State<'_, AppState>,
request: CreateTaskRequest,
) -> Result<TaskDto, String> {
let energy = validate_energy(request.energy.as_deref())?;
db_insert_task(
&state.db,
&request.id,
&request.text,
&request.bucket,
request.source_transcript_id.as_deref(),
request.list_id.as_deref(),
request.effort.as_deref(),
energy,
)
.await
.map_err(|e| e.to_string())?;
// Fetch the freshly-inserted row so the frontend can stop doing
// client-side object construction. Mirrors list_tasks_cmd's shape.
db_get_task(&state.db, &request.id)
.await
.map_err(|e| e.to_string())?
.map(TaskDto::from)
.ok_or_else(|| format!("Task {} not found after insert", request.id))
}
/// Patch-shaped update. Any field omitted (or explicit `null`) leaves the
/// column untouched via `COALESCE` in the storage layer. Matches
/// `update_transcript`'s partial-update philosophy. `done` / `doneAt` are
/// intentionally absent — those flow through `complete_task_cmd` /
/// `uncomplete_task_cmd` to stamp the server-side timestamp.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTaskRequest {
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub list_id: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[tauri::command]
pub async fn update_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
patch: UpdateTaskRequest,
) -> Result<TaskDto, String> {
let row = db_update_task(
&state.db,
&id,
patch.text.as_deref(),
patch.bucket.as_deref(),
patch.list_id.as_deref(),
patch.effort.as_deref(),
patch.notes.as_deref(),
)
.await
.map_err(|e| e.to_string())?;
Ok(TaskDto::from(row))
}
#[tauri::command]
pub async fn list_tasks_cmd(state: tauri::State<'_, AppState>) -> Result<Vec<TaskDto>, String> {
db_list_tasks(&state.db)
.await
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn complete_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_complete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_task_cmd(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
db_delete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn uncomplete_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_uncomplete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
/// Phase 3: set or clear the `energy` tag on a task. Dedicated command
/// rather than a field on `update_task_cmd` because the existing update
/// path uses `COALESCE` semantics where `None` means "preserve" — which
/// makes clearing the tag impossible. This command always writes exactly
/// what you send, including `None` to explicitly clear.
#[tauri::command]
pub async fn set_task_energy_cmd(
state: tauri::State<'_, AppState>,
id: String,
energy: Option<String>,
) -> Result<TaskDto, String> {
let validated = validate_energy(energy.as_deref())?;
let row = db_set_task_energy(&state.db, &id, validated)
.await
.map_err(|e| e.to_string())?;
Ok(TaskDto::from(row))
}
/// Convert HITL feedback rows fetched from storage into the few-shot
/// exemplar shape the LLM crate consumes. We reconstruct the `input`
/// (parent task text, transcript chunk) from `context_json` where the
/// recorder has stored it. Rows without usable input are dropped —
/// the prompt builder filters them too, but doing it here keeps the
/// exemplar list tight and the prompt budget predictable.
///
/// Malformed `context_json` is logged rather than silently dropped so
/// data-integrity regressions surface instead of disappearing.
fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
rows.into_iter()
.filter_map(|r| {
let raw = r.context_json.as_deref().unwrap_or("{}");
let ctx: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "magnotia_lib::feedback",
row_id = r.id,
error = %e,
"skipping feedback row with malformed context_json"
);
return None;
}
};
let input = ctx
.get("input")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_default();
if input.trim().is_empty() {
return None;
}
Some(LlmFeedbackExample {
input,
original_output: r.original_text,
corrected_output: r.corrected_text,
})
})
.collect()
}
/// Rough character budget for the few-shot block. Qwen's tokenizer
/// averages ~3.5 chars per token in English, so 2000 chars is ~570
/// tokens — well inside the 64-token reserve + response-token gap
/// against the 8192-token context cap (see `LlmEngine::generate`).
///
/// Exceed this and we drop the oldest examples first. Rationale: the
/// retrieval already orders most-recent-first, and the most recent
/// correction is usually the one carrying the user's live preference.
const FEW_SHOT_CHAR_BUDGET: usize = 2000;
fn example_char_cost(ex: &LlmFeedbackExample) -> usize {
// Matches the render path in `prompts::render_feedback_exemplar`:
// "Input: {input}\nGood output: {good}". Prefix strings + newlines
// + the two bodies. Slight overestimate to leave headroom.
let good_len = ex
.corrected_output
.as_deref()
.or(ex.original_output.as_deref())
.map(str::len)
.unwrap_or(0);
ex.input.len() + good_len + 24
}
fn trim_to_budget(mut examples: Vec<LlmFeedbackExample>) -> Vec<LlmFeedbackExample> {
let mut running = 0usize;
let mut kept = Vec::with_capacity(examples.len());
for ex in examples.drain(..) {
let cost = example_char_cost(&ex);
if running + cost > FEW_SHOT_CHAR_BUDGET {
break;
}
running += cost;
kept.push(ex);
}
kept
}
#[tauri::command]
pub async fn decompose_and_store(
state: tauri::State<'_, AppState>,
parent_task_id: String,
profile_id: Option<String>,
) -> Result<Vec<TaskDto>, String> {
let parent = db_get_task(&state.db, &parent_task_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
// Pull recent micro-step feedback so the system prompt gets
// conditioned on the user's preferred decomposition style. We
// cap at 5 examples AND at a char budget to keep the prompt
// under token budget regardless of how much feedback has been
// captured, and scope by profile so per-profile styles do not
// leak into each other.
let examples = db_list_feedback_examples(
&state.db,
FeedbackTargetType::MicroStep,
5,
0,
profile_id.as_deref(),
)
.await
.map(to_llm_examples)
.map(trim_to_budget)
.unwrap_or_default();
let engine = state.llm_engine.clone();
let parent_text = parent.text.clone();
let steps = tokio::task::spawn_blocking(move || {
let _power_guard = PowerAssertion::begin("magnotia LLM micro-step decomposition");
engine.decompose_task_with_feedback(&parent_text, &examples)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut created = Vec::new();
for text in steps {
let id = Uuid::new_v4().to_string();
db_insert_subtask(&state.db, &id, &text, &parent_task_id)
.await
.map_err(|e| e.to_string())?;
if let Some(row) = db_get_task(&state.db, &id)
.await
.map_err(|e| e.to_string())?
{
created.push(TaskDto::from(row));
}
}
Ok(created)
}
#[tauri::command]
pub async fn extract_tasks_from_transcript_cmd(
state: tauri::State<'_, AppState>,
transcript: String,
profile_id: Option<String>,
) -> Result<Vec<String>, String> {
let examples = db_list_feedback_examples(
&state.db,
FeedbackTargetType::TaskExtraction,
5,
0,
profile_id.as_deref(),
)
.await
.map(to_llm_examples)
.map(trim_to_budget)
.unwrap_or_default();
let engine = state.llm_engine.clone();
tokio::task::spawn_blocking(move || {
let _power_guard = PowerAssertion::begin("magnotia LLM task extraction");
engine.extract_tasks_with_feedback(&transcript, &examples)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_subtasks_cmd(
state: tauri::State<'_, AppState>,
parent_task_id: String,
) -> Result<Vec<TaskDto>, String> {
db_list_subtasks(&state.db, &parent_task_id)
.await
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn complete_subtask_cmd(
state: tauri::State<'_, AppState>,
subtask_id: String,
) -> Result<(), String> {
db_complete_subtask(&state.db, &subtask_id)
.await
.map_err(|e| e.to_string())
}
/// Phase 8: daily completion counts for the Tasks-page badge and the
/// 7-day momentum sparkline. Returns a fixed-length oldest-first
/// series. Empty days are explicit zeros.
#[tauri::command]
pub async fn list_recent_completions_cmd(
state: tauri::State<'_, AppState>,
days: u32,
) -> Result<Vec<DailyCompletionCount>, String> {
db_list_recent_completions(&state.db, days)
.await
.map_err(|e| e.to_string())
}