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:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user