feat: introduce workflow management tools and commands

- Added new workflow commands: `/workflow` to open the workflow panel and `/workflow run <prompt>` to execute workflows.
- Implemented `spawn_agents` and `spawn_pipeline` tools for parallel and sequential task execution, respectively.
- Enhanced workflow engine to handle real-time agent status updates and display in the UI.
- Updated workflow panel to show agent statuses, findings count, and session counters.
- Refactored existing code to integrate new workflow functionalities and improve overall structure.
This commit is contained in:
asepharyana
2026-07-12 17:49:34 +07:00
parent 7bdfd9c4c4
commit 53b0cb271f
14 changed files with 668 additions and 173 deletions
+126 -2
View File
@@ -82,6 +82,9 @@ pub enum Action {
ModelList,
AbortTurn,
Compact,
RunWorkflow {
script: String,
},
}
/// Apply an `Action` to the application state.
@@ -106,8 +109,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Action::SwitchMode(mode) => {
state.misc.overlay = match mode {
ModeKind::Chat
| ModeKind::Bash
| ModeKind::Workflow => Overlay::None,
| ModeKind::Bash => Overlay::None,
ModeKind::Workflow => Overlay::Workflow,
ModeKind::Help => Overlay::Help,
ModeKind::Settings => Overlay::Settings,
ModeKind::QuitConfirm => Overlay::QuitConfirm,
@@ -434,6 +437,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
} else if kind == "connectivity" {
state.misc.api_connected = message == "connected";
} else if kind == "workflow_done" {
state.push_toast(Toast {
kind: ToastKind::Success,
message: message.clone(),
created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 10000,
});
state.push_transcript(ChatMessageDisplay::new(
crate::dto::chat::message::Role::System,
format!("{}", message),
));
state.dirty = true;
} else if kind == "workflow_error" {
state.push_toast(Toast {
kind: ToastKind::Error,
message: message.clone(),
created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 12000,
});
state.push_transcript(ChatMessageDisplay::new(
crate::dto::chat::message::Role::System,
format!("{}", message),
));
state.dirty = true;
} else {
state.push_toast(Toast::new(ToastKind::Info, message));
}
@@ -492,6 +519,25 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true;
}
}
TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status } => {
// Upsert the agent in the workflow engine roster.
// Running agents are pushed as new entries; status
// updates find the existing entry by id and replace it.
use crate::app::workflow::engine::WorkflowAgent;
if let Some(existing) = state.workflow_engine.agents
.iter_mut()
.find(|a| a.id == agent_id)
{
existing.status = status;
} else {
state.workflow_engine.agents.push(WorkflowAgent {
id: agent_id,
name: agent_name,
status,
});
}
state.dirty = true;
}
}
}
if turn_finished {
@@ -542,6 +588,84 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
format!("rejected lesson: {}", name)));
state.dirty = true;
}
Action::RunWorkflow { script } => {
// Open the Workflow overlay so the user can see progress.
state.misc.overlay = Overlay::Workflow;
state.dirty = true;
// Reset engine state before starting.
state.workflow_engine.agents.clear();
state.workflow_engine.findings.clear();
let turn_events = state.turn_events.clone();
let turn_events_live = state.turn_events.clone();
state.push_toast(Toast::new(
ToastKind::Info,
format!("Starting workflow: {}", &script.chars().take(40).collect::<String>()),
));
std::thread::spawn(move || {
use std::collections::HashMap;
use std::sync::Arc;
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
use crate::app::workflow::engine::{LiveStateFn, AgentStatus};
// Parse the script string:
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
// "prompt1 -> prompt2" → Pipeline of 2 stages
// "prompt" → single Agent
let parts_pipe: Vec<&str> = script.split('|').map(|s| s.trim()).collect();
let parts_arrow: Vec<&str> = script.split("->").map(|s| s.trim()).collect();
let primitive = if parts_pipe.len() > 1 {
ScriptPrimitive::Parallel(
parts_pipe.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
)
} else if parts_arrow.len() > 1 {
ScriptPrimitive::Pipeline(
parts_arrow.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
)
} else {
ScriptPrimitive::Agent(script.clone())
};
let wf = WorkflowScript {
name: script.chars().take(40).collect(),
description: script.clone(),
script: primitive,
options: ScriptOptions::default(),
};
// Build a live-state callback that pushes WorkflowAgentUpdate events
// into the turn_events queue so the TUI panel updates in real time.
let live: LiveStateFn = Arc::new(move |agent_id: String, status: AgentStatus| {
let name = agent_id.chars().take(30).collect::<String>();
if let Ok(mut q) = turn_events_live.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: agent_id.clone(),
agent_name: name,
status,
});
}
});
let args: HashMap<String, String> = HashMap::new();
let result = crate::app::workflow::engine::run_workflow_tracked(&wf, &args, Some(live));
let (kind, message) = match result {
Ok(summary) => ("workflow_done".to_string(), summary),
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {}", e)),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::SystemNote {
kind,
message,
});
}
});
}
}
}