Files
dingtalk-workspace-cli/internal/shortcut/aitable/workflow_deploy.go

244 lines
10 KiB
Go

// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var WorkflowDeploy = shortcut.Shortcut{
Service: "aitable",
Command: "+workflow-deploy",
Product: serverMain,
Description: "创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态",
Intent: "当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: aitableCompositeContract(
"+workflow-deploy",
"创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态",
"当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。",
"只查看定义用 workflow get;只启停已有流程用 enable/disable;创建回包不确定时不要盲目重试",
`dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "workflow-id", Type: shortcut.FlagString, Desc: "已有 Workflow ID;为空表示创建"},
{Name: "dsl", Type: shortcut.FlagString, Desc: "完整 workflow-dsl/v1 JSON 对象", Required: true},
{Name: "locale", Type: shortcut.FlagString, Default: "zh-CN", Desc: "发布 locale"},
{Name: "enable", Type: shortcut.FlagBool, Desc: "发布后启用并从 workflow list 验证 RUNNING"},
},
Tips: []string{`dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeWorkflowDeploy(rt)
},
}
func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error {
dsl, err := parseJSONObject("dsl", rt.Str("dsl"))
if err != nil {
return err
}
if stringValue(dsl, "version") != "workflow-dsl/v1" {
return apperrors.NewValidation("--dsl.version 必须是 workflow-dsl/v1")
}
baseID, workflowID := rt.Str("base-id"), strings.TrimSpace(rt.Str("workflow-id"))
action, tool := "create", "create_workflow"
params := map[string]any{"baseId": baseID, "dsl": dsl, "locale": rt.Str("locale")}
if workflowID != "" {
action, tool = "update", "update_workflow"
params["workflowId"] = workflowID
preflight, preflightErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if preflightErr != nil {
return preflightErr
}
if !deepContainsString(preflight, workflowID) {
return apperrors.NewValidation("workflow get preflight does not identify --workflow-id", apperrors.WithReason("target_not_found"), apperrors.WithExecutionStarted(false))
}
}
result := newCompositeResult("workflow_deploy")
result.Resolved = map[string]any{"action": action, "baseId": baseID, "workflowId": workflowID, "enable": rt.Bool("enable")}
result.Plan = []compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "planned"}}
if rt.Bool("enable") {
result.Plan = append(result.Plan, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "planned"})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if writeErr != nil {
result.Status = "unknown"
result.Checkpoint = map[string]any{"nextStep": "resolve workflow existence and definition before retrying", "workflowId": workflowID}
return compositeError(result, writeErr, false)
}
valid, validFound, responseID, publishErr := workflowPublishResult(writeData)
if publishErr != nil {
result.Status = "rejected"
result.Result = writeData
return compositeError(result, publishErr, false)
}
if !validFound || !valid || responseID == "" || (workflowID != "" && responseID != workflowID) {
result.Status = "rejected"
result.Result = writeData
return compositeError(result, fmt.Errorf("%s response must contain valid=true and the expected flowId", tool), false)
}
workflowID = responseID
result.Resolved["workflowId"] = workflowID
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "workflowId": workflowID})
readBack, verifyErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if verifyErr != nil || !deepContainsString(readBack, workflowID) {
if verifyErr == nil {
verifyErr = fmt.Errorf("get_workflow read-back does not identify workflowId %s", workflowID)
}
result.Status = "partial_success"
result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "verify published workflow"}
return compositeError(result, verifyErr, action == "update")
}
if rt.Bool("enable") {
enableData, enableErr := rt.CallMCPWriteDataStrict(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if enableErr != nil {
result.Warnings = append(result.Warnings, "enable response error: "+enableErr.Error())
}
workflow, listErr := readWorkflowFromList(rt, baseID, workflowID)
if listErr != nil || !workflowIsRunning(workflow) {
if listErr == nil {
listErr = fmt.Errorf("workflow list does not show %s as RUNNING", workflowID)
}
result.Status = "partial_success"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "enable_workflow", "workflowId": workflowID, "response": enableData})
result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "inspect and enable workflow"}
return compositeError(result, listErr, action == "update")
}
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "completed", Result: workflow})
}
result.CompletedCount = 1
result.CompletedSteps = append([]compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "completed", Result: writeData}}, result.CompletedSteps...)
result.Verification = map[string]any{"status": "verified", "valid": true, "workflowId": workflowID, "running": rt.Bool("enable")}
result.Result = map[string]any{"action": action, "workflowId": workflowID, "valid": true}
return rt.Output(result)
}
// workflowPublishResult reads only the reviewed response envelopes rather than
// recursively accepting an unrelated nested valid/flowId field. Conflicting
// values are protocol errors; an outer "success" marker never overrides
// valid=false.
func workflowPublishResult(data map[string]any) (valid bool, validFound bool, workflowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("workflow publish valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("workflow publish response contains conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("workflow publish %s must be a non-empty string", key)
}
if workflowID != "" && workflowID != value {
return fmt.Errorf("workflow publish response contains conflicting workflow IDs")
}
workflowID = value
}
for _, envelope := range []string{"data", "result", "response"} {
if nested, ok := object[envelope].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if data == nil {
return false, false, "", fmt.Errorf("workflow publish response is empty")
}
if err := visit(data); err != nil {
return false, false, "", err
}
return valid, validFound, workflowID, nil
}
func findBoolByKeys(value any, keys ...string) (bool, bool) {
keySet := map[string]bool{}
for _, key := range keys {
keySet[strings.ToLower(key)] = true
}
var walk func(any) (bool, bool)
walk = func(current any) (bool, bool) {
switch typed := current.(type) {
case map[string]any:
for key, child := range typed {
if keySet[strings.ToLower(key)] {
if value, ok := child.(bool); ok {
return value, true
}
}
}
for _, child := range typed {
if value, found := walk(child); found {
return value, true
}
}
case []any:
for _, child := range typed {
if value, found := walk(child); found {
return value, true
}
}
}
return false, false
}
return walk(value)
}
func readWorkflowFromList(rt *shortcut.RuntimeContext, baseID, workflowID string) (map[string]any, error) {
for offset := 0; offset < 10000; offset += 100 {
data, err := rt.CallMCPData(serverHelper, "list_workflows", map[string]any{"baseId": baseID, "limit": 100, "offset": offset})
if err != nil {
return nil, err
}
workflows, found := findNamedObjectList(data, "workflows", "list", "items")
if !found {
return nil, fmt.Errorf("list_workflows response is missing the workflow list")
}
for _, workflow := range workflows {
if stringValue(workflow, "flowId", "workflowId", "id") == workflowID {
return workflow, nil
}
}
if len(workflows) < 100 {
return nil, fmt.Errorf("workflow %s is not present in list_workflows", workflowID)
}
}
return nil, fmt.Errorf("workflow list exceeded 10000 entries")
}
func workflowIsRunning(workflow map[string]any) bool {
status := strings.ToUpper(stringValue(workflow, "status", "state"))
if status == "RUNNING" || status == "ENABLED" || status == "ACTIVE" {
return true
}
enabled, found := findBoolByKeys(workflow, "enabled", "isEnabled")
return found && enabled
}