use crate::api::{GraphStore, SearchStore, TaskStore};
use anyhow::Result;
use clap::Subcommand;
use schemars::JsonSchema;
use serde::Serialize;
#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TaskReceipt {
pub action: &'static str,
pub entity: String,
pub status: String,
}
const TASK_STATUSES: &[&str] = &[
"READY_TO_DISPATCH",
"DISPATCHED",
"REVIEW",
"AWAITING_VERIFY",
"DONE",
];
#[derive(Subcommand, Debug)]
pub enum TasksCommands {
Plan {
epic: String,
#[arg(long)]
objective: String,
#[arg(long = "task", value_name = "TITLE", required = true)]
tasks: Vec<String>,
},
List { epic: Option<String> },
Dispatch {
task: Option<String>,
#[arg(long, default_value = "lead")]
agent: String,
},
Sync {
task: String,
#[arg(long = "note")]
notes: Vec<String>,
#[arg(long, default_value = "REVIEW")]
status: String,
},
Close {
epic: String,
#[arg(long = "lesson")]
lessons: Vec<String>,
},
}
pub fn run(
backend: &(impl GraphStore + SearchStore + TaskStore),
subcommand: Option<TasksCommands>,
json: bool,
) -> Result<()> {
match subcommand {
None => {
println!("Use `asobi tasks --help` to see task-dispatcher commands.");
}
Some(TasksCommands::Plan {
epic,
objective,
tasks,
}) => {
if epic.trim().is_empty() || objective.trim().is_empty() {
anyhow::bail!("plan requires a non-empty epic and objective");
}
if tasks.iter().any(|title| title.trim().is_empty()) {
anyhow::bail!("plan task titles must be non-empty");
}
let epic_name = epic.clone();
let child_names: Vec<String> = tasks
.iter()
.enumerate()
.map(|(idx, _)| format!("{epic}:task-{}", idx + 1))
.collect();
let existing = backend.open_nodes(crate::api::OpenNodes {
names: std::iter::once(epic.clone())
.chain(child_names.iter().cloned())
.collect(),
..Default::default()
})?;
if !existing.entities.is_empty() {
anyhow::bail!("plan target already exists: {}", existing.entities[0].name);
}
backend.create_entities(vec![crate::model::EntityInput {
name: epic.clone(),
entity_type: "task".to_string(),
observations: vec![format!("scope: {}", objective)],
}])?;
backend.create_entities(
child_names
.iter()
.zip(&tasks)
.map(|(name, title)| crate::model::EntityInput {
name: name.clone(),
entity_type: "task".to_string(),
observations: vec![format!("plan: {title}")],
})
.collect(),
)?;
backend.truth_upsert(&epic, "objective", &objective)?;
for (name, title) in child_names.iter().zip(&tasks) {
backend.truth_upsert(name, "title", title)?;
backend.truth_upsert(name, "status", "READY_TO_DISPATCH")?;
}
backend.create_relations(
child_names
.iter()
.map(|name| crate::model::RelationInput {
from: name.clone(),
to: epic.clone(),
relation_type: "part_of".to_string(),
})
.collect(),
)?;
if json {
let graph = backend.open_nodes(crate::api::OpenNodes {
names: vec![epic_name],
expand: vec!["part_of".to_string()],
..Default::default()
})?;
print_json(graph)?;
} else {
println!("Planned {} with {} task(s).", epic, tasks.len());
}
}
Some(TasksCommands::List { epic }) => {
let graph = if let Some(epic) = epic {
let graph = backend.open_nodes(crate::api::OpenNodes {
names: vec![epic.clone()],
expand: vec!["part_of".to_string()],
..Default::default()
})?;
if graph.entities.is_empty() {
anyhow::bail!("epic not found: {epic}");
}
graph
} else {
let mut graph = backend.read_graph()?;
let task_names: std::collections::HashSet<_> = graph
.entities
.iter()
.filter(|entity| entity.entity_type == "task")
.map(|entity| entity.name.clone())
.collect();
graph
.entities
.retain(|entity| task_names.contains(&entity.name));
graph.relations.retain(|relation| {
task_names.contains(&relation.from) && task_names.contains(&relation.to)
});
graph
};
print_json(graph)?;
}
Some(TasksCommands::Dispatch { task, agent }) => {
if agent.trim().is_empty() {
anyhow::bail!("dispatch agent must be non-empty");
}
let task = backend
.dispatch(task.as_deref(), &agent, observation_limit())?
.ok_or_else(|| {
anyhow::anyhow!(
"no READY_TO_DISPATCH task found or task was claimed by another agent"
)
})?;
if json {
print_json(TaskReceipt {
action: "dispatch",
entity: task,
status: "DISPATCHED".to_string(),
})?;
} else {
println!("Dispatched {task} to {agent}.");
}
}
Some(TasksCommands::Sync {
task,
notes,
status,
}) => {
let graph = backend.open_nodes(crate::api::OpenNodes {
names: vec![task.clone()],
..Default::default()
})?;
let entity = graph
.entities
.first()
.ok_or_else(|| anyhow::anyhow!("task not found: {task}"))?;
if entity.entity_type != "task" {
anyhow::bail!("entity is not a task: {task}");
}
if !TASK_STATUSES.contains(&status.as_str()) && !status.starts_with("BLOCKED_ON ") {
anyhow::bail!("invalid task status: {status}");
}
if graph.entities.is_empty() {
anyhow::bail!("task not found: {task}");
}
if !notes.is_empty() {
backend.add_observations(
vec![crate::model::ObservationInput {
entity_name: task.clone(),
contents: notes,
}],
observation_limit(),
)?;
}
backend.truth_upsert(&task, "status", &status)?;
if json {
print_json(TaskReceipt {
action: "sync",
entity: task,
status,
})?;
} else {
println!("Synced {task} as {status}.");
}
}
Some(TasksCommands::Close { epic, lessons }) => {
let graph = backend.open_nodes(crate::api::OpenNodes {
names: vec![epic.clone()],
expand: vec!["part_of".to_string()],
..Default::default()
})?;
let children: Vec<_> = graph
.entities
.iter()
.filter(|entity| entity.name != epic && entity.entity_type == "task")
.collect();
if graph.entities.is_empty() {
anyhow::bail!("epic not found: {epic}");
}
if children.is_empty() {
anyhow::bail!("cannot close {epic}: no child tasks found");
}
if children
.iter()
.any(|entity| entity.truths.get("status").map(String::as_str) != Some("DONE"))
{
anyhow::bail!("cannot close {epic}: every child task must be DONE");
}
let project = epic.split(':').next().unwrap_or(&epic).to_string();
if !lessons.is_empty() && !graph.entities.iter().any(|entity| entity.name == project) {
backend.create_entities(vec![crate::model::EntityInput {
name: project.clone(),
entity_type: "project".to_string(),
observations: vec![],
}])?;
}
if !lessons.is_empty() {
backend.add_observations(
vec![crate::model::ObservationInput {
entity_name: project,
contents: lessons,
}],
observation_limit(),
)?;
}
backend.truth_upsert(&epic, "status", "DONE")?;
backend.add_observations(
vec![crate::model::ObservationInput {
entity_name: epic.clone(),
contents: vec![format!(
"outcome: closed {}",
chrono::Local::now().format("%Y-%m-%d")
)],
}],
observation_limit(),
)?;
if json {
print_json(TaskReceipt {
action: "close",
entity: epic,
status: "DONE".to_string(),
})?;
} else {
println!("Closed {epic}.");
}
}
};
Ok(())
}
fn observation_limit() -> usize {
let paths = crate::paths::AsobiPaths::resolve();
std::env::var("ASOBI_OBSERVATION_LIMIT")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(paths.observation_limit.unwrap_or(200))
}
fn print_json<T: Serialize>(value: T) -> Result<()> {
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}