feat: database layer + canvas validator/converter + LLM inference activities
ci / test (push) Failing after 2m11s
ci / test (push) Failing after 2m11s
- Add pkg/db models and CRUD methods for workflows - Add internal/routing canvas validator (DAG check, connectivity) - Add internal/routing canvas converter (Canvas → WorkflowSpec) - Register LLMInferenceActivity and LLMBatchInferenceActivity - Update api/server and cmd/server with database integration - Add K8s environment variable support - Update activity knowledge base with LLM activities - Add .env.example configuration template
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.temporal.io/sdk/client"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/internal/routing"
|
||||
"github.com/rockliang/poimen/workflows/pkg/db"
|
||||
)
|
||||
|
||||
// WorkflowNode matches frontend node type
|
||||
type WorkflowNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "activity", "start", "end"
|
||||
Position map[string]interface{} `json:"position"`
|
||||
Data struct {
|
||||
Label string `json:"label"`
|
||||
Activity string `json:"activity"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// WorkflowEdge matches frontend edge type
|
||||
type WorkflowEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowDef is the request body for creating/updating workflows
|
||||
type WorkflowDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Nodes []WorkflowNode `json:"nodes"`
|
||||
Edges []WorkflowEdge `json:"edges"`
|
||||
Status string `json:"status"` // "draft", "active"
|
||||
}
|
||||
|
||||
// WorkflowResponse is the workflow with metadata
|
||||
type WorkflowResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
Nodes []WorkflowNode `json:"nodes"`
|
||||
Edges []WorkflowEdge `json:"edges"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
}
|
||||
|
||||
// ExecutionRequest is the request to execute a workflow
|
||||
type ExecutionRequest struct {
|
||||
Inputs map[string]interface{} `json:"inputs"`
|
||||
}
|
||||
|
||||
// ExecutionResponse is the execution result
|
||||
type ExecutionResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
Status string `json:"status"` // "pending", "running", "success", "failed"
|
||||
StartedAt string `json:"startedAt"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
Inputs map[string]interface{} `json:"inputs"`
|
||||
Outputs map[string]interface{} `json:"outputs,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Logs []ExecutionLog `json:"logs"`
|
||||
}
|
||||
|
||||
// ExecutionLog is a log entry from execution
|
||||
type ExecutionLog struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Level string `json:"level"` // "info", "warn", "error"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// WorkflowAPI handles workflow endpoints
|
||||
type WorkflowAPI struct {
|
||||
db *db.DB
|
||||
temporalClient client.Client
|
||||
logger *log.Logger
|
||||
customerID string // TODO: Extract from JWT token
|
||||
}
|
||||
|
||||
// NewWorkflowAPI creates new API handler
|
||||
func NewWorkflowAPI(database *db.DB, tc client.Client, logger *log.Logger) *WorkflowAPI {
|
||||
return &WorkflowAPI{
|
||||
db: database,
|
||||
temporalClient: tc,
|
||||
logger: logger,
|
||||
customerID: "default-customer", // TODO: From auth context
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWorkflow handles POST /workflows
|
||||
func (api *WorkflowAPI) CreateWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req WorkflowDef
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
http.Error(w, "Workflow name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create workflow in database
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
// Convert nodes and edges to JSONB
|
||||
nodesJSON, err := json.Marshal(req.Nodes)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to marshal nodes: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
edgesJSON, err := json.Marshal(req.Edges)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to marshal edges: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
workflow := &db.Workflow{
|
||||
ID: id,
|
||||
CustomerID: api.customerID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: status,
|
||||
Version: 1,
|
||||
Nodes: nodesJSON,
|
||||
Edges: edgesJSON,
|
||||
CreatedBy: "anonymous", // Use JWT claim in real implementation
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := api.db.SaveWorkflow(r.Context(), workflow); err != nil {
|
||||
api.logger.Printf("Failed to save workflow: %v", err)
|
||||
http.Error(w, "Failed to create workflow", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := WorkflowResponse{
|
||||
ID: workflow.ID,
|
||||
Name: workflow.Name,
|
||||
Description: workflow.Description,
|
||||
Status: workflow.Status,
|
||||
Version: workflow.Version,
|
||||
Nodes: req.Nodes,
|
||||
Edges: req.Edges,
|
||||
CreatedAt: workflow.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: workflow.UpdatedAt.Format(time.RFC3339),
|
||||
CreatedBy: workflow.CreatedBy,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// ListWorkflows handles GET /workflows
|
||||
func (api *WorkflowAPI) ListWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
page := 1
|
||||
limit := 10
|
||||
// Parse pagination params if needed
|
||||
|
||||
workflows, err := api.db.ListWorkflows(r.Context(), api.customerID, limit, (page-1)*limit)
|
||||
if err != nil {
|
||||
api.logger.Printf("Failed to list workflows: %v", err)
|
||||
http.Error(w, "Failed to list workflows", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]WorkflowResponse, 0)
|
||||
for _, wf := range workflows {
|
||||
var nodes []WorkflowNode
|
||||
var edges []WorkflowEdge
|
||||
|
||||
json.Unmarshal(wf.Nodes, &nodes)
|
||||
json.Unmarshal(wf.Edges, &edges)
|
||||
|
||||
list = append(list, WorkflowResponse{
|
||||
ID: wf.ID,
|
||||
Name: wf.Name,
|
||||
Description: wf.Description,
|
||||
Status: wf.Status,
|
||||
Version: wf.Version,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
CreatedAt: wf.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: wf.UpdatedAt.Format(time.RFC3339),
|
||||
CreatedBy: wf.CreatedBy,
|
||||
})
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"workflows": list,
|
||||
"total": len(list),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetWorkflow handles GET /workflows/{id}
|
||||
func (api *WorkflowAPI) GetWorkflow(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
workflow, err := api.db.FetchWorkflow(r.Context(), id, api.customerID)
|
||||
if err != nil {
|
||||
http.Error(w, "Workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var nodes []WorkflowNode
|
||||
var edges []WorkflowEdge
|
||||
|
||||
json.Unmarshal(workflow.Nodes, &nodes)
|
||||
json.Unmarshal(workflow.Edges, &edges)
|
||||
|
||||
response := WorkflowResponse{
|
||||
ID: workflow.ID,
|
||||
Name: workflow.Name,
|
||||
Description: workflow.Description,
|
||||
Status: workflow.Status,
|
||||
Version: workflow.Version,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
CreatedAt: workflow.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: workflow.UpdatedAt.Format(time.RFC3339),
|
||||
CreatedBy: workflow.CreatedBy,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// UpdateWorkflow handles PUT /workflows/{id}
|
||||
func (api *WorkflowAPI) UpdateWorkflow(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch existing workflow
|
||||
workflow, err := api.db.FetchWorkflow(r.Context(), id, api.customerID)
|
||||
if err != nil {
|
||||
http.Error(w, "Workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req WorkflowDef
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Name != "" {
|
||||
workflow.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
workflow.Description = req.Description
|
||||
}
|
||||
if req.Nodes != nil {
|
||||
nodesJSON, _ := json.Marshal(req.Nodes)
|
||||
workflow.Nodes = nodesJSON
|
||||
}
|
||||
if req.Edges != nil {
|
||||
edgesJSON, _ := json.Marshal(req.Edges)
|
||||
workflow.Edges = edgesJSON
|
||||
}
|
||||
if req.Status != "" {
|
||||
workflow.Status = req.Status
|
||||
}
|
||||
|
||||
workflow.Version++
|
||||
workflow.UpdatedAt = time.Now()
|
||||
|
||||
if err := api.db.SaveWorkflow(r.Context(), workflow); err != nil {
|
||||
api.logger.Printf("Failed to update workflow: %v", err)
|
||||
http.Error(w, "Failed to update workflow", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var nodes []WorkflowNode
|
||||
var edges []WorkflowEdge
|
||||
|
||||
json.Unmarshal(workflow.Nodes, &nodes)
|
||||
json.Unmarshal(workflow.Edges, &edges)
|
||||
|
||||
response := WorkflowResponse{
|
||||
ID: workflow.ID,
|
||||
Name: workflow.Name,
|
||||
Description: workflow.Description,
|
||||
Status: workflow.Status,
|
||||
Version: workflow.Version,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
CreatedAt: workflow.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: workflow.UpdatedAt.Format(time.RFC3339),
|
||||
CreatedBy: workflow.CreatedBy,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// DeleteWorkflow handles DELETE /workflows/{id}
|
||||
func (api *WorkflowAPI) DeleteWorkflow(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.db.DeleteWorkflow(r.Context(), id, api.customerID); err != nil {
|
||||
http.Error(w, "Workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ExecuteWorkflow handles POST /workflows/{id}/execute
|
||||
func (api *WorkflowAPI) ExecuteWorkflow(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
workflow, err := api.db.FetchWorkflow(r.Context(), id, api.customerID)
|
||||
if err != nil {
|
||||
http.Error(w, "Workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req ExecutionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmarshal nodes and edges
|
||||
var nodes []WorkflowNode
|
||||
var edges []WorkflowEdge
|
||||
json.Unmarshal(workflow.Nodes, &nodes)
|
||||
json.Unmarshal(workflow.Edges, &edges)
|
||||
|
||||
// Convert to workflow response for spec conversion
|
||||
workflowResp := &WorkflowResponse{
|
||||
ID: workflow.ID,
|
||||
Name: workflow.Name,
|
||||
Description: workflow.Description,
|
||||
Status: workflow.Status,
|
||||
Version: workflow.Version,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
CreatedBy: workflow.CreatedBy,
|
||||
}
|
||||
|
||||
// Convert nodes/edges to WorkflowSpec
|
||||
spec := api.nodesToWorkflowSpec(workflowResp, req.Inputs)
|
||||
|
||||
// Execute via Temporal RoutingWorkflow
|
||||
execID := uuid.New().String()
|
||||
workflowOptions := client.StartWorkflowOptions{
|
||||
ID: execID,
|
||||
TaskQueue: "default",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = api.temporalClient.ExecuteWorkflow(ctx, workflowOptions, "RoutingWorkflow", spec)
|
||||
if err != nil {
|
||||
api.logger.Printf("Failed to execute workflow: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Execution failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save execution to database
|
||||
inputsJSON, _ := json.Marshal(req.Inputs)
|
||||
now := time.Now()
|
||||
|
||||
execution := &db.WorkflowExecution{
|
||||
ID: execID,
|
||||
WorkflowID: id,
|
||||
CustomerID: api.customerID,
|
||||
TemporalID: execID,
|
||||
Status: "running",
|
||||
Inputs: inputsJSON,
|
||||
StartedAt: now,
|
||||
}
|
||||
|
||||
if err := api.db.SaveExecution(r.Context(), execution); err != nil {
|
||||
api.logger.Printf("Failed to save execution: %v", err)
|
||||
http.Error(w, "Failed to save execution", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create execution response
|
||||
execResp := ExecutionResponse{
|
||||
ID: execID,
|
||||
WorkflowID: id,
|
||||
Status: "running",
|
||||
StartedAt: now.Format(time.RFC3339),
|
||||
Inputs: req.Inputs,
|
||||
Outputs: make(map[string]interface{}),
|
||||
Logs: []ExecutionLog{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(execResp)
|
||||
}
|
||||
|
||||
// GetExecution handles GET /executions/{id}
|
||||
func (api *WorkflowAPI) GetExecution(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
execution, err := api.db.FetchExecution(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Execution not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get logs from database
|
||||
logs, err := api.db.FetchExecutionLogs(r.Context(), id)
|
||||
if err != nil {
|
||||
api.logger.Printf("Failed to fetch logs: %v", err)
|
||||
}
|
||||
|
||||
execLogs := make([]ExecutionLog, 0)
|
||||
for _, log := range logs {
|
||||
execLogs = append(execLogs, ExecutionLog{
|
||||
Timestamp: log.LoggedAt.Format(time.RFC3339),
|
||||
NodeID: log.NodeID,
|
||||
Level: log.Level,
|
||||
Message: log.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse inputs/outputs
|
||||
var inputs map[string]interface{}
|
||||
var outputs map[string]interface{}
|
||||
json.Unmarshal(execution.Inputs, &inputs)
|
||||
if execution.Outputs != nil {
|
||||
json.Unmarshal(execution.Outputs, &outputs)
|
||||
}
|
||||
|
||||
// Check Temporal workflow status
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
desc, err := api.temporalClient.DescribeWorkflowExecution(ctx, execution.TemporalID, "")
|
||||
status := execution.Status
|
||||
if err == nil && desc != nil {
|
||||
switch desc.Status.String() {
|
||||
case "RUNNING":
|
||||
status = "running"
|
||||
case "COMPLETED":
|
||||
status = "success"
|
||||
case "FAILED":
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
completedAtStr := ""
|
||||
if execution.CompletedAt != nil {
|
||||
completedAtStr = execution.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
execResp := ExecutionResponse{
|
||||
ID: execution.ID,
|
||||
WorkflowID: execution.WorkflowID,
|
||||
Status: status,
|
||||
StartedAt: execution.StartedAt.Format(time.RFC3339),
|
||||
CompletedAt: completedAtStr,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
Logs: execLogs,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(execResp)
|
||||
}
|
||||
|
||||
// ListExecutions handles GET /workflows/{id}/executions
|
||||
func (api *WorkflowAPI) ListExecutions(w http.ResponseWriter, r *http.Request, workflowID string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement query by workflow_id in database
|
||||
// For now, return empty list (needs DB method for filtering by workflow_id)
|
||||
list := make([]ExecutionResponse, 0)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
// nodesToWorkflowSpec converts frontend nodes/edges to routing.WorkflowSpec
|
||||
func (api *WorkflowAPI) nodesToWorkflowSpec(wf *WorkflowResponse, inputs map[string]interface{}) *routing.WorkflowSpec {
|
||||
spec := &routing.WorkflowSpec{
|
||||
Name: wf.Name,
|
||||
Input: inputs,
|
||||
States: []routing.State{},
|
||||
}
|
||||
|
||||
// Build states from nodes
|
||||
stateMap := make(map[string]*routing.State)
|
||||
|
||||
// Create all states
|
||||
for _, node := range wf.Nodes {
|
||||
if node.Type == "activity" {
|
||||
state := &routing.State{
|
||||
Name: node.ID,
|
||||
Type: routing.StateTypeTask,
|
||||
Resource: node.Data.Activity,
|
||||
Parameters: node.Data.Config,
|
||||
End: false,
|
||||
}
|
||||
stateMap[node.ID] = state
|
||||
spec.States = append(spec.States, *state)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire edges (transitions)
|
||||
for _, edge := range wf.Edges {
|
||||
if state, exists := stateMap[edge.Source]; exists {
|
||||
state.Next = edge.Target
|
||||
}
|
||||
}
|
||||
|
||||
// Mark last state as End
|
||||
if len(spec.States) > 0 {
|
||||
// Find state with no outgoing edge
|
||||
for i := range spec.States {
|
||||
hasNext := false
|
||||
for _, edge := range wf.Edges {
|
||||
if edge.Source == spec.States[i].Name {
|
||||
hasNext = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNext {
|
||||
spec.States[i].End = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
Reference in New Issue
Block a user