Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
207a0fa
docs(base): sync workflow guide to current branch
bytedance-zhangbinkai Aug 21, 2026
9d40b65
docs(base): sync workflow schema to current branch
bytedance-zhangbinkai Aug 21, 2026
b7b21b1
feat(base): support AI classification workflow validation
bytedance-zhangbinkai Aug 21, 2026
35eb059
docs(base): document AI classification workflow schema
bytedance-zhangbinkai Aug 21, 2026
a73fdef
feat(base): validate AI classification agent data
bytedance-zhangbinkai Aug 21, 2026
75c1b7a
fix(base): relax ai classification optional fields
bytedance-zhangbinkai Aug 25, 2026
ec62624
fix(base): validate workflow ai analysis json
bytedance-zhangbinkai Aug 26, 2026
c8ed1fc
fix(base): validate workflow ai analysis json
bytedance-zhangbinkai Aug 26, 2026
d921b30
fix(base): validate workflow ai analysis json
bytedance-zhangbinkai Aug 26, 2026
2440a80
Merge remote-tracking branch 'origin/main' into harness/01m0h8561g3cn…
bytedance-zhangbinkai Aug 26, 2026
5bedd45
fix(base): default ai classification no match action
bytedance-zhangbinkai Aug 26, 2026
ebe869d
fix(base): scope workflow ai analysis validation
bytedance-zhangbinkai Aug 26, 2026
5904183
fix(base): keep ai analysis validation scoped
bytedance-zhangbinkai Aug 26, 2026
aa6db35
fix(base): keep ai analysis validation scoped
bytedance-zhangbinkai Aug 26, 2026
8f3b4fd
fix(base): normalize workflow empty steps
bytedance-zhangbinkai Aug 27, 2026
b137b9f
fix(base): reject ai classification mode input
bytedance-zhangbinkai Aug 28, 2026
0906422
fix: polish skill
bytedance-zhangbinkai Aug 28, 2026
2172ecc
Merge remote-tracking branch 'fork/harness/01m0eyexkh33e3y8jgcdhw3che…
bytedance-zhangbinkai Sep 1, 2026
f3667d2
fix: 还原 step 判断逻辑
bytedance-zhangbinkai Sep 1, 2026
181f475
fix: 调整校验逻辑组织形式
bytedance-zhangbinkai Sep 1, 2026
af948b0
fix: polish skill
bytedance-zhangbinkai Sep 1, 2026
57992ba
fix: CR Comment
bytedance-zhangbinkai Sep 2, 2026
cef8f78
feat: support development environment overrides
bytedance-zhangbinkai Aug 19, 2026
3ff4d5d
fix: CR Comment
bytedance-zhangbinkai Sep 2, 2026
d57b55f
Revert "feat: support development environment overrides"
bytedance-zhangbinkai Sep 2, 2026
087cf41
fix: polish skill
bytedance-zhangbinkai Sep 2, 2026
403d8a9
fix: compress skill
bytedance-zhangbinkai Sep 2, 2026
a7d7223
feat: support development environment overrides
bytedance-zhangbinkai Aug 19, 2026
7babc38
Revert "feat: support development environment overrides"
bytedance-zhangbinkai Sep 2, 2026
f785a78
fix: polish skill
bytedance-zhangbinkai Sep 2, 2026
0f9044d
feat: support development environment overrides
bytedance-zhangbinkai Aug 19, 2026
a2ddae9
fix(base): preserve omitted AI classification strategy
bytedance-zhangbinkai Sep 2, 2026
69215cf
Revert "feat: support development environment overrides"
bytedance-zhangbinkai Sep 2, 2026
b822ce8
fix: polish skill
bytedance-zhangbinkai Sep 2, 2026
ad9181b
fix: ut
bytedance-zhangbinkai Sep 2, 2026
f805081
feat: support development environment overrides
bytedance-zhangbinkai Aug 19, 2026
d48d5de
fix: 沿用全量更新逻辑
bytedance-zhangbinkai Sep 2, 2026
482fa9b
Revert "feat: support development environment overrides"
bytedance-zhangbinkai Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions shortcuts/base/workflow_ai_analysis_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import "fmt"

const aiAnalysisActionType = "AIAnalysisAction"

var aiAnalysisIdentityTypes = map[string]bool{
"maker": true,
"triggerPersonal": true,
}

