feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
All checks were successful
Beta Release / beta (push) Successful in 2m24s

Major changes:
- Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version)
- Add LSP registry with health checks, auto-install, and editor config generation
- Add MCP registry with editor detection, status tracking, and per-editor configuration
- Add workflow engine with planner and step execution for automated task chains
- Add conversation search, export (Markdown/JSON), and detailed token counting
- Add streaming shell chat handler with tool call/result events
- Add skill validation, dry-run testing, and export endpoints
- Enrich dashboard with Tools/Activity/Status tabs and tool cards grid
- Add PRD documentation
- Complete i18n for both EN and FR

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 22:22:05 +02:00
parent 66b773ff86
commit 2e50366cd8
42 changed files with 6779 additions and 319 deletions

362
internal/workflow/engine.go Normal file
View File

@@ -0,0 +1,362 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusDone Status = "done"
StatusFailed Status = "failed"
StatusSkipped Status = "skipped"
StatusAwaiting Status = "awaiting_approval"
)
type StepType string
const (
TypeToolCall StepType = "tool_call"
TypeCondition StepType = "condition"
TypeParallel StepType = "parallel"
TypeApproval StepType = "approval"
)
type Step struct {
ID string `json:"id"`
Name string `json:"name"`
Type StepType `json:"type"`
Tool string `json:"tool,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Status Status `json:"status"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Condition string `json:"condition,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
ApproveRole string `json:"approve_role,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
type Workflow struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Steps []Step `json:"steps"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Engine struct {
mu sync.RWMutex
workflows map[string]*Workflow
agentRegistry *agent.Registry
storePath string
}
func NewEngine(registry *agent.Registry) (*Engine, error) {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
storePath := filepath.Join(dir, "workflows.json")
engine := &Engine{
workflows: make(map[string]*Workflow),
agentRegistry: registry,
storePath: storePath,
}
engine.load()
return engine, nil
}
func (e *Engine) load() {
data, err := os.ReadFile(e.storePath)
if err != nil {
return
}
var workflows []*Workflow
if err := json.Unmarshal(data, &workflows); err != nil {
return
}
for _, w := range workflows {
e.workflows[w.ID] = w
}
}
func (e *Engine) save() error {
dir := filepath.Dir(e.storePath)
os.MkdirAll(dir, 0755)
e.mu.RLock()
workflows := make([]*Workflow, 0, len(e.workflows))
for _, w := range e.workflows {
workflows = append(workflows, w)
}
e.mu.RUnlock()
data, err := json.MarshalIndent(workflows, "", " ")
if err != nil {
return err
}
return os.WriteFile(e.storePath, data, 0600)
}
func (e *Engine) Create(name, description, wfType string, steps []Step) *Workflow {
wf := &Workflow{
ID: fmt.Sprintf("wf-%d", time.Now().UnixNano()),
Name: name,
Description: description,
Type: wfType,
Steps: steps,
Status: StatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for i := range wf.Steps {
if wf.Steps[i].ID == "" {
wf.Steps[i].ID = fmt.Sprintf("step-%d", i)
}
if wf.Steps[i].Status == "" {
wf.Steps[i].Status = StatusPending
}
}
e.mu.Lock()
e.workflows[wf.ID] = wf
e.mu.Unlock()
e.save()
return wf
}
func (e *Engine) Get(id string) (*Workflow, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
wf, ok := e.workflows[id]
return wf, ok
}
func (e *Engine) List() []*Workflow {
e.mu.RLock()
defer e.mu.RUnlock()
result := make([]*Workflow, 0, len(e.workflows))
for _, w := range e.workflows {
result = append(result, w)
}
return result
}
func (e *Engine) Delete(id string) error {
e.mu.Lock()
defer e.mu.Unlock()
if _, ok := e.workflows[id]; !ok {
return fmt.Errorf("workflow not found: %s", id)
}
delete(e.workflows, id)
return e.save()
}
func (e *Engine) UpdateStep(workflowID, stepID string, update func(*Step)) error {
e.mu.Lock()
defer e.mu.Unlock()
wf, ok := e.workflows[workflowID]
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
for i := range wf.Steps {
if wf.Steps[i].ID == stepID {
update(&wf.Steps[i])
wf.UpdatedAt = time.Now()
e.save()
return nil
}
}
return fmt.Errorf("step not found: %s", stepID)
}
func (e *Engine) UpdateWorkflowStatus(workflowID string, status Status) error {
e.mu.Lock()
defer e.mu.Unlock()
wf, ok := e.workflows[workflowID]
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
wf.Status = status
wf.UpdatedAt = time.Now()
return e.save()
}
func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(step *Step, event string)) error {
wf, ok := e.Get(workflowID)
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
if err := e.UpdateWorkflowStatus(workflowID, StatusRunning); err != nil {
return err
}
stepStatuses := make(map[string]Status)
for _, step := range wf.Steps {
stepStatuses[step.ID] = StatusPending
}
resolveDeps := func(stepID string) bool {
step := wf.findStep(stepID)
if step == nil {
return false
}
for _, dep := range step.DependsOn {
if stepStatuses[dep] != StatusDone {
return false
}
}
return true
}
executeStep := func(step *Step) error {
now := time.Now()
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusRunning
s.StartedAt = &now
})
if onStep != nil {
onStep(step, "started")
}
var result string
var stepErr error
switch step.Type {
case TypeToolCall:
if step.Tool == "" {
stepErr = fmt.Errorf("tool not specified for step %s", step.ID)
} else {
call := agent.ToolCall{
ID: step.ID,
Name: step.Tool,
Arguments: step.Args,
}
resp, err := e.agentRegistry.Execute(ctx, call)
if err != nil {
stepErr = err
} else {
result = resp.Content
if resp.IsError {
stepErr = fmt.Errorf("%s", result)
}
}
}
case TypeApproval:
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusAwaiting
})
if onStep != nil {
onStep(step, "awaiting_approval")
}
return nil
case TypeCondition:
result = fmt.Sprintf("condition '%s' evaluated", step.Condition)
default:
stepErr = fmt.Errorf("unknown step type: %s", step.Type)
}
endTime := time.Now()
if stepErr != nil {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusFailed
s.Error = stepErr.Error()
s.EndedAt = &endTime
})
if onStep != nil {
onStep(step, "failed")
}
} else {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusDone
s.Result = result
s.EndedAt = &endTime
})
stepStatuses[step.ID] = StatusDone
if onStep != nil {
onStep(step, "done")
}
}
return stepErr
}
hasFailures := false
for _, step := range wf.Steps {
if step.Type == TypeParallel {
continue
}
for !resolveDeps(step.ID) {
time.Sleep(100 * time.Millisecond)
}
if err := executeStep(&step); err != nil {
hasFailures = true
break
}
}
if hasFailures {
e.UpdateWorkflowStatus(workflowID, StatusFailed)
} else {
e.UpdateWorkflowStatus(workflowID, StatusDone)
}
return nil
}
func (w *Workflow) findStep(id string) *Step {
for i := range w.Steps {
if w.Steps[i].ID == id {
return &w.Steps[i]
}
}
return nil
}
func (e *Engine) ApproveStep(workflowID, stepID string) error {
return e.UpdateStep(workflowID, stepID, func(s *Step) {
s.Status = StatusDone
})
}
func (e *Engine) SkipStep(workflowID, stepID string) error {
return e.UpdateStep(workflowID, stepID, func(s *Step) {
s.Status = StatusSkipped
})
}

View File

@@ -0,0 +1,172 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/orchestrator"
)
type Planner struct {
orchestrator *orchestrator.Orchestrator
}
func NewPlanner(cfg *config.MuyueConfig) (*Planner, error) {
orb, err := orchestrator.New(cfg)
if err != nil {
return nil, err
}
orb.SetSystemPrompt(plannerSystemPrompt)
return &Planner{orchestrator: orb}, nil
}
func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) {
prompt := buildPlanPrompt(goal)
messages := []orchestrator.Message{
{Role: "user", Content: prompt},
}
resp, err := p.orchestrator.SendWithTools(messages)
if err != nil {
return nil, err
}
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
return nil, fmt.Errorf("no plan generated")
}
content := resp.Choices[0].Message.Content
plan, err := parsePlanResponse(content)
if err != nil {
return nil, err
}
return plan, nil
}
func buildPlanPrompt(goal string) string {
return fmt.Sprintf(`Tu es un planificateur de workflows pour Muyue. L'utilisateur veut accomplir la tâche suivante:
"%s"
Analyse cette tâche et génère un plan d'exécution en une série d'étapes. Chaque étape est un appel d'outil.
Les outils disponibles sont:
- terminal: Exécuter une commande shell
- read_file: Lire un fichier
- list_files: Lister les fichiers d'un répertoire
- search_files: Rechercher des fichiers par pattern
- grep_content: Rechercher du texte dans des fichiers
- get_config: Lire la configuration Muyue
- set_provider: Configurer un provider AI
- manage_ssh: Gérer les connexions SSH
- web_fetch: Récupérer le contenu d'une URL
Réponds UNIQUEMENT avec un JSON valide représentant un tableau d'étapes, sans texte avant ou après:
[
{"name": "Nom de l'étape", "tool": "terminal", "args": {"command": "ls -la"}},
{"name": "Lire le fichier config", "tool": "read_file", "args": {"path": "~/.muyue/config.json"}}
]
Règles:
- Chaque étape doit avoir: name, tool, args
- Les args varient selon le tool (voir les définitions)
- Sois précis dans les commandes
- Sépare en étapes logiques
- Ne génère pas plus de 10 étapes`, goal)
}
func parsePlanResponse(content string) ([]Step, error) {
content = strings.TrimSpace(content)
var jsonStr string
if strings.HasPrefix(content, "```json") {
lines := strings.Split(content, "\n")
var jsonLines []string
for _, line := range lines[1:] {
if strings.HasPrefix(line, "```") {
break
}
jsonLines = append(jsonLines, line)
}
jsonStr = strings.Join(jsonLines, "\n")
} else if strings.HasPrefix(content, "```") {
lines := strings.Split(content, "\n")
var jsonLines []string
for _, line := range lines[1:] {
if strings.HasPrefix(line, "```") {
break
}
jsonLines = append(jsonLines, line)
}
jsonStr = strings.Join(jsonLines, "\n")
} else {
jsonStr = content
}
var rawSteps []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &rawSteps); err != nil {
return nil, fmt.Errorf("failed to parse plan JSON: %v\nContent: %s", err, content)
}
steps := make([]Step, 0, len(rawSteps))
for i, raw := range rawSteps {
step := Step{
ID: fmt.Sprintf("step-%d", i),
Status: StatusPending,
}
if name, ok := raw["name"].(string); ok {
step.Name = name
} else {
step.Name = fmt.Sprintf("Step %d", i+1)
}
if tool, ok := raw["tool"].(string); ok {
step.Tool = tool
step.Type = TypeToolCall
}
if args, ok := raw["args"].(map[string]interface{}); ok {
argsJSON, err := json.Marshal(args)
if err == nil {
step.Args = argsJSON
}
}
if tool, ok := raw["type"].(string); ok {
switch tool {
case "approval":
step.Type = TypeApproval
case "condition":
step.Type = TypeCondition
if cond, ok := raw["condition"].(string); ok {
step.Condition = cond
}
default:
step.Type = TypeToolCall
}
}
steps = append(steps, step)
}
return steps, nil
}
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils.
Pour générer un plan:
1. Comprends l'objectif de l'utilisateur
2. Identifie les outils nécessaires
3. Décompose en étapes logiques
4. Spécifie les paramètres de chaque outil
Réponds toujours en JSON valide, sans texte additionnel.`
var _ = plannerSystemPrompt