feat(tasks): add decompose_and_store, list_subtasks, complete_subtask commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 14:46:36 +00:00
parent c61f0585e0
commit 954f83d2f4
3 changed files with 180 additions and 3 deletions

View File

@@ -185,6 +185,12 @@ fn parse_task_suggestions(raw: &str) -> Result<Vec<TaskSuggestion>, String> {
.map_err(|e| format!("Failed to parse LLM task output: {e}")) .map_err(|e| format!("Failed to parse LLM task output: {e}"))
} }
/// Parse LLM output as a JSON array of strings (micro-steps).
/// Public so decompose_and_store in tasks.rs can reuse it.
pub fn parse_steps(raw: &str) -> Result<Vec<String>, String> {
parse_string_array(raw)
}
/// Parse LLM output as a JSON array of strings. /// Parse LLM output as a JSON array of strings.
fn parse_string_array(raw: &str) -> Result<Vec<String>, String> { fn parse_string_array(raw: &str) -> Result<Vec<String>, String> {
let json = extract_json_array(raw); let json = extract_json_array(raw);

View File

@@ -3,8 +3,8 @@ use sqlx::Row;
use crate::AppState; use crate::AppState;
use kon_storage::{ use kon_storage::{
delete_task, insert_task_v2, list_tasks, list_tasks_by_status, reorder_tasks, update_task_v2, all_subtasks_done, delete_task, insert_task_v2, list_subtasks as db_list_subtasks,
TaskRow, list_tasks, list_tasks_by_status, reorder_tasks, update_task_v2, TaskRow,
}; };
/// Maximum number of tasks with status "active" — a core design constraint /// Maximum number of tasks with status "active" — a core design constraint
@@ -29,6 +29,7 @@ pub struct TaskResponse {
pub sort_order: i64, pub sort_order: i64,
pub notes: String, pub notes: String,
pub source_transcript_id: Option<String>, pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
} }
fn task_response_from(row: TaskRow) -> TaskResponse { fn task_response_from(row: TaskRow) -> TaskResponse {
@@ -47,6 +48,7 @@ fn task_response_from(row: TaskRow) -> TaskResponse {
sort_order: row.sort_order, sort_order: row.sort_order,
notes: row.notes, notes: row.notes,
source_transcript_id: row.source_transcript_id, source_transcript_id: row.source_transcript_id,
parent_task_id: row.parent_task_id,
} }
} }
@@ -133,6 +135,7 @@ pub async fn create_task(
sort_order, sort_order,
notes: String::new(), notes: String::new(),
source_transcript_id, source_transcript_id,
parent_task_id: None,
}) })
} }
@@ -213,7 +216,12 @@ pub async fn list_tasks_cmd(
all.into_iter().take(lim).collect() all.into_iter().take(lim).collect()
}; };
Ok(rows.into_iter().map(task_response_from).collect()) // Exclude subtasks — only top-level tasks appear in the main list.
// Subtasks are accessed via list_subtasks by parent ID.
Ok(rows.into_iter()
.filter(|r| r.parent_task_id.is_none())
.map(task_response_from)
.collect())
} }
/// Reorder tasks by setting sort_order based on position in the ID list. /// Reorder tasks by setting sort_order based on position in the ID list.
@@ -259,3 +267,163 @@ pub async fn uncomplete_task_cmd(
.map_err(|e| format!("Uncomplete task failed: {e}")) .map_err(|e| format!("Uncomplete task failed: {e}"))
.map(|_| ()) .map(|_| ())
} }
/// Decompose a task into micro-steps using the LLM, then store as child tasks.
#[tauri::command]
pub async fn decompose_and_store(
state: tauri::State<'_, AppState>,
parent_task_id: String,
) -> Result<Vec<TaskResponse>, String> {
// Read the parent task text
let parent = sqlx::query("SELECT text, project FROM tasks WHERE id = ?")
.bind(&parent_task_id)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("Failed to find parent task: {e}"))?
.ok_or_else(|| "Parent task not found".to_string())?;
let task_text: String = parent.get("text");
let project: Option<String> = parent.get("project");
// Call the LLM to decompose
if !state.llm_engine.is_loaded() {
return Err("Download an AI model in Settings to break down tasks.".to_string());
}
let engine = state.llm_engine.clone();
let system_prompt = r#"Break this task into 3-7 micro-steps.
Each step MUST start with a specific physical verb (e.g. 'Open', 'Type', 'Click', 'Pick up').
Each step must be completable in under 5 minutes.
Never use abstract verbs like 'organise', 'plan', 'consider'.
Return as JSON array of strings."#.to_string();
let result = kon_llm::inference::run_llm_inference(
engine,
system_prompt,
task_text,
256,
)
.await
.map_err(|e| e.to_string())?;
// Parse the JSON array of step strings
let steps = crate::commands::llm::parse_steps(&result)?;
// Create child tasks
let mut children = Vec::new();
for (i, step_text) in steps.iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string();
insert_task_v2(
&state.db,
&id,
step_text,
"medium",
project.as_deref(),
"pending",
"today",
Some("quick"),
None,
i as i64,
)
.await
.map_err(|e| e.to_string())?;
// Set parent_task_id
sqlx::query("UPDATE tasks SET parent_task_id = ? WHERE id = ?")
.bind(&parent_task_id)
.bind(&id)
.execute(&state.db)
.await
.map_err(|e| format!("Set parent_task_id failed: {e}"))?;
// Re-fetch for accurate timestamps
let row = sqlx::query("SELECT created_at, updated_at FROM tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("Fetch child task failed: {e}"))?;
let (created_at, updated_at) = row
.map(|r| (r.get::<String, _>("created_at"), r.get::<String, _>("updated_at")))
.unwrap_or_default();
children.push(TaskResponse {
id,
text: step_text.clone(),
priority: "medium".to_string(),
project: project.clone(),
status: "pending".to_string(),
bucket: "today".to_string(),
effort: Some("quick".to_string()),
done: false,
done_at: None,
created_at,
updated_at,
sort_order: i as i64,
notes: String::new(),
source_transcript_id: None,
parent_task_id: Some(parent_task_id.clone()),
});
}
Ok(children)
}
/// List subtasks (micro-steps) for a parent task.
#[tauri::command]
pub async fn list_subtasks(
state: tauri::State<'_, AppState>,
parent_task_id: String,
) -> Result<Vec<TaskResponse>, String> {
let rows = db_list_subtasks(&state.db, &parent_task_id)
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(task_response_from).collect())
}
/// Complete a subtask. If all siblings are done, auto-complete the parent.
#[tauri::command]
pub async fn complete_subtask(
state: tauri::State<'_, AppState>,
subtask_id: String,
) -> Result<bool, String> {
// Mark the subtask as done
sqlx::query(
"UPDATE tasks SET status = 'done', done = 1, done_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
)
.bind(&subtask_id)
.execute(&state.db)
.await
.map_err(|e| format!("Complete subtask failed: {e}"))?;
// Check if this subtask has a parent
let parent_id: Option<String> = sqlx::query_scalar(
"SELECT parent_task_id FROM tasks WHERE id = ?",
)
.bind(&subtask_id)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("Fetch parent_task_id failed: {e}"))?
.flatten();
// If parent exists, check if all siblings are done → auto-complete parent
if let Some(ref pid) = parent_id {
let all_done = all_subtasks_done(&state.db, pid)
.await
.map_err(|e| e.to_string())?;
if all_done {
sqlx::query(
"UPDATE tasks SET status = 'done', done = 1, done_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
)
.bind(pid)
.execute(&state.db)
.await
.map_err(|e| format!("Auto-complete parent failed: {e}"))?;
return Ok(true); // parent was auto-completed
}
}
Ok(false) // parent not auto-completed
}

View File

@@ -163,6 +163,9 @@ pub fn run() {
commands::tasks::reorder_tasks_cmd, commands::tasks::reorder_tasks_cmd,
commands::tasks::complete_task_cmd, commands::tasks::complete_task_cmd,
commands::tasks::uncomplete_task_cmd, commands::tasks::uncomplete_task_cmd,
commands::tasks::decompose_and_store,
commands::tasks::list_subtasks,
commands::tasks::complete_subtask,
// LLM // LLM
commands::llm::list_llm_models, commands::llm::list_llm_models,
commands::llm::download_llm, commands::llm::download_llm,