func validateWorkflowAIAnalysisAction(stepIndex int, step map[string]interface{}) error {
data, ok := step["data"].(map[string]interface{})
if !ok {
return nil
}
if value, exists := data["analysis_table_names"]; exists {
items, ok := value.([]interface{})
if !ok {
return baseFlagErrorf("%s must be a string array", workflowJSONPath(stepIndex, "analysis_table_names"))
}
for itemIndex, item := range items {
if _, ok := item.(string); !ok {
return baseFlagErrorf("%s[%d] must be a string", workflowJSONPath(stepIndex, "analysis_table_names"), itemIndex)
}
}
}
if value, exists := data["identity_type"]; exists {
identityType, ok := value.(string)
if !ok {
return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type"))
}
if !aiAnalysisIdentityTypes[identityType] {
return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type"))
}
}
return nil
}

func workflowJSONPath(stepIndex int, suffix string) string {
return fmt.Sprintf("--json.steps[%d].data.%s", stepIndex, suffix)
}
226 changes: 226 additions & 0 deletions shortcuts/base/workflow_ai_classification_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"fmt"
"strings"
)

const (
workflowAIClassificationStepType = "AIClassificationBranch"
workflowAIClassificationDefaultNoMatchAction = "classifyToOther"
)

func indexWorkflowStepIDs(steps []interface{}) map[string]int {
stepIDs := make(map[string]int, len(steps))
for i, raw := range steps {
step, _ := raw.(map[string]interface{})
id, _ := step["id"].(string)
if strings.TrimSpace(id) != "" {
stepIDs[id] = i
}
}
return stepIDs
}

func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error {
path := fmt.Sprintf("--json steps[%d]", index)
data, ok := step["data"].(map[string]interface{})
if !ok || data == nil {
return baseValidationErrorf("%s.data must be an object for AIClassificationBranch", path)
}
classes, err := validateAIClassificationAgentData(path, data, index, stepIDs)
if err != nil {
return err
}
return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data))
}

func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) {
if _, ok := data["mode"]; ok {
return nil, baseValidationErrorf("%s.data.mode is not supported; omit it because AI classification only supports Exclusive mode", path)
}

rawClasses, ok := data["classes"].([]interface{})
if !ok {
return nil, baseValidationErrorf("%s.data.classes must be an array", path)
}
if len(rawClasses) < 2 {
return nil, baseValidationErrorf("%s.data.classes must contain at least 2 items", path)
}
classes := make([]string, 0, len(rawClasses))
seen := map[string]int{}
for i, raw := range rawClasses {
classPath := fmt.Sprintf("%s.data.classes[%d]", path, i)
item, ok := raw.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("%s must be an object", classPath)
}
name, _ := item["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
return nil, baseValidationErrorf("%s.name must be a non-empty string", classPath)
}
if strings.ContainsAny(name, "\r\n") {
return nil, baseValidationErrorf("%s.name must not contain newlines", classPath)
}
if prev, exists := seen[name]; exists {
return nil, baseValidationErrorf("%s.name duplicates %s.data.classes[%d].name", classPath, path, prev)
}
if _, ok := item["desc"].(string); !ok {
return nil, baseValidationErrorf("%s.desc must be a string", classPath)
}
seen[name] = i
classes = append(classes, name)
}

if err := validateAIClassificationContent(path, data["content"], stepIndex, stepIDs); err != nil {
return nil, err
}
if rule, ok := data["classification_rule"]; ok {
if _, ok := rule.(string); !ok {
return nil, baseValidationErrorf("%s.data.classification_rule must be a string when set", path)
}
}
if actionRaw, ok := data["no_match_action"]; ok {
action, ok := actionRaw.(string)
if !ok || strings.TrimSpace(action) == "" {
return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path)
}
if action != "classifyToOther" && action != "fail" {
return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path)
}
}
return classes, nil
}

func validateAIClassificationContent(path string, raw interface{}, stepIndex int, stepIDs map[string]int) error {
items, ok := raw.([]interface{})
if !ok {
return baseValidationErrorf("%s.data.content must be an array", path)
}
hasContent := false
for i, rawItem := range items {
itemPath := fmt.Sprintf("%s.data.content[%d]", path, i)
item, ok := rawItem.(map[string]interface{})
if !ok {
return baseValidationErrorf("%s must be an object", itemPath)
}
valueType, _ := item["value_type"].(string)
value, _ := item["value"].(string)
switch valueType {
case "text":
if strings.TrimSpace(value) != "" {
hasContent = true
}
case "ref":
refStep, ok := workflowRefStepID(value)
if !ok {
return baseValidationErrorf("%s.value must be a workflow ref path starting with $.", itemPath)
}
refIndex, exists := stepIDs[refStep]
if !exists {
return baseValidationErrorf("%s.value references unknown step id %q", itemPath, refStep)
}
if refIndex >= stepIndex {
return baseValidationErrorf("%s.value must reference a previous step, got %q", itemPath, refStep)
}
hasContent = true
default:
return baseValidationErrorf("%s.value_type must be text or ref", itemPath)
}
}
if !hasContent {
return baseValidationErrorf("%s.data.content must contain non-empty text or ref", path)
}
return nil
}

