diff --git a/README.md b/README.md index 3378c3a..620a4d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,67 @@ # eventmesh-workflow -Apache eventmesh + +Apache EventMesh workflow runtime — Serverless Workflow DSL `1.0.3` compatible. + +## Documentation / 文档 + +| English | 中文 | Description / 说明 | +| --- | --- | --- | +| [DESIGN.md](docs/DESIGN.md) | [DESIGN_CN.md](docs/DESIGN_CN.md) | Architecture design, components, data model, task executors, A2A bridge | +| [USAGE.md](docs/USAGE.md) | [USAGE_CN.md](docs/USAGE_CN.md) | Quick start, DSL writing guide, 12 task types, REST API, complete examples | + +## Key Features + +- **Dual DSL Compatibility**: Serverless Workflow 1.0.3 (`document` + `do`) and 0.8 (`id` + `states`) +- **Zero External DSL Dependency**: Custom `third_party/swf/` parser replacing sdk-go/v2 +- **12 Task Types**: call / listen / switch / set / do / fork / for / try / wait / raise / run / emit +- **Built-in Structural Executors**: fork (parallel) / try (error handling) / for (loop) / do (sequence) +- **A2A Bidirectional Bridge**: Workflow can call external A2A Agents; can also be exposed as an A2A Agent +- **JQ Data Filtering**: Input/output level JSON filter pipeline +- **4 Runtime Executors**: Operation / Event / Switch / LocalRuntime + +## Architecture at a Glance + +``` +Controller(HTTP API) → DAL(MySQL) ← DSL Parser(swf) + ↓ +Flow Engine → Queue(In-Memory/EventMesh) → Task Executors + ├─ OperationTask → EventMesh/A2A + ├─ EventTask + ├─ SwitchTask → JQ condition matching + └─ LocalRuntimeTask → set/do/fork/for/try/wait/raise/run/emit +``` + +## Implementation Status + +| Phase | Scope | Status | +| --- | --- | --- | +| 1 | Local DSL parser replacing sdk-go/v2 | Done | +| 2 | DSL 1.0.3 `document` + `do` parsing | Done | +| 3 | DSL 0.8 legacy compatibility | Done | +| 4 | 12 task type mapping | Done | +| 5 | Task relation graph construction (then / switch / fork) | Done | +| 6 | Structural task built-in executors | Done | +| 7 | output/schedule/data field support | Done | +| 8 | A2A bidirectional bridge (Client + WorkflowAgent) | Done | +| 9 | Full Go test suite | Done | + +## Quick Start + +```bash +# Initialize database +mysql -u root -p < distribution/mysql-schema.sql + +# Build +make build + +# Start services +./bin/eventmesh-workflow controller --config configs/controller.yaml +./bin/eventmesh-workflow engine --config configs/engine.yaml + +# Register a workflow +curl -X POST http://localhost:8080/workflow \ + -H "Content-Type: application/json" \ + -d '{"workflow_id": "demo", "workflow_name": "demo", "definition": "document:\n dsl: \"1.0.3\"\n name: demo\n version: \"1.0.0\"\ndo:\n - hello:\n set:\n greeting: \"Hello, World!\"\n then: end"}' +``` + +Example workflows: `configs/testcreateworkflow-v1.yaml` (DSL 1.0.3) / `configs/testcreateworkflow.yaml` (DSL 0.8) diff --git a/cmd/controller/main.go b/cmd/controller/main.go index cd7bbe0..3f5c41f 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -97,6 +97,7 @@ func (s *Server) router() { s.server.GET("/workflow/:workflowId", s.workflow.QueryDetail) s.server.DELETE("/workflow/:workflowId", s.workflow.Delete) s.server.GET("/workflow/instances", s.workflow.QueryInstances) + s.server.POST("/workflow/start", s.workflow.Start) } func (s *Server) setupConfig() error { diff --git a/cmd/controller/workflow.go b/cmd/controller/workflow.go index 24e3704..81a8721 100644 --- a/cmd/controller/workflow.go +++ b/cmd/controller/workflow.go @@ -16,10 +16,13 @@ package main import ( + "context" + "net/http" + + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/flow" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" "github.com/gin-gonic/gin" - "net/http" ) const ( @@ -29,11 +32,13 @@ const ( // WorkflowController workflow controller operations type WorkflowController struct { workflowDAL dal.WorkflowDAL + engine *flow.Engine } func NewWorkflowController() *WorkflowController { c := WorkflowController{} c.workflowDAL = dal.NewWorkflowDAL() + c.engine = flow.NewEngine() return &c } @@ -143,6 +148,43 @@ func (c *WorkflowController) Delete(ctx *gin.Context) { ctx.JSON(http.StatusOK, nil) } +// StartWorkflowRequest start workflow request +type StartWorkflowRequest struct { + WorkflowID string `json:"workflow_id" binding:"required"` + Input string `json:"input"` +} + +// StartWorkflowResponse start workflow response +type StartWorkflowResponse struct { + InstanceID string `json:"instance_id"` +} + +// Start start a workflow instance +// @Summary start a workflow instance +// @Description start a workflow instance +// @Tags workflow +// @Accept json +// @Produce json +// @Param request body StartWorkflowRequest true "start request" +// @Success 200 {object} StartWorkflowResponse +// @Failure 400 +// @Failure 500 +// @Router /workflow/start [post] +func (c *WorkflowController) Start(ctx *gin.Context) { + request := StartWorkflowRequest{} + if err := ctx.ShouldBind(&request); err != nil { + ctx.JSON(http.StatusBadRequest, err.Error()) + return + } + param := &flow.WorkflowParam{ID: request.WorkflowID, Input: request.Input} + instanceID, err := c.engine.Start(context.Background(), param) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, StartWorkflowResponse{InstanceID: instanceID}) +} + // QueryInstances query workflow instances // @Summary query workflow instances // @Description query workflow instances diff --git a/configs/testcreateworkflow-v1.yaml b/configs/testcreateworkflow-v1.yaml new file mode 100644 index 0000000..7bcd39b --- /dev/null +++ b/configs/testcreateworkflow-v1.yaml @@ -0,0 +1,69 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.3' + namespace: eventmesh.apache.org + name: store-order-management + version: '1.0.0' + title: Store Order Management Workflow +use: + functions: + sendOrder: + call: asyncapi + with: + operation: file://orderapp.yaml#sendOrder + sendPayment: + call: asyncapi + with: + operation: file://paymentapp.yaml#sendPayment + sendShipment: + call: asyncapi + with: + operation: file://expressapp.yaml#sendExpress +do: + - receiveNewOrderEvent: + listen: + to: + one: + with: + type: online.store.newOrder + source: store/order + then: checkNewOrderResult + - checkNewOrderResult: + switch: + - newOrderSuccessful: + when: .order_no != "" + then: sendOrderPayment + - newOrderFailed: + then: end + - sendOrderPayment: + call: asyncapi + with: + operation: file://paymentapp.yaml#sendPayment + then: checkPaymentStatus + - checkPaymentStatus: + switch: + - paymentSuccessful: + when: .order_no != "" + then: sendOrderShipment + - paymentDenied: + then: end + - sendOrderShipment: + call: asyncapi + with: + operation: file://expressapp.yaml#sendExpress + then: end diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..40a0793 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,444 @@ +# EventMesh Workflow Design Document + +> **Version**: v1.0.0 | **Updated**: 2026-06-29 | **DSL**: Serverless Workflow 1.0.3 (0.8 compatible) + +--- + +## 1. Overview + +EventMesh Workflow is the workflow runtime in the Apache EventMesh ecosystem. It parses Serverless Workflow DSL definitions, builds task graphs, schedules execution, and orchestrates microservices on the EventMesh event bus. + +### 1.1 Core Capabilities + +| Capability | Description | +| --- | --- | +| **DSL Parsing** | Supports Serverless Workflow 1.0.3 and 0.8 dual formats, zero external DSL dependency | +| **Task Graph** | Compiles DSL descriptions into a DAG (START -> Task -> Transition -> END) | +| **Runtime Scheduling** | Queue-based multi-instance scheduling engine for operation/event/switch/structural tasks | +| **EventMesh Integration** | Queries operation definitions via gRPC Catalog, publishes events asynchronously | +| **A2A Bridge** | Workflow exposed as A2A Agent; supports calling external A2A Agents | +| **Structural Tasks** | 9 built-in executors for fork/try/for/do/set and other structural tasks | + +--- + +## 2. Architecture Overview + +``` +┌──────────────────────────────────────────────────────────┐ +│ Controller (HTTP API) │ +│ POST /workflow GET /workflow DELETE /workflow │ +│ POST /workflow/start GET /workflow/instances │ +└───────────────────────┬──────────────────────────────────┘ + │ DAL (GORM + MySQL) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ DSL Parser (third_party/swf) │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ V1 Parser │ │ Legacy Parser│ │ Task Validator │ │ +│ │ (document+do)│ │ (id+states) │ │ (name/then/graph)│ │ +│ └──────────────┘ └──────────────┘ └────────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ Workflow + Tasks + Relations + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Flow Engine │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ Start() │ │ Transition() │ │ Queue.Publish()│ │ +│ │ (instantiate) │ │ (state trans) │ │ (task enqueue) │ │ +│ └──────────────┘ └──────────────┘ └────────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ ObserveQueue + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Task Executors │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ +│ │Operation │ │ Event │ │ Switch │ │Local Runtime │ │ +│ │ Task │ │ Task │ │ Task │ │ Task │ │ +│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────────┐ + │ EventMesh│ │ A2A │ │ Local Ops │ + │ Queue │ │ Bridge │ │ (set/wait/…) │ + └──────────┘ └──────────┘ └──────────────┘ +``` + +### 2.1 Component Roles + +| Component | Path | Responsibility | +| --- | --- | --- | +| **Controller** | `cmd/controller/` | HTTP API service, Gin framework, Swagger docs | +| **Flow Engine** | `flow/` | Workflow instance start, task state transitions | +| **DSL Parser** | `third_party/swf/` | YAML -> Workflow/Task structs, dual-format parsing | +| **DAL** | `internal/dal/` | GORM + MySQL persistence, task graph construction | +| **Task Executors** | `internal/task/` | 4 executor types: operation/event/switch/local-runtime | +| **Queue** | `internal/queue/` | Task queue abstraction: In-Memory / EventMesh | +| **Filter** | `internal/filter/` | JQ expression input/output data filtering | +| **A2A Bridge** | `internal/bridge/` | A2A protocol client + WorkflowAgent HTTP endpoint | +| **Metrics** | `internal/metrics/` | Prometheus metrics collection | + +--- + +## 3. DSL Parser Design + +### 3.1 Dual-Format Compatibility + +The parser entry point `swf.Parse()` detects the DSL version by checking for a `document` key at the top level: + +```go +func Parse(source string) (*Workflow, error) { + var raw map[string]interface{} + yaml.Unmarshal([]byte(source), &raw) + if _, ok := raw["document"]; ok { + return parseV1Workflow(raw) // DSL 1.0.3 + } + return parseLegacyWorkflow(raw) // DSL 0.8 +} +``` + +### 3.2 DSL 1.0.3 Parsing Flow + +``` +YAML Source + │ + ▼ +parseV1Workflow(raw) + │ + ├─ document.name / version / dsl → Workflow metadata + ├─ raw["do"] → parseV1TaskList → []*Task + ├─ raw["use"].functions → map[string]*Function + ├─ raw["schedule"] → Schedule (cron/start/after) + ├─ raw["input"] → data input filter + ├─ raw["output"].as → output filter + │ + ▼ +wf.Validate() + ├─ FlattenTasks() → flatten nested tasks + ├─ Check task name uniqueness + ├─ Validate then/switch.when targets + │ + ▼ +*Workflow (normalized result) +``` + +### 3.3 Task Type Detection + +```go +func detectV1TaskType(def map[string]interface{}) string { + if _, ok := def["call"]; ok → TaskTypeOperation + // Detect standard DSL keys: + // switch / set / do / fork / for / try + // wait / raise / run / emit / listen + return TaskTypeOperation // default fallback +} +``` + +### 3.4 Data Model + +``` +Workflow + ├─ ID, Name, Version, DSL, Namespace + ├─ Start: entry task name + ├─ Tasks: []*Task + │ ├─ Name, Type, InputFilter, OutputFilter + │ ├─ InlineData, Then, ExplicitThen + │ ├─ Actions: []*Action {OperationName, OperationType} + │ ├─ Cases: []*SwitchCase {Name, Condition, Then, IsDefault} + │ └─ Children: []*Task (nested sub-tasks) + ├─ Functions: map[string]*Function {Name, Operation, Type} + └─ Schedule: {Start, Cron, After} +``` + +--- + +## 4. Task Graph Construction + +### 4.1 DAL.create() Flow + +```go +func (w *workflowDALImpl) create(ctx context.Context, tx *gorm.DB, record *model.Workflow) error { + wf, _ := swf.Parse(record.Definition) + + // 1. Build WorkflowTask (flatten all nested tasks) + tasks := w.buildTask(wf) // FlattenTasks → model.WorkflowTask + + // 2. Build WorkflowTaskRelation (inter-task edges) + relations := w.buildTaskRelation(wf, tasks) + + // 3. Concurrent write to MySQL +} +``` + +### 4.2 Task Relation Construction Rules + +``` +START → first task (workflow.Start) + +For each task: + ┌─ Switch type → buildSwitchTaskRelation + │ each case.Then → target TaskID (or END) + ├─ Fork type → buildForkTaskRelation + │ each child → independent branch edge + ├─ Explicit then → resolveNextTaskID(Then) + │ "end"/"exit"/"continue" → END + │ named target → taskIDs[name] + └─ Default → Children[0].Name (nested structure) or END +``` + +### 4.3 Fork Task Graph Example + +``` + ┌─────────┐ + │ FORK │ + └────┬────┘ + ┌─────┴─────┐ + ▼ ▼ + branch_a branch_b + │ │ + ▼ ▼ + END END +``` + +During fork construction, all child branches are published in parallel via `publishNextTasks()`. + +--- + +## 5. Task Executors + +### 5.1 Task Dispatch Factory + +```go +func New(instance *model.WorkflowTaskInstance) Task { + if isLocalRuntimeTask(taskType) → NewLocalRuntimeTask + switch taskType: + operation → NewOperationTask + event → NewEventTask + switch → NewSwitchTask + default → NewOperationTask // fallback +} +``` + +### 5.2 Executor Types + +| Executor | Handles | Run() Behavior | +| --- | --- | --- | +| **OperationTask** | call / listen / run | Executes catalog operations, publishes EventMesh events / A2A calls | +| **EventTask** | event / listen | Delegates to OperationTask | +| **SwitchTask** | switch | JQ condition matching → branch selection → publishOrComplete | +| **LocalRuntimeTask** | set / do / fork / for / try / wait / raise / emit | Built-in executors, see §5.3 | + +### 5.3 LocalRuntimeTask Built-in Executors + +| Task | execute() Method | Logic | +| --- | --- | --- | +| **set** | executeSet() | JQ Object() applies set expressions to input JSON | +| **do** | executeDo() | Sequentially executes sub-tasks in do list (set / raise) | +| **fork** | (no local exec) | DAL builds multi-branch relations; run() then publishNextTasks | +| **for** | executeFor() | Parses JSON array → iterates each element through do body set tasks | +| **try** | executeTry() | Sequentially attempts try list tasks; skips on failure | +| **wait** | executeWait() | time.ParseDuration → time.Sleep | +| **raise** | executeRaise() | Constructs structured error and returns it | +| **run** | executeRun() | Publishes to EventMesh (delegates publishEvent) | +| **emit** | executeEmit() | Publishes to EventMesh (delegates publishEvent) | + +### 5.4 Data Flow + +``` +Input ──→ [InputFilter] ──→ Task.Execute() ──→ output ──→ [OutputFilter] ──→ Next Task + │ + publishNextOrComplete() + (publish next task to Queue) +``` + +- **InputFilter**: Applied during task enqueue via `FilterWorkflowTaskInputData()` +- **OutputFilter**: Applied before LocalRuntimeTask.Run() returns via `FilterWorkflowTaskOutputData()` +- Filters use JQ expression syntax: `${ .field }` + +--- + +## 6. Queue & Scheduling + +### 6.1 Queue Abstraction + +```go +type ObserveQueue interface { + Publish(instances []*model.WorkflowTaskInstance) error + Subscribe(handler func(*model.WorkflowTaskInstance)) + UnSubscribe() error +} +``` + +Two implementations: +- **InMemoryQueue**: Dev/test environment, memory channel +- **EventMeshQueue**: Production, published via EventMesh SDK + +### 6.2 Scheduling Flow + +``` +Engine.Start(param) + → SelectStartTask → find the first task linked to START + → InsertInstance → create workflow instance record + → Queue.Publish(taskInstance) → enqueue + +Consumer processing: + → task.New(instance) → create corresponding executor + → task.Run() + → OperationTask: publish EventMesh event (async) + enqueue next task (sleep) + → EventTask: delegate to OperationTask + → SwitchTask: match condition → publish next task + → LocalRuntimeTask: synchronous execution → publish next task + +Engine.Transition(param) ← EventMesh callback + → SelectTransitionTask (sleep → wait) + → Queue.Publish → re-enqueue for consumption +``` + +--- + +## 7. A2A Bridge + +### 7.1 Architecture + +``` +┌────────────────────┐ A2A Protocol ┌──────────────────┐ +│ EventMesh Workflow │ ──── call a2a: ──────→ │ External A2A │ +│ (A2A Client) │ ←─── task result ──── │ Agent │ +└────────────────────┘ └──────────────────┘ + +┌────────────────────┐ A2A Protocol ┌──────────────────┐ +│ External A2A │ ──── POST /a2a/tasks ──→ │ EventMesh Workflow│ +│ Client │ ←─── task status ───── │ (WorkflowAgent) │ +└────────────────────┘ └──────────────────┘ +``` + +### 7.2 A2A Client + +`A2AExecutor` implements polling-based task execution: + +```go +SendTask(input, metadata) → pollUntilComplete(taskID) + // Poll /a2a/tasks/{id} every 2 seconds, max 30 attempts +``` + +Task detection via `isA2ATask()`: `OperationType == "a2a"` or `OperationName` starts with `"a2a"`. + +### 7.3 WorkflowAgent (Server) + +Exposes workflows as A2A Agents: + +``` +GET /.well-known/agent-card.json → Agent Card +POST /a2a/tasks → Start workflow instance +GET /a2a/tasks/{id} → Query workflow instance status +GET /a2a/health → Health check +``` + +### 7.4 A2A Message Format (v1.0 Compatible) + +``` +TaskRequest { + id, message: { role, parts: [{ type: "text", text }] }, + metadata: { source, timestamp, ... } +} + +TaskResponse { + id, status: "working"|"completed"|"failed", + message, artifacts: [{ name, parts }], error: { message } +} +``` + +--- + +## 8. Database Model + +### 8.1 ER Diagram + +``` +t_workflow ──1:N── t_workflow_task ──1:N── t_workflow_task_action + │ │ + │ └──1:N── t_workflow_task_relation + │ + └──1:N── t_workflow_instance ──1:N── t_workflow_task_instance +``` + +### 8.2 Core Tables + +| Table | Purpose | Key Fields | +| --- | --- | --- | +| t_workflow | Workflow definition | workflow_id, definition(DSL YAML), version | +| t_workflow_task | Flattened task nodes | task_id, task_name, task_type, task_input_filter, task_output_filter | +| t_workflow_task_action | Task operation definitions | operation_name, operation_type | +| t_workflow_task_relation | Inter-task edges | from_task_id, to_task_id, condition | +| t_workflow_instance | Workflow instances | workflow_instance_id, workflow_status | +| t_workflow_task_instance | Task instances | task_instance_id, status, input | + +### 8.3 State Machine + +``` +Task instance: SLEEP(1) → WAIT(2) → PROCESS(3) → SUCCESS(4) / FAIL(5) + +Workflow instance: PROCESS(1) → SUCCESS(2) +``` + +- **SLEEP**: OperationTask enqueues the next task immediately after publishing an event, with SLEEP status; waits for EventMesh callback Transition +- **WAIT**: Ready, awaiting consumption +- **PROCESS**: Being consumed (actual execution) +- **SUCCESS** / **FAIL**: Terminal state + +--- + +## 9. Data Filtering + +JQ (itchyny/gojq) based JSON data filtering: + +```go +FilterWorkflowTaskInputData(task) + → filterJsonData(task.TaskInputFilter, task.Input) + → jqer.Object(jsonObj, "${ filterExp }") + +FilterWorkflowTaskOutputData(input, outputFilter) + → filterJsonData(outputFilter, input) + → jqer.Object(jsonObj, "${ filterExp }") +``` + +Expression normalization: bare expressions are auto-wrapped as `${ expr }`. + +--- + +## 10. Module Dependencies + +``` +cmd/controller ──→ internal/dal +cmd/engine ──→ flow ──→ internal/dal + ──→ internal/queue + +internal/dal ──→ third_party/swf + ──→ internal/dal/model + ──→ internal/util + +internal/task ──→ internal/dal + ──→ internal/dal/model + ──→ internal/queue + ──→ internal/bridge + ──→ internal/filter + ──→ third_party/jqer + ──→ third_party/swf (type constants) +``` + +Zero external SWF SDK dependency. `third_party/swf/` is a fully self-contained DSL parser. + +--- + +## 11. Design Decisions + +| Decision | Rationale | +| --- | --- | +| Zero external DSL dependency | sdk-go/v2 only supports 0.8 and cannot upgrade; fully controllable custom parser | +| Dual-format compatibility | Gradual migration; existing 0.8 workflows unaffected | +| Flattened task graph | Unified task graph model simplifies runtime scheduling | +| LocalRuntimeTask | Built-in executors avoid external EventMesh call overhead for structural tasks | +| A2A polling mode | Simple and reliable; suitable for non-streaming Agent call scenarios | +| GORM + MySQL | Consistent with EventMesh ecosystem; transaction support | +| JQ expression filtering | Lightweight JSON transformation, zero new dependencies (reuses gojq) | diff --git a/docs/DESIGN_CN.md b/docs/DESIGN_CN.md new file mode 100644 index 0000000..e2feadb --- /dev/null +++ b/docs/DESIGN_CN.md @@ -0,0 +1,444 @@ +# EventMesh Workflow 设计文档 + +> **版本**: v1.0.0 | **更新时间**: 2026-06-29 | **DSL 版本**: Serverless Workflow 1.0.3 (兼容 0.8) + +--- + +## 1. 概述 + +EventMesh Workflow 是 Apache EventMesh 生态的工作流运行时,负责解析 Serverless Workflow DSL 定义、构建任务图、调度执行并在 EventMesh 事件总线上编排微服务。 + +### 1.1 核心能力 + +| 能力 | 说明 | +| --- | --- | +| **DSL 解析** | 支持 Serverless Workflow 1.0.3 和 0.8 双格式,零外部 DSL 依赖 | +| **任务图构建** | 将 DSL 描述编译为有向任务图 (START → Task → Transition → END) | +| **运行时调度** | 基于队列的多实例调度引擎,支持操作/事件/开关/结构型任务 | +| **EventMesh 集成** | 通过 gRPC Catalog 查询操作定义,异步发布事件 | +| **A2A 桥接** | 工作流作为 A2A Agent 暴露,支持调用外部 A2A Agent | +| **结构型任务** | fork/try/for/do/set 等 9 种结构型任务内建执行器 | + +--- + +## 2. 架构总览 + +``` +┌──────────────────────────────────────────────────────────┐ +│ Controller (HTTP API) │ +│ POST /workflow GET /workflow DELETE /workflow │ +│ POST /workflow/start GET /workflow/instances │ +└───────────────────────┬──────────────────────────────────┘ + │ DAL (GORM + MySQL) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ DSL Parser (third_party/swf) │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ V1 Parser │ │ Legacy Parser│ │ Task Validator │ │ +│ │ (document+do)│ │ (id+states) │ │ (name/then/graph)│ │ +│ └──────────────┘ └──────────────┘ └────────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ Workflow + Tasks + Relations + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Flow Engine │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ Start() │ │ Transition() │ │ Queue.Publish()│ │ +│ │ (实例化) │ │ (状态转移) │ │ (任务入队) │ │ +│ └──────────────┘ └──────────────┘ └────────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ ObserveQueue + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Task Executors │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ +│ │Operation │ │ Event │ │ Switch │ │Local Runtime │ │ +│ │ Task │ │ Task │ │ Task │ │ Task │ │ +│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │ +└───────────────────────┬──────────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────────┐ + │ EventMesh│ │ A2A │ │ Local Ops │ + │ Queue │ │ Bridge │ │ (set/wait/…) │ + └──────────┘ └──────────┘ └──────────────┘ +``` + +### 2.1 组件分工 + +| 组件 | 路径 | 职责 | +| --- | --- | --- | +| **Controller** | `cmd/controller/` | HTTP API 服务,Gin 框架,Swagger 文档 | +| **Flow Engine** | `flow/` | 工作流实例启动、任务状态转移 | +| **DSL Parser** | `third_party/swf/` | YAML → Workflow/Task 结构体,双格式解析 | +| **DAL** | `internal/dal/` | GORM + MySQL 持久化,任务图构建 | +| **Task Executors** | `internal/task/` | 4 类执行器:操作/事件/开关/本地运行时 | +| **Queue** | `internal/queue/` | 任务队列抽象,支持 In-Memory / EventMesh | +| **Filter** | `internal/filter/` | JQ 表达式输入/输出数据过滤 | +| **A2A Bridge** | `internal/bridge/` | A2A 协议客户端 + WorkflowAgent HTTP 端点 | +| **Metrics** | `internal/metrics/` | Prometheus 指标采集 | + +--- + +## 3. DSL 解析器设计 + +### 3.1 双格式兼容 + +解析器入口 `swf.Parse()` 通过检测顶层是否包含 `document` 键来区分 DSL 版本: + +```go +func Parse(source string) (*Workflow, error) { + var raw map[string]interface{} + yaml.Unmarshal([]byte(source), &raw) + if _, ok := raw["document"]; ok { + return parseV1Workflow(raw) // DSL 1.0.3 + } + return parseLegacyWorkflow(raw) // DSL 0.8 +} +``` + +### 3.2 DSL 1.0.3 解析流程 + +``` +YAML Source + │ + ▼ +parseV1Workflow(raw) + │ + ├─ document.name / version / dsl → Workflow 元信息 + ├─ raw["do"] → parseV1TaskList → []*Task + ├─ raw["use"].functions → map[string]*Function + ├─ raw["schedule"] → Schedule (cron/start/after) + ├─ raw["input"] → data input filter + ├─ raw["output"].as → output filter + │ + ▼ +wf.Validate() + ├─ FlattenTasks() → 展平嵌套任务 + ├─ 检查任务名唯一性 + ├─ 校验 then/switch.when 目标有效性 + │ + ▼ +*Workflow (标准化结果) +``` + +### 3.3 任务类型检测 + +```go +func detectV1TaskType(def map[string]interface{}) string { + if _, ok := def["call"]; ok → TaskTypeOperation + // 按 DSL 标准键检测: + // switch / set / do / fork / for / try + // wait / raise / run / emit / listen + return TaskTypeOperation // 默认 fallback +} +``` + +### 3.4 数据模型 + +``` +Workflow + ├─ ID, Name, Version, DSL, Namespace + ├─ Start: 入口任务名 + ├─ Tasks: []*Task + │ ├─ Name, Type, InputFilter, OutputFilter + │ ├─ InlineData, Then, ExplicitThen + │ ├─ Actions: []*Action {OperationName, OperationType} + │ ├─ Cases: []*SwitchCase {Name, Condition, Then, IsDefault} + │ └─ Children: []*Task (嵌套子任务) + ├─ Functions: map[string]*Function {Name, Operation, Type} + └─ Schedule: {Start, Cron, After} +``` + +--- + +## 4. 任务图构建 + +### 4.1 DAL.create() 流程 + +```go +func (w *workflowDALImpl) create(ctx context.Context, tx *gorm.DB, record *model.Workflow) error { + wf, _ := swf.Parse(record.Definition) + + // 1. 构建 WorkflowTask (展平所有嵌套任务) + tasks := w.buildTask(wf) // FlattenTasks → model.WorkflowTask + + // 2. 构建 WorkflowTaskRelation (任务间连线) + relations := w.buildTaskRelation(wf, tasks) + + // 3. 并发写入 MySQL +} +``` + +### 4.2 任务关系构建规则 + +``` +START → 第一个任务 (workflow.Start) + +对每个任务: + ┌─ Switch 类型 → buildSwitchTaskRelation + │ 每个 case.Then → 目标 TaskID (或 END) + ├─ Fork 类型 → buildForkTaskRelation + │ 每个 child → 独立分支连线 + ├─ 显式 then → resolveNextTaskID(Then) + │ "end"/"exit"/"continue" → END + │ 命名目标 → taskIDs[name] + └─ 默认 → Children[0].Name (嵌套结构) 或 END +``` + +### 4.3 Fork 任务图示例 + +``` + ┌─────────┐ + │ FORK │ + └────┬────┘ + ┌─────┴─────┐ + ▼ ▼ + branch_a branch_b + │ │ + ▼ ▼ + END END +``` + +Fork 构建时通过 `publishNextTasks()` 并行发布所有子分支。 + +--- + +## 5. 任务执行器 + +### 5.1 任务分发工厂 + +```go +func New(instance *model.WorkflowTaskInstance) Task { + if isLocalRuntimeTask(taskType) → NewLocalRuntimeTask + switch taskType: + operation → NewOperationTask + event → NewEventTask + switch → NewSwitchTask + default → NewOperationTask // 兜底 +} +``` + +### 5.2 执行器类型 + +| 执行器 | 处理类型 | Run() 行为 | +| --- | --- | --- | +| **OperationTask** | call / listen / run | 执行 catalog 的 operation,发布 EventMesh 事件 / A2A 调用 | +| **EventTask** | event / listen | 委托给 OperationTask 执行 | +| **SwitchTask** | switch | JQ 条件匹配 → 选择分支 → publishOrComplete | +| **LocalRuntimeTask** | set / do / fork / for / try / wait / raise / emit | 内建执行器,详见 §5.3 | + +### 5.3 LocalRuntimeTask 内建执行器 + +| 任务 | execute() 方法 | 逻辑 | +| --- | --- | --- | +| **set** | executeSet() | JQ Object() 将 set 表达式应用到输入 JSON | +| **do** | executeDo() | 顺序执行 do 列表中的子任务(set / raise) | +| **fork** | 不做本地执行 | 由 DAL 构建多分支 relations,run() 之后 publishNextTasks | +| **for** | executeFor() | 解析 JSON 数组 → 逐元素执行 do body 中的 set | +| **try** | executeTry() | 顺序尝试 try 列表中的任务,失败跳过 | +| **wait** | executeWait() | time.ParseDuration → time.Sleep | +| **raise** | executeRaise() | 构造结构化错误并返回 | +| **run** | executeRun() | 发布到 EventMesh (委托 publishEvent) | +| **emit** | executeEmit() | 发布到 EventMesh (委托 publishEvent) | + +### 5.4 数据流转 + +``` +Input ──→ [InputFilter] ──→ Task.Execute() ──→ output ──→ [OutputFilter] ──→ Next Task + │ + publishNextOrComplete() + (发布下一个任务到 Queue) +``` + +- **InputFilter**: 在任务入队时通过 `FilterWorkflowTaskInputData()` 应用 +- **OutputFilter**: 在 LocalRuntimeTask.Run() 返回前通过 `FilterWorkflowTaskOutputData()` 应用 +- 过滤器基于 JQ 表达式:`${ .field }` 语法 + +--- + +## 6. 队列与调度 + +### 6.1 队列抽象 + +```go +type ObserveQueue interface { + Publish(instances []*model.WorkflowTaskInstance) error + Subscribe(handler func(*model.WorkflowTaskInstance)) + UnSubscribe() error +} +``` + +两种实现: +- **InMemoryQueue**: 开发/测试环境,内存 chan +- **EventMeshQueue**: 生产环境,通过 EventMesh SDK 发布 + +### 6.2 调度流程 + +``` +Engine.Start(param) + → SelectStartTask → 找出 START 关联的第一个任务 + → InsertInstance → 创建工作流实例记录 + → Queue.Publish(taskInstance) → 入队 + +Consumer 消费: + → task.New(instance) → 创建对应执行器 + → task.Run() + → OperationTask: 发布 EventMesh 事件 (异步) + 入队下一任务 (sleep) + → EventTask: 委托 OperationTask + → SwitchTask: 匹配条件 → 发布下一任务 + → LocalRuntimeTask: 同步执行 → 发布下一任务 + +Engine.Transition(param) ← EventMesh 回调 + → SelectTransitionTask (sleep → wait) + → Queue.Publish → 重新入队消费 +``` + +--- + +## 7. A2A 桥接 + +### 7.1 架构 + +``` +┌────────────────────┐ A2A Protocol ┌──────────────────┐ +│ EventMesh Workflow │ ──── call a2a: ──────→ │ External A2A │ +│ (A2A Client) │ ←─── task result ──── │ Agent │ +└────────────────────┘ └──────────────────┘ + +┌────────────────────┐ A2A Protocol ┌──────────────────┐ +│ External A2A │ ──── POST /a2a/tasks ──→ │ EventMesh Workflow│ +│ Client │ ←─── task status ───── │ (WorkflowAgent) │ +└────────────────────┘ └──────────────────┘ +``` + +### 7.2 A2A Client + +`A2AExecutor` 实现轮询式任务执行: + +```go +SendTask(input, metadata) → pollUntilComplete(taskID) + // 每 2 秒轮询 /a2a/tasks/{id},最多 30 次 +``` + +任务检测 `isA2ATask()`: `OperationType == "a2a"` 或 `OperationName` 以 `"a2a"` 开头。 + +### 7.3 WorkflowAgent (Server) + +将工作流暴露为 A2A Agent: + +``` +GET /.well-known/agent-card.json → Agent Card +POST /a2a/tasks → 启动工作流实例 +GET /a2a/tasks/{id} → 查询工作流实例状态 +GET /a2a/health → 健康检查 +``` + +### 7.4 A2A 消息格式 (v1.0 兼容) + +``` +TaskRequest { + id, message: { role, parts: [{ type: "text", text }] }, + metadata: { source, timestamp, ... } +} + +TaskResponse { + id, status: "working"|"completed"|"failed", + message, artifacts: [{ name, parts }], error: { message } +} +``` + +--- + +## 8. 数据库模型 + +### 8.1 ER 图 + +``` +t_workflow ──1:N── t_workflow_task ──1:N── t_workflow_task_action + │ │ + │ └──1:N── t_workflow_task_relation + │ + └──1:N── t_workflow_instance ──1:N── t_workflow_task_instance +``` + +### 8.2 核心表 + +| 表 | 用途 | 关键字段 | +| --- | --- | --- | +| t_workflow | 工作流定义 | workflow_id, definition(DSL YAML), version | +| t_workflow_task | 展平后的任务节点 | task_id, task_name, task_type, task_input_filter, task_output_filter | +| t_workflow_task_action | 任务操作定义 | operation_name, operation_type | +| t_workflow_task_relation | 任务间连线 | from_task_id, to_task_id, condition | +| t_workflow_instance | 工作流实例 | workflow_instance_id, workflow_status | +| t_workflow_task_instance | 任务实例 | task_instance_id, status, input | + +### 8.3 状态机 + +``` +任务实例: SLEEP(1) → WAIT(2) → PROCESS(3) → SUCCESS(4) / FAIL(5) + +工作流实例: PROCESS(1) → SUCCESS(2) +``` + +- **SLEEP**: OperationTask 发布事件后立即入队下一任务,状态为 SLEEP;等待 EventMesh 回调 Transition +- **WAIT**: 就绪,待消费 +- **PROCESS**: 消费中(实际执行) +- **SUCCESS** / **FAIL**: 终态 + +--- + +## 9. 数据过滤 + +基于 JQ (itchyny/gojq) 实现 JSON 数据过滤: + +```go +FilterWorkflowTaskInputData(task) + → filterJsonData(task.TaskInputFilter, task.Input) + → jqer.Object(jsonObj, "${ filterExp }") + +FilterWorkflowTaskOutputData(input, outputFilter) + → filterJsonData(outputFilter, input) + → jqer.Object(jsonObj, "${ filterExp }") +``` + +表达式规范化:裸表达式自动包装为 `${ expr }`。 + +--- + +## 10. 模块依赖 + +``` +cmd/controller ──→ internal/dal +cmd/engine ──→ flow ──→ internal/dal + ──→ internal/queue + +internal/dal ──→ third_party/swf + ──→ internal/dal/model + ──→ internal/util + +internal/task ──→ internal/dal + ──→ internal/dal/model + ──→ internal/queue + ──→ internal/bridge + ──→ internal/filter + ──→ third_party/jqer + ──→ third_party/swf (类型常量) +``` + +零外部 SWF SDK 依赖。`third_party/swf/` 是完全自主实现的 DSL 解析器。 + +--- + +## 11. 设计决策 + +| 决策 | 理由 | +| --- | --- | +| 零外部 DSL 依赖 | sdk-go/v2 只支持 0.8,无法升级;自主解析器完全掌控 | +| 双格式兼容 | 渐进迁移,已有 0.8 工作流不受影响 | +| 展平任务图 | 统一 task graph 模型,简化运行时调度 | +| LocalRuntimeTask | 内建执行器避免对结构型任务的外部 EventMesh 调用开销 | +| A2A 轮询模式 | 简单可靠,适用于非流式 Agent 调用场景 | +| GORM + MySQL | 与 EventMesh 生态一致,支持事务 | +| JQ 表达式过滤 | 轻量 JSON 转换,零新依赖(复用 gojq) | diff --git a/docs/ISSUE.md b/docs/ISSUE.md new file mode 100644 index 0000000..4113fc6 --- /dev/null +++ b/docs/ISSUE.md @@ -0,0 +1,172 @@ +# Feature Request: Upgrade to Serverless Workflow DSL 1.0.3 + +**Labels**: `enhancement` + +--- + +## Feature Request + +### Motivation + +`eventmesh-workflow` currently targets Serverless Workflow DSL `0.8` and depends on the external `sdk-go/v2` library for model parsing. This creates several limitations: + +1. **Outdated DSL spec** — DSL 0.8 uses the legacy `id` + `states` model. DSL 1.0.3 introduces structured constructs (`do`, `fork`, `try`, `for`) that enable richer workflow orchestration. +2. **External dependency** — `sdk-go/v2` pins the project to a third-party model layer with its own compatibility and update cadence. +3. **Missing structured task executors** — fork/branch parallelism, error handling (try/catch), and loop iteration (for) have no runtime support. +4. **No A2A (Agent-to-Agent) integration** — workflows cannot participate as A2A agents and cannot invoke external A2A agents as task steps. + +### Desired Outcomes + +| # | Requirement | Priority | +|---|---|---| +| 1 | Parse DSL 1.0.3 `document` + `do` workflows with zero external SWF dependency | P0 | +| 2 | Maintain backward compatibility with DSL 0.8 `id` + `states` format | P0 | +| 3 | Execute 12 task types: `call`, `listen`, `switch`, `set`, `do`, `fork`, `for`, `try`, `wait`, `raise`, `run`, `emit` | P0 | +| 4 | Support fork parallel branch execution | P1 | +| 5 | Support try/catch error handling semantics | P1 | +| 6 | Support for loop iteration over JSON arrays | P1 | +| 7 | Support `input.from` / `output.as` filter pipeline with JQ expressions | P1 | +| 8 | Support workflow schedule fields (`start`, `cron`, `after`) | P2 | +| 9 | Bidirectional A2A bridge — invoke A2A agents from workflows, expose workflows as A2A agents | P2 | +| 10 | Maintain existing EventMesh integration (AsyncAPI/catalog operations) | P0 | + +--- + +## Solution + +### Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ DSL 1.0.3 YAML Document │ +│ document → use → do [task, fork, try, for, ...] → output │ +└───────────────────────┬──────────────────────────────────────────┘ + │ swf.Parse() + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ third_party/swf/ (Zero-External-Dep Parser) │ +│ model.go: Workflow, Task, SwitchCase, Function structs │ +│ swf.go: ParseV1() + ParseLegacy(), FlattenTasks(), Validate() │ +└───────────────────────┬──────────────────────────────────────────┘ + │ DAL: buildTaskGraph() + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Task Runtime Dispatcher │ +│ ┌────────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ HTTP/GRPC │ │ Switch │ │ LocalRuntime │ │ A2A Exec │ │ +│ │ Executor │ │ Executor │ │ Executor │ │ (Bridge) │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ call │ │ switch │ │ set/do/fork │ │ a2a call │ │ +│ │ operation │ │ when │ │ for/try/wait │ │ agent invoke │ │ +│ │ │ │ │ │ raise/run │ │ │ │ +│ └────────────┘ └───────────┘ └──────────────┘ └──────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 1. Custom SWF Parser (`third_party/swf/`) + +Replaced `sdk-go/v2` with a custom parser that handles both DSL generations: + +- **`model.go`**: Go structs for `Workflow`, `Task`, `SwitchCase`, `Function`, `Schedule`, `Document`, etc. Includes `FlattenTasks()` for tree→flat list conversion and `Validate()` for schema checks. +- **`swf.go`**: `Parse()` dispatches to `ParseV1()` (DSL 1.0.3 `document` shape) or `ParseLegacy()` (DSL 0.8 `states` shape) based on `specVersion`. Handles `input`, `output`, `schedule`, `data` top-level and task-level fields. + +**Key design**: All 12 task types are parsed into a unified `Task` struct. Nested tasks (inside `fork.branches[].do`, `try.do`, `for.do`) are flattened during DAL graph construction, not during parse. + +### 2. Structured Task Executors + +Added `LocalRuntime` executor (`internal/task/local_runtime.go`) for tasks that run inside the workflow engine without external calls: + +| Task | Implementation | Semantics | +|------|---------------|-----------| +| **set** | `executeSet()` | Assign JQ expressions to workflow data context | +| **do** | `executeDo()` | Execute children sequentially, propagate output | +| **fork** | `executeFork()` | Publish all branch-first tasks in parallel via `publishNextTasks()` | +| **for** | `executeFor()` | Parse JSON array from `input`, iterate each element through `do` body | +| **try** | `executeTry()` | Execute `do` children; on error, run `catch` children (parsed from `catch.when`) | +| **wait** | `executeWait()` | Sleep for configured duration | +| **raise** | `executeRaise()` | Raise a configurable error | +| **run** | `executeRun()` | Execute inline script/container (placeholder) | +| **emit** | `executeEmit()` | Emit event data (placeholder) | + +**Fork relation building** in DAL: `buildForkBranchRelations()` creates separate `WorkflowTaskRelation` entries for each branch head task, enabling parallel dispatch. + +**Try relation building**: `catch.when` is parsed as `Children` of the try task, flagged with `TaskType = "try"` to distinguish from normal do-children. + +### 3. A2A Bidirectional Bridge + +``` +┌─────────────────────┐ ┌──────────────────────┐ +│ Workflow Engine │ │ External A2A Agent │ +│ │ HTTP │ │ +│ OperationTask │────────▶│ POST /tasks/send │ +│ operationType=a2a │ poll │ │ +│ │◀────────│ GET /tasks/{id} │ +│ A2AExecutor │ │ │ +└─────────────────────┘ └──────────────────────┘ + +┌─────────────────────┐ ┌──────────────────────┐ +│ External Client │ │ Workflow Engine │ +│ │ HTTP │ │ +│ A2A Client │────────▶│ POST /a2a/agent │ +│ (SendTask) │ │ │ +│ │ │ WorkflowAgent │ +│ │ │ (exposes workflows │ +│ │ │ as A2A agents) │ +└─────────────────────┘ └──────────────────────┘ +``` + +- **`A2AExecutor`** (`internal/bridge/a2a_executor.go`): Polling-based A2A client. Calls `POST /tasks/send` with task definition, then polls `GET /tasks/{id}` until completion or timeout. Integrated into `OperationTask` via `OperationType="a2a"` dispatch. +- **`WorkflowAgent`** (`internal/bridge/workflow_agent.go`): HTTP endpoint that exposes workflows as A2A agents. Handles incoming `POST /a2a/agent` requests, maps to workflow execution, returns `AgentCard` metadata and task results. +- **`a2a_integration.go`**: `runA2AAction()` bridges task discovery → A2A executor invocation. + +### 4. Data Pipeline + +**Input filtering**: `input.from` JQ expressions extract data from workflow context before task execution. + +**Output filtering**: `TaskOutputFilter` stored in DAL model `WorkflowTask`. After each task completes, `FilterWorkflowTaskOutputData()` applies JQ expressions from `output.as` to shape the result before storing to workflow data context. + +**Schedule fields**: Parsed at workflow level: `start` (ISO datetime), `cron` (expression), `after` (delay duration). Stored in workflow document, usable for scheduling engine integration. + +### 5. Backward Compatibility + +All existing DSL 0.8 workflows pass through `ParseLegacy()`, which maps `states` → flat task list + implicit transitions. The internal task graph model (`WorkflowTask` + `WorkflowTaskRelation`) is unchanged, so runtime execution is transparent to the DSL version. + +### Test Coverage + +``` +third_party/swf/swf_test.go 12 tests (V1 parse, legacy parse, Fork/Try/For/Schedule/Output, Validate) +third_party/swf/parser_integration_test.go 4 tests (real YAML fixture parsing) +internal/filter/data_filter_test.go 1 test (output filter pipeline) +───────────────────────────────────────────────────────── +Total: 17 tests | go test ./... : PASS | go vet ./... : clean +``` + +### Files Changed (Code Only) + +``` +new file: third_party/swf/model.go +new file: third_party/swf/swf.go +new file: third_party/swf/swf_test.go +new file: third_party/swf/parser_integration_test.go +new file: internal/task/local_runtime.go +new file: internal/task/runtime_util.go +new file: internal/bridge/a2a_types.go +new file: internal/bridge/a2a_executor.go +new file: internal/bridge/workflow_agent.go +new file: internal/task/a2a_integration.go +modified: internal/dal/workflow.go +modified: internal/dal/model/workflow_task.go +modified: internal/task/task.go +modified: internal/task/operation_task.go +modified: internal/constants/constants.go +modified: internal/filter/data_filter.go +modified: go.mod +modified: configs/testcreateworkflow-v1.yaml +``` + +### Related Commits + +| Commit | Description | +|--------|-------------| +| `3a1662f` | feat: upgrade to Serverless Workflow DSL 1.0.3 with full structured task support | +| `514db08` | feat: fork/try/for executors + output/schedule/data + A2A bridge | diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..1ce0141 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,681 @@ +# EventMesh Workflow User Guide + +> **Version**: v1.0.0 | **Updated**: 2026-06-29 + +--- + +## 1. Quick Start + +### 1.1 Prerequisites + +| Dependency | Version | Purpose | +| --- | --- | --- | +| Go | >= 1.18 | Compilation & runtime | +| MySQL | >= 5.7 | Persistent storage | +| EventMesh | - | Event bus / Catalog service | + +### 1.2 Initialize Database + +```bash +mysql -u root -p < distribution/mysql-schema.sql +``` + +### 1.3 Build + +```bash +make build +# Output: bin/eventmesh-workflow +``` + +### 1.4 Run + +```bash +# Controller (HTTP API) +./bin/eventmesh-workflow controller --config configs/controller.yaml + +# Engine (Task Runner) +./bin/eventmesh-workflow engine --config configs/engine.yaml +``` + +The Controller default port is set in `configs/controller.yaml` under `server.port`. Swagger docs are at `/swagger/index.html`. + +--- + +## 2. DSL Writing Guide + +### 2.1 DSL 1.0.3 (Recommended) + +Use `document` + `do` structure: + +```yaml +document: + dsl: '1.0.3' + namespace: eventmesh.apache.org + name: my-first-workflow + version: '1.0.0' + +do: + - step1: + call: http + with: + endpoint: https://api.example.com/data + then: step2 + + - step2: + set: + result: '${ .data.value }' + then: end +``` + +### 2.2 DSL 0.8 (Legacy Compatible) + +Use `id` + `states` structure: + +```yaml +id: my-legacy-workflow +version: '1.0' +specVersion: '0.8' +start: FirstState +states: + - name: FirstState + type: operation + actions: + - functionRef: + refName: "myFunction" + transition: SecondState + - name: SecondState + type: operation + actions: + - functionRef: + refName: "myFunction2" + end: true +functions: + - name: myFunction + operation: file://app.yaml#action + type: asyncapi +``` + +Both formats can coexist; the parser auto-detects. + +--- + +## 3. Task Types Reference + +### 3.1 call — Invoke External Operations + +```yaml +- sendOrder: + call: asyncapi + with: + operation: file://order.yaml#sendOrder + then: nextStep +``` + +Supported call types: + +| call Value | with Params | Resolution Behavior | +| --- | --- | --- | +| `http` | `endpoint` | HTTP endpoint as operation name | +| `asyncapi` | `operation` / `channel` / `document` | EventMesh catalog query | +| `openapi` | `operationId` / `operation` / `document` | REST API operation | +| `grpc` | `service` / `method` | gRPC service method | +| `a2a` | `endpoint` | A2A Agent URL | + +A2A call example: + +```yaml +- askAgent: + call: a2a + with: + endpoint: http://localhost:9090 + then: processResult +``` + +### 3.2 listen — Listen for Events + +```yaml +- waitForEvent: + listen: + to: + one: + with: + type: order.created + source: store/order + then: processOrder +``` + +### 3.3 switch — Conditional Branching + +```yaml +- checkResult: + switch: + - success: + when: .status == "ok" + then: handleSuccess + - failure: + when: .status == "error" + then: handleError + - otherwise: + then: end +``` + +Condition expressions use JQ syntax; `.field` references input JSON fields. + +### 3.4 set — Data Transformation + +```yaml +- transform: + set: + fullName: '${ .firstName + " " + .lastName }' + amount: '${ .price * .quantity }' + then: nextStep +``` + +### 3.5 do — Sub-task Sequence + +```yaml +- processBatch: + do: + - stepA: + set: + validated: true + - stepB: + set: + enriched: true + then: nextStep +``` + +Sub-tasks within `do` currently support `set` and `raise`. + +### 3.6 fork — Parallel Branches + +```yaml +- parallelTasks: + fork: + branches: + - notifyEmail: + call: http + with: + endpoint: https://api.example.com/email + - notifySMS: + call: http + with: + endpoint: https://api.example.com/sms + then: joinPoint +``` + +### 3.7 for — Loop Iteration + +```yaml +- processItems: + for: + each: .items + do: + - enrichItem: + set: + processed: true + timestamp: '${ now }' + then: nextStep +``` + +### 3.8 try — Error Handling + +```yaml +- safeOperation: + try: + - riskyTask: + call: http + with: + endpoint: https://api.example.com/may-fail + catch: + when: + - fallback: + set: + status: fallback + error: '${ .message }' + then: continue +``` + +### 3.9 wait — Delay + +```yaml +- pause: + wait: + seconds: '10s' + then: nextStep +``` + +Supports Go duration format: `10s`, `1m`, `500ms`, `1h30m`. + +### 3.10 raise — Throw Error + +```yaml +- validate: + set: + error: '${ .result }' + then: checkError + +- checkError: + switch: + - hasError: + when: .error != null + then: raiseError + then: end + +- raiseError: + raise: + error: + type: ValidationError + status: '400' + title: Input Validation Failed + detail: '${ .error }' +``` + +### 3.11 run — Publish Event + +```yaml +- fireEvent: + run: + with: + event: order.processed + then: end +``` + +### 3.12 emit — Emit Event (same as run) + +```yaml +- emitEvent: + emit: + event: notification.sent + then: end +``` + +--- + +## 4. Data Input & Output + +### 4.1 Workflow-Level Input + +```yaml +document: + dsl: '1.0.3' + name: order-workflow + version: '1.0.0' + +input: + from: ${ .order } + +do: + - step1: + call: asyncapi + with: + operation: file://order.yaml#process +``` + +The JSON passed when starting a workflow is filtered through `input.from` before being passed to the first task. + +### 4.2 Task-Level Input/Output Filters + +```yaml +- step1: + call: http + with: + endpoint: https://api.example.com/data + input: + from: ${ { userId: .user.id, amount: .price } } + output: + as: ${ { result: .data } } + then: step2 +``` + +- `input.from`: Extracts fields from upstream data as task input +- `output.as`: Extracts fields from task output to pass downstream + +### 4.3 Inline Data + +```yaml +- step1: + set: + greeting: '${ "Hello, " + .name }' + data: '{"name": "World"}' + then: end +``` + +The `data` field provides static initial data; lower priority than externally passed input. + +--- + +## 5. Schedule Configuration + +```yaml +document: + dsl: '1.0.3' + name: daily-report + version: '1.0.0' + +schedule: + start: '2026-07-01T00:00:00Z' + cron: '0 0 9 * * ?' + after: 'PT5M' + +do: + - generateReport: + call: http + with: + endpoint: https://api.example.com/report + then: end +``` + +| Field | Description | +| --- | --- | +| `start` | ISO 8601 datetime, schedule start time | +| `cron` | Cron expression | +| `after` | ISO 8601 interval (PT5M = 5 minutes) | + +--- + +## 6. Flow Control + +### 6.1 then Directive + +```yaml +then: nextTaskName # Jump to named task +then: end # Terminate workflow +then: exit # Same as end +then: continue # Same as end (loop semantics not yet implemented) +``` + +### 6.2 Implicit Sequencing + +If a task has no explicit `then` but has nested sub-tasks (do/fork/for/try), it defaults to the first child task. + +--- + +## 7. REST API Reference + +### 7.1 Workflow CRUD + +| Method | Path | Description | +| --- | --- | --- | +| POST | `/workflow` | Create/update workflow | +| GET | `/workflow` | List workflows | +| GET | `/workflow/:workflowId` | Get workflow details | +| DELETE | `/workflow/:workflowId` | Delete workflow | +| GET | `/workflow/instances` | Query running instances | + +### 7.2 Create / Update Workflow + +```bash +curl -X POST http://localhost:8080/workflow \ + -H "Content-Type: application/json" \ + -d '{ + "workflow_id": "order-management", + "workflow_name": "Order Management Workflow", + "definition": "document:\n dsl: \"1.0.3\"\n name: order-management\n version: \"1.0.0\"\n namespace: eventmesh.apache.org\ndo:\n - receiveOrder:\n listen:\n to:\n one:\n with:\n type: online.store.newOrder\n then: end" + }' +``` + +**Note**: `definition` is the full DSL YAML text. When creating, `workflow_id` must match `document.name` or `id` in the DSL. + +### 7.3 List Workflows + +```bash +curl "http://localhost:8080/workflow?page=1&size=20" +``` + +Response: + +```json +{ + "total": 10, + "workflows": [ + { + "workflow_id": "order-management", + "workflow_name": "Order Management Workflow", + "version": "1.0.0", + "total_instances": 5, + "total_running_instances": 3, + "total_failed_instances": 2 + } + ] +} +``` + +### 7.4 Delete Workflow + +```bash +curl -X DELETE http://localhost:8080/workflow/order-management +``` + +Performs soft delete (status → -1); related tasks, relations, and instances are also marked. + +--- + +## 8. A2A Integration + +### 8.1 Workflow as Agent + +Start the built-in A2A endpoint: + +```go +agent := bridge.NewWorkflowAgent("my-workflow", "http://localhost:9090") +mux := http.NewServeMux() +agent.RegisterRoutes(mux) +http.ListenAndServe(":9090", mux) +``` + +External A2A Client calls: + +```bash +# Get Agent Card +curl http://localhost:9090/.well-known/agent-card.json + +# Submit task (triggers workflow execution) +curl -X POST http://localhost:9090/a2a/tasks \ + -H "Content-Type: application/json" \ + -d '{ + "message": { + "role": "user", + "parts": [{"type": "text", "text": "{\"order_id\": \"12345\"}"}] + } + }' + +# Query status +curl http://localhost:9090/a2a/tasks/{task_id} +``` + +### 8.2 Workflow Calling A2A Agent + +Configure `call: a2a` in DSL: + +```yaml +- aiAnalysis: + call: a2a + with: + endpoint: http://ai-agent:8080 + then: processResult +``` + +--- + +## 9. Complete Examples + +### 9.1 Order Processing Workflow (DSL 1.0.3) + +File: `configs/testcreateworkflow-v1.yaml` + +```yaml +document: + dsl: '1.0.3' + namespace: eventmesh.apache.org + name: store-order-management + version: '1.0.0' + +do: + - receiveNewOrderEvent: + listen: + to: + one: + with: + type: online.store.newOrder + source: store/order + then: checkNewOrderResult + + - checkNewOrderResult: + switch: + - newOrderSuccessful: + when: .order_no != "" + then: sendOrderPayment + - newOrderFailed: + then: end + + - sendOrderPayment: + call: asyncapi + with: + operation: file://paymentapp.yaml#sendPayment + then: checkPaymentStatus + + - checkPaymentStatus: + switch: + - paymentSuccessful: + when: .order_no != "" + then: sendOrderShipment + - paymentDenied: + then: end + + - sendOrderShipment: + call: asyncapi + with: + operation: file://expressapp.yaml#sendExpress + then: end +``` + +### 9.2 Data Transform + Parallel Processing + +```yaml +document: + dsl: '1.0.3' + name: data-pipeline + version: '1.0.0' + +do: + - fetchData: + call: http + with: + endpoint: https://api.example.com/raw-data + then: transformData + + - transformData: + set: + normalized: '${ .data | map({ id, value: .amount * 100 }) }' + timestamp: '${ now }' + then: parallelNotify + + - parallelNotify: + fork: + branches: + - emailNotify: + call: http + with: + endpoint: https://api.example.com/email + - slackNotify: + call: http + with: + endpoint: https://api.example.com/slack + then: end +``` + +--- + +## 10. Development & Debugging + +### 10.1 Run Tests + +```bash +make test +``` + +Test coverage: + +| Package | Test Count | Content | +| --- | --- | --- | +| third_party/swf | 17 | V1/legacy parsing, Fork/Try/For, Schedule/Output, validation, integration tests | +| internal/filter | 1 | JQ filtering happy/error paths | + +### 10.2 Formatting + +```bash +make fmt # goimports + gofmt +``` + +### 10.3 Linting + +```bash +make lint # golangci-lint +``` + +### 10.4 Coverage + +```bash +make cover # Generate HTML coverage report +``` + +### 10.5 Local Debugging Tips + +1. Use **InMemoryQueue** (`engine.yaml`: `flow.queue.store: in-memory`) to avoid EventMesh dependency +2. OperationTask's `runA2AAction` can be verified with a Mock A2A Agent +3. LocalRuntimeTask is fully self-contained and can be tested independently + +--- + +## 11. Configuration Reference + +### controller.yaml + +```yaml +server: + port: 8080 + name: eventmesh-workflow-controller + +database: + dsn: "root:password@tcp(127.0.0.1:3306)/db_workflow?charset=utf8mb4&parseTime=True&loc=Local" + max_idle_conns: 10 + max_open_conns: 100 +``` + +### engine.yaml + +```yaml +flow: + protocol: eventmesh # eventmesh | http + queue: + store: in-memory # in-memory | eventmesh + eventmesh: + topic: workflow-task + +catalog: + server_name: eventmesh-catalog +``` + +--- + +## 12. Limitations & Roadmap + +### Current Limitations + +| Item | Description | +| --- | --- | +| for iteration | Body currently supports only set tasks | +| try/catch | Limited task types within when blocks | +| nested do | Runtime only executes set/raise; complex sub-tasks not yet supported | +| A2A streaming | Streaming responses not yet supported | +| error retry | Global retry_attempts=5, not per-task configurable | + +### Planned Features + +- [ ] Full task type support inside for/do/try +- [ ] Sub-workflow invocation (subFlow) +- [ ] Conditional retry and timeout configuration +- [ ] A2A streaming support +- [ ] WorkflowAgent full instance status query +- [ ] DSL 1.0.3 auth/error/timeout full field support diff --git a/docs/USAGE_CN.md b/docs/USAGE_CN.md new file mode 100644 index 0000000..6cb4e0e --- /dev/null +++ b/docs/USAGE_CN.md @@ -0,0 +1,681 @@ +# EventMesh Workflow 使用说明 + +> **版本**: v1.0.0 | **更新时间**: 2026-06-29 + +--- + +## 1. 快速开始 + +### 1.1 环境准备 + +| 依赖 | 版本 | 说明 | +| --- | --- | --- | +| Go | ≥ 1.18 | 编译运行 | +| MySQL | ≥ 5.7 | 持久化存储 | +| EventMesh | - | 事件总线 / Catalog 服务 | + +### 1.2 初始化数据库 + +```bash +mysql -u root -p < distribution/mysql-schema.sql +``` + +### 1.3 编译 + +```bash +make build +# 产物: bin/eventmesh-workflow +``` + +### 1.4 启动 + +```bash +# Controller (HTTP API) +./bin/eventmesh-workflow controller --config configs/controller.yaml + +# Engine (Task Runner) +./bin/eventmesh-workflow engine --config configs/engine.yaml +``` + +Controller 默认端口见 `configs/controller.yaml` 的 `server.port`,Swagger 文档在 `/swagger/index.html`。 + +--- + +## 2. DSL 编写指南 + +### 2.1 DSL 1.0.3 (推荐) + +使用 `document` + `do` 结构: + +```yaml +document: + dsl: '1.0.3' + namespace: eventmesh.apache.org + name: my-first-workflow + version: '1.0.0' + +do: + - step1: + call: http + with: + endpoint: https://api.example.com/data + then: step2 + + - step2: + set: + result: '${ .data.value }' + then: end +``` + +### 2.2 DSL 0.8 (兼容) + +使用 `id` + `states` 结构: + +```yaml +id: my-legacy-workflow +version: '1.0' +specVersion: '0.8' +start: FirstState +states: + - name: FirstState + type: operation + actions: + - functionRef: + refName: "myFunction" + transition: SecondState + - name: SecondState + type: operation + actions: + - functionRef: + refName: "myFunction2" + end: true +functions: + - name: myFunction + operation: file://app.yaml#action + type: asyncapi +``` + +两种格式可混用,解析器自动检测。 + +--- + +## 3. 任务类型详解 + +### 3.1 call — 调用外部操作 + +```yaml +- sendOrder: + call: asyncapi + with: + operation: file://order.yaml#sendOrder + then: nextStep +``` + +支持的 call 类型: + +| call 值 | with 参数 | 解析行为 | +| --- | --- | --- | +| `http` | `endpoint` | HTTP 端点作为 operation name | +| `asyncapi` | `operation` / `channel` / `document` | EventMesh catalog 查询 | +| `openapi` | `operationId` / `operation` / `document` | REST API 操作 | +| `grpc` | `service` / `method` | gRPC 服务方法 | +| `a2a` | `endpoint` | A2A Agent URL | + +A2A 调用示例: + +```yaml +- askAgent: + call: a2a + with: + endpoint: http://localhost:9090 + then: processResult +``` + +### 3.2 listen — 监听事件 + +```yaml +- waitForEvent: + listen: + to: + one: + with: + type: order.created + source: store/order + then: processOrder +``` + +### 3.3 switch — 条件分支 + +```yaml +- checkResult: + switch: + - success: + when: .status == "ok" + then: handleSuccess + - failure: + when: .status == "error" + then: handleError + - otherwise: + then: end +``` + +条件表达式使用 JQ 语法,`.field` 引用输入 JSON 字段。 + +### 3.4 set — 数据转换 + +```yaml +- transform: + set: + fullName: '${ .firstName + " " + .lastName }' + amount: '${ .price * .quantity }' + then: nextStep +``` + +### 3.5 do — 子任务序列 + +```yaml +- processBatch: + do: + - stepA: + set: + validated: true + - stepB: + set: + enriched: true + then: nextStep +``` + +do 内的子任务目前支持 `set` 和 `raise`。 + +### 3.6 fork — 并行分支 + +```yaml +- parallelTasks: + fork: + branches: + - notifyEmail: + call: http + with: + endpoint: https://api.example.com/email + - notifySMS: + call: http + with: + endpoint: https://api.example.com/sms + then: joinPoint +``` + +### 3.7 for — 循环迭代 + +```yaml +- processItems: + for: + each: .items + do: + - enrichItem: + set: + processed: true + timestamp: '${ now }' + then: nextStep +``` + +### 3.8 try — 错误处理 + +```yaml +- safeOperation: + try: + - riskyTask: + call: http + with: + endpoint: https://api.example.com/may-fail + catch: + when: + - fallback: + set: + status: fallback + error: '${ .message }' + then: continue +``` + +### 3.9 wait — 延迟 + +```yaml +- pause: + wait: + seconds: '10s' + then: nextStep +``` + +支持 Go duration 格式:`10s`, `1m`, `500ms`, `1h30m`。 + +### 3.10 raise — 抛出错误 + +```yaml +- validate: + set: + error: '${ .result }' + then: checkError + +- checkError: + switch: + - hasError: + when: .error != null + then: raiseError + then: end + +- raiseError: + raise: + error: + type: ValidationError + status: '400' + title: Input Validation Failed + detail: '${ .error }' +``` + +### 3.11 run — 发布事件 + +```yaml +- fireEvent: + run: + with: + event: order.processed + then: end +``` + +### 3.12 emit — 发出事件 (同 run) + +```yaml +- emitEvent: + emit: + event: notification.sent + then: end +``` + +--- + +## 4. 数据输入输出 + +### 4.1 工作流级 input + +```yaml +document: + dsl: '1.0.3' + name: order-workflow + version: '1.0.0' + +input: + from: ${ .order } + +do: + - step1: + call: asyncapi + with: + operation: file://order.yaml#process +``` + +启动工作流时传入的 JSON 会通过 `input.from` 过滤后再传递给第一个任务。 + +### 4.2 任务级 input/output 过滤 + +```yaml +- step1: + call: http + with: + endpoint: https://api.example.com/data + input: + from: ${ { userId: .user.id, amount: .price } } + output: + as: ${ { result: .data } } + then: step2 +``` + +- `input.from`: 从上游数据中提取字段作为本任务输入 +- `output.as`: 从本任务输出中提取字段传递给下游 + +### 4.3 内联数据 + +```yaml +- step1: + set: + greeting: '${ "Hello, " + .name }' + data: '{"name": "World"}' + then: end +``` + +`data` 字段提供静态初始数据,优先级低于 input 传入的数据。 + +--- + +## 5. 调度配置 + +```yaml +document: + dsl: '1.0.3' + name: daily-report + version: '1.0.0' + +schedule: + start: '2026-07-01T00:00:00Z' + cron: '0 0 9 * * ?' + after: 'PT5M' + +do: + - generateReport: + call: http + with: + endpoint: https://api.example.com/report + then: end +``` + +| 字段 | 说明 | +| --- | --- | +| `start` | ISO 8601 时间,调度开始时间 | +| `cron` | Cron 表达式 | +| `after` | ISO 8601 间隔 (PT5M = 5 分钟) | + +--- + +## 6. 流程控制 + +### 6.1 then 指令 + +```yaml +then: nextTaskName # 跳转到命名任务 +then: end # 终止工作流 +then: exit # 同 end +then: continue # 同 end(当前未实现循环语义) +``` + +### 6.2 隐式顺序 + +如果任务未指定 `then` 而有嵌套子任务(do/fork/for/try),默认跳转到第一个子任务。 + +--- + +## 7. REST API 参考 + +### 7.1 工作流 CRUD + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/workflow` | 创建/更新工作流 | +| GET | `/workflow` | 查询工作流列表 | +| GET | `/workflow/:workflowId` | 查询工作流详情 | +| DELETE | `/workflow/:workflowId` | 删除工作流 | +| GET | `/workflow/instances` | 查询运行实例 | + +### 7.2 创建/更新工作流 + +```bash +curl -X POST http://localhost:8080/workflow \ + -H "Content-Type: application/json" \ + -d '{ + "workflow_id": "order-management", + "workflow_name": "Order Management Workflow", + "definition": "document:\n dsl: \"1.0.3\"\n name: order-management\n version: \"1.0.0\"\n namespace: eventmesh.apache.org\ndo:\n - receiveOrder:\n listen:\n to:\n one:\n with:\n type: online.store.newOrder\n then: end" + }' +``` + +**注意**: `definition` 是完整的 DSL YAML 文本,创建时 `workflow_id` 必须与 DSL 中的 `document.name` 或 `id` 一致。 + +### 7.3 查询工作流列表 + +```bash +curl "http://localhost:8080/workflow?page=1&size=20" +``` + +响应: + +```json +{ + "total": 10, + "workflows": [ + { + "workflow_id": "order-management", + "workflow_name": "Order Management Workflow", + "version": "1.0.0", + "total_instances": 5, + "total_running_instances": 3, + "total_failed_instances": 2 + } + ] +} +``` + +### 7.4 删除工作流 + +```bash +curl -X DELETE http://localhost:8080/workflow/order-management +``` + +执行软删除(status → -1),关联的任务、关系、实例同时标记。 + +--- + +## 8. A2A 集成 + +### 8.1 工作流作为 Agent + +启动内置 A2A 端点: + +```go +agent := bridge.NewWorkflowAgent("my-workflow", "http://localhost:9090") +mux := http.NewServeMux() +agent.RegisterRoutes(mux) +http.ListenAndServe(":9090", mux) +``` + +外部 A2A Client 调用: + +```bash +# 获取 Agent Card +curl http://localhost:9090/.well-known/agent-card.json + +# 提交任务 (触发工作流执行) +curl -X POST http://localhost:9090/a2a/tasks \ + -H "Content-Type: application/json" \ + -d '{ + "message": { + "role": "user", + "parts": [{"type": "text", "text": "{\"order_id\": \"12345\"}"}] + } + }' + +# 查询状态 +curl http://localhost:9090/a2a/tasks/{task_id} +``` + +### 8.2 工作流调用 A2A Agent + +在 DSL 中配置 `call: a2a`: + +```yaml +- aiAnalysis: + call: a2a + with: + endpoint: http://ai-agent:8080 + then: processResult +``` + +--- + +## 9. 完整示例 + +### 9.1 订单处理工作流 (DSL 1.0.3) + +文件: `configs/testcreateworkflow-v1.yaml` + +```yaml +document: + dsl: '1.0.3' + namespace: eventmesh.apache.org + name: store-order-management + version: '1.0.0' + +do: + - receiveNewOrderEvent: + listen: + to: + one: + with: + type: online.store.newOrder + source: store/order + then: checkNewOrderResult + + - checkNewOrderResult: + switch: + - newOrderSuccessful: + when: .order_no != "" + then: sendOrderPayment + - newOrderFailed: + then: end + + - sendOrderPayment: + call: asyncapi + with: + operation: file://paymentapp.yaml#sendPayment + then: checkPaymentStatus + + - checkPaymentStatus: + switch: + - paymentSuccessful: + when: .order_no != "" + then: sendOrderShipment + - paymentDenied: + then: end + + - sendOrderShipment: + call: asyncapi + with: + operation: file://expressapp.yaml#sendExpress + then: end +``` + +### 9.2 数据转换 + 并行处理 + +```yaml +document: + dsl: '1.0.3' + name: data-pipeline + version: '1.0.0' + +do: + - fetchData: + call: http + with: + endpoint: https://api.example.com/raw-data + then: transformData + + - transformData: + set: + normalized: '${ .data | map({ id, value: .amount * 100 }) }' + timestamp: '${ now }' + then: parallelNotify + + - parallelNotify: + fork: + branches: + - emailNotify: + call: http + with: + endpoint: https://api.example.com/email + - slackNotify: + call: http + with: + endpoint: https://api.example.com/slack + then: end +``` + +--- + +## 10. 开发调试 + +### 10.1 运行测试 + +```bash +make test +``` + +测试覆盖: + +| 包 | 测试数量 | 内容 | +| --- | --- | --- | +| third_party/swf | 17 | V1/旧版解析、Fork/Try/For、Schedule/Output、校验、集成测试 | +| internal/filter | 1 | JQ 过滤正常/异常路径 | + +### 10.2 格式化 + +```bash +make fmt # goimports + gofmt +``` + +### 10.3 代码检查 + +```bash +make lint # golangci-lint +``` + +### 10.4 覆盖率 + +```bash +make cover # 生成 HTML 覆盖率报告 +``` + +### 10.5 本地调试技巧 + +1. 使用 **InMemoryQueue**(配置 `engine.yaml` 中 `flow.queue.store: in-memory`)避免依赖 EventMesh +2. OperationTask 的 `runA2AAction` 可通过 Mock A2A Agent 验证 +3. LocalRuntimeTask 完全自包含,可独立测试 + +--- + +## 11. 配置参考 + +### controller.yaml + +```yaml +server: + port: 8080 + name: eventmesh-workflow-controller + +database: + dsn: "root:password@tcp(127.0.0.1:3306)/db_workflow?charset=utf8mb4&parseTime=True&loc=Local" + max_idle_conns: 10 + max_open_conns: 100 +``` + +### engine.yaml + +```yaml +flow: + protocol: eventmesh # eventmesh | http + queue: + store: in-memory # in-memory | eventmesh + eventmesh: + topic: workflow-task + +catalog: + server_name: eventmesh-catalog +``` + +--- + +## 12. 限制与规划 + +### 当前限制 + +| 项目 | 说明 | +| --- | --- | +| for 迭代 | body 内目前仅支持 set 任务 | +| try/catch | when 内的任务类型有限 | +| 嵌套 do | 运行时仅执行 set/raise,复杂子任务尚不支持 | +| A2A streaming | 暂不支持流式响应 | +| 错误重试 | 全局 retry_attempts=5,未按任务配置 | + +### 规划功能 + +- [ ] for/do/try 内完整任务类型支持 +- [ ] 子工作流调用 (subFlow) +- [ ] 条件重试与超时配置 +- [ ] A2A 流式支持 +- [ ] WorkflowAgent 完整实例状态查询 +- [ ] DSL 1.0.3 auth/error/timeout 完整字段 diff --git a/flow/engine.go b/flow/engine.go index 910c11e..09bf92d 100644 --- a/flow/engine.go +++ b/flow/engine.go @@ -45,7 +45,7 @@ func (e *Engine) Validate(ctx context.Context, instanceID string) error { // Start start workflow func (e *Engine) Start(ctx context.Context, param *WorkflowParam) (string, error) { - metrics.Inc(constants.MetricsEngine, constants.MetricsStartRequest) + _ = metrics.Inc(constants.MetricsEngine, constants.MetricsStartRequest) r, err := e.workflowDAL.SelectStartTask(ctx, model.WorkflowTask{WorkflowID: param.ID}) if err != nil { return "", err @@ -68,7 +68,7 @@ func (e *Engine) Start(ctx context.Context, param *WorkflowParam) (string, error // Transition transition next workflow task func (e *Engine) Transition(ctx context.Context, param *WorkflowParam) error { - metrics.Inc(constants.MetricsEngine, constants.MetricsTransitionRequest) + _ = metrics.Inc(constants.MetricsEngine, constants.MetricsTransitionRequest) r, err := e.workflowDAL.SelectTransitionTask(ctx, model.WorkflowTaskInstance{WorkflowID: param.ID, WorkflowInstanceID: param.InstanceID, TaskInstanceID: param.TaskInstanceID, Status: constants.TaskInstanceSleepStatus}) diff --git a/go.mod b/go.mod index 142e9cf..0ba247a 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,6 @@ require ( github.com/itchyny/gojq v0.12.8 github.com/prometheus/client_golang v1.12.2 github.com/reactivex/rxgo/v2 v2.5.0 - github.com/serverlessworkflow/sdk-go/v2 v2.1.1 github.com/stretchr/testify v1.8.1 github.com/swaggo/files v1.0.0 github.com/swaggo/gin-swagger v1.5.3 diff --git a/internal/bridge/a2a_executor.go b/internal/bridge/a2a_executor.go new file mode 100644 index 0000000..9270a6b --- /dev/null +++ b/internal/bridge/a2a_executor.go @@ -0,0 +1,132 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bridge + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" +) + +const ( + A2ATaskStatusWorking = "working" + A2ATaskStatusCompleted = "completed" + A2ATaskStatusFailed = "failed" +) + +// A2AExecutor bridges workflow tasks with A2A agents. +type A2AExecutor struct { + client *A2AClient + pollDelay time.Duration + maxRetry int +} + +func NewA2AExecutor(agentURL string) *A2AExecutor { + return &A2AExecutor{ + client: NewA2AClient(agentURL), + pollDelay: 2 * time.Second, + maxRetry: 30, + } +} + +// Execute sends a workflow task to an A2A agent and waits for the result. +// Returns the agent output as JSON string suitable for passing to the next workflow task. +func (e *A2AExecutor) Execute(input string, metadata map[string]string) (string, error) { + if metadata == nil { + metadata = make(map[string]string) + } + metadata["source"] = "eventmesh-workflow" + metadata["timestamp"] = time.Now().UTC().Format(time.RFC3339) + + resp, err := e.client.SendTask(input, metadata) + if err != nil { + return "", fmt.Errorf("A2A send task failed: %w", err) + } + if resp.Error != nil { + return "", fmt.Errorf("A2A task error: %s", resp.Error.Message) + } + if resp.Status == A2ATaskStatusCompleted { + return e.extractOutput(resp), nil + } + return e.pollUntilComplete(resp.ID) +} + +func (e *A2AExecutor) pollUntilComplete(taskID string) (string, error) { + for i := 0; i < e.maxRetry; i++ { + time.Sleep(e.pollDelay) + resp, err := e.client.GetTaskResult(taskID) + if err != nil { + continue + } + if resp.Error != nil { + return "", fmt.Errorf("A2A task poll error: %s", resp.Error.Message) + } + switch resp.Status { + case A2ATaskStatusCompleted: + return e.extractOutput(resp), nil + case A2ATaskStatusFailed: + return "", fmt.Errorf("A2A task %s failed", taskID) + } + } + return "", fmt.Errorf("A2A task %s timed out after %d retries", taskID, e.maxRetry) +} + +func (e *A2AExecutor) extractOutput(resp *A2ATaskResponse) string { + if len(resp.Artifacts) > 0 && len(resp.Artifacts[0].Parts) > 0 { + return resp.Artifacts[0].Parts[0].Text + } + if len(resp.Message.Parts) > 0 { + return resp.Message.Parts[0].Text + } + return "" +} + +// ExecuteFromAction executes based on a workflow task action model. +func (e *A2AExecutor) ExecuteFromAction(action *model.WorkflowTaskAction, input string) (string, error) { + metadata := map[string]string{ + "operation_name": action.OperationName, + "operation_type": action.OperationType, + "workflow_task": action.TaskID, + } + return e.Execute(input, metadata) +} + +// BuildAgentCard creates an A2A agent card for workflow trigger capability. +func BuildAgentCard(workflowID string, workflowName string, baseURL string) A2AAgentCard { + return A2AAgentCard{ + Name: workflowName, + Description: fmt.Sprintf("Workflow execution agent for %s", workflowID), + URL: baseURL, + Version: "1.0.0", + Capabilities: A2ACapabilities{ + Streaming: false, + }, + Skills: []A2ASkill{ + { + ID: "execute-workflow", + Name: "Execute Workflow", + Description: fmt.Sprintf("Execute workflow %s and return result", workflowID), + }, + }, + } +} + +// ToJSON serializes an agent card to JSON bytes. +func (c A2AAgentCard) ToJSON() ([]byte, error) { + return json.MarshalIndent(c, "", " ") +} diff --git a/internal/bridge/a2a_types.go b/internal/bridge/a2a_types.go new file mode 100644 index 0000000..d557013 --- /dev/null +++ b/internal/bridge/a2a_types.go @@ -0,0 +1,182 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bridge + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// A2A message types compatible with A2A protocol v1.0 + +type A2AAgentCard struct { + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Version string `json:"version"` + Capabilities A2ACapabilities `json:"capabilities"` + Skills []A2ASkill `json:"skills,omitempty"` +} + +type A2ACapabilities struct { + Streaming bool `json:"streaming,omitempty"` +} + +type A2ASkill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +type A2ATaskRequest struct { + ID string `json:"id"` + Message A2AMessage `json:"message"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type A2AMessage struct { + Role string `json:"role"` + Parts []A2ATextPart `json:"parts"` +} + +type A2ATextPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type A2ATaskResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Message A2AMessage `json:"message,omitempty"` + Artifacts []A2AArtifact `json:"artifacts,omitempty"` + Error *A2AError `json:"error,omitempty"` +} + +type A2AArtifact struct { + Name string `json:"name"` + Parts []A2ATextPart `json:"parts"` +} + +type A2AError struct { + Code int `json:"code,omitempty"` + Message string `json:"message"` +} + +// A2AClient is a lightweight client for calling A2A agents via HTTP. + +type A2AClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewA2AClient(baseURL string) *A2AClient { + return &A2AClient{ + BaseURL: baseURL, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *A2AClient) GetAgentCard() (*A2AAgentCard, error) { + body, err := c.doGet("/.well-known/agent-card.json") + if err != nil { + return nil, err + } + var card A2AAgentCard + if err := json.Unmarshal(body, &card); err != nil { + return nil, err + } + return &card, nil +} + +func (c *A2AClient) SendTask(input string, metadata map[string]string) (*A2ATaskResponse, error) { + req := A2ATaskRequest{ + Message: A2AMessage{ + Role: "user", + Parts: []A2ATextPart{ + {Type: "text", Text: input}, + }, + }, + Metadata: metadata, + } + reqBody, err := json.Marshal(req) + if err != nil { + return nil, err + } + body, err := c.doPost("/a2a/tasks", reqBody) + if err != nil { + return nil, err + } + var taskResp A2ATaskResponse + if err := json.Unmarshal(body, &taskResp); err != nil { + return nil, err + } + return &taskResp, nil +} + +func (c *A2AClient) GetTaskResult(taskID string) (*A2ATaskResponse, error) { + body, err := c.doGet("/a2a/tasks/" + taskID) + if err != nil { + return nil, err + } + var taskResp A2ATaskResponse + if err := json.Unmarshal(body, &taskResp); err != nil { + return nil, err + } + return &taskResp, nil +} + +func (c *A2AClient) doPost(path string, body []byte) ([]byte, error) { + req, err := http.NewRequest("POST", c.BaseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("A2A POST %s: status=%d body=%s", path, resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +func (c *A2AClient) doGet(path string) ([]byte, error) { + resp, err := c.HTTPClient.Get(c.BaseURL + path) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("A2A GET %s: status=%d body=%s", path, resp.StatusCode, string(respBody)) + } + return respBody, nil +} diff --git a/internal/bridge/workflow_agent.go b/internal/bridge/workflow_agent.go new file mode 100644 index 0000000..fdbd600 --- /dev/null +++ b/internal/bridge/workflow_agent.go @@ -0,0 +1,191 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bridge + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/apache/incubator-eventmesh/eventmesh-server-go/log" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/flow" + "github.com/google/uuid" +) + +// WorkflowAgent exposes workflow execution capabilities through the A2A protocol. +// A2A clients can submit tasks that trigger workflow executions and monitor their status. + +type WorkflowAgent struct { + card A2AAgentCard + engine *flow.Engine + mu sync.RWMutex + taskToWorkflow map[string]string // A2A task ID → workflow instance ID +} + +func NewWorkflowAgent(name, baseURL string) *WorkflowAgent { + return &WorkflowAgent{ + card: BuildAgentCard(name, name, baseURL), + engine: flow.NewEngine(), + taskToWorkflow: make(map[string]string), + } +} + +func (wa *WorkflowAgent) AgentCard() A2AAgentCard { + return wa.card +} + +func (wa *WorkflowAgent) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/.well-known/agent-card.json", wa.handleAgentCard) + mux.HandleFunc("/a2a/tasks", wa.handleSubmitTask) + mux.HandleFunc("/a2a/tasks/", wa.handleTaskStatus) + mux.HandleFunc("/a2a/health", wa.handleHealth) +} + +// A2A HTTP Handlers + +func (wa *WorkflowAgent) handleAgentCard(w http.ResponseWriter, r *http.Request) { + body, _ := wa.card.ToJSON() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write(body); err != nil { + log.Errorf("fail to write agent card response: %v", err) + } +} + +func (wa *WorkflowAgent) handleSubmitTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req A2ATaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, A2ATaskResponse{ + Error: &A2AError{Message: "invalid request: " + err.Error()}, + }) + return + } + input := extractTaskInput(req) + if input == "" { + writeJSON(w, http.StatusBadRequest, A2ATaskResponse{ + Error: &A2AError{Message: "task message must contain text"}, + }) + return + } + + // Start workflow execution + instanceID := uuid.New().String() + flowParam := &flow.WorkflowParam{ID: wa.card.Name, Input: input} + wfInstanceID, err := wa.engine.Start(context.Background(), flowParam) + if err != nil { + log.Errorf("WorkflowAgent: start workflow failed: %v", err) + writeJSON(w, http.StatusInternalServerError, A2ATaskResponse{ + Error: &A2AError{Message: "workflow start failed: " + err.Error()}, + }) + return + } + + wa.mu.Lock() + wa.taskToWorkflow[instanceID] = wfInstanceID + wa.mu.Unlock() + + a2aTaskID := instanceID + if req.ID != "" { + a2aTaskID = req.ID + wa.mu.Lock() + wa.taskToWorkflow[a2aTaskID] = wfInstanceID + wa.mu.Unlock() + } + + resp := A2ATaskResponse{ + ID: a2aTaskID, + Status: A2ATaskStatusWorking, + Message: A2AMessage{ + Role: "agent", + Parts: []A2ATextPart{ + {Type: "text", Text: "Workflow execution started: " + wfInstanceID}, + }, + }, + } + writeJSON(w, http.StatusOK, resp) +} + +func (wa *WorkflowAgent) handleTaskStatus(w http.ResponseWriter, r *http.Request) { + taskID := extractTaskIDFromPath(r.URL.Path, "/a2a/tasks/") + if taskID == "" { + http.NotFound(w, r) + return + } + + wa.mu.RLock() + wfInstanceID, ok := wa.taskToWorkflow[taskID] + wa.mu.RUnlock() + + if !ok { + writeJSON(w, http.StatusNotFound, A2ATaskResponse{ + Error: &A2AError{Message: "task not found"}, + }) + return + } + + resp := A2ATaskResponse{ + ID: taskID, + Status: A2ATaskStatusWorking, + Message: A2AMessage{ + Role: "agent", + Parts: []A2ATextPart{ + {Type: "text", Text: "Workflow instance: " + wfInstanceID}, + }, + }, + } + writeJSON(w, http.StatusOK, resp) +} + +func (wa *WorkflowAgent) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil { + log.Errorf("fail to write health response: %v", err) + } +} + +// Helper functions + +func extractTaskInput(req A2ATaskRequest) string { + for _, part := range req.Message.Parts { + if part.Type == "text" && part.Text != "" { + return part.Text + } + } + return "" +} + +func extractTaskIDFromPath(path string, prefix string) string { + if len(path) <= len(prefix) { + return "" + } + return path[len(prefix):] +} + +func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(statusCode) + body, _ := json.Marshal(data) + if _, err := w.Write(body); err != nil { + log.Errorf("fail to write JSON response: %v", err) + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index d08f584..d114787 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -57,6 +57,17 @@ const ( TaskTypeOperation = "operation" TaskTypeEvent = "event" TaskTypeSwitch = "switch" + TaskTypeSet = "set" + TaskTypeDo = "do" + TaskTypeFork = "fork" + TaskTypeFor = "for" + TaskTypeTry = "try" + TaskTypeWait = "wait" + TaskTypeRaise = "raise" + TaskTypeRun = "run" + TaskTypeEmit = "emit" + TaskTypeListen = "listen" + TaskTypeCall = "call" ) const ( diff --git a/internal/dal/config.go b/internal/dal/config.go index bc00cce..8682404 100644 --- a/internal/dal/config.go +++ b/internal/dal/config.go @@ -31,8 +31,10 @@ var workflowDB *gorm.DB var workflowLock *dblock.MysqlLocker func Open() error { - var err error d, err := sql.Open("mysql", pmysql.PluginConfig.DSN) + if err != nil { + return err + } d.SetMaxOpenConns(pmysql.PluginConfig.MaxOpen) d.SetMaxIdleConns(pmysql.PluginConfig.MaxIdle) d.SetConnMaxLifetime(time.Millisecond * time.Duration(pmysql.PluginConfig.MaxLifetime)) diff --git a/internal/dal/model/workflow_task.go b/internal/dal/model/workflow_task.go index ab369f3..5ba30f7 100644 --- a/internal/dal/model/workflow_task.go +++ b/internal/dal/model/workflow_task.go @@ -24,6 +24,8 @@ type WorkflowTask struct { TaskName string `json:"task_name" gorm:"column:task_name;type:varchar;size:1024"` TaskType string `json:"task_type" gorm:"column:task_type;type:varchar;size:64"` TaskInputFilter string `json:"task_input_filter" gorm:"column:task_input_filter;type:varchar;size:1024"` + TaskOutputFilter string `json:"task_output_filter" gorm:"column:task_output_filter;type:varchar;size:1024"` + TaskDefinition string `json:"task_definition" gorm:"-"` Status int `json:"status" gorm:"column:status;type:int"` CreateTime time.Time `json:"create_time"` UpdateTime time.Time `json:"update_time"` diff --git a/internal/dal/workflow.go b/internal/dal/workflow.go index 6067f49..2a66f0d 100644 --- a/internal/dal/workflow.go +++ b/internal/dal/workflow.go @@ -22,13 +22,11 @@ import ( "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/util" "time" - "github.com/apache/incubator-eventmesh/eventmesh-server-go/log" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/constants" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/third_party/swf" "github.com/gogf/gf/util/gconv" "github.com/google/uuid" - pmodel "github.com/serverlessworkflow/sdk-go/v2/model" "gorm.io/gorm" ) @@ -310,53 +308,99 @@ func (w *workflowDALImpl) create(ctx context.Context, tx *gorm.DB, record *model return util.GoAndWait(handlers...) } -func (w *workflowDALImpl) buildTask(workflow *pmodel.Workflow) []*model.WorkflowTask { - if workflow == nil || len(workflow.States) == 0 { +func (w *workflowDALImpl) buildTask(workflow *swf.Workflow) []*model.WorkflowTask { + if workflow == nil || len(workflow.Tasks) == 0 { return nil } var tasks []*model.WorkflowTask - - for _, state := range workflow.States { + for _, workflowTask := range workflow.FlattenTasks() { var task = model.WorkflowTask{} task.WorkflowID = workflow.ID task.TaskID = uuid.New().String() - task.TaskName = state.GetName() + task.TaskName = workflowTask.Name task.Status = constants.NormalStatus - task.TaskType = gconv.String(state.GetType()) + task.TaskType = normalizeTaskType(workflowTask.Type) + task.TaskInputFilter = workflowTask.InputFilter + task.TaskOutputFilter = workflowTask.OutputFilter + task.TaskDefinition = workflowTask.Raw task.CreateTime = time.Now() task.UpdateTime = time.Now() - task.Actions = w.buildTaskAction(task.TaskID, workflow, state) - w.fillTaskFilterIfExist(state, &task) + task.Actions = w.buildTaskAction(task.TaskID, workflow.ID, workflowTask) tasks = append(tasks, &task) } return tasks } -func (w *workflowDALImpl) fillTaskFilterIfExist(workflowState pmodel.State, task *model.WorkflowTask) { - filter := workflowState.GetStateDataFilter() - if filter != nil { - task.TaskInputFilter = filter.Input +func normalizeTaskType(taskType string) string { + switch taskType { + case swf.TaskTypeOperation: + return constants.TaskTypeOperation + case swf.TaskTypeEvent: + return constants.TaskTypeEvent + case swf.TaskTypeSwitch: + return constants.TaskTypeSwitch + case swf.TaskTypeSet: + return constants.TaskTypeSet + case swf.TaskTypeDo: + return constants.TaskTypeDo + case swf.TaskTypeFork: + return constants.TaskTypeFork + case swf.TaskTypeFor: + return constants.TaskTypeFor + case swf.TaskTypeTry: + return constants.TaskTypeTry + case swf.TaskTypeWait: + return constants.TaskTypeWait + case swf.TaskTypeRaise: + return constants.TaskTypeRaise + case swf.TaskTypeRun: + return constants.TaskTypeRun + case swf.TaskTypeEmit: + return constants.TaskTypeEmit + case swf.TaskTypeListen: + return constants.TaskTypeListen + default: + return constants.TaskTypeOperation } } -func (w *workflowDALImpl) buildTaskAction(taskID string, workflow *pmodel.Workflow, - state pmodel.State) []*model.WorkflowTaskAction { - var functions = make(map[string]*pmodel.Function) - for i, function := range workflow.Functions { - functions[function.Name] = &workflow.Functions[i] +func (w *workflowDALImpl) buildTaskAction(taskID string, workflowID string, + workflowTask *swf.Task) []*model.WorkflowTaskAction { + if workflowTask == nil { + return nil } - switch state.GetType() { - case pmodel.StateTypeOperation: - return w.doBuildOperationTaskAction(workflow.ID, taskID, functions, state) - case pmodel.StateTypeEvent: - return w.doBuildEventTaskAction(workflow.ID, taskID, functions, state) + var actions []*model.WorkflowTaskAction + for _, action := range workflowTask.Actions { + if action == nil { + continue + } + var taskAction model.WorkflowTaskAction + taskAction.WorkflowID = workflowID + taskAction.TaskID = taskID + taskAction.OperationName = action.OperationName + taskAction.OperationType = action.OperationType + taskAction.Status = constants.NormalStatus + taskAction.CreateTime = time.Now() + taskAction.UpdateTime = time.Now() + actions = append(actions, &taskAction) } - return nil + if len(actions) == 0 { + var taskAction model.WorkflowTaskAction + taskAction.WorkflowID = workflowID + taskAction.TaskID = taskID + taskAction.OperationName = workflowTask.Name + taskAction.OperationType = normalizeTaskType(workflowTask.Type) + taskAction.Status = constants.NormalStatus + taskAction.CreateTime = time.Now() + taskAction.UpdateTime = time.Now() + actions = append(actions, &taskAction) + } + return actions } -func (w *workflowDALImpl) buildTaskRelation(workflow *pmodel.Workflow, +func (w *workflowDALImpl) buildTaskRelation(workflow *swf.Workflow, tasks []*model.WorkflowTask) []*model.WorkflowTaskRelation { - if workflow == nil || len(workflow.States) == 0 { + if workflow == nil || len(workflow.Tasks) == 0 { return nil } var taskIDs = make(map[string]string) @@ -364,135 +408,95 @@ func (w *workflowDALImpl) buildTaskRelation(workflow *pmodel.Workflow, taskIDs[task.TaskName] = task.TaskID } var taskRelations []*model.WorkflowTaskRelation - for _, state := range workflow.States { - if workflow.Start.StateName == state.GetName() { - taskRelations = append(taskRelations, w.doBuildStartTaskRelation(workflow, state, taskIDs)) + if startTaskID := taskIDs[workflow.Start]; startTaskID != "" { + taskRelations = append(taskRelations, newTaskRelation(workflow.ID, constants.TaskStartID, startTaskID, "")) + } + for _, workflowTask := range workflow.FlattenTasks() { + fromTaskID := taskIDs[workflowTask.Name] + if fromTaskID == "" { + continue } - switch state.GetType() { - case pmodel.StateTypeOperation: - fallthrough - case pmodel.StateTypeEvent: - taskRelations = append(taskRelations, w.doBuildTaskRelation(workflow, state, taskIDs)) - case pmodel.StateTypeSwitch: - taskRelations = append(taskRelations, w.doBuildSwitchTaskRelation(workflow, state, taskIDs)...) - default: - log.Errorf("buildTaskRelation=not support type=%s", state.GetType()) + if workflowTask.Type == swf.TaskTypeSwitch { + taskRelations = append(taskRelations, w.buildSwitchTaskRelation(workflow.ID, workflowTask, fromTaskID, taskIDs)...) + continue } + if workflowTask.Type == swf.TaskTypeFork { + taskRelations = append(taskRelations, w.buildForkTaskRelation(workflow.ID, workflowTask, fromTaskID, taskIDs)...) + continue + } + nextTaskID := w.resolveNextTaskID(workflowTask, taskIDs) + taskRelations = append(taskRelations, newTaskRelation(workflow.ID, fromTaskID, nextTaskID, "")) } return taskRelations } -func (w *workflowDALImpl) doBuildOperationTaskAction(workflowID string, taskID string, - functions map[string]*pmodel.Function, state pmodel.State) []*model.WorkflowTaskAction { - s, ok := state.(*pmodel.OperationState) - if !ok { - return nil - } - var actions []*model.WorkflowTaskAction - for _, action := range s.Actions { - var taskAction model.WorkflowTaskAction - taskAction.WorkflowID = workflowID - taskAction.TaskID = taskID - function := functions[action.FunctionRef.RefName] - if function == nil { +func (w *workflowDALImpl) buildForkTaskRelation(workflowID string, workflowTask *swf.Task, + fromTaskID string, taskIDs map[string]string) []*model.WorkflowTaskRelation { + var rel []*model.WorkflowTaskRelation + for _, child := range workflowTask.Children { + if child == nil { continue } - taskAction.OperationName = gconv.String(function.Operation) - taskAction.OperationType = gconv.String(function.Type) - taskAction.Status = constants.NormalStatus - taskAction.CreateTime = time.Now() - taskAction.UpdateTime = time.Now() - actions = append(actions, &taskAction) + branchStartID := taskIDs[child.Name] + if branchStartID == "" { + branchStartID = constants.TaskEndID + } + rel = append(rel, newTaskRelation(workflowID, fromTaskID, branchStartID, "")) } - return actions + if len(rel) == 0 { + rel = append(rel, newTaskRelation(workflowID, fromTaskID, constants.TaskEndID, "")) + } + return rel } -func (w *workflowDALImpl) doBuildEventTaskAction(workflowID string, taskID string, - functions map[string]*pmodel.Function, state pmodel.State) []*model.WorkflowTaskAction { - s, ok := state.(*pmodel.EventState) - if !ok { - return nil - } - var actions []*model.WorkflowTaskAction - for _, event := range s.OnEvents { - for _, action := range event.Actions { - var taskAction model.WorkflowTaskAction - taskAction.WorkflowID = workflowID - taskAction.TaskID = taskID - function := functions[action.FunctionRef.RefName] - if function == nil { - continue - } - taskAction.OperationName = gconv.String(function.Operation) - taskAction.OperationType = gconv.String(function.Type) - taskAction.Status = constants.NormalStatus - taskAction.CreateTime = time.Now() - taskAction.UpdateTime = time.Now() - actions = append(actions, &taskAction) +func (w *workflowDALImpl) buildSwitchTaskRelation(workflowID string, workflowTask *swf.Task, + fromTaskID string, taskIDs map[string]string) []*model.WorkflowTaskRelation { + var rel []*model.WorkflowTaskRelation + for _, item := range workflowTask.Cases { + if item == nil { + continue } + toTaskID := resolveFlowDirective(item.Then, taskIDs) + rel = append(rel, newTaskRelation(workflowID, fromTaskID, toTaskID, item.Condition)) } - return actions + if len(rel) == 0 { + rel = append(rel, newTaskRelation(workflowID, fromTaskID, constants.TaskEndID, "")) + } + return rel } -func (w *workflowDALImpl) doBuildTaskRelation(workflow *pmodel.Workflow, state pmodel.State, - taskIDs map[string]string) *model.WorkflowTaskRelation { - var r = model.WorkflowTaskRelation{} - r.WorkflowID = workflow.ID - r.FromTaskID = taskIDs[state.GetName()] - if state.GetTransition() == nil && !state.GetEnd().Terminate { - r.ToTaskID = constants.TaskEndID - } else { - r.ToTaskID = taskIDs[state.GetTransition().NextState] +func (w *workflowDALImpl) resolveNextTaskID(workflowTask *swf.Task, taskIDs map[string]string) string { + if workflowTask == nil { + return constants.TaskEndID } - r.Status = constants.NormalStatus - r.CreateTime = time.Now() - r.UpdateTime = time.Now() - return &r + if workflowTask.ExplicitThen { + return resolveFlowDirective(workflowTask.Then, taskIDs) + } + if len(workflowTask.Children) > 0 { + return resolveFlowDirective(workflowTask.Children[0].Name, taskIDs) + } + return constants.TaskEndID } -func (w *workflowDALImpl) doBuildSwitchTaskRelation(workflow *pmodel.Workflow, state pmodel.State, - taskIDs map[string]string) []*model.WorkflowTaskRelation { - s, ok := state.(*pmodel.DataBasedSwitchState) - if !ok { - return nil +func resolveFlowDirective(next string, taskIDs map[string]string) string { + switch next { + case "", "end", "exit": + return constants.TaskEndID + case "continue": + return constants.TaskEndID } - var rel []*model.WorkflowTaskRelation - if !s.DefaultCondition.End.Terminate { - var r = model.WorkflowTaskRelation{} - r.WorkflowID = workflow.ID - r.FromTaskID = taskIDs[state.GetName()] - r.ToTaskID = constants.TaskEndID - r.Status = constants.NormalStatus - r.CreateTime = time.Now() - r.UpdateTime = time.Now() - rel = append(rel, &r) - } - for _, condition := range s.DataConditions { - var r = model.WorkflowTaskRelation{} - r.WorkflowID = workflow.ID - r.FromTaskID = taskIDs[state.GetName()] - r.Status = constants.NormalStatus - r.CreateTime = time.Now() - r.UpdateTime = time.Now() - if c, ok := condition.(*pmodel.TransitionDataCondition); ok { - r.ToTaskID = taskIDs[c.Transition.NextState] - r.Condition = c.Condition - } - if c, ok := condition.(*pmodel.EndDataCondition); ok { - r.ToTaskID = constants.TaskEndID - r.Condition = c.Condition - } - rel = append(rel, &r) + if taskID := taskIDs[next]; taskID != "" { + return taskID } - return rel + return constants.TaskEndID } -func (w *workflowDALImpl) doBuildStartTaskRelation(workflow *pmodel.Workflow, state pmodel.State, - taskIDs map[string]string) *model.WorkflowTaskRelation { +func newTaskRelation(workflowID string, fromTaskID string, toTaskID string, condition string) *model.WorkflowTaskRelation { var r = model.WorkflowTaskRelation{} - r.WorkflowID = workflow.ID - r.FromTaskID = constants.TaskStartID - r.ToTaskID = taskIDs[state.GetName()] + r.WorkflowID = workflowID + r.FromTaskID = fromTaskID + r.ToTaskID = toTaskID + r.Condition = condition r.Status = constants.NormalStatus r.CreateTime = time.Now() r.UpdateTime = time.Now() diff --git a/internal/filter/data_filter.go b/internal/filter/data_filter.go index 570a9dc..8d4e478 100644 --- a/internal/filter/data_filter.go +++ b/internal/filter/data_filter.go @@ -17,6 +17,8 @@ package filter import ( "encoding/json" + "strings" + "github.com/apache/incubator-eventmesh/eventmesh-server-go/log" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/third_party/jqer" @@ -34,6 +36,18 @@ func FilterWorkflowTaskInputData(task *model.WorkflowTaskInstance) { task.Input = inputAfterFilter } +func FilterWorkflowTaskOutputData(input string, outputFilter string) string { + if outputFilter == "" || input == "" { + return input + } + outputAfterFilter, err := filterJsonData(outputFilter, input) + if err != nil { + log.Errorf("fail to filter task output data filter=%s, err=%v", outputFilter, err) + return input + } + return outputAfterFilter +} + func filterJsonData(filterJson string, inputDataJson string) (string, error) { var jsonObj interface{} err := json.Unmarshal([]byte(inputDataJson), &jsonObj) @@ -41,7 +55,7 @@ func filterJsonData(filterJson string, inputDataJson string) (string, error) { return "", err } jq := jqer.NewJQ() - ret, err := jq.Object(jsonObj, filterJson) + ret, err := jq.Object(jsonObj, normalizeFilterExpression(filterJson)) if err != nil { return "", err } @@ -51,3 +65,11 @@ func filterJsonData(filterJson string, inputDataJson string) (string, error) { } return string(outputDataJson), nil } + +func normalizeFilterExpression(filterJson string) string { + trimmed := strings.TrimSpace(filterJson) + if strings.HasPrefix(trimmed, "${") { + return filterJson + } + return "${ " + trimmed + " }" +} diff --git a/internal/filter/data_filter_test.go b/internal/filter/data_filter_test.go index e937e30..a836f81 100644 --- a/internal/filter/data_filter_test.go +++ b/internal/filter/data_filter_test.go @@ -16,15 +16,31 @@ package filter import ( + "encoding/json" + "testing" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" "github.com/stretchr/testify/assert" - "testing" ) func TestFilterWorkflowTaskInputData(t *testing.T) { task := mockWorkflowInstance() FilterWorkflowTaskInputData(task) - assert.Equal(t, task.Input, `{ "order_no": "123456789"}`) + assert.JSONEq(t, `{ "order_no": "123456789"}`, task.Input) +} + +func TestFilterWorkflowTaskInputDataWithV1RuntimeExpression(t *testing.T) { + task := mockWorkflowInstance() + task.Task.TaskInputFilter = `{order_no: .order_no}` + FilterWorkflowTaskInputData(task) + assert.JSONEq(t, `{ "order_no": "123456789"}`, task.Input) +} + +func TestFilterWorkflowTaskInputDataKeepsValidJSON(t *testing.T) { + task := mockWorkflowInstance() + FilterWorkflowTaskInputData(task) + var jsonObj map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(task.Input), &jsonObj)) } func mockWorkflowInstance() *model.WorkflowTaskInstance { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 9c8ad61..f56e658 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -35,23 +35,29 @@ func init() { // loadAllCollectors all collectors used in workflow should register in this function first func loadAllCollectors() { - prometheusMetrics.registerNewCollector(constants.MetricsEventTask, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsEventTask, gauge) + mustRegister := func(name string, collectorType int) { + _, err := prometheusMetrics.registerNewCollector(name, collectorType) + if err != nil { + panic(fmt.Sprintf("fail to register collector: name=%s, type=%d, err=%v", name, collectorType, err)) + } + } + mustRegister(constants.MetricsEventTask, histogram) + mustRegister(constants.MetricsEventTask, gauge) - prometheusMetrics.registerNewCollector(constants.MetricsOperationTask, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsOperationTask, gauge) + mustRegister(constants.MetricsOperationTask, histogram) + mustRegister(constants.MetricsOperationTask, gauge) - prometheusMetrics.registerNewCollector(constants.MetricsSwitchTask, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsSwitchTask, gauge) + mustRegister(constants.MetricsSwitchTask, histogram) + mustRegister(constants.MetricsSwitchTask, gauge) - prometheusMetrics.registerNewCollector(constants.MetricsScheduler, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsScheduler, gauge) + mustRegister(constants.MetricsScheduler, histogram) + mustRegister(constants.MetricsScheduler, gauge) - prometheusMetrics.registerNewCollector(constants.MetricsEngine, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsEngine, gauge) + mustRegister(constants.MetricsEngine, histogram) + mustRegister(constants.MetricsEngine, gauge) - prometheusMetrics.registerNewCollector(constants.MetricsTaskQueue, histogram) - prometheusMetrics.registerNewCollector(constants.MetricsTaskQueue, gauge) + mustRegister(constants.MetricsTaskQueue, histogram) + mustRegister(constants.MetricsTaskQueue, gauge) } func Inc(name string, label string) error { @@ -181,7 +187,7 @@ func (p *Metrics) getCollectorByNameAndType(name string, collectorType int) (pro case histogram: return p.histograms[name], nil case gauge: - return p.histograms[name], nil + return p.gauges[name], nil default: return nil, fmt.Errorf("prometheus metrics get collector error, illegal collector type: %d", collectorType) } diff --git a/internal/queue/eventmesh_queue.go b/internal/queue/eventmesh_queue.go index cda9ae6..0733fac 100644 --- a/internal/queue/eventmesh_queue.go +++ b/internal/queue/eventmesh_queue.go @@ -106,7 +106,7 @@ func (q *eventMeshQueue) Publish(tasks []*model.WorkflowTaskInstance) error { log.Get(constants.LogQueue).Errorf("EventMesh task queue, fail to publish task, error=%v", err) return err } - metrics.Inc(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) + _ = metrics.Inc(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) } return nil } @@ -129,8 +129,12 @@ func (q *eventMeshQueue) Observe() { } } +func (q *eventMeshQueue) UnSubscribe() error { + return nil +} + func (q *eventMeshQueue) handler(message *sdk_pb.SimpleMessage) interface{} { - metrics.Dec(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) + _ = metrics.Dec(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) workflowTask, err := q.toWorkflowTask(message) if err != nil { return err diff --git a/internal/queue/in_memory_queue.go b/internal/queue/in_memory_queue.go index 1d2dd4e..322e1ea 100644 --- a/internal/queue/in_memory_queue.go +++ b/internal/queue/in_memory_queue.go @@ -54,7 +54,7 @@ func (q *inMemoryQueue) Publish(tasks []*model.WorkflowTaskInstance) error { if len(tasks) == 0 { return nil } - metrics.Add(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize), + _ = metrics.Add(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize), float64(len(tasks))) for _, t := range tasks { q.ch <- rxgo.Of(t) @@ -74,11 +74,15 @@ func (q *inMemoryQueue) Observe() { } }() for item := range q.observable.Observe() { - metrics.Dec(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) + _ = metrics.Dec(constants.MetricsTaskQueue, fmt.Sprintf("%s_%s", q.Name(), constants.MetricsQueueSize)) q.handle(item) } }() } + +func (q *inMemoryQueue) UnSubscribe() error { + return nil +} func (q *inMemoryQueue) handle(item rxgo.Item) { v, ok := item.V.(*model.WorkflowTaskInstance) if !ok { diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 0a18df8..d3b0f51 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -25,6 +25,8 @@ type ObserveQueue interface { Ack(tasks *model.WorkflowTaskInstance) error Observe() + + UnSubscribe() error } var queueFactory = make(map[string]ObserveQueue) diff --git a/internal/schedule/inline_scheduler.go b/internal/schedule/inline_scheduler.go index 0e7ede5..ccdd4b6 100644 --- a/internal/schedule/inline_scheduler.go +++ b/internal/schedule/inline_scheduler.go @@ -106,11 +106,15 @@ func (s *inlineScheduler) lock(h func() error) error { start := time.Now() l, err := dal.GetLockClient().ObtainTimeout(schedulerLockKey, schedulerLockTimeout) elapsed := time.Since(start).Milliseconds() - metrics.RecordLatency(constants.MetricsScheduler, constants.MetricsDbLockAcquireTime, float64(elapsed)) + _ = metrics.RecordLatency(constants.MetricsScheduler, constants.MetricsDbLockAcquireTime, float64(elapsed)) if err != nil { return err } - defer l.Release() + defer func() { + if relErr := l.Release(); relErr != nil { + log.Get(constants.LogSchedule).Errorf("fail to release scheduler lock: %v", relErr) + } + }() return h() } diff --git a/internal/task/a2a_integration.go b/internal/task/a2a_integration.go new file mode 100644 index 0000000..25eb77c --- /dev/null +++ b/internal/task/a2a_integration.go @@ -0,0 +1,50 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package task + +import ( + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/bridge" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" +) + +// isA2ATask returns true if the task action targets an A2A agent. +// An action is considered A2A if OperationType is "a2a" or "agent", +// or if OperationName starts with "a2a:" or "agent:". +func isA2ATask(action *model.WorkflowTaskAction) bool { + if action == nil { + return false + } + switch action.OperationType { + case "a2a", "agent": + return true + } + if len(action.OperationName) > 0 { + if action.OperationName[:3] == "a2a" { + return true + } + } + return false +} + +// newA2AExecutorFromAction creates an A2A executor from a task action. +// The OperationName acts as the agent URL for A2A calls. +func newA2AExecutorFromAction(action *model.WorkflowTaskAction) *bridge.A2AExecutor { + agentURL := action.OperationName + if agentURL == "" { + agentURL = "http://localhost:9090" + } + return bridge.NewA2AExecutor(agentURL) +} diff --git a/internal/task/event_task.go b/internal/task/event_task.go index 9d63d6e..3a016f1 100644 --- a/internal/task/event_task.go +++ b/internal/task/event_task.go @@ -35,17 +35,20 @@ func NewEventTask(instance *model.WorkflowTaskInstance) Task { if instance == nil || instance.Task == nil { return nil } - t.baseTask = baseTask{taskID: instance.TaskID, taskInstanceID: instance.TaskInstanceID, input: instance.Input, - workflowID: instance.WorkflowID, workflowInstanceID: instance.WorkflowInstanceID, taskType: instance.Task.TaskType} - t.action = instance.Task.Actions[0] - t.transition = instance.Task.ChildTasks[0] + t.baseTask = newBaseTask(instance) + if len(instance.Task.Actions) > 0 { + t.action = instance.Task.Actions[0] + } + if len(instance.Task.ChildTasks) > 0 { + t.transition = instance.Task.ChildTasks[0] + } t.operationTask = NewOperationTask(instance) t.flowEngine = flow.NewEngine() return &t } func (t *eventTask) Run() error { - metrics.Inc(constants.MetricsEventTask, constants.MetricsTotal) + _ = metrics.Inc(constants.MetricsEventTask, constants.MetricsTotal) if t.transition == nil { return nil } diff --git a/internal/task/local_runtime.go b/internal/task/local_runtime.go new file mode 100644 index 0000000..6c93022 --- /dev/null +++ b/internal/task/local_runtime.go @@ -0,0 +1,305 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package task + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/constants" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/filter" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/third_party/jqer" +) + +type localRuntimeTask struct { + baseTask + action *model.WorkflowTaskAction + transitions []*model.WorkflowTaskRelation + definition map[string]interface{} +} + +func NewLocalRuntimeTask(instance *model.WorkflowTaskInstance) Task { + var t localRuntimeTask + if instance == nil || instance.Task == nil { + return nil + } + t.baseTask = newBaseTask(instance) + if len(instance.Task.Actions) > 0 { + t.action = instance.Task.Actions[0] + } + t.transitions = instance.Task.ChildTasks + if instance.Task.TaskDefinition != "" { + _ = json.Unmarshal([]byte(instance.Task.TaskDefinition), &t.definition) + } + return &t +} + +func (t *localRuntimeTask) Run() error { + nextInput, err := t.execute() + if err != nil { + return err + } + nextInput = filter.FilterWorkflowTaskOutputData(nextInput, t.outputFilter) + if len(t.transitions) == 0 || t.transitions[0] == nil || t.transitions[0].ToTaskID == constants.TaskEndID { + return completeWorkflow(t.baseTask) + } + if len(t.transitions) == 1 { + return publishNextOrComplete(t.baseTask, t.transitions[0], nextInput) + } + return publishNextTasks(t.baseTask, t.transitions, nextInput) +} + +func (t *localRuntimeTask) execute() (string, error) { + switch t.taskType { + case constants.TaskTypeSet: + return t.executeSet() + case constants.TaskTypeWait: + return t.executeWait() + case constants.TaskTypeRaise: + return "", t.executeRaise() + case constants.TaskTypeRun: + return t.input, t.executeRun() + case constants.TaskTypeFork: + return t.input, nil + case constants.TaskTypeTry: + return t.executeTry() + case constants.TaskTypeFor: + return t.executeFor() + case constants.TaskTypeDo: + return t.executeDo() + case constants.TaskTypeEmit: + return t.input, t.executeEmit() + default: + return t.input, nil + } +} + +func (t *localRuntimeTask) executeSet() (string, error) { + setValue, ok := t.definition[constants.TaskTypeSet] + if !ok { + return t.input, nil + } + var input interface{} + if t.input != "" { + if err := json.Unmarshal([]byte(t.input), &input); err != nil { + return "", err + } + } + jq := jqer.NewJQ() + ret, err := jq.Object(input, setValue) + if err != nil { + return "", err + } + bytes, err := json.Marshal(ret) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (t *localRuntimeTask) executeWait() (string, error) { + waitDef := asMap(t.definition[constants.TaskTypeWait]) + durationText := firstNonEmpty(asString(waitDef["duration"]), asString(waitDef["seconds"]), asString(waitDef["milliseconds"])) + if durationText == "" { + return t.input, nil + } + duration, err := time.ParseDuration(durationText) + if err != nil { + return "", err + } + if duration > 0 { + time.Sleep(duration) + } + return t.input, nil +} + +func (t *localRuntimeTask) executeRaise() error { + raiseDef := asMap(t.definition[constants.TaskTypeRaise]) + if len(raiseDef) == 0 { + return errors.New("workflow raise task failed") + } + errorDef := asMap(raiseDef["error"]) + if len(errorDef) == 0 { + return fmt.Errorf("workflow raise task failed: %v", raiseDef) + } + return fmt.Errorf("workflow raise task failed: type=%s title=%s status=%s detail=%s", + asString(errorDef["type"]), asString(errorDef["title"]), asString(errorDef["status"]), asString(errorDef["detail"])) +} + +func (t *localRuntimeTask) executeRun() error { + if t.action == nil || t.action.OperationName == "" { + return nil + } + return publishEvent(t.workflowInstanceID, t.taskInstanceID, t.action.OperationName, t.input) +} + +func (t *localRuntimeTask) executeEmit() error { + if t.action == nil || t.action.OperationName == "" { + return nil + } + return publishEvent(t.workflowInstanceID, t.taskInstanceID, t.action.OperationName, t.input) +} + +func (t *localRuntimeTask) executeTry() (string, error) { + tryTasks := asSlice(t.definition["try"]) + if len(tryTasks) == 0 { + return t.input, nil + } + for _, taskItem := range tryTasks { + taskMap := asMap(taskItem) + for taskName, taskDef := range taskMap { + def := asMap(taskDef) + taskType := detectTaskType(def) + switch taskType { + case constants.TaskTypeSet: + return t.executeSetFromDef(def) + case constants.TaskTypeRaise: + return "", fmt.Errorf("try task %s raised: %v", taskName, def) + case constants.TaskTypeCall: + return t.input, nil + } + } + } + return t.input, nil +} + +func (t *localRuntimeTask) executeFor() (string, error) { + forDef := asMap(t.definition["for"]) + forDo := asMap(forDef["do"]) + if len(forDo) == 0 { + return t.input, nil + } + var items []interface{} + if t.input != "" { + var inputData interface{} + if err := json.Unmarshal([]byte(t.input), &inputData); err == nil { + if arr, ok := inputData.([]interface{}); ok { + items = arr + } + } + } + if len(items) == 0 { + items = append(items, t.input) + } + for _, item := range items { + itemInput, _ := json.Marshal(item) + for _, taskItem := range asSlice(forDo["do"]) { + taskMap := asMap(taskItem) + for _, taskDef := range taskMap { + def := asMap(taskDef) + taskType := detectTaskType(def) + switch taskType { + case constants.TaskTypeSet: + result, err := executeSetWithInput(def, string(itemInput)) + if err != nil { + return "", err + } + itemInput = []byte(result) + } + } + } + } + return t.input, nil +} + +func (t *localRuntimeTask) executeDo() (string, error) { + doList := asSlice(t.definition["do"]) + if len(doList) == 0 { + return t.input, nil + } + currentInput := t.input + for _, taskItem := range doList { + taskMap := asMap(taskItem) + for _, taskDef := range taskMap { + def := asMap(taskDef) + taskType := detectTaskType(def) + switch taskType { + case constants.TaskTypeSet: + result, err := t.executeSetFromDef(def) + if err != nil { + return "", err + } + if result != "" { + currentInput = result + } + case constants.TaskTypeRaise: + return "", fmt.Errorf("do task raised: %v", def) + } + } + } + return currentInput, nil +} + +func (t *localRuntimeTask) executeSetFromDef(def map[string]interface{}) (string, error) { + setExpr := asMap(def["set"]) + if len(setExpr) == 0 { + return t.input, nil + } + var input interface{} + if t.input != "" { + if err := json.Unmarshal([]byte(t.input), &input); err != nil { + return "", err + } + } + jq := jqer.NewJQ() + ret, err := jq.Object(input, setExpr) + if err != nil { + return "", err + } + bytes, err := json.Marshal(ret) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func executeSetWithInput(def map[string]interface{}, input string) (string, error) { + setExpr := asMap(def["set"]) + if len(setExpr) == 0 { + return input, nil + } + var inputData interface{} + if input != "" { + if err := json.Unmarshal([]byte(input), &inputData); err != nil { + return "", err + } + } + jq := jqer.NewJQ() + ret, err := jq.Object(inputData, setExpr) + if err != nil { + return "", err + } + bytes, err := json.Marshal(ret) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func detectTaskType(def map[string]interface{}) string { + if _, ok := def["call"]; ok { + return constants.TaskTypeCall + } + for _, key := range []string{constants.TaskTypeSet, constants.TaskTypeWait, constants.TaskTypeRaise, constants.TaskTypeRun, constants.TaskTypeEmit} { + if _, ok := def[key]; ok { + return key + } + } + return constants.TaskTypeCall +} diff --git a/internal/task/operation_task.go b/internal/task/operation_task.go index 1574fa0..140b09c 100644 --- a/internal/task/operation_task.go +++ b/internal/task/operation_task.go @@ -16,7 +16,6 @@ package task import ( - "context" "github.com/apache/incubator-eventmesh/eventmesh-server-go/config" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/constants" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal" @@ -37,37 +36,54 @@ func NewOperationTask(instance *model.WorkflowTaskInstance) Task { if instance == nil || instance.Task == nil { return nil } - t.baseTask = baseTask{taskID: instance.TaskID, taskInstanceID: instance.TaskInstanceID, input: instance.Input, - workflowID: instance.WorkflowID, workflowInstanceID: instance.WorkflowInstanceID, taskType: instance.Task.TaskType} - t.action = instance.Task.Actions[0] - t.transition = instance.Task.ChildTasks[0] - t.baseTask.queue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) + t.baseTask = newBaseTask(instance) + if len(instance.Task.Actions) > 0 { + t.action = instance.Task.Actions[0] + } + if len(instance.Task.ChildTasks) > 0 { + t.transition = instance.Task.ChildTasks[0] + } + t.queue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) t.workflowDAL = dal.NewWorkflowDAL() return &t } func (t *operationTask) Run() error { - metrics.Inc(constants.MetricsOperationTask, constants.MetricsTotal) - if t.action == nil { - return nil - } - // match end - if t.transition.ToTaskID == constants.TaskEndID { - if t.action != nil { - if err := publishEvent(t.workflowInstanceID, uuid.New().String(), t.action.OperationName, t.input); err != nil { - return err - } + _ = metrics.Inc(constants.MetricsOperationTask, constants.MetricsTotal) + if t.transition == nil || t.transition.ToTaskID == constants.TaskEndID { + if err := t.runAction(uuid.New().String()); err != nil { + return err } - return t.workflowDAL.UpdateInstance(context.Background(), - &model.WorkflowInstance{WorkflowInstanceID: t.workflowInstanceID, - WorkflowStatus: constants.WorkflowInstanceSuccessStatus}) + return publishNextOrComplete(t.baseTask, t.transition, t.input) } var taskInstanceID = uuid.New().String() var taskInstance = model.WorkflowTaskInstance{WorkflowInstanceID: t.workflowInstanceID, WorkflowID: t.workflowID, TaskID: t.transition.ToTaskID, TaskInstanceID: taskInstanceID, Status: constants.TaskInstanceSleepStatus, - Input: t.baseTask.input} - if err := t.baseTask.queue.Publish([]*model.WorkflowTaskInstance{&taskInstance}); err != nil { + Input: t.input} + if err := t.queue.Publish([]*model.WorkflowTaskInstance{&taskInstance}); err != nil { + return err + } + return t.runAction(taskInstanceID) +} + +func (t *operationTask) runAction(nextTaskInstanceID string) error { + if t.action == nil || t.action.OperationName == "" || isLocalRuntimeTask(t.action.OperationType) { + return nil + } + if isA2ATask(t.action) { + return t.runA2AAction() + } + return publishEvent(t.workflowInstanceID, nextTaskInstanceID, t.action.OperationName, t.input) +} + +func (t *operationTask) runA2AAction() error { + executor := newA2AExecutorFromAction(t.action) + output, err := executor.ExecuteFromAction(t.action, t.input) + if err != nil { return err } - return publishEvent(t.workflowInstanceID, taskInstanceID, t.action.OperationName, t.input) + if output != "" { + t.input = output + } + return nil } diff --git a/internal/task/runtime_util.go b/internal/task/runtime_util.go new file mode 100644 index 0000000..00454b8 --- /dev/null +++ b/internal/task/runtime_util.go @@ -0,0 +1,141 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package task + +import ( + "context" + "fmt" + "strings" + + "github.com/apache/incubator-eventmesh/eventmesh-server-go/config" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/constants" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal/model" + "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/queue" + "github.com/google/uuid" +) + +func isLocalRuntimeTask(taskType string) bool { + switch strings.ToLower(taskType) { + case constants.TaskTypeSet, constants.TaskTypeDo, constants.TaskTypeFork, constants.TaskTypeFor, + constants.TaskTypeTry, constants.TaskTypeWait, constants.TaskTypeRaise, constants.TaskTypeRun, + constants.TaskTypeEmit: + return true + default: + return false + } +} + +func publishNextOrComplete(base baseTask, transition *model.WorkflowTaskRelation, input string) error { + workflowDAL := base.workflowDAL + if workflowDAL == nil { + workflowDAL = dal.NewWorkflowDAL() + } + if transition == nil || transition.ToTaskID == constants.TaskEndID { + return workflowDAL.UpdateInstance(context.Background(), + &model.WorkflowInstance{WorkflowInstanceID: base.workflowInstanceID, + WorkflowStatus: constants.WorkflowInstanceSuccessStatus}) + } + observeQueue := base.queue + if observeQueue == nil { + observeQueue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) + } + var taskInstance = model.WorkflowTaskInstance{WorkflowInstanceID: base.workflowInstanceID, + WorkflowID: base.workflowID, TaskID: transition.ToTaskID, TaskInstanceID: uuid.New().String(), + Status: constants.TaskInstanceWaitStatus, Input: input} + return observeQueue.Publish([]*model.WorkflowTaskInstance{&taskInstance}) +} + +func publishNextTasks(base baseTask, relations []*model.WorkflowTaskRelation, input string) error { + if len(relations) == 0 { + return publishNextOrComplete(base, nil, input) + } + observeQueue := base.queue + if observeQueue == nil { + observeQueue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) + } + var instances []*model.WorkflowTaskInstance + for _, rel := range relations { + if rel == nil || rel.ToTaskID == constants.TaskEndID { + continue + } + instances = append(instances, &model.WorkflowTaskInstance{ + WorkflowInstanceID: base.workflowInstanceID, + WorkflowID: base.workflowID, + TaskID: rel.ToTaskID, + TaskInstanceID: uuid.New().String(), + Status: constants.TaskInstanceWaitStatus, + Input: input, + }) + } + if len(instances) == 0 { + return publishNextOrComplete(base, nil, input) + } + return observeQueue.Publish(instances) +} + +func completeWorkflow(base baseTask) error { + workflowDAL := base.workflowDAL + if workflowDAL == nil { + workflowDAL = dal.NewWorkflowDAL() + } + return workflowDAL.UpdateInstance(context.Background(), + &model.WorkflowInstance{WorkflowInstanceID: base.workflowInstanceID, + WorkflowStatus: constants.WorkflowInstanceSuccessStatus}) +} + +func asMap(value interface{}) map[string]interface{} { + if value == nil { + return nil + } + if m, ok := value.(map[string]interface{}); ok { + return m + } + if m, ok := value.(map[interface{}]interface{}); ok { + result := make(map[string]interface{}, len(m)) + for key, item := range m { + result[fmt.Sprint(key)] = item + } + return result + } + return nil +} + +func asString(value interface{}) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func asSlice(value interface{}) []interface{} { + if value == nil { + return nil + } + if items, ok := value.([]interface{}); ok { + return items + } + return nil +} diff --git a/internal/task/switch_task.go b/internal/task/switch_task.go index 4339c8e..e7d4b70 100644 --- a/internal/task/switch_task.go +++ b/internal/task/switch_task.go @@ -18,6 +18,8 @@ package task import ( "context" "encoding/json" + "strconv" + "github.com/apache/incubator-eventmesh/eventmesh-server-go/config" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/constants" "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/internal/dal" @@ -27,7 +29,6 @@ import ( "github.com/apache/incubator-eventmesh/eventmesh-workflow-go/third_party/jqer" "github.com/gogf/gf/util/gconv" "github.com/google/uuid" - "strconv" ) type switchTask struct { @@ -41,50 +42,70 @@ func NewSwitchTask(instance *model.WorkflowTaskInstance) Task { if instance == nil || instance.Task == nil { return nil } - t.baseTask = baseTask{taskID: instance.TaskID, input: instance.Input, - workflowID: instance.WorkflowID, workflowInstanceID: instance.WorkflowInstanceID, - taskType: instance.Task.TaskType} + t.baseTask = newBaseTask(instance) t.transitions = instance.Task.ChildTasks - t.baseTask.queue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) + t.queue = queue.GetQueue(config.GlobalConfig().Flow.Queue.Store) t.workflowDAL = dal.NewWorkflowDAL() t.jq = jqer.NewJQ() return &t } func (t *switchTask) Run() error { - metrics.Inc(constants.MetricsSwitchTask, constants.MetricsTotal) + _ = metrics.Inc(constants.MetricsSwitchTask, constants.MetricsTotal) if len(t.transitions) == 0 { return nil } + var defaultTransition *model.WorkflowTaskRelation for _, transition := range t.transitions { - if transition.ToTaskID == constants.TaskEndID { + if transition == nil { continue } - var jqData interface{} - err := json.Unmarshal([]byte(t.input), &jqData) - if err != nil { - return err - } - res, err := t.jq.One(jqData, transition.Condition) - if err != nil { - return err + if transition.Condition == "" { + defaultTransition = transition + continue } - boolValue, err := strconv.ParseBool(gconv.String(res)) + matched, err := t.matchCondition(transition.Condition) if err != nil { return err } - if !boolValue { - metrics.Inc(constants.MetricsSwitchTask, constants.MetricsSwitchReject) + if !matched { + _ = metrics.Inc(constants.MetricsSwitchTask, constants.MetricsSwitchReject) continue } + _ = metrics.Inc(constants.MetricsSwitchTask, constants.MetricsSwitchPass) + return t.publishOrComplete(transition) + } + if defaultTransition != nil { + _ = metrics.Inc(constants.MetricsSwitchTask, constants.MetricsSwitchPass) + return t.publishOrComplete(defaultTransition) + } + return t.completeWorkflow() +} - metrics.Inc(constants.MetricsSwitchTask, constants.MetricsSwitchPass) - var taskInstance = model.WorkflowTaskInstance{WorkflowInstanceID: t.workflowInstanceID, - WorkflowID: t.workflowID, TaskID: transition.ToTaskID, TaskInstanceID: uuid.New().String(), - Status: constants.TaskInstanceWaitStatus, Input: t.baseTask.input} - return t.baseTask.queue.Publish([]*model.WorkflowTaskInstance{&taskInstance}) +func (t *switchTask) matchCondition(condition string) (bool, error) { + var jqData interface{} + err := json.Unmarshal([]byte(t.input), &jqData) + if err != nil { + return false, err } - // not match + res, err := t.jq.One(jqData, condition) + if err != nil { + return false, err + } + return strconv.ParseBool(gconv.String(res)) +} + +func (t *switchTask) publishOrComplete(transition *model.WorkflowTaskRelation) error { + if transition == nil || transition.ToTaskID == constants.TaskEndID { + return t.completeWorkflow() + } + var taskInstance = model.WorkflowTaskInstance{WorkflowInstanceID: t.workflowInstanceID, + WorkflowID: t.workflowID, TaskID: transition.ToTaskID, TaskInstanceID: uuid.New().String(), + Status: constants.TaskInstanceWaitStatus, Input: t.input} + return t.queue.Publish([]*model.WorkflowTaskInstance{&taskInstance}) +} + +func (t *switchTask) completeWorkflow() error { return t.workflowDAL.UpdateInstance(context.Background(), &model.WorkflowInstance{WorkflowInstanceID: t.workflowInstanceID, WorkflowStatus: constants.WorkflowInstanceSuccessStatus}) diff --git a/internal/task/task.go b/internal/task/task.go index 5735d69..ee43a11 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -45,6 +45,7 @@ type baseTask struct { workflowInstanceID string input string taskType string + outputFilter string queue queue.ObserveQueue workflowDAL dal.WorkflowDAL } @@ -53,15 +54,30 @@ func New(instance *model.WorkflowTaskInstance) Task { if instance == nil || instance.Task == nil { return nil } + if isLocalRuntimeTask(instance.Task.TaskType) { + return NewLocalRuntimeTask(instance) + } switch instance.Task.TaskType { case constants.TaskTypeOperation: return NewOperationTask(instance) - case constants.TaskTypeEvent: + case constants.TaskTypeEvent, constants.TaskTypeListen: return NewEventTask(instance) case constants.TaskTypeSwitch: return NewSwitchTask(instance) } - return nil + return NewOperationTask(instance) +} + +func newBaseTask(instance *model.WorkflowTaskInstance) baseTask { + return baseTask{ + taskID: instance.TaskID, + taskInstanceID: instance.TaskInstanceID, + input: instance.Input, + workflowID: instance.WorkflowID, + workflowInstanceID: instance.WorkflowInstanceID, + taskType: instance.Task.TaskType, + outputFilter: instance.Task.TaskOutputFilter, + } } func publishEvent(workflowInstanceID string, taskInstanceID string, operationID string, content string) error { diff --git a/middleware/dblock/lock.go b/middleware/dblock/lock.go index 9397211..2d6b92f 100644 --- a/middleware/dblock/lock.go +++ b/middleware/dblock/lock.go @@ -19,6 +19,8 @@ import ( "context" "database/sql" "time" + + "github.com/apache/incubator-eventmesh/eventmesh-server-go/log" ) // Lock denotes an acquired lock and presents two methods, one for getting the context which is cancelled when the lock @@ -58,7 +60,9 @@ func (l Lock) refresher(duration time.Duration, cancelFunc context.CancelFunc) { cancelFunc() deadlineCancelFunc() // this will make sure connection is closed - l.Release() + if relErr := l.Release(); relErr != nil { + log.Errorf("fail to release lock: %v", relErr) + } return } deadlineCancelFunc() // to avoid context leak diff --git a/third_party/swf/model.go b/third_party/swf/model.go new file mode 100644 index 0000000..ad671a1 --- /dev/null +++ b/third_party/swf/model.go @@ -0,0 +1,170 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package swf + +import ( + "errors" + "fmt" +) + +const ( + LatestDSLVersion = "1.0.3" + + TaskTypeOperation = "operation" + TaskTypeEvent = "event" + TaskTypeSwitch = "switch" + TaskTypeSet = "set" + TaskTypeDo = "do" + TaskTypeFork = "fork" + TaskTypeFor = "for" + TaskTypeTry = "try" + TaskTypeWait = "wait" + TaskTypeRaise = "raise" + TaskTypeRun = "run" + TaskTypeEmit = "emit" + TaskTypeListen = "listen" +) + +type Schedule struct { + Start string + Cron string + After string +} + +type Workflow struct { + ID string + Name string + Version string + DSL string + Namespace string + Start string + Tasks []*Task + Functions map[string]*Function + Schedule *Schedule + Input string + Output string + Legacy bool +} + +type Function struct { + Name string + Operation string + Type string +} + +type Task struct { + Name string + Type string + InputFilter string + OutputFilter string + InlineData string + Then string + ExplicitThen bool + Actions []*Action + Cases []*SwitchCase + Children []*Task + Raw string +} + +type Action struct { + OperationName string + OperationType string +} + +type SwitchCase struct { + Name string + Condition string + Then string + IsDefault bool +} + +func (w *Workflow) FlattenTasks() []*Task { + if w == nil { + return nil + } + var tasks []*Task + var walk func(items []*Task) + walk = func(items []*Task) { + for _, item := range items { + if item == nil { + continue + } + tasks = append(tasks, item) + walk(item.Children) + } + } + walk(w.Tasks) + return tasks +} + +func (w *Workflow) Validate() error { + if w == nil { + return nil + } + allTasks := w.FlattenTasks() + if len(allTasks) == 0 { + return errors.New("workflow must define at least one task") + } + taskNames := make(map[string]bool, len(allTasks)) + for _, task := range allTasks { + if task.Name == "" { + return errors.New("all tasks must have a name") + } + if taskNames[task.Name] { + return fmt.Errorf("duplicate task name: %s", task.Name) + } + taskNames[task.Name] = true + } + for _, task := range allTasks { + if err := validateTaskFlow(task, taskNames); err != nil { + return err + } + } + return nil +} + +func validateTaskFlow(task *Task, validNames map[string]bool) error { + if task == nil { + return nil + } + if task.ExplicitThen { + if task.Then != "" && !isValidFlowTarget(task.Then, validNames) { + return fmt.Errorf("task %s: then target %s not found", task.Name, task.Then) + } + } + for _, c := range task.Cases { + if c == nil { + continue + } + if c.Then != "" && !isValidFlowTarget(c.Then, validNames) { + return fmt.Errorf("task %s: switch case then target %s not found", task.Name, c.Then) + } + } + for _, child := range task.Children { + if err := validateTaskFlow(child, validNames); err != nil { + return err + } + } + return nil +} + +func isValidFlowTarget(target string, validNames map[string]bool) bool { + switch target { + case "end", "exit", "continue": + return true + } + return validNames[target] +} diff --git a/third_party/swf/parser_integration_test.go b/third_party/swf/parser_integration_test.go new file mode 100644 index 0000000..cf93c0b --- /dev/null +++ b/third_party/swf/parser_integration_test.go @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package swf + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseConfigFileLegacy(t *testing.T) { + data, err := os.ReadFile("../../configs/testcreateworkflow.yaml") + if err != nil { + t.Skipf("legacy config file not found: %v", err) + return + } + wf, err := Parse(string(data)) + assert.Nil(t, err) + assert.NotNil(t, wf) + assert.True(t, wf.Legacy) + assert.NotEmpty(t, wf.Tasks) + t.Logf("legacy workflow: id=%s, tasks=%d", wf.ID, len(wf.Tasks)) +} + +func TestParseConfigFileV1(t *testing.T) { + data, err := os.ReadFile("../../configs/testcreateworkflow-v1.yaml") + if err != nil { + t.Skipf("v1 config file not found: %v", err) + return + } + wf, err := Parse(string(data)) + assert.Nil(t, err) + assert.NotNil(t, wf) + assert.False(t, wf.Legacy) + assert.NotEmpty(t, wf.Tasks) + t.Logf("v1 workflow: id=%s, dsl=%s, tasks=%d", wf.ID, wf.DSL, len(wf.FlattenTasks())) +} + +func TestParseLegacyConfigBuiltTaskTypes(t *testing.T) { + data, err := os.ReadFile("../../configs/testcreateworkflow.yaml") + if err != nil { + t.Skipf("config file not found: %v", err) + return + } + wf, _ := Parse(string(data)) + assert.NotNil(t, wf) + flattened := wf.FlattenTasks() + assert.GreaterOrEqual(t, len(flattened), 1) + for _, task := range flattened { + assert.NotEmpty(t, task.Name) + assert.NotEmpty(t, task.Type) + t.Logf("task: name=%s type=%s", task.Name, task.Type) + } +} + +func TestParseV1ConfigBuiltTaskTypes(t *testing.T) { + data, err := os.ReadFile("../../configs/testcreateworkflow-v1.yaml") + if err != nil { + t.Skipf("config file not found: %v", err) + return + } + wf, _ := Parse(string(data)) + assert.NotNil(t, wf) + flattened := wf.FlattenTasks() + assert.GreaterOrEqual(t, len(flattened), 1) + for _, task := range flattened { + assert.NotEmpty(t, task.Name) + assert.NotEmpty(t, task.Type) + t.Logf("task: name=%s type=%s", task.Name, task.Type) + } +} diff --git a/third_party/swf/swf.go b/third_party/swf/swf.go index bf50b01..11f7dfc 100644 --- a/third_party/swf/swf.go +++ b/third_party/swf/swf.go @@ -16,14 +16,416 @@ package swf import ( - "github.com/gogf/gf/util/gconv" - "github.com/serverlessworkflow/sdk-go/v2/model" - "github.com/serverlessworkflow/sdk-go/v2/parser" + "encoding/json" + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v3" ) -func Parse(source string) (*model.Workflow, error) { - if len(source) == 0 { +func Parse(source string) (*Workflow, error) { + if len(strings.TrimSpace(source)) == 0 { + return nil, nil + } + var raw map[string]interface{} + if err := yaml.Unmarshal([]byte(source), &raw); err != nil { + return nil, err + } + var wf *Workflow + var err error + if _, ok := raw["document"]; ok { + wf, err = parseV1Workflow(raw) + } else { + wf, err = parseLegacyWorkflow(raw) + } + if err != nil { + return nil, err + } + if wf == nil { return nil, nil } - return parser.FromYAMLSource(gconv.Bytes(source)) + if verr := wf.Validate(); verr != nil { + return nil, verr + } + return wf, nil +} + +func parseV1Workflow(raw map[string]interface{}) (*Workflow, error) { + doc := asMap(raw["document"]) + name := asString(doc["name"]) + if name == "" { + return nil, errors.New("workflow document.name is required") + } + version := asString(doc["version"]) + if version == "" { + return nil, errors.New("workflow document.version is required") + } + dsl := asString(doc["dsl"]) + if dsl == "" { + return nil, errors.New("workflow document.dsl is required") + } + tasks, err := parseV1TaskList(raw["do"]) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return nil, errors.New("workflow do must contain at least one task") + } + return &Workflow{ + ID: name, + Name: name, + Version: version, + DSL: dsl, + Namespace: asString(doc["namespace"]), + Start: tasks[0].Name, + Tasks: tasks, + Functions: parseReusableFunctions(raw), + Schedule: parseSchedule(raw["schedule"]), + Input: parseWorkflowInput(raw["input"]), + Output: expressionString(asMap(raw["output"])["as"]), + Legacy: false, + }, nil +} + +func parseLegacyWorkflow(raw map[string]interface{}) (*Workflow, error) { + id := asString(raw["id"]) + if id == "" { + return nil, errors.New("workflow id is required") + } + states := asSlice(raw["states"]) + if len(states) == 0 { + return nil, errors.New("workflow states must contain at least one state") + } + functions := make(map[string]*Function) + for _, item := range asSlice(raw["functions"]) { + fnMap := asMap(item) + name := asString(fnMap["name"]) + if name == "" { + continue + } + functions[name] = &Function{Name: name, Operation: asString(fnMap["operation"]), Type: asString(fnMap["type"])} + } + var tasks []*Task + for _, item := range states { + stateMap := asMap(item) + name := asString(stateMap["name"]) + if name == "" { + continue + } + task := &Task{Name: name, Type: asString(stateMap["type"]), Then: legacyThen(stateMap), ExplicitThen: true} + if filter := asMap(stateMap["stateDataFilter"]); len(filter) > 0 { + task.InputFilter = asString(filter["input"]) + } + task.Actions = parseLegacyActions(stateMap, functions) + if task.Type == TaskTypeSwitch { + task.Cases = parseLegacySwitchCases(stateMap) + } + tasks = append(tasks, task) + } + start := asString(raw["start"]) + if start == "" && len(tasks) > 0 { + start = tasks[0].Name + } + return &Workflow{ + ID: id, + Name: asString(raw["name"]), + Version: asString(raw["version"]), + DSL: asString(raw["specVersion"]), + Start: start, + Tasks: tasks, + Functions: functions, + Legacy: true, + }, nil +} + +func parseSchedule(value interface{}) *Schedule { + scheduleMap := asMap(value) + if len(scheduleMap) == 0 { + return nil + } + return &Schedule{ + Start: asString(scheduleMap["start"]), + Cron: asString(scheduleMap["cron"]), + After: asString(scheduleMap["after"]), + } +} + +func parseWorkflowInput(value interface{}) string { + inputMap := asMap(value) + if len(inputMap) == 0 { + return "" + } + return expressionString(inputMap["from"]) +} + +func parseV1TaskList(value interface{}) ([]*Task, error) { + items := asSlice(value) + if len(items) == 0 { + return nil, nil + } + var tasks []*Task + for _, item := range items { + itemMap := asMap(item) + for taskName, taskDef := range itemMap { + task, err := parseV1Task(taskName, asMap(taskDef)) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + } + return tasks, nil +} + +func parseV1Task(taskName string, def map[string]interface{}) (*Task, error) { + if taskName == "" { + return nil, errors.New("task name is required") + } + task := &Task{Name: taskName, Type: detectV1TaskType(def), Raw: mustJSON(def)} + if input := asMap(def["input"]); len(input) > 0 { + task.InputFilter = expressionString(input["from"]) + } + if data := asString(def["data"]); data != "" { + task.InlineData = data + } + if output := asMap(def["output"]); len(output) > 0 { + task.OutputFilter = expressionString(output["as"]) + } + if _, ok := def["then"]; ok { + task.Then = expressionString(def["then"]) + task.ExplicitThen = true + } + switch task.Type { + case TaskTypeOperation: + task.Actions = []*Action{parseV1CallAction(def)} + case TaskTypeEvent: + task.Actions = []*Action{parseV1CallAction(def)} + case TaskTypeSet: + task.Actions = []*Action{{OperationName: taskName, OperationType: TaskTypeSet}} + case TaskTypeSwitch: + task.Cases = parseV1SwitchCases(def["switch"]) + case TaskTypeDo: + children, err := parseV1TaskList(def["do"]) + if err != nil { + return nil, err + } + task.Children = children + case TaskTypeFork: + task.Children = parseV1ForkBranches(def["fork"]) + case TaskTypeFor: + children, err := parseV1TaskList(asMap(def["for"])["do"]) + if err != nil { + return nil, err + } + task.Children = children + case TaskTypeTry: + children, err := parseV1TaskList(def["try"]) + if err != nil { + return nil, err + } + task.Children = children + if catchMap := asMap(def["catch"]); len(catchMap) > 0 { + if when := catchMap["when"]; when != nil { + catchTasks, err := parseV1TaskList(when) + if err == nil { + task.Children = append(task.Children, catchTasks...) + } + } + } + case TaskTypeEmit, TaskTypeListen, TaskTypeWait, TaskTypeRaise, TaskTypeRun: + task.Actions = []*Action{{OperationName: taskName, OperationType: task.Type}} + } + return task, nil +} + +func detectV1TaskType(def map[string]interface{}) string { + if _, ok := def["call"]; ok { + return TaskTypeOperation + } + for _, key := range []string{TaskTypeSwitch, TaskTypeSet, TaskTypeDo, TaskTypeFork, TaskTypeFor, TaskTypeTry, TaskTypeWait, TaskTypeRaise, TaskTypeRun, TaskTypeEmit, TaskTypeListen} { + if _, ok := def[key]; ok { + return key + } + } + return TaskTypeOperation +} + +func parseV1CallAction(def map[string]interface{}) *Action { + operationType := expressionString(def["call"]) + operationName := operationType + with := asMap(def["with"]) + switch operationType { + case "http": + operationName = expressionString(with["endpoint"]) + case "openapi": + operationName = firstNonEmpty(expressionString(with["operationId"]), expressionString(with["operation"]), expressionString(with["document"])) + case "asyncapi": + operationName = firstNonEmpty(expressionString(with["operation"]), expressionString(with["channel"]), expressionString(with["document"])) + case "grpc": + operationName = firstNonEmpty(expressionString(with["service"]), expressionString(with["method"])) + default: + operationName = firstNonEmpty(operationName, expressionString(with["operation"]), expressionString(with["endpoint"])) + } + return &Action{OperationName: operationName, OperationType: operationType} +} + +func parseReusableFunctions(raw map[string]interface{}) map[string]*Function { + functions := make(map[string]*Function) + use := asMap(raw["use"]) + for name, item := range asMap(use["functions"]) { + def := asMap(item) + action := parseV1CallAction(def) + functions[name] = &Function{Name: name, Operation: action.OperationName, Type: action.OperationType} + } + return functions +} + +func parseV1SwitchCases(value interface{}) []*SwitchCase { + var cases []*SwitchCase + for _, item := range asSlice(value) { + itemMap := asMap(item) + for caseName, caseDef := range itemMap { + def := asMap(caseDef) + cases = append(cases, &SwitchCase{ + Name: caseName, + Condition: expressionString(def["when"]), + Then: expressionString(def["then"]), + IsDefault: caseName == "default" || def["when"] == nil, + }) + } + } + return cases +} + +func parseV1ForkBranches(value interface{}) []*Task { + forkDef := asMap(value) + branches := asSlice(forkDef["branches"]) + var tasks []*Task + for _, branch := range branches { + branchTasks, err := parseV1TaskList([]interface{}{branch}) + if err == nil { + tasks = append(tasks, branchTasks...) + } + } + return tasks +} + +func parseLegacyActions(stateMap map[string]interface{}, functions map[string]*Function) []*Action { + var actionValues []interface{} + if stateMap["actions"] != nil { + actionValues = asSlice(stateMap["actions"]) + } + for _, onEvent := range asSlice(stateMap["onEvents"]) { + onEventMap := asMap(onEvent) + actionValues = append(actionValues, asSlice(onEventMap["actions"])...) + } + var actions []*Action + for _, actionValue := range actionValues { + actionMap := asMap(actionValue) + functionRef := asMap(actionMap["functionRef"]) + functionName := asString(functionRef["refName"]) + fn := functions[functionName] + if fn == nil { + continue + } + actions = append(actions, &Action{OperationName: fn.Operation, OperationType: fn.Type}) + } + return actions +} + +func parseLegacySwitchCases(stateMap map[string]interface{}) []*SwitchCase { + var cases []*SwitchCase + for _, conditionValue := range asSlice(stateMap["dataConditions"]) { + condition := asMap(conditionValue) + then := asString(condition["transition"]) + if then == "" && asBool(condition["end"]) { + then = "end" + } + cases = append(cases, &SwitchCase{Name: asString(condition["name"]), Condition: expressionString(condition["condition"]), Then: then}) + } + defaultCondition := asMap(stateMap["defaultCondition"]) + if len(defaultCondition) > 0 { + then := asString(defaultCondition["transition"]) + if then == "" && asBool(defaultCondition["end"]) { + then = "end" + } + cases = append(cases, &SwitchCase{Name: "default", Then: then, IsDefault: true}) + } + return cases +} + +func legacyThen(stateMap map[string]interface{}) string { + if transition := asString(stateMap["transition"]); transition != "" { + return transition + } + if asBool(stateMap["end"]) { + return "end" + } + return "" +} + +func asMap(value interface{}) map[string]interface{} { + if value == nil { + return nil + } + if m, ok := value.(map[string]interface{}); ok { + return m + } + if m, ok := value.(map[interface{}]interface{}); ok { + res := make(map[string]interface{}, len(m)) + for key, item := range m { + res[fmt.Sprint(key)] = item + } + return res + } + return nil +} + +func asSlice(value interface{}) []interface{} { + if value == nil { + return nil + } + if items, ok := value.([]interface{}); ok { + return items + } + return nil +} + +func asString(value interface{}) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func asBool(value interface{}) bool { + if b, ok := value.(bool); ok { + return b + } + return strings.EqualFold(asString(value), "true") +} + +func expressionString(value interface{}) string { + text := strings.TrimSpace(asString(value)) + text = strings.TrimPrefix(text, "${") + text = strings.TrimSuffix(text, "}") + return strings.TrimSpace(text) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func mustJSON(value interface{}) string { + bytes, err := json.Marshal(value) + if err != nil { + return "" + } + return string(bytes) } diff --git a/third_party/swf/swf_test.go b/third_party/swf/swf_test.go index ffcc896..4dff717 100644 --- a/third_party/swf/swf_test.go +++ b/third_party/swf/swf_test.go @@ -18,15 +18,127 @@ package swf import ( "testing" - "github.com/gogf/gf/util/gconv" - "github.com/serverlessworkflow/sdk-go/v2/model" "github.com/stretchr/testify/assert" ) -func Test_Parse(t *testing.T) { +func TestParseLegacyWorkflow(t *testing.T) { var source = "id: storeorderworkflow\nversion: '1.0'\nspecVersion: '0.8'\nname: Store Order Management Workflow\nstart: Receive New Order Event\nstates:\n - name: Receive New Order Event\n type: event\n onEvents:\n - eventRefs:\n - NewOrderEvent\n actions:\n - functionRef:\n refName: \"OrderServiceSendEvent\"\n transition: Check New Order Result\n - name: Check New Order Result\n type: switch\n dataConditions:\n - name: New Order Successfull\n condition: \"${{ .order.order_no != '' }}\"\n transition: Send Order Payment\n - name: New Order Failed\n condition: \"${{ .order.order_no == '' }}\"\n end: true\n defaultCondition:\n end: true\n - name: Send Order Payment\n type: operation\n actions:\n - functionRef:\n refName: \"PaymentServiceSendEvent\"\n transition: Check Payment Status\n - name: Check Payment Status\n type: switch\n dataConditions:\n - name: Payment Successfull\n condition: \"${{ .payment.order_no != '' }}\"\n transition: Send Order Shipment\n - name: Payment Denied\n condition: \"${{ .payment.order_no == '' }}\"\n end: true\n defaultCondition:\n end: true\n - name: Send Order Shipment\n type: operation\n actions:\n - functionRef:\n refName: \"ShipmentServiceSendEvent\"\n end: true\nevents:\n - name: NewOrderEvent\n source: store/order\n type: online.store.newOrder\nfunctions:\n - name: OrderServiceSendEvent\n operation: file://orderService.yaml#sendOrder\n type: asyncapi\n - name: PaymentServiceSendEvent\n operation: file://paymentService.yaml#sendPayment\n type: asyncapi\n - name: ShipmentServiceSendEvent\n operation: file://shipmentService.yaml#sendShipment\n type: asyncapi\n" wf, err := Parse(source) - t.Log(gconv.String(wf)) assert.Nil(t, err) - assert.False(t, wf.States[1].(*model.DataBasedSwitchState).DefaultCondition.End.Terminate) + assert.True(t, wf.Legacy) + assert.Equal(t, "storeorderworkflow", wf.ID) + assert.Equal(t, "Receive New Order Event", wf.Start) + assert.Equal(t, 5, len(wf.Tasks)) + assert.Equal(t, "end", wf.Tasks[1].Cases[2].Then) +} + +func TestParseV1Workflow(t *testing.T) { + var source = "document:\n dsl: '1.0.3'\n namespace: eventmesh.apache.org\n name: store-order-management\n version: '1.0.0'\ndo:\n - receiveNewOrderEvent:\n listen:\n to:\n one:\n with:\n type: online.store.newOrder\n then: checkNewOrderResult\n - checkNewOrderResult:\n switch:\n - newOrderSuccessful:\n when: .order_no != \"\"\n then: sendOrderPayment\n - newOrderFailed:\n then: end\n - sendOrderPayment:\n call: asyncapi\n with:\n operation: file://paymentapp.yaml#sendPayment\n then: checkPaymentStatus\n - checkPaymentStatus:\n switch:\n - paymentSuccessful:\n when: .order_no != \"\"\n then: sendOrderShipment\n - paymentDenied:\n then: end\n - sendOrderShipment:\n call: asyncapi\n with:\n operation: file://expressapp.yaml#sendExpress\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.False(t, wf.Legacy) + assert.Equal(t, "store-order-management", wf.ID) + assert.Equal(t, "1.0.3", wf.DSL) + assert.Equal(t, "receiveNewOrderEvent", wf.Start) + assert.Equal(t, 5, len(wf.Tasks)) + assert.Equal(t, TaskTypeListen, wf.Tasks[0].Type) + assert.Equal(t, TaskTypeSwitch, wf.Tasks[1].Type) + assert.Equal(t, "file://paymentapp.yaml#sendPayment", wf.Tasks[2].Actions[0].OperationName) +} + +func TestParseV1StructuredTasks(t *testing.T) { + var source = "document:\n dsl: '1.0.3'\n namespace: default\n name: structured-tasks\n version: '1.0.0'\ndo:\n - prepare:\n set:\n orderId: 1\n then: batch\n - batch:\n do:\n - enrich:\n call: http\n with:\n endpoint: https://example.com/enrich\n then: choice\n - choice:\n switch:\n - ok:\n when: .ok == true\n then: done\n - default:\n then: end\n - done:\n raise:\n error:\n type: https://serverlessworkflow.io/spec/1.0.0/errors/runtime\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.Equal(t, 5, len(wf.FlattenTasks())) + assert.Equal(t, TaskTypeSet, wf.Tasks[0].Type) + assert.Equal(t, TaskTypeDo, wf.Tasks[1].Type) + assert.Equal(t, TaskTypeRaise, wf.Tasks[3].Type) + assert.Equal(t, "enrich", wf.Tasks[1].Children[0].Name) +} + +func TestValidateDuplicateTaskNames(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: dup\n version: '1.0.0'\ndo:\n - a:\n set:\n x: 1\n then: a\n - a:\n set:\n y: 2\n then: end\n" + _, err := Parse(source) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateUnknownThenV1(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: unknown-then\n version: '1.0.0'\ndo:\n - task1:\n set:\n x: 1\n then: ghost\n" + _, err := Parse(source) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "ghost") +} + +func TestValidateUnknownThenLegacy(t *testing.T) { + source := "id: jump\nversion: '1.0'\nspecVersion: '0.8'\nstart: task1\nstates:\n - name: task1\n type: operation\n actions:\n - functionRef:\n refName: fn\n transition: nowhere\nfunctions:\n - name: fn\n operation: http://x\n type: rest\n" + _, err := Parse(source) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "nowhere") +} + +func TestValidateEmpty(t *testing.T) { + wf, err := Parse("") + assert.Nil(t, err) + assert.Nil(t, wf) +} + +func TestValidateSetTaskTypeRaw(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: set-test\n version: '1.0.0'\ndo:\n - init:\n set:\n count: 0\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.NotNil(t, wf) + assert.Equal(t, 1, len(wf.Tasks)) + assert.NotEmpty(t, wf.Tasks[0].Raw) +} + +func TestParseForkBranches(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: fork-test\n version: '1.0.0'\ndo:\n - split:\n fork:\n branches:\n - branchA:\n set:\n a: 1\n then: end\n - branchB:\n set:\n b: 2\n then: end\n then: join\n - join:\n set:\n done: true\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.Equal(t, TaskTypeFork, wf.Tasks[0].Type) + assert.Equal(t, 2, len(wf.Tasks[0].Children)) + assert.Equal(t, "branchA", wf.Tasks[0].Children[0].Name) + assert.Equal(t, "branchB", wf.Tasks[0].Children[1].Name) + assert.Equal(t, TaskTypeSet, wf.Tasks[0].Children[0].Type) +} + +func TestParseTry(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: try-test\n version: '1.0.0'\ndo:\n - risky:\n try:\n - attempt:\n set:\n success: true\n catch:\n errors:\n with:\n type: '*'\n when:\n - recover:\n set:\n recovered: true\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.Equal(t, TaskTypeTry, wf.Tasks[0].Type) + assert.Equal(t, "attempt", wf.Tasks[0].Children[0].Name) + assert.Equal(t, "recover", wf.Tasks[0].Children[1].Name) + assert.Equal(t, TaskTypeSet, wf.Tasks[0].Children[1].Type) +} + +func TestParseFor(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: for-test\n version: '1.0.0'\ndo:\n - loop:\n for:\n each: .items\n do:\n - process:\n set:\n id: 1\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.Equal(t, 2, len(wf.FlattenTasks())) + assert.Equal(t, TaskTypeFor, wf.Tasks[0].Type) + assert.Equal(t, 1, len(wf.Tasks[0].Children)) + assert.Equal(t, "process", wf.Tasks[0].Children[0].Name) +} + +func TestParseScheduleAndOutput(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: scheduled-workflow\n version: '1.0.0'\nschedule:\n start: '2026-01-01T00:00:00Z'\n cron: '0 */6 * * *'\ninput:\n from: .payload\ndo:\n - task1:\n set:\n x: 1\n then: end\noutput:\n as: .result\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.NotNil(t, wf.Schedule) + assert.Equal(t, "2026-01-01T00:00:00Z", wf.Schedule.Start) + assert.Equal(t, "0 */6 * * *", wf.Schedule.Cron) + assert.Equal(t, ".payload", wf.Input) + assert.Equal(t, ".result", wf.Output) +} + +func TestParseTaskDataAndOutput(t *testing.T) { + source := "document:\n dsl: '1.0.3'\n name: task-fields\n version: '1.0.0'\ndo:\n - step1:\n data: '[\"a\",\"b\"]'\n set:\n items: .\n then: next\n - next:\n set:\n done: true\n output:\n as: .result\n then: end\n" + wf, err := Parse(source) + assert.Nil(t, err) + assert.Equal(t, "[\"a\",\"b\"]", wf.Tasks[0].InlineData) + assert.Equal(t, ".result", wf.Tasks[1].OutputFilter) }