func validateAIClassificationLinks(path string, step map[string]interface{}, stepIDs map[string]int, classes []string, noMatchAction string) error {
children, _ := step["children"].(map[string]interface{})
links, _ := children["links"].([]interface{})
if len(links) == 0 {
return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path)
}

ordinaryLinks := 0
defaultLinks := 0
seenTargets := map[string]int{}
for i, raw := range links {
linkPath := fmt.Sprintf("%s.children.links[%d]", path, i)
link, ok := raw.(map[string]interface{})
if !ok {
return baseValidationErrorf("%s must be an object", linkPath)
}
kind, _ := link["kind"].(string)
if kind != "case" {
return baseValidationErrorf("%s.kind must be case for AIClassificationBranch", linkPath)
}
label, _ := link["label"].(string)
if label == "other" {
return baseValidationErrorf("%s.label must be default for AIClassificationBranch default branch, not other", linkPath)
}
to, _ := link["to"].(string)
to = strings.TrimSpace(to)
if to == "" {
return baseValidationErrorf("%s.to must not be blank", linkPath)
}
if _, ok := stepIDs[to]; !ok {
return baseValidationErrorf("%s.to references unknown step id %q", linkPath, to)
}
if prev, exists := seenTargets[to]; exists {
return baseValidationErrorf("%s.to duplicates %s.children.links[%d].to", linkPath, path, prev)
}
seenTargets[to] = i

if label == "default" {
defaultLinks++
continue
}
wantLabel := fmt.Sprintf("branch_%d", ordinaryLinks+1)
if label != wantLabel {
return baseValidationErrorf("%s.label must be %s for AIClassificationBranch class link", linkPath, wantLabel)
}
desc, _ := link["desc"].(string)
if ordinaryLinks < len(classes) && desc != classes[ordinaryLinks] {
return baseValidationErrorf("%s.desc must equal --json steps data.classes[%d].name", linkPath, ordinaryLinks)
}
ordinaryLinks++
}
if ordinaryLinks != len(classes) {
return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path)
}
if noMatchAction == "classifyToOther" && defaultLinks != 1 {
return baseValidationErrorf("%s.children.links must contain exactly one default link when no_match_action is classifyToOther", path)
}
if noMatchAction == "fail" && defaultLinks != 0 {
return baseValidationErrorf("%s.children.links must not contain a default link when no_match_action is fail", path)
}
return nil
}

func aiClassificationNoMatchAction(data map[string]interface{}) string {
action, _ := data["no_match_action"].(string)
action = strings.TrimSpace(action)
if action == "" {
return workflowAIClassificationDefaultNoMatchAction
}
return action
}

func workflowRefStepID(value string) (string, bool) {
value = strings.TrimSpace(value)
if !strings.HasPrefix(value, "$.") {
return "", false
}
value = strings.TrimPrefix(value, "$.")
if value == "" {
return "", false
}
if idx := strings.Index(value, "."); idx >= 0 {
value = value[:idx]
}
return value, value != ""
}
19 changes: 4 additions & 15 deletions shortcuts/base/workflow_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,34 +33,23 @@ var BaseWorkflowCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank")
}
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
if _, err := parseJSONObject(pc, raw, "json"); err != nil {
if _, err := parseWorkflowBodyJSON(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
var body map[string]interface{}
if raw, err := loadJSONInput(pc, runtime.Str("json"), "json"); err == nil {
body, _ = parseJSONObject(pc, raw, "json")
if parsed, err := parseWorkflowBodyJSON(runtime); err == nil {
body = parsed
}
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/workflows").
Body(body).
Set("base_token", runtime.Str("base-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
body, err := parseJSONObject(pc, raw, "json")
body, err := parseWorkflowBodyJSON(runtime)
if err != nil {
return err
}
Expand Down
Loading
Loading