diff --git a/.cursor/rules/project-core.mdc b/.cursor/rules/project-core.mdc index 34993cf89..a3a3896c6 100644 --- a/.cursor/rules/project-core.mdc +++ b/.cursor/rules/project-core.mdc @@ -162,6 +162,13 @@ merged, do not mark the feature complete. Report the blocker explicitly. Prefer capturing a small id/string or building a new tiny value over cloning. Copy only when an algorithm must mutate a distinct shared value in place. +## FDG 2.0 Step Explanations + +Every Step type in a Flow Definition Graph 2.0 Go source must declare exactly +one `// dex:explanation text:"..."` directive next to its `dex:group`. The text +is one sentence that states what the Step does. The analyzer stores it on the +Step node as `metadata.explanation` for Dex Web v2 Definition. + ## Python Examples Do not use `del` in Python examples merely to mark parameters or local values diff --git a/AGENTS.md b/AGENTS.md index cd90169aa..f926c33e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,13 @@ LazyLock`. Do not use a function to construct or return one of these definitions. Reuse the static directly, or clone its initialized value only when an owned field is required. +### FDG 2.0 Step Explanations + +Every Step type in a Flow Definition Graph 2.0 Go source must declare exactly +one `// dex:explanation text:"..."` directive next to its `dex:group`. The text +is one sentence that states what the Step does. The analyzer stores it on the +Step node as `metadata.explanation` for Dex Web v2 Definition. + ### Python Examples Do not use `del` in Python examples merely to mark parameters or local values diff --git a/CLAUDE.md b/CLAUDE.md index ac50a54f2..1ec255f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,13 @@ LazyLock`. Do not use a function to construct or return one of these definitions. Reuse the static directly, or clone its initialized value only when an owned field is required. +# FDG 2.0 Step Explanations + +Every Step type in a Flow Definition Graph 2.0 Go source must declare exactly +one `// dex:explanation text:"..."` directive next to its `dex:group`. The text +is one sentence that states what the Step does. The analyzer stores it on the +Step node as `metadata.explanation` for Dex Web v2 Definition. + # Python Examples Do not use `del` in Python examples merely to mark parameters or local values diff --git a/cli/README.md b/cli/README.md index 39894ce8a..04ef73457 100644 --- a/cli/README.md +++ b/cli/README.md @@ -201,6 +201,7 @@ with **--out**: ```bash dexcli visualize ./order_flow.py --json dexcli visualize ./order_flow.go --json --out ./build/order-flow +dexcli visualize ./refund_flow.go --schema-version 2.0 --json --out ./build/refund dexcli dev --flow-rendering-dir ./build ``` @@ -237,14 +238,27 @@ Dynamic targets produce an Unknown node and a blocking diagnostic. The default renderer still shows the partial graph. With **--json**, a partial JSON artifact is written, and the command exits with status 1. +Version 2 is Go-only and adds ordered Step groups plus a Dex Web v2 contract. +The contract declares Indexed Attributes, the fixed `GetDexSummary` and +`GetDexDisplay` RPCs, editable Display fields, and conditional Action RPCs. +Every Version 2 Step must declare exactly one `dex:group` and one +`dex:explanation text:"..."` directive. The explanation is one sentence that +states what the Step does; it is stored on the Step node as +`metadata.explanation`. Directives use unordered named `name:value` arguments. +Repeated directive lines retain source order; no `order` property is generated. +Every Version 2 Step, Attribute, RPC, input struct, and directive must be in the +same source file. + ```text -dexcli visualize SOURCE [--language auto|go|python] [--open=true|false] +dexcli visualize SOURCE [--language auto|go|python] [--schema-version 1.0|2.0] + [--open=true|false] [--json [--out PATH_PREFIX|-]] [--python PYTHON_PATH] ``` -Invalid command usage exits with status 2. The JSON contract is documented by -[`schema/flow-definition-graph.v1.schema.json`](schema/flow-definition-graph.v1.schema.json). +Invalid command usage exits with status 2. The JSON contracts are documented by +[`schema/flow-definition-graph.v1.schema.json`](schema/flow-definition-graph.v1.schema.json) +and [`schema/flow-definition-graph.v2.schema.json`](schema/flow-definition-graph.v2.schema.json). The friendly Flow commands are: diff --git a/cli/internal/command/testfixtures/visualization-v2-invalid/go.mod b/cli/internal/command/testfixtures/visualization-v2-invalid/go.mod new file mode 100644 index 000000000..5fdd860d9 --- /dev/null +++ b/cli/internal/command/testfixtures/visualization-v2-invalid/go.mod @@ -0,0 +1,7 @@ +module github.com/superdurable/dex/visualization-v2-invalid + +go 1.26.0 + +require github.com/superdurable/dex/sdk-go v0.0.0 + +replace github.com/superdurable/dex/sdk-go => ../../../../../sdk-go diff --git a/cli/internal/command/testfixtures/visualization-v2-invalid/workflow.go b/cli/internal/command/testfixtures/visualization-v2-invalid/workflow.go new file mode 100644 index 000000000..e7adffb37 --- /dev/null +++ b/cli/internal/command/testfixtures/visualization-v2-invalid/workflow.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +package visualizationv2invalid + +import "github.com/superdurable/dex/sdk-go/dex" + +// dex:indexed-attribute attribute-key:state attribute-key:state index-key:state index-type:keyword value-type:string description:"State" +var state = dex.DefineAttribute[string]( + "state", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) + +// dex:indexed-attribute attribute-key:declared-indexed index-key:actual-indexed index-type:keyword value-type:string description:"Mismatched index" +var mismatchedIndex = dex.DefineAttribute[string]( + "actual-indexed", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) + +var flag = dex.DefineAttribute[bool]("flag") + +type BrokenActionInput struct { + Reason string `json:"reason"` +} + +type InvalidV2Flow struct { + dex.FlowDefaults +} + +func (*InvalidV2Flow) GetFlowType() string { + return "InvalidV2Flow" +} + +func (*InvalidV2Flow) GetSteps() []dex.StepDef { + return []dex.StepDef{ + dex.DefineStartStep(invalidV2Step{}), + dex.DefineStep(missingGroupStep{}), + } +} + +func (flow *InvalidV2Flow) GetRPCs() []dex.RPCDef { + return []dex.RPCDef{ + dex.DefineRPC(flow.GetDexSummary, nil), + dex.DefineRPC(flow.GetDexDisplay, nil), + dex.DefineRPC(flow.BreakActionInput, nil), + } +} + +func (*InvalidV2Flow) GetPersistenceSchema() dex.PersistenceSchema { + return dex.PersistenceSchema{Attributes: []dex.AttributeDef{state, mismatchedIndex, flag}} +} + +// dex:field attribute-key:state value-type:string editable:false +func (*InvalidV2Flow) GetDexSummary( + _ dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + return &dex.RPCResult[map[string]any]{Output: map[string]any{"state": nil}}, nil +} + +// dex:field attribute-key:state value-type:string editable:false description:"State" +// dex:field attribute-key:flag value-type:string editable:false description:"Wrong field type" +// dex:field attribute-key:state value-type:string editable:false description:"unterminated +func (*InvalidV2Flow) GetDexDisplay( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + if err := mutateStateFromView(ctx); err != nil { + return nil, err + } + return &dex.RPCResult[map[string]any]{Output: map[string]any{"state": nil, "flag": nil}}, nil +} + +func mutateStateFromView(ctx dex.Context) error { + return state.Set(ctx, "changed") +} + +// dex:action action-label:"Break input" +// dex:when attribute-key:state operator:in values:["open"] +// dex:input field-name:missing value-type:string source:user required:true description:"Missing field" +func (*InvalidV2Flow) BreakActionInput( + _ dex.Context, + _ BrokenActionInput, +) (*dex.RPCResult[dex.None], error) { + return &dex.RPCResult[dex.None]{}, nil +} + +// dex:action action-label:"Unregistered" +// dex:when attribute-key:state operator:in values:["open"] +func (*InvalidV2Flow) UnregisteredAction( + _ dex.Context, + _ dex.None, +) (*dex.RPCResult[dex.None], error) { + return &dex.RPCResult[dex.None]{}, nil +} + +// dex:group group-id:invalid group-label:"Invalid" unexpected:true +type invalidV2Step struct { + dex.StepDefaultsNoWaitFor[dex.None] +} + +func (invalidV2Step) GetStepType() string { + return "InvalidV2Step" +} + +func (invalidV2Step) Execute( + _ dex.Context, + _ dex.None, +) (*dex.StepDecision, error) { + return dex.ForceComplete(nil), nil +} + +type missingGroupStep struct { + dex.StepDefaultsNoWaitFor[dex.None] +} + +func (missingGroupStep) GetStepType() string { + return "MissingGroupStep" +} + +func (missingGroupStep) Execute( + _ dex.Context, + _ dex.None, +) (*dex.StepDecision, error) { + return dex.ForceComplete(nil), nil +} diff --git a/cli/internal/command/visualize.go b/cli/internal/command/visualize.go index aac08d0f1..9391683b3 100644 --- a/cli/internal/command/visualize.go +++ b/cli/internal/command/visualize.go @@ -29,17 +29,19 @@ import ( ) type visualizeOptions struct { - language string - json bool - openBrowser bool - output string - pythonPath string + language string + schemaVersion string + json bool + openBrowser bool + output string + pythonPath string } func (a *App) executeVisualize(ctx context.Context, args []string) error { flags := newFlagSet("dexcli visualize", a.stderr) options := visualizeOptions{openBrowser: true} flags.StringVar(&options.language, "language", "auto", "auto, go, or python") + flags.StringVar(&options.schemaVersion, "schema-version", flowviz.SchemaVersionV1, "Flow Definition Graph schema version: 1.0 or 2.0") flags.BoolVar(&options.json, "json", false, "write Flow Definition Graph JSON instead of opening Flow Rendering") flags.BoolVar(&options.openBrowser, "open", true, "open Flow Rendering in the default browser") flags.StringVar(&options.output, "out", "", "JSON output prefix, or - for stdout (requires --json)") @@ -56,8 +58,9 @@ func (a *App) executeVisualize(ctx context.Context, args []string) error { return newUsageError("visualize", err) } graph, err := flowviz.Analyze(ctx, source, flowviz.AnalyzeOptions{ - Language: options.language, - PythonPath: options.pythonPath, + Language: options.language, + PythonPath: options.pythonPath, + SchemaVersion: options.schemaVersion, }) if err != nil { return newOperationError("visualize", err) @@ -112,6 +115,9 @@ func validateVisualizeOptions(options visualizeOptions) error { default: return fmt.Errorf("language must be auto, go, or python") } + if options.schemaVersion != flowviz.SchemaVersionV1 && options.schemaVersion != flowviz.SchemaVersionV2 { + return fmt.Errorf("schema-version must be 1.0 or 2.0") + } if !options.json && options.output != "" { return fmt.Errorf("--out requires --json") } @@ -136,7 +142,7 @@ func (a *App) renderVisualization(ctx context.Context, isValid bool, graph []byt } serverErrors <- err }() - url := "http://" + listener.Addr().String() + "/rendering" + url := "http://" + listener.Addr().String() + "/v1/rendering" if options.openBrowser { if err := a.openBrowser(url); err != nil { shutdownErr := shutdownVisualizationServer(server, serverErrors) @@ -219,6 +225,7 @@ func printVisualizeUsage(output io.Writer) { fmt.Fprintln(output) fmt.Fprintln(output, "Flags:") fmt.Fprintln(output, " --language auto|go|python source language (default auto)") + fmt.Fprintln(output, " --schema-version 1.0|2.0 Flow Definition Graph schema version (default 1.0)") fmt.Fprintln(output, " --json write Flow Definition Graph JSON instead of rendering") fmt.Fprintln(output, " --open open Flow Rendering in the default browser (default true)") fmt.Fprintln(output, " --out path-prefix|- JSON output prefix, or - for stdout (requires --json)") diff --git a/cli/internal/command/visualize_integ_test.go b/cli/internal/command/visualize_integ_test.go index 889dd7807..18a0af152 100644 --- a/cli/internal/command/visualize_integ_test.go +++ b/cli/internal/command/visualize_integ_test.go @@ -36,7 +36,7 @@ func TestVisualizeDefaultsToFlowRendering(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() app.openBrowser = func(url string) error { - response, err := http.Get(strings.TrimSuffix(url, "/rendering") + "/api/flow-definitions") + response, err := http.Get(strings.TrimSuffix(url, "/v1/rendering") + "/api/flow-definitions") if err != nil { return err } diff --git a/cli/internal/command/visualize_v2_integ_test.go b/cli/internal/command/visualize_v2_integ_test.go new file mode 100644 index 000000000..ccfe68187 --- /dev/null +++ b/cli/internal/command/visualize_v2_integ_test.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +//go:build integration + +package command + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/superdurable/dex/cli/internal/flowviz" +) + +func TestVisualizeV2RefundFlows(t *testing.T) { + repositoryRoot := visualizerRepositoryRoot(t) + tests := []struct { + name string + source string + wantFlowType string + wantGroupIDs []string + wantSummaryFields []string + wantActionRPCNames []string + }{ + { + name: "deterministic", + source: filepath.Join(repositoryRoot, + "examples/go/products/customer-refund/deterministic/workflow.go"), + wantFlowType: "CustomerRefundFlow", + wantGroupIDs: []string{"intake", "evidence", "control", "resolution", "failure", "close"}, + wantSummaryFields: []string{"charge-reference", "refund-amount", "recommended-action"}, + wantActionRPCNames: []string{}, + }, + { + name: "agentic", + source: filepath.Join(repositoryRoot, + "examples/go/products/customer-refund/agentic/workflow.go"), + wantFlowType: "AgenticCustomerRefundFlow", + wantGroupIDs: []string{"intake", "reasoning", "evidence", "control", "resolution", "failure", "close"}, + wantSummaryFields: []string{"in-charge-ref", "ev-payment-amount", "recommended-action", "guardrail-rule"}, + wantActionRPCNames: []string{"ApproveRefund", "RejectRefund"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, err := flowviz.Analyze(context.Background(), test.source, flowviz.AnalyzeOptions{ + SchemaVersion: flowviz.SchemaVersionV2, + }) + require.NoError(t, err) + require.True(t, graph.Valid, "%+v", graph.Diagnostics) + require.Equal(t, flowviz.SchemaVersionV2, graph.SchemaVersion) + require.Equal(t, test.wantFlowType, graph.Flow.Name) + require.NotNil(t, graph.V2) + require.Equal(t, test.wantGroupIDs, v2GroupIDs(graph.Groups)) + require.Equal(t, test.wantSummaryFields, v2ViewFieldKeys(graph.V2.Summary.Fields)) + require.Equal(t, test.wantActionRPCNames, v2ActionRPCNames(graph.V2.Actions)) + require.Equal(t, "case-status", graph.V2.IndexedAttributes[0].AttributeKey) + require.Equal(t, "case-status", graph.V2.IndexedAttributes[0].IndexKey) + require.Equal(t, "keyword", graph.V2.IndexedAttributes[0].IndexType) + for _, node := range graph.Nodes { + if node.Kind != "step" { + continue + } + require.NotNil(t, node.Metadata, node.ID) + explanation, ok := node.Metadata["explanation"].(string) + require.True(t, ok, node.ID) + require.NotEmpty(t, explanation, node.ID) + } + + firstJSON, err := flowviz.MarshalJSON(graph) + require.NoError(t, err) + secondGraph, err := flowviz.Analyze(context.Background(), test.source, flowviz.AnalyzeOptions{ + SchemaVersion: flowviz.SchemaVersionV2, + }) + require.NoError(t, err) + secondJSON, err := flowviz.MarshalJSON(secondGraph) + require.NoError(t, err) + require.Equal(t, firstJSON, secondJSON) + require.NotContains(t, string(firstJSON), `"order"`) + }) + } +} + +func TestVisualizeV2PreservesDirectiveLineOrderWithoutParameterOrder(t *testing.T) { + repositoryRoot := visualizerRepositoryRoot(t) + source := filepath.Join(repositoryRoot, "examples/go/products/customer-refund/agentic/workflow.go") + graph, err := flowviz.Analyze(context.Background(), source, flowviz.AnalyzeOptions{ + SchemaVersion: flowviz.SchemaVersionV2, + }) + require.NoError(t, err) + require.True(t, graph.Valid, "%+v", graph.Diagnostics) + + require.Equal(t, "intake", graph.Groups[0].ID) + require.Equal(t, "Intake", graph.Groups[0].Label) + require.Equal(t, []string{"reason", "gateRequestKey"}, v2ActionInputNames(graph.V2.Actions[1].Input.Fields)) + require.Equal(t, []string{"user", "attribute"}, v2ActionInputSources(graph.V2.Actions[1].Input.Fields)) +} + +func TestVisualizeV2RejectsPython(t *testing.T) { + repositoryRoot := visualizerRepositoryRoot(t) + _, err := flowviz.Analyze( + context.Background(), + filepath.Join(repositoryRoot, "examples/python/dex_examples/patterns/recovery/failure_recovery_flow.py"), + flowviz.AnalyzeOptions{SchemaVersion: flowviz.SchemaVersionV2}, + ) + require.EqualError(t, err, "schema version 2.0 supports Go source only") +} + +func TestVisualizeV2ReportsMalformedNamedDirectives(t *testing.T) { + repositoryRoot := visualizerRepositoryRoot(t) + graph, err := flowviz.Analyze( + context.Background(), + filepath.Join(repositoryRoot, "cli/internal/command/testfixtures/visualization-v2-invalid/workflow.go"), + flowviz.AnalyzeOptions{SchemaVersion: flowviz.SchemaVersionV2}, + ) + require.NoError(t, err) + require.False(t, graph.Valid) + messages := make([]string, 0, len(graph.Diagnostics)) + for _, diagnostic := range graph.Diagnostics { + messages = append(messages, diagnostic.Message) + } + require.Contains(t, messages, "dex:group unknown argument unexpected") + require.Contains(t, messages, "dex:indexed-attribute repeats argument attribute-key") + require.Contains(t, messages, "dex:field missing required argument description") + require.Contains(t, messages, "dex:field description: unterminated quoted string") + require.Contains(t, messages, "Step missingGroupStep must declare exactly one dex:group directive") + require.Contains(t, messages, "Step missingGroupStep must declare exactly one dex:explanation directive") + require.Contains(t, messages, "Step invalidV2Step must declare exactly one dex:explanation directive") + require.Contains(t, messages, `dex:indexed-attribute attribute-key "declared-indexed" does not match the Go declaration "actual-indexed"`) + require.Contains(t, messages, `dex:field value-type "string" does not match Attribute "flag" type "bool"`) + require.Contains(t, messages, "GetDexDisplay must be read-only") + require.Contains(t, messages, `dex:input input field "missing" is not in the RPC input struct`) + require.Contains(t, messages, "Action RPC UnregisteredAction must be registered in GetRPCs") + encoded, err := flowviz.MarshalJSON(graph) + require.NoError(t, err) + require.Contains(t, string(encoded), `"groups": []`) + require.Contains(t, string(encoded), `"v2": {`) +} + +func v2GroupIDs(groups []flowviz.StepGroup) []string { + ids := make([]string, 0, len(groups)) + for _, group := range groups { + ids = append(ids, group.ID) + } + return ids +} + +func v2ViewFieldKeys(fields []flowviz.ViewField) []string { + keys := make([]string, 0, len(fields)) + for _, field := range fields { + keys = append(keys, field.AttributeKey) + } + return keys +} + +func v2ActionRPCNames(actions []flowviz.Action) []string { + names := make([]string, 0, len(actions)) + for _, action := range actions { + names = append(names, action.RPCName) + } + return names +} + +func v2ActionInputNames(fields []flowviz.ActionInputField) []string { + names := make([]string, 0, len(fields)) + for _, field := range fields { + names = append(names, field.FieldName) + } + return names +} + +func v2ActionInputSources(fields []flowviz.ActionInputField) []string { + sources := make([]string, 0, len(fields)) + for _, field := range fields { + sources = append(sources, field.Source) + } + return sources +} diff --git a/cli/internal/flowviz/analyze.go b/cli/internal/flowviz/analyze.go index 8fab32586..ba44266a0 100644 --- a/cli/internal/flowviz/analyze.go +++ b/cli/internal/flowviz/analyze.go @@ -18,8 +18,9 @@ import ( ) type AnalyzeOptions struct { - Language string - PythonPath string + Language string + PythonPath string + SchemaVersion string } func Analyze(ctx context.Context, sourcePath string, options AnalyzeOptions) (*Graph, error) { @@ -35,10 +36,17 @@ func Analyze(ctx context.Context, sourcePath string, options AnalyzeOptions) (*G if err != nil { return nil, err } + schemaVersion, err := resolveSchemaVersion(options.SchemaVersion) + if err != nil { + return nil, err + } + if schemaVersion == SchemaVersionV2 && language != "go" { + return nil, fmt.Errorf("schema version 2.0 supports Go source only") + } var graph *Graph switch language { case "go": - graph, err = analyzeGo(ctx, absolutePath, data) + graph, err = analyzeGo(ctx, absolutePath, data, schemaVersion) case "python": graph, err = analyzePython(ctx, absolutePath, data, options.PythonPath) default: @@ -47,13 +55,45 @@ func Analyze(ctx context.Context, sourcePath string, options AnalyzeOptions) (*G if err != nil { return nil, err } + if schemaVersion == SchemaVersionV2 { + if graph.Groups == nil { + graph.Groups = make([]StepGroup, 0) + } + if graph.V2 == nil { + graph.V2 = &V2Definition{ + IndexedAttributes: make([]IndexedAttribute, 0), + Summary: RPCView{RPCName: "GetDexSummary", Fields: make([]ViewField, 0)}, + Display: RPCView{RPCName: "GetDexDisplay", Fields: make([]ViewField, 0)}, + Actions: make([]Action, 0), + } + } + } graph.Source.Path = filepath.ToSlash(filepath.Clean(sourcePath)) graph.Normalize() return graph, nil } +func resolveSchemaVersion(requested string) (string, error) { + switch strings.TrimSpace(requested) { + case "", SchemaVersionV1: + return SchemaVersionV1, nil + case SchemaVersionV2: + return SchemaVersionV2, nil + default: + return "", fmt.Errorf("schema version must be 1.0 or 2.0") + } +} + func MarshalJSON(graph *Graph) ([]byte, error) { - data, err := json.MarshalIndent(graph, "", " ") + var payload interface{} = graph + if graph.SchemaVersion == SchemaVersionV2 { + payload = struct { + *Graph + Groups []StepGroup `json:"groups"` + V2 *V2Definition `json:"v2"` + }{Graph: graph, Groups: graph.Groups, V2: graph.V2} + } + data, err := json.MarshalIndent(payload, "", " ") if err != nil { return nil, fmt.Errorf("encode graph JSON: %w", err) } diff --git a/cli/internal/flowviz/go_analyzer.go b/cli/internal/flowviz/go_analyzer.go index 008c6d93a..0e518983f 100644 --- a/cli/internal/flowviz/go_analyzer.go +++ b/cli/internal/flowviz/go_analyzer.go @@ -44,6 +44,8 @@ type goAnalyzer struct { steps map[string]string resources map[types.Object]string resourceVars map[string]string + schemaVersion string + registeredSteps []string } type goExternalMethod struct { @@ -75,8 +77,9 @@ type goDecisionOutcome struct { span *Span } -func analyzeGo(ctx context.Context, sourcePath string, source []byte) (*Graph, error) { +func analyzeGo(ctx context.Context, sourcePath string, source []byte, schemaVersion string) (*Graph, error) { graph := NewGraph("go", sourcePath) + graph.SchemaVersion = schemaVersion config := &packages.Config{ Context: ctx, Dir: filepath.Dir(sourcePath), @@ -114,7 +117,7 @@ func analyzeGo(ctx context.Context, sourcePath string, source []byte) (*Graph, e for _, packageError := range selectedPackage.Errors { graph.AddDiagnostic("error", "go_type_check_failed", packageError.Msg, nil) } - analyzer := newGoAnalyzer(graph, selectedFile, selectedPackage.Syntax, selectedPackage.Fset, selectedPackage.TypesInfo, sourcePath) + analyzer := newGoAnalyzer(graph, selectedFile, selectedPackage.Syntax, selectedPackage.Fset, selectedPackage.TypesInfo, sourcePath, schemaVersion) analyzer.Analyze() return graph, nil } @@ -126,6 +129,7 @@ func newGoAnalyzer( fileSet *token.FileSet, typeInfo *types.Info, sourcePath string, + schemaVersion string, ) *goAnalyzer { if typeInfo == nil { typeInfo = &types.Info{} @@ -150,6 +154,8 @@ func newGoAnalyzer( steps: make(map[string]string), resources: make(map[types.Object]string), resourceVars: make(map[string]string), + schemaVersion: schemaVersion, + registeredSteps: make([]string, 0), } } @@ -178,6 +184,9 @@ func (analyzer *goAnalyzer) Analyze() { analyzer.analyzeStep(stepType, nodeID) } analyzer.analyzeFlowHandlers(flowName) + if analyzer.schemaVersion == SchemaVersionV2 { + analyzer.analyzeVisualizationV2(flowName) + } } func (analyzer *goAnalyzer) indexImportsAndMethods() { @@ -393,6 +402,7 @@ func (analyzer *goAnalyzer) analyzeStepRegistration(getSteps *ast.FuncDecl) { nodeID := "step:" + stepType isStart := callName == "DefineStartStep" analyzer.steps[stepType] = nodeID + analyzer.registeredSteps = append(analyzer.registeredSteps, stepType) analyzer.graph.AddNode(Node{ID: nodeID, Kind: "step", Name: stepName, Start: isStart, Span: analyzer.span(call)}) if isStart { if analyzer.graph.Flow.StartStepID != "" { diff --git a/cli/internal/flowviz/model.go b/cli/internal/flowviz/model.go index c9c942e4c..1a8705771 100644 --- a/cli/internal/flowviz/model.go +++ b/cli/internal/flowviz/model.go @@ -14,16 +14,81 @@ import ( "strings" ) -const SchemaVersion = "1.0" +const ( + SchemaVersionV1 = "1.0" + SchemaVersionV2 = "2.0" +) type Graph struct { - SchemaVersion string `json:"schemaVersion"` - Valid bool `json:"valid"` - Source Source `json:"source"` - Flow Flow `json:"flow"` - Nodes []Node `json:"nodes"` - Edges []Edge `json:"edges"` - Diagnostics []Diagnostic `json:"diagnostics"` + SchemaVersion string `json:"schemaVersion"` + Valid bool `json:"valid"` + Source Source `json:"source"` + Flow Flow `json:"flow"` + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` + Diagnostics []Diagnostic `json:"diagnostics"` + Groups []StepGroup `json:"groups,omitempty"` + V2 *V2Definition `json:"v2,omitempty"` +} + +type StepGroup struct { + ID string `json:"id"` + Label string `json:"label"` + StepIDs []string `json:"stepIds"` +} + +type V2Definition struct { + IndexedAttributes []IndexedAttribute `json:"indexedAttributes"` + Summary RPCView `json:"summary"` + Display RPCView `json:"display"` + Actions []Action `json:"actions"` +} + +type IndexedAttribute struct { + AttributeKey string `json:"attributeKey"` + IndexKey string `json:"indexKey"` + IndexType string `json:"indexType"` + ValueType string `json:"valueType"` + Description string `json:"description"` +} + +type RPCView struct { + RPCName string `json:"rpcName"` + Fields []ViewField `json:"fields"` +} + +type ViewField struct { + AttributeKey string `json:"attributeKey"` + ValueType string `json:"valueType"` + Editable bool `json:"editable"` + Description string `json:"description"` +} + +type Action struct { + RPCName string `json:"rpcName"` + Label string `json:"label"` + Condition ActionCondition `json:"condition"` + Input ActionInput `json:"input"` +} + +type ActionCondition struct { + AttributeKey string `json:"attributeKey"` + Operator string `json:"operator"` + Values []any `json:"values"` +} + +type ActionInput struct { + Kind string `json:"kind"` + Fields []ActionInputField `json:"fields,omitempty"` +} + +type ActionInputField struct { + FieldName string `json:"fieldName"` + ValueType string `json:"valueType"` + Source string `json:"source"` + AttributeKey string `json:"attributeKey,omitempty"` + Required bool `json:"required"` + Description string `json:"description"` } type Source struct { @@ -111,7 +176,7 @@ type Span struct { func NewGraph(language string, path string) *Graph { return &Graph{ - SchemaVersion: SchemaVersion, + SchemaVersion: SchemaVersionV1, Valid: true, Source: Source{Language: language, Path: path}, Nodes: make([]Node, 0), diff --git a/cli/internal/flowviz/v2_directives.go b/cli/internal/flowviz/v2_directives.go new file mode 100644 index 000000000..3e4efcf9d --- /dev/null +++ b/cli/internal/flowviz/v2_directives.go @@ -0,0 +1,1267 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +package flowviz + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/token" + "go/types" + "math" + "reflect" + "regexp" + "strconv" + "strings" + "time" + "unicode" +) + +var v2GroupIDPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`) + +type v2DirectiveArgument struct { + text string + array bool +} + +type v2Directive struct { + name string + arguments map[string]v2DirectiveArgument + comment *ast.Comment +} + +type v2AttributeDeclaration struct { + key string + valueType string + isMap bool + isIndexed bool + indexKey string + indexType string + directives []v2Directive + span *Span +} + +type v2StructField struct { + jsonName string + valueType string + required bool + span *Span +} + +func (analyzer *goAnalyzer) analyzeVisualizationV2(flowType string) { + attributes := analyzer.collectV2Attributes() + analyzer.graph.Groups = analyzer.collectV2Groups() + analyzer.applyV2Explanations() + registeredRPCNames := analyzer.registeredV2RPCNames(flowType) + registeredRPCs := make(map[string]bool, len(registeredRPCNames)) + for _, rpcName := range registeredRPCNames { + registeredRPCs[rpcName] = true + } + indexedAttributes := analyzer.collectV2IndexedAttributes(attributes) + summary := analyzer.collectV2View( + flowType, + "GetDexSummary", + attributes, + registeredRPCs, + indexedAttributes, + false, + ) + display := analyzer.collectV2View( + flowType, + "GetDexDisplay", + attributes, + registeredRPCs, + indexedAttributes, + true, + ) + actions := analyzer.collectV2Actions(flowType, attributes, registeredRPCNames) + analyzer.graph.V2 = &V2Definition{ + IndexedAttributes: indexedAttributes, + Summary: summary, + Display: display, + Actions: actions, + } +} + +func (analyzer *goAnalyzer) collectV2Groups() []StepGroup { + groups := make([]StepGroup, 0) + groupIndexes := make(map[string]int) + typeDirectives := analyzer.v2TypeDirectives() + for _, stepType := range analyzer.registeredSteps { + directives := directivesNamed(typeDirectives[stepType], "group") + if len(directives) != 1 { + analyzer.graph.AddDiagnostic( + "error", + "v2_step_group", + fmt.Sprintf("Step %s must declare exactly one dex:group directive", stepType), + nil, + ) + continue + } + directive := directives[0] + if !analyzer.validateV2Directive(directive, []string{"group-id", "group-label"}, []string{"group-id", "group-label"}) { + continue + } + groupID := directive.arguments["group-id"].text + groupLabel := directive.arguments["group-label"].text + if !v2GroupIDPattern.MatchString(groupID) { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("group-id %q must be kebab-case", groupID)) + continue + } + if strings.TrimSpace(groupLabel) == "" { + analyzer.addV2DirectiveError(directive, "group-label must not be empty") + continue + } + groupIndex, found := groupIndexes[groupID] + if !found { + groupIndexes[groupID] = len(groups) + groups = append(groups, StepGroup{ID: groupID, Label: groupLabel, StepIDs: make([]string, 0)}) + groupIndex = len(groups) - 1 + } else if groups[groupIndex].Label != groupLabel { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("group %q uses conflicting labels", groupID)) + continue + } + groups[groupIndex].StepIDs = append(groups[groupIndex].StepIDs, "step:"+stepType) + } + return groups +} + +func (analyzer *goAnalyzer) applyV2Explanations() { + typeDirectives := analyzer.v2TypeDirectives() + for _, stepType := range analyzer.registeredSteps { + directives := directivesNamed(typeDirectives[stepType], "explanation") + if len(directives) != 1 { + analyzer.graph.AddDiagnostic( + "error", + "v2_step_explanation", + fmt.Sprintf("Step %s must declare exactly one dex:explanation directive", stepType), + nil, + ) + continue + } + directive := directives[0] + if !analyzer.validateV2Directive(directive, []string{"text"}, []string{"text"}) { + continue + } + explanation := strings.TrimSpace(directive.arguments["text"].text) + if explanation == "" { + analyzer.addV2DirectiveError(directive, "text must not be empty") + continue + } + nodeID := "step:" + stepType + for index := range analyzer.graph.Nodes { + if analyzer.graph.Nodes[index].ID != nodeID { + continue + } + if analyzer.graph.Nodes[index].Metadata == nil { + analyzer.graph.Nodes[index].Metadata = make(map[string]any) + } + analyzer.graph.Nodes[index].Metadata["explanation"] = explanation + break + } + } +} + +func (analyzer *goAnalyzer) v2TypeDirectives() map[string][]v2Directive { + directives := make(map[string][]v2Directive) + for _, declaration := range analyzer.file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.TYPE { + continue + } + for _, specification := range general.Specs { + typeSpec, typeOK := specification.(*ast.TypeSpec) + if !typeOK { + continue + } + comments := typeSpec.Doc + if comments == nil && len(general.Specs) == 1 { + comments = general.Doc + } + directives[typeSpec.Name.Name] = analyzer.parseV2Directives(comments) + } + } + return directives +} + +func (analyzer *goAnalyzer) collectV2Attributes() map[string]v2AttributeDeclaration { + attributes := make(map[string]v2AttributeDeclaration) + for _, declaration := range analyzer.file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.VAR { + continue + } + for _, specification := range general.Specs { + valueSpec, valueOK := specification.(*ast.ValueSpec) + if !valueOK { + continue + } + comments := valueSpec.Doc + if comments == nil && len(general.Specs) == 1 { + comments = general.Doc + } + directives := analyzer.parseV2Directives(comments) + for index, name := range valueSpec.Names { + if index >= len(valueSpec.Values) { + continue + } + call, callOK := valueSpec.Values[index].(*ast.CallExpr) + if !callOK { + continue + } + resourceKind := goResourceKind(analyzer.callName(call)) + if resourceKind != "attribute" { + continue + } + attributeKey := name.Name + if len(call.Args) > 0 { + if staticKey, static := analyzer.staticString(call.Args[0]); static { + attributeKey = staticKey + } + } + resource := analyzer.resourceDetails(call) + valueType := analyzer.v2ValueTypeForGenericCall(call) + if valueType == "" { + valueType = normalizeV2TypeName(resource.ValueType) + } + if resource.Map { + valueType = "attribute-map" + } + isIndexed, indexKey, indexType := analyzer.v2IndexConfiguration(call, attributeKey, resource.Map) + attributeDirectives := directives + if len(valueSpec.Names) > 1 && len(directivesNamed(directives, "indexed-attribute")) > 0 { + analyzer.graph.AddDiagnostic( + "error", + "v2_indexed_attribute", + "dex:indexed-attribute must annotate a single variable declaration", + analyzer.span(valueSpec), + ) + attributeDirectives = nil + } + attributes[attributeKey] = v2AttributeDeclaration{ + key: attributeKey, + valueType: valueType, + isMap: resource.Map, + isIndexed: isIndexed, + indexKey: indexKey, + indexType: indexType, + directives: attributeDirectives, + span: analyzer.span(valueSpec), + } + } + } + } + return attributes +} + +func (analyzer *goAnalyzer) collectV2IndexedAttributes( + attributes map[string]v2AttributeDeclaration, +) []IndexedAttribute { + indexedAttributes := make([]IndexedAttribute, 0) + for _, declaration := range analyzer.v2AttributeDeclarationsInSourceOrder(attributes) { + directives := directivesNamed(declaration.directives, "indexed-attribute") + if declaration.isIndexed && len(directives) != 1 { + analyzer.graph.AddDiagnostic( + "error", + "v2_indexed_attribute", + fmt.Sprintf("indexed Attribute %q must declare exactly one dex:indexed-attribute directive", declaration.key), + declaration.span, + ) + continue + } + if !declaration.isIndexed && len(directives) > 0 { + analyzer.addV2DirectiveError(directives[0], fmt.Sprintf("Attribute %q is not indexed", declaration.key)) + continue + } + if !declaration.isIndexed { + continue + } + directive := directives[0] + allowed := []string{"attribute-key", "index-key", "index-type", "value-type", "description"} + if !analyzer.validateV2Directive(directive, allowed, allowed) { + continue + } + comparisons := []struct { + argument string + actual string + }{ + {argument: "attribute-key", actual: declaration.key}, + {argument: "index-key", actual: declaration.indexKey}, + {argument: "index-type", actual: declaration.indexType}, + {argument: "value-type", actual: declaration.valueType}, + } + isValid := true + for _, comparison := range comparisons { + declared := directive.arguments[comparison.argument].text + if declared == comparison.actual { + continue + } + analyzer.addV2DirectiveError( + directive, + fmt.Sprintf("%s %q does not match the Go declaration %q", comparison.argument, declared, comparison.actual), + ) + isValid = false + } + if !isV2IndexCompatible(declaration.indexType, declaration.valueType) { + analyzer.addV2DirectiveError( + directive, + fmt.Sprintf("index-type %q does not support value-type %q", declaration.indexType, declaration.valueType), + ) + isValid = false + } + if !isValid { + continue + } + indexedAttributes = append(indexedAttributes, IndexedAttribute{ + AttributeKey: declaration.key, + IndexKey: declaration.indexKey, + IndexType: declaration.indexType, + ValueType: declaration.valueType, + Description: directive.arguments["description"].text, + }) + } + return indexedAttributes +} + +func (analyzer *goAnalyzer) v2AttributeDeclarationsInSourceOrder( + attributes map[string]v2AttributeDeclaration, +) []v2AttributeDeclaration { + ordered := make([]v2AttributeDeclaration, 0, len(attributes)) + for _, declaration := range analyzer.file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.VAR { + continue + } + for _, specification := range general.Specs { + valueSpec, valueOK := specification.(*ast.ValueSpec) + if !valueOK { + continue + } + for index := range valueSpec.Values { + call, callOK := valueSpec.Values[index].(*ast.CallExpr) + if !callOK || goResourceKind(analyzer.callName(call)) != "attribute" || len(call.Args) == 0 { + continue + } + attributeKey, static := analyzer.staticString(call.Args[0]) + if !static { + continue + } + if attribute, found := attributes[attributeKey]; found { + ordered = append(ordered, attribute) + } + } + } + } + return ordered +} + +func (analyzer *goAnalyzer) collectV2View( + flowType string, + rpcName string, + attributes map[string]v2AttributeDeclaration, + registeredRPCs map[string]bool, + indexedAttributes []IndexedAttribute, + canEdit bool, +) RPCView { + view := RPCView{RPCName: rpcName, Fields: make([]ViewField, 0)} + method := analyzer.methods[flowType][rpcName] + if method == nil { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc", fmt.Sprintf("Flow must define %s", rpcName), nil) + return view + } + if !registeredRPCs[rpcName] { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc", fmt.Sprintf("%s must be registered in GetRPCs", rpcName), analyzer.span(method)) + } + analyzer.validateV2RPCSignature(method, "none", "map") + analyzer.validateV2ReadOnlyRPC(method) + indexedKeys := make(map[string]bool, len(indexedAttributes)) + for _, attribute := range indexedAttributes { + indexedKeys[attribute.AttributeKey] = true + } + seen := make(map[string]bool) + for _, directive := range directivesNamed(analyzer.parseV2Directives(method.Doc), "field") { + allowed := []string{"attribute-key", "value-type", "editable", "description"} + if !analyzer.validateV2Directive(directive, allowed, allowed) { + continue + } + attributeKey := directive.arguments["attribute-key"].text + attribute, found := attributes[attributeKey] + if !found { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("field Attribute %q must be declared in this file", attributeKey)) + continue + } + if seen[attributeKey] { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("field Attribute %q is duplicated", attributeKey)) + continue + } + seen[attributeKey] = true + valueType := directive.arguments["value-type"].text + if valueType != attribute.valueType { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("value-type %q does not match Attribute %q type %q", valueType, attributeKey, attribute.valueType)) + continue + } + isEditable, err := strconv.ParseBool(directive.arguments["editable"].text) + if err != nil { + analyzer.addV2DirectiveError(directive, "editable must be true or false") + continue + } + if isEditable && !canEdit { + analyzer.addV2DirectiveError(directive, "GetDexSummary fields cannot be editable") + continue + } + if isEditable && !isV2EditableType(valueType) { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("value-type %q is not editable", valueType)) + continue + } + if !canEdit && indexedKeys[attributeKey] { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("Summary field %q duplicates an indexed Attribute", attributeKey)) + continue + } + view.Fields = append(view.Fields, ViewField{ + AttributeKey: attributeKey, + ValueType: valueType, + Editable: isEditable, + Description: directive.arguments["description"].text, + }) + } + analyzer.validateV2ViewOutputKeys(method, view.Fields) + return view +} + +func (analyzer *goAnalyzer) collectV2Actions( + flowType string, + attributes map[string]v2AttributeDeclaration, + registeredRPCNames []string, +) []Action { + actions := make([]Action, 0) + for _, rpcName := range registeredRPCNames { + method := analyzer.methods[flowType][rpcName] + if method == nil { + continue + } + directives := analyzer.parseV2Directives(method.Doc) + actionDirectives := directivesNamed(directives, "action") + if len(actionDirectives) == 0 { + continue + } + if len(actionDirectives) != 1 { + analyzer.graph.AddDiagnostic("error", "v2_action", fmt.Sprintf("RPC %s must declare exactly one dex:action", rpcName), analyzer.span(method)) + continue + } + actionDirective := actionDirectives[0] + if !analyzer.validateV2Directive(actionDirective, []string{"action-label"}, []string{"action-label"}) { + continue + } + whenDirectives := directivesNamed(directives, "when") + if len(whenDirectives) != 1 { + analyzer.graph.AddDiagnostic("error", "v2_action", fmt.Sprintf("Action RPC %s must declare exactly one dex:when", rpcName), analyzer.span(method)) + continue + } + condition, conditionOK := analyzer.v2ActionCondition(whenDirectives[0], attributes) + if !conditionOK { + continue + } + inputDirectives := directivesNamed(directives, "input") + input, inputOK := analyzer.v2ActionInput(method, inputDirectives, attributes) + if !inputOK { + continue + } + actions = append(actions, Action{ + RPCName: rpcName, + Label: actionDirective.arguments["action-label"].text, + Condition: condition, + Input: input, + }) + } + for methodName, method := range analyzer.methods[flowType] { + if len(directivesNamed(analyzer.parseV2Directives(method.Doc), "action")) == 0 { + continue + } + if !containsString(registeredRPCNames, methodName) { + analyzer.graph.AddDiagnostic("error", "v2_action", fmt.Sprintf("Action RPC %s must be registered in GetRPCs", methodName), analyzer.span(method)) + } + } + return actions +} + +func (analyzer *goAnalyzer) v2ActionCondition( + directive v2Directive, + attributes map[string]v2AttributeDeclaration, +) (ActionCondition, bool) { + allowed := []string{"attribute-key", "operator", "values"} + if !analyzer.validateV2Directive(directive, allowed, allowed) { + return ActionCondition{}, false + } + attributeKey := directive.arguments["attribute-key"].text + attribute, found := attributes[attributeKey] + if !found || attribute.isMap { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("condition Attribute %q must be a scalar Attribute in this file", attributeKey)) + return ActionCondition{}, false + } + operator := directive.arguments["operator"].text + if operator != "in" { + analyzer.addV2DirectiveError(directive, "operator must be in") + return ActionCondition{}, false + } + argument := directive.arguments["values"] + if !argument.array { + analyzer.addV2DirectiveError(directive, "values must be a JSON array") + return ActionCondition{}, false + } + values, err := decodeV2JSONArray(argument.text) + if err != nil || len(values) == 0 { + analyzer.addV2DirectiveError(directive, "values must be a non-empty JSON array") + return ActionCondition{}, false + } + for _, value := range values { + if !v2JSONValueMatchesType(value, attribute.valueType) { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("condition value does not match Attribute %q type %q", attributeKey, attribute.valueType)) + return ActionCondition{}, false + } + } + return ActionCondition{AttributeKey: attributeKey, Operator: operator, Values: values}, true +} + +func (analyzer *goAnalyzer) v2ActionInput( + method *ast.FuncDecl, + directives []v2Directive, + attributes map[string]v2AttributeDeclaration, +) (ActionInput, bool) { + if len(directives) == 0 { + return ActionInput{Kind: "none"}, analyzer.validateV2RPCSignature(method, "none", "none") + } + if !analyzer.validateV2RPCSignature(method, "object", "none") { + return ActionInput{}, false + } + structFields, found := analyzer.v2RPCInputStruct(method) + if !found { + return ActionInput{}, false + } + fieldsByName := make(map[string]v2StructField, len(structFields)) + for _, field := range structFields { + fieldsByName[field.jsonName] = field + } + input := ActionInput{Kind: "object", Fields: make([]ActionInputField, 0, len(directives))} + seen := make(map[string]bool) + for _, directive := range directives { + allowed := []string{"field-name", "value-type", "source", "attribute-key", "required", "description"} + required := []string{"field-name", "value-type", "source", "required", "description"} + if !analyzer.validateV2Directive(directive, allowed, required) { + continue + } + fieldName := directive.arguments["field-name"].text + structField, fieldFound := fieldsByName[fieldName] + if !fieldFound { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("input field %q is not in the RPC input struct", fieldName)) + continue + } + if seen[fieldName] { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("input field %q is duplicated", fieldName)) + continue + } + seen[fieldName] = true + valueType := directive.arguments["value-type"].text + if valueType != structField.valueType || !isV2EditableType(valueType) { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("input field %q type %q does not match Go type %q", fieldName, valueType, structField.valueType)) + continue + } + isRequired, err := strconv.ParseBool(directive.arguments["required"].text) + if err != nil || isRequired != structField.required { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("input field %q required must match Go pointer semantics", fieldName)) + continue + } + source := directive.arguments["source"].text + attributeKey := "" + switch source { + case "user": + if _, exists := directive.arguments["attribute-key"]; exists { + analyzer.addV2DirectiveError(directive, "source:user forbids attribute-key") + continue + } + case "attribute": + argument, exists := directive.arguments["attribute-key"] + if !exists { + analyzer.addV2DirectiveError(directive, "source:attribute requires attribute-key") + continue + } + attributeKey = argument.text + attribute, attributeFound := attributes[attributeKey] + if !attributeFound || attribute.isMap || attribute.valueType != valueType { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("source Attribute %q must be a matching scalar Attribute", attributeKey)) + continue + } + default: + analyzer.addV2DirectiveError(directive, "source must be user or attribute") + continue + } + input.Fields = append(input.Fields, ActionInputField{ + FieldName: fieldName, + ValueType: valueType, + Source: source, + AttributeKey: attributeKey, + Required: isRequired, + Description: directive.arguments["description"].text, + }) + } + if len(seen) != len(structFields) { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "every RPC input struct field must have one dex:input directive", analyzer.span(method)) + return input, false + } + return input, len(input.Fields) == len(structFields) +} + +func (analyzer *goAnalyzer) v2RPCInputStruct(method *ast.FuncDecl) ([]v2StructField, bool) { + inputType := analyzer.v2RPCInputType(method) + inputTypeName := namedTypeName(inputType) + if inputTypeName == "" { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "Action RPC input must be a named struct in the Flow file", analyzer.span(method)) + return nil, false + } + for _, declaration := range analyzer.file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.TYPE { + continue + } + for _, specification := range general.Specs { + typeSpec, typeOK := specification.(*ast.TypeSpec) + if !typeOK || typeSpec.Name.Name != inputTypeName { + continue + } + structType, structOK := typeSpec.Type.(*ast.StructType) + if !structOK { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "Action RPC input must be a struct", analyzer.span(typeSpec)) + return nil, false + } + fields := make([]v2StructField, 0) + for _, field := range structType.Fields.List { + if len(field.Names) != 1 || !field.Names[0].IsExported() || field.Tag == nil { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "Action RPC input fields must be exported and have JSON tags", analyzer.span(field)) + continue + } + tagText, err := strconv.Unquote(field.Tag.Value) + if err != nil { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "Action RPC input has an invalid struct tag", analyzer.span(field)) + continue + } + jsonName := strings.Split(reflect.StructTag(tagText).Get("json"), ",")[0] + if jsonName == "" || jsonName == "-" { + analyzer.graph.AddDiagnostic("error", "v2_action_input", "Action RPC input fields require explicit JSON names", analyzer.span(field)) + continue + } + valueExpression := field.Type + required := true + if pointer, isPointer := field.Type.(*ast.StarExpr); isPointer { + valueExpression = pointer.X + required = false + } + fields = append(fields, v2StructField{ + jsonName: jsonName, + valueType: analyzer.v2ValueTypeForExpression(valueExpression), + required: required, + span: analyzer.span(field), + }) + } + return fields, true + } + } + analyzer.graph.AddDiagnostic("error", "v2_action_input", fmt.Sprintf("Action RPC input type %s must be declared in the Flow file", inputTypeName), analyzer.span(method)) + return nil, false +} + +func (analyzer *goAnalyzer) registeredV2RPCNames(flowType string) []string { + method := analyzer.methods[flowType]["GetRPCs"] + if method == nil || method.Body == nil { + analyzer.graph.AddDiagnostic("error", "v2_rpc_registration", "Flow must define GetRPCs in the Flow file", nil) + return nil + } + names := make([]string, 0) + ast.Inspect(method.Body, func(current ast.Node) bool { + call, ok := current.(*ast.CallExpr) + if !ok || analyzer.callName(call) != "DefineRPC" || len(call.Args) == 0 { + return true + } + selector, selectorOK := call.Args[0].(*ast.SelectorExpr) + if selectorOK { + names = append(names, selector.Sel.Name) + } + return false + }) + return names +} + +func (analyzer *goAnalyzer) validateV2RPCSignature(method *ast.FuncDecl, inputKind string, outputKind string) bool { + object := analyzer.typeInfo.Defs[method.Name] + if object == nil { + return false + } + signature, ok := object.Type().(*types.Signature) + if !ok || signature.Params().Len() != 2 || signature.Results().Len() != 2 { + analyzer.graph.AddDiagnostic("error", "v2_rpc_signature", fmt.Sprintf("RPC %s has an invalid signature", method.Name.Name), analyzer.span(method)) + return false + } + inputType := signature.Params().At(1).Type() + inputText := types.TypeString(inputType, nil) + if inputKind == "none" && !strings.HasSuffix(inputText, "/dex.None") && inputText != "dex.None" { + analyzer.graph.AddDiagnostic("error", "v2_rpc_signature", fmt.Sprintf("RPC %s input must be dex.None", method.Name.Name), analyzer.span(method)) + return false + } + if inputKind == "object" { + underlying := inputType.Underlying() + if _, isStruct := underlying.(*types.Struct); !isStruct { + analyzer.graph.AddDiagnostic("error", "v2_rpc_signature", fmt.Sprintf("RPC %s input must be a named struct", method.Name.Name), analyzer.span(method)) + return false + } + } + resultText := types.TypeString(signature.Results().At(0).Type(), nil) + expectedOutput := "map[string]any" + if outputKind == "none" { + expectedOutput = "dex.None" + } + if outputKind == "map" && !strings.Contains(resultText, "RPCResult[map[string]any]") { + analyzer.graph.AddDiagnostic("error", "v2_rpc_signature", fmt.Sprintf("RPC %s output must be map[string]any", method.Name.Name), analyzer.span(method)) + return false + } + if outputKind == "none" && (!strings.Contains(resultText, "RPCResult[") || !strings.Contains(resultText, ".None]")) { + analyzer.graph.AddDiagnostic("error", "v2_rpc_signature", fmt.Sprintf("RPC %s output must be %s", method.Name.Name, expectedOutput), analyzer.span(method)) + return false + } + return true +} + +func (analyzer *goAnalyzer) v2RPCInputType(method *ast.FuncDecl) types.Type { + object := analyzer.typeInfo.Defs[method.Name] + if object == nil { + return nil + } + signature, ok := object.Type().(*types.Signature) + if !ok || signature.Params().Len() != 2 { + return nil + } + return signature.Params().At(1).Type() +} + +func (analyzer *goAnalyzer) validateV2ReadOnlyRPC(method *ast.FuncDecl) { + declarations := make(map[types.Object]*ast.FuncDecl) + for _, declaration := range analyzer.file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } + object := analyzer.typeInfo.Defs[function.Name] + if object != nil { + declarations[object] = function + } + } + analyzer.doValidateV2ReadOnlyRPC(method, method.Name.Name, declarations, make(map[*ast.FuncDecl]bool)) +} + +func (analyzer *goAnalyzer) doValidateV2ReadOnlyRPC( + method *ast.FuncDecl, + rpcName string, + declarations map[types.Object]*ast.FuncDecl, + visited map[*ast.FuncDecl]bool, +) { + if method == nil || method.Body == nil || visited[method] { + return + } + visited[method] = true + forbidden := map[string]bool{ + "Set": true, "Delete": true, "Publish": true, "DeleteChannelMessage": true, + "GoTo": true, "GoToMany": true, "CancelSteps": true, + } + ast.Inspect(method.Body, func(current ast.Node) bool { + call, ok := current.(*ast.CallExpr) + if ok { + if forbidden[analyzer.callName(call)] { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc_side_effect", fmt.Sprintf("%s must be read-only", rpcName), analyzer.span(call)) + } + if called := analyzer.v2LocalFunctionDeclaration(call, declarations); called != nil { + analyzer.doValidateV2ReadOnlyRPC(called, rpcName, declarations, visited) + } + } + composite, ok := current.(*ast.CompositeLit) + if !ok || !strings.Contains(analyzer.expressionString(composite.Type), "RPCResult") { + return true + } + for _, element := range composite.Elts { + keyValue, keyValueOK := element.(*ast.KeyValueExpr) + if !keyValueOK { + continue + } + fieldName := analyzer.expressionString(keyValue.Key) + if fieldName == "NextSteps" || fieldName == "CancelingSteps" { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc_side_effect", fmt.Sprintf("%s must be read-only", rpcName), analyzer.span(keyValue)) + } + } + return true + }) +} + +func (analyzer *goAnalyzer) v2LocalFunctionDeclaration( + call *ast.CallExpr, + declarations map[types.Object]*ast.FuncDecl, +) *ast.FuncDecl { + var object types.Object + switch function := call.Fun.(type) { + case *ast.Ident: + object = analyzer.typeInfo.Uses[function] + case *ast.SelectorExpr: + if selection := analyzer.typeInfo.Selections[function]; selection != nil { + object = selection.Obj() + } else { + object = analyzer.typeInfo.Uses[function.Sel] + } + } + return declarations[object] +} + +func (analyzer *goAnalyzer) validateV2ViewOutputKeys(method *ast.FuncDecl, fields []ViewField) { + declared := make(map[string]bool, len(fields)) + for _, field := range fields { + declared[field.AttributeKey] = true + } + var outputKeys map[string]bool + ast.Inspect(method.Body, func(current ast.Node) bool { + composite, ok := current.(*ast.CompositeLit) + if !ok || analyzer.expressionString(composite.Type) != "map[string]any" { + return true + } + keys := make(map[string]bool, len(composite.Elts)) + for _, element := range composite.Elts { + keyValue, keyValueOK := element.(*ast.KeyValueExpr) + if !keyValueOK { + return true + } + key, static := analyzer.staticString(keyValue.Key) + if !static { + return true + } + keys[key] = true + } + if outputKeys == nil { + outputKeys = keys + } + return true + }) + if outputKeys == nil { + analyzer.graph.AddDiagnostic( + "error", + "v2_view_rpc_output", + fmt.Sprintf("%s must return its declared fields from a map[string]any literal", method.Name.Name), + analyzer.span(method), + ) + return + } + for key := range declared { + if !outputKeys[key] { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc_output", fmt.Sprintf("%s omits declared field %q", method.Name.Name, key), analyzer.span(method)) + } + } + for key := range outputKeys { + if !declared[key] { + analyzer.graph.AddDiagnostic("error", "v2_view_rpc_output", fmt.Sprintf("%s returns undeclared field %q", method.Name.Name, key), analyzer.span(method)) + } + } +} + +func (analyzer *goAnalyzer) v2IndexConfiguration( + attributeCall *ast.CallExpr, + attributeKey string, + isMap bool, +) (bool, string, string) { + for _, option := range attributeCall.Args[1:] { + indexedCall, ok := option.(*ast.CallExpr) + if !ok || analyzer.callName(indexedCall) != "Indexed" || len(indexedCall.Args) != 1 { + continue + } + composite, compositeOK := indexedCall.Args[0].(*ast.CompositeLit) + if !compositeOK { + return true, "", "unknown" + } + indexKey := "" + indexType := "unknown" + for _, element := range composite.Elts { + keyValue, keyValueOK := element.(*ast.KeyValueExpr) + if !keyValueOK { + continue + } + key := analyzer.expressionString(keyValue.Key) + switch key { + case "Type": + indexType = normalizeV2IndexType(analyzer.expressionString(keyValue.Value)) + case "IndexKey": + if staticValue, static := analyzer.staticString(keyValue.Value); static { + indexKey = staticValue + } + } + } + if indexKey == "" && !isMap { + indexKey = attributeKey + } + return true, indexKey, indexType + } + return false, "", "" +} + +func (analyzer *goAnalyzer) v2ValueTypeForGenericCall(call *ast.CallExpr) string { + switch function := call.Fun.(type) { + case *ast.IndexExpr: + return analyzer.v2ValueTypeForExpression(function.Index) + case *ast.IndexListExpr: + if len(function.Indices) > 0 { + return analyzer.v2ValueTypeForExpression(function.Indices[0]) + } + } + return "" +} + +func (analyzer *goAnalyzer) v2ValueTypeForExpression(expression ast.Expr) string { + if typeAndValue, found := analyzer.typeInfo.Types[expression]; found && typeAndValue.Type != nil { + return normalizeV2GoType(typeAndValue.Type) + } + return normalizeV2TypeName(analyzer.expressionString(expression)) +} + +func normalizeV2GoType(valueType types.Type) string { + if valueType == nil { + return "json" + } + if named, ok := valueType.(*types.Named); ok && named.Obj() != nil && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "time" && named.Obj().Name() == "Time" { + return "datetime" + } + if _, ok := valueType.(*types.Named); ok { + return "json" + } + switch underlying := valueType.Underlying().(type) { + case *types.Basic: + switch underlying.Kind() { + case types.String: + return "string" + case types.Bool: + return "bool" + case types.Int64: + return "int64" + case types.Float64: + return "double" + } + case *types.Slice: + if basic, ok := underlying.Elem().Underlying().(*types.Basic); ok && basic.Kind() == types.String { + return "string-array" + } + return "array" + case *types.Array: + return "array" + case *types.Struct, *types.Map: + return "object" + } + return "json" +} + +func normalizeV2TypeName(typeName string) string { + trimmed := strings.TrimSpace(typeName) + switch trimmed { + case "string": + return "string" + case "bool": + return "bool" + case "int64": + return "int64" + case "float64": + return "double" + case "[]string": + return "string-array" + case "time.Time": + return "datetime" + default: + return "json" + } +} + +func normalizeV2IndexType(typeName string) string { + trimmed := strings.TrimPrefix(strings.TrimSpace(typeName), "dex.") + switch trimmed { + case "IndexKeyword": + return "keyword" + case "IndexFullText": + return "fulltext" + case "IndexKeywordArray": + return "keyword-array" + case "IndexInt": + return "int" + case "IndexDouble": + return "double" + case "IndexBool": + return "bool" + case "IndexDatetime": + return "datetime" + default: + return "unknown" + } +} + +func isV2EditableType(valueType string) bool { + switch valueType { + case "string", "int64", "double", "bool", "datetime": + return true + default: + return false + } +} + +func isV2IndexCompatible(indexType string, valueType string) bool { + compatibleValueTypes := map[string]string{ + "keyword": "string", + "fulltext": "string", + "keyword-array": "string-array", + "int": "int64", + "double": "double", + "bool": "bool", + "datetime": "datetime", + } + return compatibleValueTypes[indexType] == valueType +} + +func v2JSONValueMatchesType(value any, valueType string) bool { + switch valueType { + case "string": + _, ok := value.(string) + return ok + case "datetime": + text, ok := value.(string) + if !ok { + return false + } + _, err := time.Parse(time.RFC3339Nano, text) + return err == nil + case "bool": + _, ok := value.(bool) + return ok + case "int64": + number, ok := value.(json.Number) + if !ok { + return false + } + _, err := number.Int64() + return err == nil + case "double": + number, ok := value.(json.Number) + if !ok { + return false + } + parsed, err := number.Float64() + return err == nil && !math.IsInf(parsed, 0) && !math.IsNaN(parsed) + default: + return false + } +} + +func (analyzer *goAnalyzer) parseV2Directives(comments *ast.CommentGroup) []v2Directive { + if comments == nil { + return nil + } + directives := make([]v2Directive, 0) + for _, comment := range comments.List { + text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) + if !strings.HasPrefix(text, "dex:") { + continue + } + directive, err := parseV2Directive(text, comment) + if err != nil { + analyzer.graph.AddDiagnostic("error", "v2_directive", err.Error(), analyzer.span(comment)) + continue + } + directives = append(directives, directive) + } + return directives +} + +func parseV2Directive(text string, comment *ast.Comment) (v2Directive, error) { + remaining := strings.TrimPrefix(text, "dex:") + nameEnd := strings.IndexFunc(remaining, unicode.IsSpace) + name := remaining + if nameEnd >= 0 { + name = remaining[:nameEnd] + remaining = strings.TrimSpace(remaining[nameEnd:]) + } else { + remaining = "" + } + if name == "" { + return v2Directive{}, fmt.Errorf("dex directive name is required") + } + directive := v2Directive{name: name, arguments: make(map[string]v2DirectiveArgument), comment: comment} + for remaining != "" { + colon := strings.IndexByte(remaining, ':') + if colon <= 0 { + return v2Directive{}, fmt.Errorf("dex:%s arguments must use name:value", name) + } + argumentName := remaining[:colon] + if strings.IndexFunc(argumentName, unicode.IsSpace) >= 0 { + return v2Directive{}, fmt.Errorf("dex:%s has an invalid argument name", name) + } + if _, duplicated := directive.arguments[argumentName]; duplicated { + return v2Directive{}, fmt.Errorf("dex:%s repeats argument %s", name, argumentName) + } + remaining = remaining[colon+1:] + argument, tail, err := parseV2DirectiveArgument(remaining) + if err != nil { + return v2Directive{}, fmt.Errorf("dex:%s %s: %w", name, argumentName, err) + } + directive.arguments[argumentName] = argument + remaining = strings.TrimSpace(tail) + } + return directive, nil +} + +func parseV2DirectiveArgument(input string) (v2DirectiveArgument, string, error) { + if input == "" { + return v2DirectiveArgument{}, "", fmt.Errorf("value is required") + } + switch input[0] { + case '"': + end, err := scanV2JSONString(input) + if err != nil { + return v2DirectiveArgument{}, "", err + } + var value string + if err := json.Unmarshal([]byte(input[:end]), &value); err != nil { + return v2DirectiveArgument{}, "", fmt.Errorf("invalid quoted string") + } + return v2DirectiveArgument{text: value}, input[end:], nil + case '[': + end, err := scanV2JSONArray(input) + if err != nil { + return v2DirectiveArgument{}, "", err + } + if _, err := decodeV2JSONArray(input[:end]); err != nil { + return v2DirectiveArgument{}, "", fmt.Errorf("invalid JSON array") + } + return v2DirectiveArgument{text: input[:end], array: true}, input[end:], nil + default: + end := strings.IndexFunc(input, unicode.IsSpace) + if end < 0 { + end = len(input) + } + if end == 0 { + return v2DirectiveArgument{}, "", fmt.Errorf("value is required") + } + return v2DirectiveArgument{text: input[:end]}, input[end:], nil + } +} + +func decodeV2JSONArray(value string) ([]any, error) { + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.UseNumber() + var values []any + if err := decoder.Decode(&values); err != nil { + return nil, err + } + return values, nil +} + +func scanV2JSONString(input string) (int, error) { + escaped := false + for index := 1; index < len(input); index++ { + switch { + case escaped: + escaped = false + case input[index] == '\\': + escaped = true + case input[index] == '"': + return index + 1, nil + } + } + return 0, fmt.Errorf("unterminated quoted string") +} + +func scanV2JSONArray(input string) (int, error) { + depth := 0 + inString := false + escaped := false + for index := 0; index < len(input); index++ { + character := input[index] + if inString { + switch { + case escaped: + escaped = false + case character == '\\': + escaped = true + case character == '"': + inString = false + } + continue + } + switch character { + case '"': + inString = true + case '[': + depth++ + case ']': + depth-- + if depth == 0 { + return index + 1, nil + } + } + } + return 0, fmt.Errorf("unterminated JSON array") +} + +func (analyzer *goAnalyzer) validateV2Directive( + directive v2Directive, + allowed []string, + required []string, +) bool { + allowedNames := make(map[string]bool, len(allowed)) + for _, name := range allowed { + allowedNames[name] = true + } + isValid := true + for name := range directive.arguments { + if allowedNames[name] { + argument := directive.arguments[name] + if argument.array && !(directive.name == "when" && name == "values") { + analyzer.addV2DirectiveError(directive, fmt.Sprintf("argument %s must not be a JSON array", name)) + isValid = false + } + continue + } + analyzer.addV2DirectiveError(directive, fmt.Sprintf("unknown argument %s", name)) + isValid = false + } + for _, name := range required { + if _, found := directive.arguments[name]; found { + continue + } + analyzer.addV2DirectiveError(directive, fmt.Sprintf("missing required argument %s", name)) + isValid = false + } + return isValid +} + +func (analyzer *goAnalyzer) addV2DirectiveError(directive v2Directive, message string) { + analyzer.graph.AddDiagnostic("error", "v2_directive", fmt.Sprintf("dex:%s %s", directive.name, message), analyzer.span(directive.comment)) +} + +func directivesNamed(directives []v2Directive, name string) []v2Directive { + matched := make([]v2Directive, 0) + for _, directive := range directives { + if directive.name == name { + matched = append(matched, directive) + } + } + return matched +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/cli/schema/flow-definition-graph.v2.schema.json b/cli/schema/flow-definition-graph.v2.schema.json new file mode 100644 index 000000000..36819f39c --- /dev/null +++ b/cli/schema/flow-definition-graph.v2.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://superdurable.com/schemas/flow-definition-graph.v2.schema.json", + "title": "Dex Flow Definition Graph v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "valid", + "source", + "flow", + "nodes", + "edges", + "diagnostics", + "groups", + "v2" + ], + "properties": { + "schemaVersion": {"const": "2.0"}, + "valid": {"type": "boolean"}, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["language", "path"], + "properties": { + "language": {"const": "go"}, + "path": {"type": "string"} + } + }, + "flow": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "startStepId": {"type": "string"}, + "span": {"$ref": "flow-definition-graph.v1.schema.json#/$defs/span"} + } + }, + "nodes": { + "type": "array", + "items": {"$ref": "flow-definition-graph.v1.schema.json#/$defs/node"} + }, + "edges": { + "type": "array", + "items": {"$ref": "flow-definition-graph.v1.schema.json#/$defs/edge"} + }, + "diagnostics": { + "type": "array", + "items": {"$ref": "flow-definition-graph.v1.schema.json#/$defs/diagnostic"} + }, + "groups": { + "type": "array", + "items": {"$ref": "#/$defs/group"} + }, + "v2": {"$ref": "#/$defs/v2"} + }, + "$defs": { + "group": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "stepIds"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"}, + "label": {"type": "string", "minLength": 1}, + "stepIds": {"type": "array", "items": {"type": "string"}, "minItems": 1} + } + }, + "v2": { + "type": "object", + "additionalProperties": false, + "required": ["indexedAttributes", "summary", "display", "actions"], + "properties": { + "indexedAttributes": { + "type": "array", + "items": {"$ref": "#/$defs/indexedAttribute"} + }, + "summary": {"$ref": "#/$defs/rpcView"}, + "display": {"$ref": "#/$defs/rpcView"}, + "actions": { + "type": "array", + "items": {"$ref": "#/$defs/action"} + } + } + }, + "indexedAttribute": { + "type": "object", + "additionalProperties": false, + "required": ["attributeKey", "indexKey", "indexType", "valueType", "description"], + "properties": { + "attributeKey": {"type": "string"}, + "indexKey": {"type": "string"}, + "indexType": {"enum": ["keyword", "fulltext", "keyword-array", "int", "double", "bool", "datetime"]}, + "valueType": {"enum": ["string", "string-array", "int64", "double", "bool", "datetime"]}, + "description": {"type": "string"} + } + }, + "rpcView": { + "type": "object", + "additionalProperties": false, + "required": ["rpcName", "fields"], + "properties": { + "rpcName": {"type": "string"}, + "fields": { + "type": "array", + "items": {"$ref": "#/$defs/viewField"} + } + } + }, + "viewField": { + "type": "object", + "additionalProperties": false, + "required": ["attributeKey", "valueType", "editable", "description"], + "properties": { + "attributeKey": {"type": "string"}, + "valueType": {"$ref": "#/$defs/valueType"}, + "editable": {"type": "boolean"}, + "description": {"type": "string"} + } + }, + "action": { + "type": "object", + "additionalProperties": false, + "required": ["rpcName", "label", "condition", "input"], + "properties": { + "rpcName": {"type": "string"}, + "label": {"type": "string", "minLength": 1}, + "condition": {"$ref": "#/$defs/actionCondition"}, + "input": {"$ref": "#/$defs/actionInput"} + } + }, + "actionCondition": { + "type": "object", + "additionalProperties": false, + "required": ["attributeKey", "operator", "values"], + "properties": { + "attributeKey": {"type": "string"}, + "operator": {"const": "in"}, + "values": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "boolean"} + ] + } + } + } + }, + "actionInput": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": {"enum": ["none", "object"]}, + "fields": { + "type": "array", + "items": {"$ref": "#/$defs/actionInputField"} + } + }, + "allOf": [ + { + "if": {"properties": {"kind": {"const": "object"}}}, + "then": {"required": ["fields"], "properties": {"fields": {"type": "array", "minItems": 1}}}, + "else": {"not": {"required": ["fields"]}} + } + ] + }, + "actionInputField": { + "type": "object", + "additionalProperties": false, + "required": ["fieldName", "valueType", "source", "required", "description"], + "properties": { + "fieldName": {"type": "string"}, + "valueType": {"enum": ["string", "int64", "double", "bool", "datetime"]}, + "source": {"enum": ["user", "attribute"]}, + "attributeKey": {"type": "string"}, + "required": {"type": "boolean"}, + "description": {"type": "string"} + }, + "allOf": [ + { + "if": {"properties": {"source": {"const": "attribute"}}}, + "then": {"required": ["attributeKey"]}, + "else": {"not": {"required": ["attributeKey"]}} + } + ] + }, + "valueType": { + "enum": ["string", "string-array", "int64", "double", "bool", "datetime", "json", "object", "array", "attribute-map"] + } + } +} diff --git a/docs/content/production/dex-web.mdx b/docs/content/production/dex-web.mdx new file mode 100644 index 000000000..05786061a --- /dev/null +++ b/docs/content/production/dex-web.mdx @@ -0,0 +1,71 @@ +--- +title: Dex Web +sidebar_label: Dex Web +--- + +# Dex Web + +Dex Web has two URL prefixes. **v1** is the existing Flows search, run details, and Flow Rendering workspace. **v2** is the operator workspace: a run list and filters on the left, and the Flow Definition Graph on the right. + +Start Dex without a JSON directory to open **v1** at **/v1/flows**. Start Dex with **--flow-rendering-dir** to open **v2** at **/v2**. The top-right Version menu switches back to **v1**. + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 --json --out ./build/refund +dexcli dev --flow-rendering-dir ./build +``` + +**v2** searches current runs for one Flow type. The canvas uses that Flow type's generated definition, so you do not pick a JSON file again. Historical runs stay on **v1**. + +Version 1 remains the default analyzer schema and supports Go and Python. Version 2 currently supports Go only. Version 1 and Version 2 definition files can coexist. Dex Web rejects malformed Version 2 files and duplicate valid definitions for the same Flow type at startup. An analyzer result with **valid: false** remains available on **v1** Flow Rendering but does not appear as a **v2** Flow type. + +## Named directives + +Version 2 directives use named values. Parameter position within one line has no meaning. Quote strings containing spaces as JSON strings, and write multiple values as a JSON array. + + + + +```go +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Apply refund guardrails and set the recommended action." +// dex:indexed-attribute value-type:string attribute-key:case-status description:"Current case status" index-type:keyword index-key:case-status +// dex:field attribute-key:operator-note value-type:string editable:true description:"Operator note" +// dex:action action-label:"Reject" +// dex:when attribute-key:case-status operator:in values:["awaiting-manager-rule","awaiting-manager-agent"] +// dex:input field-name:reason value-type:string source:user required:true description:"Rejection reason" +``` + + + + +Unknown or repeated parameters, missing required parameters, and invalid JSON are blocking diagnostics. Separate indexed Attribute, field, Action, and input lines keep source order. This order becomes column, detail-field, Action, and form order. There is no separate order property. + +Every registered Step has one group declaration and one explanation declaration. The explanation is one sentence that states what the Step does. Group order follows the first member's registration position in **GetSteps**. Step order within a group also follows **GetSteps**. + +## Search and Summary + +An indexed Attribute declaration binds the Dex Attribute key, physical search index key, index type, application value type, and description. The analyzer compares every value with the Go definition and the SDK's effective index configuration. + +The **v2** list searches one Flow type at a time. Filters include Flow ID, execution status, start and close time, and the declared indexed Attributes. Dex Web compiles these controls into a visibility query. It does not accept a raw query from the browser. Different fields use AND; multiple values for one field use OR. + +Each row calls **GetDexSummary** for the logical Flow ID's current run. The RPC accepts **dex.None** and returns a **map[string]any**. Dex Web loads at most eight summaries concurrently, applies a five-second timeout to each call, and isolates a failure to its row. Indexed Attributes appear before Summary fields. + +## Display and edits + +Selecting a row keeps the Flow ID in the URL, not a run ID. **GetDexDisplay**, Attribute reads, edits, and Actions omit the run ID, so Dex resolves the current run. After Continue-as-New, refreshing the same URL automatically addresses the new run. + +On **v2**, selecting a Step opens a panel split into **Definition** and **Execution**. Definition shows the Step **explanation**, WaitFor, and Execute branches from the Flow Definition Graph. Long Definition content scrolls so Execution stays visible. When a Step has more than three Execute branches, the branch list starts collapsed. Execution has a dropdown when the Step ran more than once, then **Input**, **Output**, and **Context** for the selected WaitFor or Execute history event in the same structured form as **v1** Selected event. The execution payload defaults to Details and can switch to Raw JSON of the hydrated payload. AttributeMap and ChannelMap instances that are decimal numbers sort by numeric value, matching **v1**. Execute shows the live next Steps from that run's decision, not every FDG exit. When Continue-as-New moved a Step into a previous run, use Load more from previous run. + +Display fields are ordered by their directive lines. Primitive string, integer, double, boolean, and datetime fields can be editable. JSON, objects, arrays, and AttributeMap values remain read-only. Missing fields must be returned as null. The backend rejects omitted, undeclared, or mistyped values. + +Edits are available only while the current run is active. Dex Web writes the Attribute through **SetAttributes** and includes its declared index configuration when the Attribute is indexed. + +## Actions + +An Action is an RPC with an Action directive and one condition. The first version supports the **in** operator. Dex Web re-reads the condition Attribute before invoking the RPC. + +An Action without input accepts **dex.None** and renders as a direct button. An Action with input accepts a named Go struct. User-sourced fields render controls in directive order. Attribute-sourced fields stay hidden and use the selected run's Attribute snapshot. + +The browser condition only controls presentation. Every Action RPC must check the current state again before changing durable state or publishing a Channel message. Use locks when that check and the effect must be atomic. + +The first version adds no login or role system. Deploy it behind the same trusted network or reverse-proxy boundary used for Dex Web. diff --git a/docs/content/production/index.mdx b/docs/content/production/index.mdx index 417804212..ec4db4c1b 100644 --- a/docs/content/production/index.mdx +++ b/docs/content/production/index.mdx @@ -11,6 +11,7 @@ Operate Dex applications and servers safely in production. - [Application operation](/production/application-operations) — Worker health, read-only debugging, recovery, and Flow-code versioning +- [Dex Web](/production/dex-web) — v1 run search and v2 listing with Flow rendering - [Server operation](/production/server-operations) — deployment, configuration, storage, and Dex Web access control - [Metrics](/production/metrics) — built-in Dex Server metric catalog and diff --git a/docs/content/references/flow-visualization.mdx b/docs/content/references/flow-visualization.mdx index 6812bd4f3..67e7828ee 100644 --- a/docs/content/references/flow-visualization.mdx +++ b/docs/content/references/flow-visualization.mdx @@ -11,6 +11,12 @@ sidebar_label: Flow visualization dexcli visualize ./order_flow.go ``` +The default schema version is 1.0. Go sources can opt into Version 2.0 for Dex Web v2: + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 +``` + This opens the local **Flow Rendering** page and keeps it available until you press Ctrl+C. Use **--json** when you need a Flow Definition Graph artifact. Without **--out**, it writes JSON to stdout. Use **--out** to choose a file prefix: ```bash @@ -31,7 +37,9 @@ Start Dex with a directory containing generated JSON files: dexcli dev --flow-rendering-dir ./build ``` -Open **Flow Rendering** in Dex Web. This page is independent from Flow search and run details. It renders the selected definition interactively. Its legend can show or hide control flow, WaitFor, RPCs, Attributes, Channels, Streams, SubFlows, and diagnostics. Flow timeout handlers are always shown as part of the Flow. Streams are hidden by default. The other layers start visible. +Open **Flow Rendering** at **/v1/rendering**. This page is independent from Flow search and run details. It renders the selected definition interactively. Its legend can show or hide control flow, WaitFor, RPCs, Attributes, Channels, Streams, SubFlows, and diagnostics. Flow timeout handlers are always shown as part of the Flow. Streams are hidden by default. The other layers start visible. + +When Dex starts with a JSON directory, **v2** at **/v2** also renders the selected Flow type's definition beside the run list. See [Dex Web](/production/dex-web). Dex scans JSON files recursively and takes a snapshot when it starts. Restart Dex after changing the files. Invalid JSON, an unsupported schema version, or a path that is not a directory stops startup with an error. @@ -89,6 +97,8 @@ For Python, the analyzer recognizes Stream outputs yielded by synchronous Step g Version 1 does not support multiple Flows in one file, reflection, dynamic classes, wildcard Dex imports, getattr-based targets, monkeypatching, or movement collections that escape the handler. TypeScript, Java, Rust, and recursive multi-file analysis are not included. +Version 2.0 is Go-only. It retains every Version 1 field and requires Step groups and the Dex Web v2 contract in the same file. Named directive parameters can appear in any order within a line. Multiple directive lines keep source order, which becomes UI order. Unknown, repeated, missing, or invalid parameters are blocking diagnostics. See [Dex Web](/production/dex-web) for Indexed Attribute, Summary, Display, and Action rules. + ## Unknown nodes and diagnostics Unsupported dynamic control flow is never omitted silently. The output contains an Unknown node and an error diagnostic. The default renderer still shows the partial graph. With **--json**, a partial JSON artifact is written, and the command exits with status 1. @@ -99,6 +109,6 @@ The JSON **valid** field is false when any blocking diagnostic is present. Consu ## JSON contract -The [Flow Definition Graph v1 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v1.schema.json) defines source spans, parent-child relationships, structured WaitFor and decision details, resource types, branch conditions, edge direction, and diagnostics. The schema is the integration boundary for future visual editors and additional language analyzers. +The [Flow Definition Graph v1 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v1.schema.json) defines source spans, parent-child relationships, structured WaitFor and decision details, resource types, branch conditions, edge direction, and diagnostics. The [Flow Definition Graph v2 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v2.schema.json) extends it with ordered groups and Dex Web v2 metadata. See [Dex CLI](/references/cli) for commands that operate on running Flows. diff --git a/docs/content/use-cases/customer-refund.mdx b/docs/content/use-cases/customer-refund.mdx new file mode 100644 index 000000000..0423ad2f0 --- /dev/null +++ b/docs/content/use-cases/customer-refund.mdx @@ -0,0 +1,48 @@ +--- +title: Customer refund +--- + +import deterministicFlow from '@site/src/data/flow-definitions/customer-refund.json'; +import agenticFlow from '@site/src/data/flow-definitions/customer-refund-agentic.json'; + +# Customer refund + +The Go customer-refund product contains two self-contained Flows. Both publish Flow Definition Graph 2.0 metadata for Dex Web v2. + +## Deterministic policy + +**CustomerRefundFlow** follows seven registered Steps. It verifies that the order exists, applies the 30-day rule, records an idempotency key before calling the payment provider, and preserves declined and unknown provider outcomes. + + + +Its Summary shows the charge reference, requested amount, and recommendation. Its Display adds evidence, billing outcome, and an editable operator note. It deliberately declares no Action, so an empty Action list remains a valid contract. + +## Agentic review + +**AgenticCustomerRefundFlow** gathers evidence through a durable decision loop. A guardrail either binds an automatic effect or opens a keyed manager gate. Intent is recorded before the idempotent billing, subscription, and customer-message effects run. + + + +The approval Action has no input and appears as a direct button. The rejection Action asks for a reason. Dex Web supplies its hidden gate key from the Attribute snapshot captured when the detail page opened. The RPC compares that key with the current gate before publishing a verdict. + +Both Actions also read the current case status. The visibility condition in Flow Definition Graph 2.0 controls whether a button appears; it is not authorization. + +## Run the examples + +The runnable files are linked from the Go example tabs. + + + + +```go +// dex:indexed-attribute attribute-key:case-status index-key:case-status index-type:keyword value-type:string description:"Current case status" +var deterministicCaseStatus = dex.DefineAttribute[string]( + "case-status", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) +``` + + + + +See [Dex Web](/production/dex-web) for the protocol and operator workflow. diff --git a/docs/content/use-cases/index.mdx b/docs/content/use-cases/index.mdx index 723017990..888313be3 100644 --- a/docs/content/use-cases/index.mdx +++ b/docs/content/use-cases/index.mdx @@ -19,6 +19,7 @@ Product-style examples beyond design patterns. Each demonstrates composing Dex p - [Deal DSL](/use-cases/deal-dsl) — Durable deal interpreter for any sellable item - [Microservice orchestration](/use-cases/microservice-orchestration) — concurrent APIs + channels/timers - [Job posting](/use-cases/job-post) — locked updates feeding independent FIFO job-board consumers +- [Customer refund](/use-cases/customer-refund) — deterministic and agentic refund handling ## Related example diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/dex-web.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/dex-web.mdx new file mode 100644 index 000000000..539d1b77b --- /dev/null +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/dex-web.mdx @@ -0,0 +1,71 @@ +--- +title: Dex Web +sidebar_label: Dex Web +--- + +# Dex Web + +Dex Web 使用两个 URL 前缀。**v1** 是原有的 Flows 搜索、run 详情和 Flow Rendering。**v2** 是操作工作区:左边是 run 列表和过滤器,右边是该 Flow type 的 Flow Definition Graph。 + +不带 JSON 目录启动 Dex 时,打开 **v1** 的 **/v1/flows**。使用 **--flow-rendering-dir** 启动时,默认打开 **v2** 的 **/v2**。右上角 Version 菜单可以回到 **v1**。 + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 --json --out ./build/refund +dexcli dev --flow-rendering-dir ./build +``` + +**v2** 按一个 Flow type 搜索 current run。画布使用该 Flow type 已生成的 definition,因此不必再选 JSON 文件。历史 run 仍在 **v1**。 + +Version 1 仍是默认 analyzer schema,并支持 Go 与 Python。Version 2 第一版只支持 Go。Version 1 与 Version 2 definition 可以共存。Dex Web 会在启动时拒绝格式错误的 Version 2 文件,以及同一 Flow type 的多个有效 Version 2 definition。Analyzer 输出中的 **valid: false** 文件仍可在 **v1** Flow Rendering 中查看 diagnostic,但不会作为 **v2** Flow type 出现。 + +## 具名 directive + +Version 2 directive 使用具名值。同一行内的参数位置没有语义。包含空格的字符串使用 JSON 双引号,多值使用 JSON array。 + + + + +```go +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Apply refund guardrails and set the recommended action." +// dex:indexed-attribute value-type:string attribute-key:case-status description:"Current case status" index-type:keyword index-key:case-status +// dex:field attribute-key:operator-note value-type:string editable:true description:"Operator note" +// dex:action action-label:"Reject" +// dex:when attribute-key:case-status operator:in values:["awaiting-manager-rule","awaiting-manager-agent"] +// dex:input field-name:reason value-type:string source:user required:true description:"Rejection reason" +``` + + + + +未知或重复参数、缺少必填参数以及非法 JSON 都会产生 blocking diagnostic。多条 indexed Attribute、field、Action 与 input 声明保留源码顺序;这个顺序就是列、详情字段、Action 和表单顺序,不需要额外的 order 属性。 + +每个已注册 Step 都必须有一个 group 声明和一个 explanation 声明。explanation 用一句话说明该 Step 做什么。Group 顺序由第一个成员在 **GetSteps** 中的注册位置决定,组内 Step 顺序也使用 **GetSteps**。 + +## 搜索与 Summary + +Indexed Attribute 声明绑定 Dex Attribute key、物理搜索 index key、index type、应用 value type 和 description。Analyzer 会把每个值与 Go 定义和 SDK 计算出的有效 index 配置比较。 + +**v2** 列表每次搜索一个 Flow type。过滤条件包括 Flow ID、execution status、开始和结束时间,以及声明的 indexed Attribute。Dex Web 会把这些控件编译成 visibility query,不接收浏览器提供的原始 query。不同字段使用 AND,同一字段的多个值使用 OR。 + +每一行都会针对逻辑 Flow ID 的 current run 调用 **GetDexSummary**。该 RPC 接受 **dex.None** 并返回 **map[string]any**。Dex Web 最多并发加载八个 Summary,每次调用超时五秒,单条失败只影响对应行。Indexed Attribute 显示在 Summary 字段之前。 + +## Display 与编辑 + +点击一行会把 Flow ID 留在 URL 中,而不是 run ID。**GetDexDisplay**、Attribute 读取、编辑和 Action 都省略 run ID,因此 Dex 会定位 current run。Continue-as-New 后,刷新同一个 URL 会自动指向新 run。 + +在 **v2** 中,选中 Step 会打开分为 **Definition** 与 **Execution** 的面板。Definition 展示 Step 的 **explanation**、WaitFor 与 Execute 分支;过长内容可滚动,以便 Execution 仍可见。Execute 分支超过三条时默认折叠。若该 Step 执行过多次,Execution 提供下拉框选择某次执行,再以与 **v1** Selected event 相同的结构化方式展示所选 WaitFor 或 Execute history 事件的 **Input**、**Output** 和 **Context**。执行 payload 默认 Details,可切换为已水合 payload 的 Raw JSON。十进制数字形式的 AttributeMap / ChannelMap instance 按数值排序,行为与 **v1** 一致。Execute 显示该次 run 决策里的 live next Steps,而不是 FDG 上的全部出口。若 Continue-as-New 把该 Step 留在上一 hop,使用 Load more from previous run。 + +Display 字段按照 directive 行排序。String、integer、double、boolean 和 datetime 字段可以编辑。JSON、object、array 和 AttributeMap 只读。缺失字段必须返回 null。后端会拒绝遗漏、未声明或类型错误的值。 + +只有 current run 仍为 active 时才能编辑。Dex Web 通过 **SetAttributes** 写入 Attribute;如果该 Attribute 已建立索引,还会附带声明的 index 配置。 + +## Action + +Action 是带 Action directive 和一个 condition 的 RPC。第一版只支持 **in** operator。Dex Web 会在调用 RPC 前重新读取 condition Attribute。 + +无输入 Action 接受 **dex.None**,直接显示为按钮。有输入 Action 接受具名 Go struct。来自用户的字段按 directive 顺序渲染控件;来自 Attribute 的字段保持隐藏,并使用当前选中 run 的 Attribute snapshot。 + +浏览器中的 condition 只控制展示。每个 Action RPC 都必须在修改持久化状态或发布 Channel message 前再次检查当前状态。当检查与 effect 必须保持原子性时,应使用 lock。 + +第一版不增加登录或 role 系统。请继续把它部署在 Dex Web 所在的可信网络或 reverse-proxy 边界之后。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/index.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/index.mdx index 43d2e2ad5..7dd423cb4 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/index.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/index.mdx @@ -11,6 +11,7 @@ slug: /production - [应用运维](/production/application-operations) — Worker 健康状况、只读排障、恢复和 Flow 代码版本控制 +- [Dex Web](/production/dex-web) — v1 run 搜索,以及带 Flow rendering 的 v2 列表 - [服务器运维](/production/server-operations) — 部署、配置、存储和 Dex Web 访问控制 - [指标](/production/metrics) — Dex Server 内建指标目录和 Prometheus 查询 - [自定义 Attribute Store](/production/attribute-store) — SQL warehouse 与 MongoDB diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/references/flow-visualization.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/references/flow-visualization.mdx index b335c0186..767a7d280 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/references/flow-visualization.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/references/flow-visualization.mdx @@ -11,6 +11,12 @@ sidebar_label: Flow 可视化 dexcli visualize ./order_flow.go ``` +默认 schema version 是 1.0。Go 源码可以选择 Version 2.0,以启用 Dex Web v2: + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 +``` + 该命令会打开本地的 **Flow Rendering** 页面,并持续提供图形直到按下 Ctrl+C。需要 Flow Definition Graph 文件时使用 **--json**。未指定 **--out** 时,JSON 会写入标准输出;使用 **--out** 可以选择文件前缀: ```bash @@ -31,7 +37,9 @@ dexcli visualize ./order_flow.py --json dexcli dev --flow-rendering-dir ./build ``` -在 Dex Web 中打开 **Flow Rendering**。该页面独立于 Flow 搜索页和运行详情页,用于交互式渲染所选定义图。通过图例可以显示或隐藏控制流、WaitFor、RPC、Attribute、Channel、Stream、SubFlow 及诊断。Flow timeout handler 始终作为 Flow 的一部分显示。Stream 默认隐藏,其他图层默认显示。 +在 Dex Web 的 **/v1/rendering** 打开 **Flow Rendering**。该页面独立于 Flow 搜索页和运行详情页,用于交互式渲染所选定义图。通过图例可以显示或隐藏控制流、WaitFor、RPC、Attribute、Channel、Stream、SubFlow 及诊断。Flow timeout handler 始终作为 Flow 的一部分显示。Stream 默认隐藏,其他图层默认显示。 + +当 Dex 带着 JSON 目录启动时,**/v2** 也会在 run 列表右侧渲染当前 Flow type 的 definition。见 [Dex Web](/production/dex-web)。 Dex 会递归扫描 JSON 文件,并在启动时创建一次快照。文件改变后需要重启 Dex。无效 JSON、不支持的 schema 版本或非目录路径都会使启动失败并返回错误。 @@ -89,6 +97,8 @@ Go 分析需要本地 Go toolchain,输入必须属于能够完成类型检查 首版不支持单文件中的多个 Flow、反射、动态 class、Dex 通配符导入、基于 getattr 的目标、monkeypatch,以及逃逸出处理方法的 movement 集合。TypeScript、Java、Rust 和递归多文件分析也不在首版范围内。 +Version 2.0 第一版只支持 Go。它保留所有 Version 1 字段,并要求 Step group 与 Dex Web v2 contract 和 Flow 位于同一文件。同一行内的具名 directive 参数可以任意排列。多条 directive 行保留源码顺序,并成为 UI 顺序。未知、重复、缺失或非法参数都会产生 blocking diagnostic。Indexed Attribute、Summary、Display 与 Action 规则见 [Dex Web](/production/dex-web)。 + ## Unknown 节点和诊断 不支持的动态控制流不会被静默省略。输出会包含 Unknown 节点和错误诊断。默认 renderer 仍会显示部分图。使用 **--json** 时,命令会写出部分 JSON,并以状态码 1 退出。 @@ -99,6 +109,6 @@ warning 不会使图失效。例如,无法确定 value type 的 Attribute 会 ## JSON 合约 -[Flow Definition Graph v1 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v1.schema.json) 定义了源码位置、父子关系、结构化 WaitFor 和 decision 详情、资源类型、分支条件、边方向和诊断。该 schema 是未来可视化编辑器及其他语言 analyzer 的集成边界。 +[Flow Definition Graph v1 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v1.schema.json) 定义了源码位置、父子关系、结构化 WaitFor 和 decision 详情、资源类型、分支条件、边方向和诊断。[Flow Definition Graph v2 schema](https://github.com/superdurable/dex/blob/main/cli/schema/flow-definition-graph.v2.schema.json) 在此基础上增加有序 group 与 Dex Web v2 metadata。 运行中 Flow 的操作命令请参阅 [Dex CLI](/references/cli)。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/customer-refund.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/customer-refund.mdx new file mode 100644 index 000000000..7b64963fa --- /dev/null +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/customer-refund.mdx @@ -0,0 +1,48 @@ +--- +title: 客户退款 +--- + +import deterministicFlow from '@site/src/data/flow-definitions/customer-refund.json'; +import agenticFlow from '@site/src/data/flow-definitions/customer-refund-agentic.json'; + +# 客户退款 + +Go customer-refund 产品包含两个自包含的 Flow。两者都会发布 Flow Definition Graph 2.0 元数据,供 Dex Web v2 使用。 + +## 确定性策略 + +**CustomerRefundFlow** 固定执行七个已注册 Step。它确认订单存在、应用 30 天规则、在调用支付 provider 前记录幂等 key,并保留 provider 的 declined 与 unknown 结果。 + + + +Summary 展示 charge reference、请求金额和 recommendation。Display 额外展示证据、计费结果和可编辑的 operator note。它不声明任何 Action,用于验证空 Action 列表也是有效 contract。 + +## Agentic 审核 + +**AgenticCustomerRefundFlow** 通过持久化 decision loop 收集证据。Guardrail 会绑定自动 effect,或打开带 key 的 manager gate。Flow 会先记录 intent,再执行幂等的 billing、subscription 和 customer-message effect。 + + + +批准 Action 没有输入,因此直接显示为按钮。拒绝 Action 要求填写原因。Dex Web 会使用详情页打开时捕获的 Attribute snapshot 注入隐藏的 gate key。RPC 在发布 verdict 前会把该 key 与当前 gate 比较。 + +两个 Action 也会读取当前 case status。Flow Definition Graph 2.0 中的可见性条件只控制按钮是否出现,不构成授权。 + +## 运行示例 + +Go 示例标签链接到可运行文件。 + + + + +```go +// dex:indexed-attribute attribute-key:case-status index-key:case-status index-type:keyword value-type:string description:"Current case status" +var deterministicCaseStatus = dex.DefineAttribute[string]( + "case-status", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) +``` + + + + +协议与操作流程见 [Dex Web](/production/dex-web)。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/index.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/index.mdx index 3e9b4d9f9..26cf4b21c 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/index.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/index.mdx @@ -19,6 +19,7 @@ slug: /use-cases - [Deal DSL](/use-cases/deal-dsl) — 适用于任意商品的持久化交易解释器 - [Microservice orchestration](/use-cases/microservice-orchestration) — 并发 API + Channel/Timer - [职位发布](/use-cases/job-post) — 带 lock 的更新与两个独立 FIFO 招聘网站 consumer +- [客户退款](/use-cases/customer-refund) — 确定性与 agentic 退款流程 ## 相关示例 diff --git a/docs/redirects.json b/docs/redirects.json index d4ac0525e..62e1d6b4c 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -18,5 +18,13 @@ { "from": "/zh-Hans/use-cases/ai-agent-email", "to": "/zh-Hans/use-cases/ai-agent" + }, + { + "from": "/production/supervision", + "to": "/production/dex-web" + }, + { + "from": "/zh-Hans/production/supervision", + "to": "/zh-Hans/production/dex-web" } ] diff --git a/docs/scripts/generate-flow-definitions.sh b/docs/scripts/generate-flow-definitions.sh index c843a1a48..49e490b65 100755 --- a/docs/scripts/generate-flow-definitions.sh +++ b/docs/scripts/generate-flow-definitions.sh @@ -4,18 +4,23 @@ set -euo pipefail script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repository_root="$(cd "${script_directory}/../.." && pwd)" -python_arguments=() -if [[ -n "${DEX_FLOW_PYTHON:-}" ]]; then - python_arguments=(--python "${DEX_FLOW_PYTHON}") -fi - cd "${repository_root}" GOWORK=off go -C cli build -trimpath -o dexcli ./cmd/dexcli generate_flow_definition() { local source_path="$1" local output_path="$2" - ./cli/dexcli visualize "${source_path}" "${python_arguments[@]}" --json --out "docs/src/data/flow-definitions/${output_path}" + if [[ -n "${DEX_FLOW_PYTHON:-}" ]]; then + ./cli/dexcli visualize "${source_path}" --python "${DEX_FLOW_PYTHON}" --json --out "docs/src/data/flow-definitions/${output_path}" + else + ./cli/dexcli visualize "${source_path}" --json --out "docs/src/data/flow-definitions/${output_path}" + fi +} + +generate_flow_definition_v2() { + local source_path="$1" + local output_path="$2" + ./cli/dexcli visualize "${source_path}" --schema-version 2.0 --json --out "docs/src/data/flow-definitions/${output_path}" } generate_flow_definition examples/python/dex_examples/products/ai-agent/ai_agent_flow.py ai-agent @@ -26,6 +31,8 @@ generate_flow_definition examples/python/dex_examples/products/microservices/orc generate_flow_definition examples/python/dex_examples/products/money-transfer/money_transfer_flow.py money-transfer generate_flow_definition examples/python/dex_examples/products/subscription/subscription_flow.py subscription generate_flow_definition examples/python/dex_examples/products/signup/user_signup_flow.py user-onboarding-process +generate_flow_definition_v2 examples/go/products/customer-refund/deterministic/workflow.go customer-refund +generate_flow_definition_v2 examples/go/products/customer-refund/agentic/workflow.go customer-refund-agentic generate_flow_definition examples/python/dex_examples/products/order-processing/order_processing_flow.py intro/order-processing diff --git a/docs/sidebars.ts b/docs/sidebars.ts index cbd1fe494..2de9c3090 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -145,6 +145,7 @@ const sidebars: SidebarsConfig = { 'use-cases/deal-dsl', 'use-cases/microservice-orchestration', 'use-cases/job-post', + 'use-cases/customer-refund', ], }, { @@ -153,6 +154,7 @@ const sidebars: SidebarsConfig = { link: {type: 'doc', id: 'production/index'}, items: [ 'production/application-operations', + 'production/dex-web', 'production/server-operations', 'production/metrics', 'production/attribute-store', diff --git a/docs/src/components/DocsFlowDefinitionGraph.tsx b/docs/src/components/DocsFlowDefinitionGraph.tsx index 084aa780d..f89f12e42 100644 --- a/docs/src/components/DocsFlowDefinitionGraph.tsx +++ b/docs/src/components/DocsFlowDefinitionGraph.tsx @@ -10,6 +10,7 @@ import {useEffect, useState} from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { FlowDefinitionGraphView, + ProcessCanvasView, type FlowDefinitionGraph, } from '@superdurable/flow-definition-renderer'; @@ -49,7 +50,9 @@ export default function DocsFlowDefinitionGraph({graph}: DocsFlowDefinitionGraph : (isChinese ? '展开 diagram' : 'Expand diagram')} - + {graph.schemaVersion === '2.0' + ? + : } ); } diff --git a/docs/src/data/flow-definitions/customer-refund-agentic.json b/docs/src/data/flow-definitions/customer-refund-agentic.json new file mode 100644 index 000000000..4a61b6ddc --- /dev/null +++ b/docs/src/data/flow-definitions/customer-refund-agentic.json @@ -0,0 +1,3407 @@ +{ + "schemaVersion": "2.0", + "valid": true, + "source": { + "language": "go", + "path": "examples/go/products/customer-refund/agentic/workflow.go" + }, + "flow": { + "name": "AgenticCustomerRefundFlow", + "startStepId": "step:agenticReceiveRequestStep", + "span": { + "startLine": 146, + "startColumn": 1, + "endLine": 172, + "endColumn": 2 + } + }, + "nodes": [ + { + "id": "decision-dispatch:step:agenticAuditIntentStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticAuditIntentStep", + "phase": "execute", + "span": { + "startLine": 767, + "startColumn": 1, + "endLine": 779, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticDecisionStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticDecisionStep", + "phase": "execute", + "span": { + "startLine": 407, + "startColumn": 1, + "endLine": 477, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticGuardrailStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticGuardrailStep", + "phase": "execute", + "span": { + "startLine": 636, + "startColumn": 1, + "endLine": 683, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticIssueRefundStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticIssueRefundStep", + "phase": "execute", + "span": { + "startLine": 800, + "startColumn": 1, + "endLine": 823, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticOfferAccountCreditStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticOfferAccountCreditStep", + "phase": "execute", + "span": { + "startLine": 835, + "startColumn": 1, + "endLine": 854, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticReCheckStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticReCheckStep", + "phase": "execute", + "span": { + "startLine": 694, + "startColumn": 1, + "endLine": 715, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticReceiveRequestStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticReceiveRequestStep", + "phase": "execute", + "span": { + "startLine": 373, + "startColumn": 1, + "endLine": 396, + "endColumn": 2 + } + }, + { + "id": "decision-dispatch:step:agenticVerifyBillingStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:agenticVerifyBillingStep", + "phase": "execute", + "span": { + "startLine": 866, + "startColumn": 1, + "endLine": 891, + "endColumn": 2 + } + }, + { + "id": "decision:rpc:ApproveRefund:334:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:ApproveRefund", + "phase": "rpc", + "span": { + "startLine": 334, + "startColumn": 9, + "endLine": 334, + "endColumn": 35 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:GetDexDisplay:304:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:GetDexDisplay", + "phase": "rpc", + "span": { + "startLine": 304, + "startColumn": 9, + "endLine": 318, + "endColumn": 4 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:GetDexSummary:253:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:GetDexSummary", + "phase": "rpc", + "span": { + "startLine": 253, + "startColumn": 9, + "endLine": 258, + "endColumn": 4 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:RejectRefund:361:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:RejectRefund", + "phase": "rpc", + "span": { + "startLine": 361, + "startColumn": 9, + "endLine": 361, + "endColumn": 35 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:step:agenticApplySubscriptionStep:917:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticApplySubscriptionStep", + "phase": "execute", + "span": { + "startLine": 917, + "startColumn": 9, + "endLine": 917, + "endColumn": 63 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticAuditIntentStep:776:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticAuditIntentStep", + "condition": "action == actionOfferAccountCredit", + "phase": "execute", + "span": { + "startLine": 776, + "startColumn": 10, + "endLine": 776, + "endColumn": 63 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticAuditIntentStep:778:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticAuditIntentStep", + "condition": "!(action == actionOfferAccountCredit)", + "phase": "execute", + "span": { + "startLine": 778, + "startColumn": 9, + "endLine": 778, + "endColumn": 55 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticBillingFailedStep:1021:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticBillingFailedStep", + "phase": "execute", + "span": { + "startLine": 1021, + "startColumn": 9, + "endLine": 1021, + "endColumn": 63 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticCheckIdentityStep:496:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticCheckIdentityStep", + "phase": "execute", + "span": { + "startLine": 496, + "startColumn": 9, + "endLine": 496, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticCheckIncidentsStep:624:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticCheckIncidentsStep", + "phase": "execute", + "span": { + "startLine": 624, + "startColumn": 9, + "endLine": 624, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticCloseCaseStep:1081:9", + "kind": "decision", + "name": "gracefulComplete", + "parentId": "step:agenticCloseCaseStep", + "phase": "execute", + "span": { + "startLine": 1081, + "startColumn": 9, + "endLine": 1081, + "endColumn": 60 + }, + "decision": { + "type": "gracefulComplete" + } + }, + { + "id": "decision:step:agenticDecisionStep:416:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "rounds \u003e= decisionRoundsBudget", + "phase": "execute", + "span": { + "startLine": 416, + "startColumn": 10, + "endLine": 416, + "endColumn": 59 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:429:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !hasIdentity", + "phase": "execute", + "span": { + "startLine": 429, + "startColumn": 10, + "endLine": 429, + "endColumn": 58 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:436:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !hasSubscription", + "phase": "execute", + "span": { + "startLine": 436, + "startColumn": 10, + "endLine": 436, + "endColumn": 60 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:443:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !hasPayment", + "phase": "execute", + "span": { + "startLine": 443, + "startColumn": 10, + "endLine": 443, + "endColumn": 55 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:450:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !hasUsage", + "phase": "execute", + "span": { + "startLine": 450, + "startColumn": 10, + "endLine": 450, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:457:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !hasHistory", + "phase": "execute", + "span": { + "startLine": 457, + "startColumn": 10, + "endLine": 457, + "endColumn": 62 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:464:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !(!hasHistory) and !hasIncidents", + "phase": "execute", + "span": { + "startLine": 464, + "startColumn": 10, + "endLine": 464, + "endColumn": 59 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:476:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDecisionStep", + "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !(!hasHistory) and !(!hasIncidents)", + "phase": "execute", + "span": { + "startLine": 476, + "startColumn": 9, + "endLine": 476, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticEmailFailedStep:1065:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticEmailFailedStep", + "phase": "execute", + "span": { + "startLine": 1065, + "startColumn": 9, + "endLine": 1065, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGetPaymentStep:543:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGetPaymentStep", + "phase": "execute", + "span": { + "startLine": 543, + "startColumn": 9, + "endLine": 543, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGetSubscriptionStep:516:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGetSubscriptionStep", + "phase": "execute", + "span": { + "startLine": 516, + "startColumn": 9, + "endLine": 516, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGetSupportHistoryStep:597:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGetSupportHistoryStep", + "phase": "execute", + "span": { + "startLine": 597, + "startColumn": 9, + "endLine": 597, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGetUsageStep:567:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGetUsageStep", + "phase": "execute", + "span": { + "startLine": 567, + "startColumn": 9, + "endLine": 567, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGuardrailStep:677:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGuardrailStep", + "condition": "verdict != \"auto\"", + "phase": "execute", + "span": { + "startLine": 677, + "startColumn": 10, + "endLine": 677, + "endColumn": 65 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticGuardrailStep:682:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticGuardrailStep", + "condition": "!(verdict != \"auto\")", + "phase": "execute", + "span": { + "startLine": 682, + "startColumn": 9, + "endLine": 682, + "endColumn": 55 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticIssueRefundStep:817:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticIssueRefundStep", + "condition": "outcome == refundmodel.BillingConfirmed", + "phase": "execute", + "span": { + "startLine": 817, + "startColumn": 10, + "endLine": 817, + "endColumn": 62 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticIssueRefundStep:819:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticIssueRefundStep", + "condition": "outcome == refundmodel.BillingUnknown", + "phase": "execute", + "span": { + "startLine": 819, + "startColumn": 10, + "endLine": 819, + "endColumn": 58 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticIssueRefundStep:821:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticIssueRefundStep", + "condition": "default", + "phase": "execute", + "span": { + "startLine": 821, + "startColumn": 10, + "endLine": 821, + "endColumn": 58 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticNonConvergenceStep:983:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticNonConvergenceStep", + "phase": "execute", + "span": { + "startLine": 983, + "startColumn": 9, + "endLine": 983, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticNotARefundStep:999:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticNotARefundStep", + "phase": "execute", + "span": { + "startLine": 999, + "startColumn": 9, + "endLine": 999, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticOfferAccountCreditStep:848:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticOfferAccountCreditStep", + "condition": "outcome != refundmodel.BillingConfirmed", + "phase": "execute", + "span": { + "startLine": 848, + "startColumn": 10, + "endLine": 848, + "endColumn": 58 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticOfferAccountCreditStep:853:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticOfferAccountCreditStep", + "condition": "!(outcome != refundmodel.BillingConfirmed)", + "phase": "execute", + "span": { + "startLine": 853, + "startColumn": 9, + "endLine": 853, + "endColumn": 61 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticReCheckStep:706:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticReCheckStep", + "condition": "verdict != \"approve\"", + "phase": "execute", + "span": { + "startLine": 706, + "startColumn": 10, + "endLine": 706, + "endColumn": 64 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticReCheckStep:714:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticReCheckStep", + "condition": "!(verdict != \"approve\")", + "phase": "execute", + "span": { + "startLine": 714, + "startColumn": 9, + "endLine": 714, + "endColumn": 55 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticReceiveRequestStep:390:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticReceiveRequestStep", + "condition": "refundCase.CaseID == \"\"", + "phase": "execute", + "span": { + "startLine": 390, + "startColumn": 10, + "endLine": 390, + "endColumn": 55 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticReceiveRequestStep:395:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticReceiveRequestStep", + "condition": "!(refundCase.CaseID == \"\")", + "phase": "execute", + "span": { + "startLine": 395, + "startColumn": 9, + "endLine": 395, + "endColumn": 52 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticRequestHumanApprovalStep:755:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticRequestHumanApprovalStep", + "phase": "execute", + "span": { + "startLine": 755, + "startColumn": 9, + "endLine": 755, + "endColumn": 51 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticSendCustomerMessageStep:964:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticSendCustomerMessageStep", + "phase": "execute", + "span": { + "startLine": 964, + "startColumn": 9, + "endLine": 964, + "endColumn": 53 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticSubscriptionFailedStep:1043:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticSubscriptionFailedStep", + "phase": "execute", + "span": { + "startLine": 1043, + "startColumn": 9, + "endLine": 1043, + "endColumn": 63 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticVerifyBillingStep:882:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticVerifyBillingStep", + "condition": "outcome == refundmodel.BillingConfirmed", + "phase": "execute", + "span": { + "startLine": 882, + "startColumn": 10, + "endLine": 882, + "endColumn": 62 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticVerifyBillingStep:888:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticVerifyBillingStep", + "condition": "!(outcome == refundmodel.BillingConfirmed) and outcome == refundmodel.BillingUnknown", + "phase": "execute", + "span": { + "startLine": 888, + "startColumn": 10, + "endLine": 888, + "endColumn": 54 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticVerifyBillingStep:890:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticVerifyBillingStep", + "condition": "!(outcome == refundmodel.BillingConfirmed) and !(outcome == refundmodel.BillingUnknown)", + "phase": "execute", + "span": { + "startLine": 890, + "startColumn": 9, + "endLine": 890, + "endColumn": 57 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "resource:attribute:agenticBillingKey", + "kind": "attribute", + "name": "billing-key", + "span": { + "startLine": 107, + "startColumn": 5, + "endLine": 107, + "endColumn": 67 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticBillingOutcome", + "kind": "attribute", + "name": "billing-outcome", + "span": { + "startLine": 109, + "startColumn": 5, + "endLine": 109, + "endColumn": 75 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticBoundAction", + "kind": "attribute", + "name": "bound-action", + "span": { + "startLine": 97, + "startColumn": 5, + "endLine": 97, + "endColumn": 69 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticCancellationPending", + "kind": "attribute", + "name": "ev-history-cancel-requested", + "span": { + "startLine": 79, + "startColumn": 5, + "endLine": 79, + "endColumn": 90 + }, + "resource": { + "valueType": "bool" + } + }, + { + "id": "resource:attribute:agenticCaseStatus", + "kind": "attribute", + "name": "case-status", + "span": { + "startLine": 118, + "startColumn": 5, + "endLine": 121, + "endColumn": 2 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticChargeReference", + "kind": "attribute", + "name": "in-charge-ref", + "span": { + "startLine": 57, + "startColumn": 5, + "endLine": 57, + "endColumn": 74 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticDecisionRounds", + "kind": "attribute", + "name": "decision-rounds", + "span": { + "startLine": 91, + "startColumn": 5, + "endLine": 91, + "endColumn": 74 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticEmailSent", + "kind": "attribute", + "name": "email-sent", + "span": { + "startLine": 113, + "startColumn": 5, + "endLine": 113, + "endColumn": 65 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticEvidenceState", + "kind": "attribute", + "name": "evidence-state", + "span": { + "startLine": 85, + "startColumn": 5, + "endLine": 85, + "endColumn": 73 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticGateEntries", + "kind": "attribute", + "name": "gate-entries", + "span": { + "startLine": 105, + "startColumn": 5, + "endLine": 105, + "endColumn": 68 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticGateRequestKey", + "kind": "attribute", + "name": "gate-request-key", + "span": { + "startLine": 103, + "startColumn": 5, + "endLine": 103, + "endColumn": 76 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticGuardrailRule", + "kind": "attribute", + "name": "guardrail-rule", + "span": { + "startLine": 95, + "startColumn": 5, + "endLine": 95, + "endColumn": 73 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticGuardrailVerdict", + "kind": "attribute", + "name": "guardrail-verdict", + "span": { + "startLine": 93, + "startColumn": 5, + "endLine": 93, + "endColumn": 79 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticHistoryStatus", + "kind": "attribute", + "name": "ev-history-status", + "span": { + "startLine": 73, + "startColumn": 5, + "endLine": 73, + "endColumn": 76 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticIdentityStatus", + "kind": "attribute", + "name": "ev-identity-status", + "span": { + "startLine": 61, + "startColumn": 5, + "endLine": 61, + "endColumn": 78 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticIncidentDays", + "kind": "attribute", + "name": "ev-incident-days", + "span": { + "startLine": 83, + "startColumn": 5, + "endLine": 83, + "endColumn": 73 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticIncidentStatus", + "kind": "attribute", + "name": "ev-incident-status", + "span": { + "startLine": 81, + "startColumn": 5, + "endLine": 81, + "endColumn": 78 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticInputEmail", + "kind": "attribute", + "name": "in-email", + "span": { + "startLine": 55, + "startColumn": 5, + "endLine": 55, + "endColumn": 64 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticManagerRejectionReason", + "kind": "attribute", + "name": "manager-rejection-reason", + "span": { + "startLine": 101, + "startColumn": 5, + "endLine": 101, + "endColumn": 92 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticManagerVerdict", + "kind": "attribute", + "name": "manager-verdict", + "span": { + "startLine": 99, + "startColumn": 5, + "endLine": 99, + "endColumn": 75 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticOperatorNote", + "kind": "attribute", + "name": "operator-note", + "span": { + "startLine": 115, + "startColumn": 5, + "endLine": 115, + "endColumn": 71 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticPaymentAgeDays", + "kind": "attribute", + "name": "ev-payment-age-days", + "span": { + "startLine": 67, + "startColumn": 5, + "endLine": 67, + "endColumn": 78 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticPaymentAmount", + "kind": "attribute", + "name": "ev-payment-amount", + "span": { + "startLine": 59, + "startColumn": 5, + "endLine": 59, + "endColumn": 76 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticPaymentStatus", + "kind": "attribute", + "name": "ev-payment-status", + "span": { + "startLine": 65, + "startColumn": 5, + "endLine": 65, + "endColumn": 76 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticPriorRefunds", + "kind": "attribute", + "name": "ev-history-prior-refunds", + "span": { + "startLine": 77, + "startColumn": 5, + "endLine": 77, + "endColumn": 81 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticRationale", + "kind": "attribute", + "name": "recommendation-rationale", + "span": { + "startLine": 89, + "startColumn": 5, + "endLine": 89, + "endColumn": 79 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticRecommendedAction", + "kind": "attribute", + "name": "recommended-action", + "span": { + "startLine": 87, + "startColumn": 5, + "endLine": 87, + "endColumn": 81 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticSubscriptionApplied", + "kind": "attribute", + "name": "subscription-applied", + "span": { + "startLine": 111, + "startColumn": 5, + "endLine": 111, + "endColumn": 85 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticSubscriptionStatus", + "kind": "attribute", + "name": "ev-subscription-status", + "span": { + "startLine": 63, + "startColumn": 5, + "endLine": 63, + "endColumn": 86 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticTenureYears", + "kind": "attribute", + "name": "ev-history-tenure-years", + "span": { + "startLine": 75, + "startColumn": 5, + "endLine": 75, + "endColumn": 79 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticUsagePercent", + "kind": "attribute", + "name": "ev-usage-pct-of-allowance", + "span": { + "startLine": 71, + "startColumn": 5, + "endLine": 71, + "endColumn": 82 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:agenticUsageStatus", + "kind": "attribute", + "name": "ev-usage-status", + "span": { + "startLine": 69, + "startColumn": 5, + "endLine": 69, + "endColumn": 72 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:channel:agenticManagerApproval", + "kind": "channel", + "name": "manager-approval", + "span": { + "startLine": 123, + "startColumn": 5, + "endLine": 123, + "endColumn": 78 + }, + "resource": { + "valueType": "string", + "map": true + } + }, + { + "id": "rpc:ApproveRefund", + "kind": "rpc", + "name": "ApproveRefund", + "span": { + "startLine": 323, + "startColumn": 1, + "endLine": 335, + "endColumn": 2 + } + }, + { + "id": "rpc:GetDexDisplay", + "kind": "rpc", + "name": "GetDexDisplay", + "span": { + "startLine": 274, + "startColumn": 1, + "endLine": 319, + "endColumn": 2 + } + }, + { + "id": "rpc:GetDexSummary", + "kind": "rpc", + "name": "GetDexSummary", + "span": { + "startLine": 233, + "startColumn": 1, + "endLine": 259, + "endColumn": 2 + } + }, + { + "id": "rpc:RejectRefund", + "kind": "rpc", + "name": "RejectRefund", + "span": { + "startLine": 341, + "startColumn": 1, + "endLine": 362, + "endColumn": 2 + } + }, + { + "id": "step:agenticApplySubscriptionStep", + "kind": "step", + "name": "ApplySubscriptionStep", + "phase": "execute", + "span": { + "startLine": 163, + "startColumn": 3, + "endLine": 163, + "endColumn": 70 + } + }, + { + "id": "step:agenticAuditIntentStep", + "kind": "step", + "name": "AuditIntentStep", + "phase": "execute", + "span": { + "startLine": 159, + "startColumn": 3, + "endLine": 159, + "endColumn": 43 + } + }, + { + "id": "step:agenticBillingFailedStep", + "kind": "step", + "name": "BillingFailedStep", + "phase": "execute", + "span": { + "startLine": 167, + "startColumn": 3, + "endLine": 167, + "endColumn": 45 + } + }, + { + "id": "step:agenticCheckIdentityStep", + "kind": "step", + "name": "CheckIdentityStep", + "phase": "execute", + "span": { + "startLine": 150, + "startColumn": 3, + "endLine": 150, + "endColumn": 66 + } + }, + { + "id": "step:agenticCheckIncidentsStep", + "kind": "step", + "name": "CheckIncidentsStep", + "phase": "execute", + "span": { + "startLine": 155, + "startColumn": 3, + "endLine": 155, + "endColumn": 67 + } + }, + { + "id": "step:agenticCloseCaseStep", + "kind": "step", + "name": "CloseCaseStep", + "phase": "execute", + "span": { + "startLine": 170, + "startColumn": 3, + "endLine": 170, + "endColumn": 41 + } + }, + { + "id": "step:agenticDecisionStep", + "kind": "step", + "name": "AgentDecisionStep", + "phase": "execute", + "span": { + "startLine": 149, + "startColumn": 3, + "endLine": 149, + "endColumn": 40 + } + }, + { + "id": "step:agenticEmailFailedStep", + "kind": "step", + "name": "EmailFailedStep", + "phase": "execute", + "span": { + "startLine": 169, + "startColumn": 3, + "endLine": 169, + "endColumn": 43 + } + }, + { + "id": "step:agenticGetPaymentStep", + "kind": "step", + "name": "GetPaymentStep", + "phase": "execute", + "span": { + "startLine": 152, + "startColumn": 3, + "endLine": 152, + "endColumn": 63 + } + }, + { + "id": "step:agenticGetSubscriptionStep", + "kind": "step", + "name": "GetSubscriptionStep", + "phase": "execute", + "span": { + "startLine": 151, + "startColumn": 3, + "endLine": 151, + "endColumn": 68 + } + }, + { + "id": "step:agenticGetSupportHistoryStep", + "kind": "step", + "name": "GetSupportHistoryStep", + "phase": "execute", + "span": { + "startLine": 154, + "startColumn": 3, + "endLine": 154, + "endColumn": 70 + } + }, + { + "id": "step:agenticGetUsageStep", + "kind": "step", + "name": "GetUsageStep", + "phase": "execute", + "span": { + "startLine": 153, + "startColumn": 3, + "endLine": 153, + "endColumn": 61 + } + }, + { + "id": "step:agenticGuardrailStep", + "kind": "step", + "name": "GuardrailStep", + "phase": "execute", + "span": { + "startLine": 156, + "startColumn": 3, + "endLine": 156, + "endColumn": 41 + } + }, + { + "id": "step:agenticIssueRefundStep", + "kind": "step", + "name": "IssueRefundStep", + "phase": "execute", + "span": { + "startLine": 160, + "startColumn": 3, + "endLine": 160, + "endColumn": 64 + } + }, + { + "id": "step:agenticNonConvergenceStep", + "kind": "step", + "name": "NonConvergenceStep", + "phase": "execute", + "span": { + "startLine": 165, + "startColumn": 3, + "endLine": 165, + "endColumn": 46 + } + }, + { + "id": "step:agenticNotARefundStep", + "kind": "step", + "name": "NotARefundStep", + "phase": "execute", + "span": { + "startLine": 166, + "startColumn": 3, + "endLine": 166, + "endColumn": 42 + } + }, + { + "id": "step:agenticOfferAccountCreditStep", + "kind": "step", + "name": "OfferAccountCreditStep", + "phase": "execute", + "span": { + "startLine": 161, + "startColumn": 3, + "endLine": 161, + "endColumn": 71 + } + }, + { + "id": "step:agenticReCheckStep", + "kind": "step", + "name": "ReCheckStep", + "phase": "execute", + "span": { + "startLine": 157, + "startColumn": 3, + "endLine": 157, + "endColumn": 39 + } + }, + { + "id": "step:agenticReceiveRequestStep", + "kind": "step", + "name": "ReceiveRequestStep", + "phase": "execute", + "start": true, + "span": { + "startLine": 148, + "startColumn": 3, + "endLine": 148, + "endColumn": 51 + } + }, + { + "id": "step:agenticRequestHumanApprovalStep", + "kind": "step", + "name": "RequestHumanApprovalStep", + "phase": "wait_for+execute", + "span": { + "startLine": 158, + "startColumn": 3, + "endLine": 158, + "endColumn": 52 + } + }, + { + "id": "step:agenticSendCustomerMessageStep", + "kind": "step", + "name": "SendCustomerMessageStep", + "phase": "execute", + "span": { + "startLine": 164, + "startColumn": 3, + "endLine": 164, + "endColumn": 72 + } + }, + { + "id": "step:agenticSubscriptionFailedStep", + "kind": "step", + "name": "SubscriptionFailedStep", + "phase": "execute", + "span": { + "startLine": 168, + "startColumn": 3, + "endLine": 168, + "endColumn": 50 + } + }, + { + "id": "step:agenticVerifyBillingStep", + "kind": "step", + "name": "VerifyBillingStep", + "phase": "execute", + "span": { + "startLine": 162, + "startColumn": 3, + "endLine": 162, + "endColumn": 66 + } + }, + { + "id": "wait:agenticRequestHumanApprovalStep:734:9", + "kind": "wait", + "name": "until", + "parentId": "step:agenticRequestHumanApprovalStep", + "phase": "wait_for", + "span": { + "startLine": 734, + "startColumn": 9, + "endLine": 734, + "endColumn": 65 + }, + "wait": { + "type": "until", + "conditions": [ + { + "kind": "channel", + "label": "manager-approval[gateRequestKey].for 1", + "resourceId": "resource:channel:agenticManagerApproval", + "expression": "gateRequestKey", + "span": { + "startLine": 734, + "startColumn": 19, + "endLine": 734, + "endColumn": 64 + } + } + ] + } + } + ], + "edges": [ + { + "id": "edge:0001", + "kind": "transition", + "from": "decision:step:agenticApplySubscriptionStep:917:9", + "to": "step:agenticSendCustomerMessageStep", + "label": "GoTo", + "span": { + "startLine": 917, + "startColumn": 9, + "endLine": 917, + "endColumn": 63 + } + }, + { + "id": "edge:0002", + "kind": "transition", + "from": "decision:step:agenticAuditIntentStep:776:10", + "to": "step:agenticOfferAccountCreditStep", + "label": "GoTo", + "span": { + "startLine": 776, + "startColumn": 10, + "endLine": 776, + "endColumn": 63 + } + }, + { + "id": "edge:0003", + "kind": "transition", + "from": "decision:step:agenticAuditIntentStep:778:9", + "to": "step:agenticIssueRefundStep", + "label": "GoTo", + "span": { + "startLine": 778, + "startColumn": 9, + "endLine": 778, + "endColumn": 55 + } + }, + { + "id": "edge:0004", + "kind": "transition", + "from": "decision:step:agenticBillingFailedStep:1021:9", + "to": "step:agenticSendCustomerMessageStep", + "label": "GoTo", + "span": { + "startLine": 1021, + "startColumn": 9, + "endLine": 1021, + "endColumn": 63 + } + }, + { + "id": "edge:0005", + "kind": "transition", + "from": "decision:step:agenticCheckIdentityStep:496:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 496, + "startColumn": 9, + "endLine": 496, + "endColumn": 52 + } + }, + { + "id": "edge:0006", + "kind": "transition", + "from": "decision:step:agenticCheckIncidentsStep:624:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 624, + "startColumn": 9, + "endLine": 624, + "endColumn": 52 + } + }, + { + "id": "edge:0007", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:416:10", + "to": "step:agenticNonConvergenceStep", + "label": "GoTo", + "span": { + "startLine": 416, + "startColumn": 10, + "endLine": 416, + "endColumn": 59 + } + }, + { + "id": "edge:0008", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:429:10", + "to": "step:agenticCheckIdentityStep", + "label": "GoTo", + "span": { + "startLine": 429, + "startColumn": 10, + "endLine": 429, + "endColumn": 58 + } + }, + { + "id": "edge:0009", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:436:10", + "to": "step:agenticGetSubscriptionStep", + "label": "GoTo", + "span": { + "startLine": 436, + "startColumn": 10, + "endLine": 436, + "endColumn": 60 + } + }, + { + "id": "edge:0010", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:443:10", + "to": "step:agenticGetPaymentStep", + "label": "GoTo", + "span": { + "startLine": 443, + "startColumn": 10, + "endLine": 443, + "endColumn": 55 + } + }, + { + "id": "edge:0011", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:450:10", + "to": "step:agenticGetUsageStep", + "label": "GoTo", + "span": { + "startLine": 450, + "startColumn": 10, + "endLine": 450, + "endColumn": 53 + } + }, + { + "id": "edge:0012", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:457:10", + "to": "step:agenticGetSupportHistoryStep", + "label": "GoTo", + "span": { + "startLine": 457, + "startColumn": 10, + "endLine": 457, + "endColumn": 62 + } + }, + { + "id": "edge:0013", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:464:10", + "to": "step:agenticCheckIncidentsStep", + "label": "GoTo", + "span": { + "startLine": 464, + "startColumn": 10, + "endLine": 464, + "endColumn": 59 + } + }, + { + "id": "edge:0014", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:476:9", + "to": "step:agenticGuardrailStep", + "label": "GoTo", + "span": { + "startLine": 476, + "startColumn": 9, + "endLine": 476, + "endColumn": 53 + } + }, + { + "id": "edge:0015", + "kind": "transition", + "from": "decision:step:agenticEmailFailedStep:1065:9", + "to": "step:agenticCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 1065, + "startColumn": 9, + "endLine": 1065, + "endColumn": 53 + } + }, + { + "id": "edge:0016", + "kind": "transition", + "from": "decision:step:agenticGetPaymentStep:543:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 543, + "startColumn": 9, + "endLine": 543, + "endColumn": 52 + } + }, + { + "id": "edge:0017", + "kind": "transition", + "from": "decision:step:agenticGetSubscriptionStep:516:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 516, + "startColumn": 9, + "endLine": 516, + "endColumn": 52 + } + }, + { + "id": "edge:0018", + "kind": "transition", + "from": "decision:step:agenticGetSupportHistoryStep:597:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 597, + "startColumn": 9, + "endLine": 597, + "endColumn": 52 + } + }, + { + "id": "edge:0019", + "kind": "transition", + "from": "decision:step:agenticGetUsageStep:567:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 567, + "startColumn": 9, + "endLine": 567, + "endColumn": 52 + } + }, + { + "id": "edge:0020", + "kind": "transition", + "from": "decision:step:agenticGuardrailStep:677:10", + "to": "step:agenticRequestHumanApprovalStep", + "label": "GoTo", + "span": { + "startLine": 677, + "startColumn": 10, + "endLine": 677, + "endColumn": 65 + } + }, + { + "id": "edge:0021", + "kind": "transition", + "from": "decision:step:agenticGuardrailStep:682:9", + "to": "step:agenticAuditIntentStep", + "label": "GoTo", + "span": { + "startLine": 682, + "startColumn": 9, + "endLine": 682, + "endColumn": 55 + } + }, + { + "id": "edge:0022", + "kind": "transition", + "from": "decision:step:agenticIssueRefundStep:817:10", + "to": "step:agenticApplySubscriptionStep", + "label": "GoTo", + "span": { + "startLine": 817, + "startColumn": 10, + "endLine": 817, + "endColumn": 62 + } + }, + { + "id": "edge:0023", + "kind": "transition", + "from": "decision:step:agenticIssueRefundStep:819:10", + "to": "step:agenticVerifyBillingStep", + "label": "GoTo", + "span": { + "startLine": 819, + "startColumn": 10, + "endLine": 819, + "endColumn": 58 + } + }, + { + "id": "edge:0024", + "kind": "transition", + "from": "decision:step:agenticIssueRefundStep:821:10", + "to": "step:agenticBillingFailedStep", + "label": "GoTo", + "span": { + "startLine": 821, + "startColumn": 10, + "endLine": 821, + "endColumn": 58 + } + }, + { + "id": "edge:0025", + "kind": "transition", + "from": "decision:step:agenticNonConvergenceStep:983:9", + "to": "step:agenticCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 983, + "startColumn": 9, + "endLine": 983, + "endColumn": 53 + } + }, + { + "id": "edge:0026", + "kind": "transition", + "from": "decision:step:agenticNotARefundStep:999:9", + "to": "step:agenticCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 999, + "startColumn": 9, + "endLine": 999, + "endColumn": 53 + } + }, + { + "id": "edge:0027", + "kind": "transition", + "from": "decision:step:agenticOfferAccountCreditStep:848:10", + "to": "step:agenticBillingFailedStep", + "label": "GoTo", + "span": { + "startLine": 848, + "startColumn": 10, + "endLine": 848, + "endColumn": 58 + } + }, + { + "id": "edge:0028", + "kind": "transition", + "from": "decision:step:agenticOfferAccountCreditStep:853:9", + "to": "step:agenticApplySubscriptionStep", + "label": "GoTo", + "span": { + "startLine": 853, + "startColumn": 9, + "endLine": 853, + "endColumn": 61 + } + }, + { + "id": "edge:0029", + "kind": "transition", + "from": "decision:step:agenticReCheckStep:706:10", + "to": "step:agenticSendCustomerMessageStep", + "label": "GoTo", + "span": { + "startLine": 706, + "startColumn": 10, + "endLine": 706, + "endColumn": 64 + } + }, + { + "id": "edge:0030", + "kind": "transition", + "from": "decision:step:agenticReCheckStep:714:9", + "to": "step:agenticAuditIntentStep", + "label": "GoTo", + "span": { + "startLine": 714, + "startColumn": 9, + "endLine": 714, + "endColumn": 55 + } + }, + { + "id": "edge:0031", + "kind": "transition", + "from": "decision:step:agenticReceiveRequestStep:390:10", + "to": "step:agenticNotARefundStep", + "label": "GoTo", + "span": { + "startLine": 390, + "startColumn": 10, + "endLine": 390, + "endColumn": 55 + } + }, + { + "id": "edge:0032", + "kind": "transition", + "from": "decision:step:agenticReceiveRequestStep:395:9", + "to": "step:agenticDecisionStep", + "label": "GoTo", + "span": { + "startLine": 395, + "startColumn": 9, + "endLine": 395, + "endColumn": 52 + } + }, + { + "id": "edge:0033", + "kind": "transition", + "from": "decision:step:agenticRequestHumanApprovalStep:755:9", + "to": "step:agenticReCheckStep", + "label": "GoTo", + "span": { + "startLine": 755, + "startColumn": 9, + "endLine": 755, + "endColumn": 51 + } + }, + { + "id": "edge:0034", + "kind": "transition", + "from": "decision:step:agenticSendCustomerMessageStep:964:9", + "to": "step:agenticCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 964, + "startColumn": 9, + "endLine": 964, + "endColumn": 53 + } + }, + { + "id": "edge:0035", + "kind": "transition", + "from": "decision:step:agenticSubscriptionFailedStep:1043:9", + "to": "step:agenticSendCustomerMessageStep", + "label": "GoTo", + "span": { + "startLine": 1043, + "startColumn": 9, + "endLine": 1043, + "endColumn": 63 + } + }, + { + "id": "edge:0036", + "kind": "transition", + "from": "decision:step:agenticVerifyBillingStep:882:10", + "to": "step:agenticApplySubscriptionStep", + "label": "GoTo", + "span": { + "startLine": 882, + "startColumn": 10, + "endLine": 882, + "endColumn": 62 + } + }, + { + "id": "edge:0037", + "kind": "transition", + "from": "decision:step:agenticVerifyBillingStep:888:10", + "to": "step:agenticCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 888, + "startColumn": 10, + "endLine": 888, + "endColumn": 54 + } + }, + { + "id": "edge:0038", + "kind": "transition", + "from": "decision:step:agenticVerifyBillingStep:890:9", + "to": "step:agenticBillingFailedStep", + "label": "GoTo", + "span": { + "startLine": 890, + "startColumn": 9, + "endLine": 890, + "endColumn": 57 + } + }, + { + "id": "edge:0039", + "kind": "resource_read", + "from": "resource:attribute:agenticBillingKey", + "to": "step:agenticVerifyBillingStep", + "label": "Get", + "span": { + "startLine": 870, + "startColumn": 14, + "endLine": 870, + "endColumn": 40 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0040", + "kind": "resource_read", + "from": "resource:attribute:agenticBoundAction", + "to": "step:agenticAuditIntentStep", + "label": "Get", + "span": { + "startLine": 771, + "startColumn": 17, + "endLine": 771, + "endColumn": 44 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0041", + "kind": "resource_read", + "from": "resource:attribute:agenticCaseStatus", + "to": "step:agenticSendCustomerMessageStep", + "label": "Get", + "span": { + "startLine": 938, + "startColumn": 17, + "endLine": 938, + "endColumn": 43 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0042", + "kind": "resource_read", + "from": "resource:attribute:agenticGateRequestKey", + "to": "step:agenticRequestHumanApprovalStep", + "label": "Get", + "span": { + "startLine": 741, + "startColumn": 25, + "endLine": 741, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0043", + "kind": "resource_read", + "from": "resource:attribute:agenticGateRequestKey", + "to": "step:agenticRequestHumanApprovalStep", + "label": "Get", + "span": { + "startLine": 730, + "startColumn": 25, + "endLine": 730, + "endColumn": 55 + }, + "metadata": { + "phase": "wait_for" + } + }, + { + "id": "edge:0044", + "kind": "resource_read", + "from": "resource:attribute:agenticManagerVerdict", + "to": "step:agenticReCheckStep", + "label": "Get", + "span": { + "startLine": 698, + "startColumn": 18, + "endLine": 698, + "endColumn": 48 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0045", + "kind": "resource_read", + "from": "resource:attribute:agenticRecommendedAction", + "to": "step:agenticGuardrailStep", + "label": "Get", + "span": { + "startLine": 640, + "startColumn": 17, + "endLine": 640, + "endColumn": 50 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0046", + "kind": "resource_read", + "from": "resource:channel:agenticManagerApproval", + "to": "step:agenticRequestHumanApprovalStep", + "label": "GetConditionResults", + "span": { + "startLine": 745, + "startColumn": 19, + "endLine": 745, + "endColumn": 82 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0047", + "kind": "wait_condition", + "from": "resource:channel:agenticManagerApproval", + "to": "wait:agenticRequestHumanApprovalStep:734:9", + "label": "manager-approval[gateRequestKey].for 1", + "span": { + "startLine": 734, + "startColumn": 19, + "endLine": 734, + "endColumn": 64 + } + }, + { + "id": "edge:0048", + "kind": "resource_publish", + "from": "rpc:ApproveRefund", + "to": "resource:channel:agenticManagerApproval", + "label": "Publish", + "span": { + "startLine": 331, + "startColumn": 12, + "endLine": 331, + "endColumn": 74 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0049", + "kind": "resource_write", + "from": "rpc:RejectRefund", + "to": "resource:attribute:agenticManagerRejectionReason", + "label": "Set", + "span": { + "startLine": 355, + "startColumn": 12, + "endLine": 355, + "endColumn": 64 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0050", + "kind": "resource_publish", + "from": "rpc:RejectRefund", + "to": "resource:channel:agenticManagerApproval", + "label": "Publish", + "span": { + "startLine": 358, + "startColumn": 12, + "endLine": 358, + "endColumn": 73 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0051", + "kind": "resource_write", + "from": "step:agenticApplySubscriptionStep", + "to": "resource:attribute:agenticSubscriptionApplied", + "label": "Set", + "span": { + "startLine": 914, + "startColumn": 12, + "endLine": 914, + "endColumn": 54 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0052", + "kind": "failure_transition", + "from": "step:agenticApplySubscriptionStep", + "to": "step:agenticSubscriptionFailedStep", + "label": "Execute failure", + "span": { + "startLine": 904, + "startColumn": 42, + "endLine": 904, + "endColumn": 109 + }, + "metadata": { + "skipWaitFor": true + } + }, + { + "id": "edge:0053", + "kind": "resource_write", + "from": "step:agenticBillingFailedStep", + "to": "resource:attribute:agenticBillingOutcome", + "label": "Set", + "span": { + "startLine": 1015, + "startColumn": 12, + "endLine": 1015, + "endColumn": 71 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0054", + "kind": "resource_write", + "from": "step:agenticBillingFailedStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 1018, + "startColumn": 12, + "endLine": 1018, + "endColumn": 61 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0055", + "kind": "resource_write", + "from": "step:agenticCheckIdentityStep", + "to": "resource:attribute:agenticIdentityStatus", + "label": "Set", + "span": { + "startLine": 493, + "startColumn": 12, + "endLine": 493, + "endColumn": 98 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0056", + "kind": "resource_write", + "from": "step:agenticCheckIncidentsStep", + "to": "resource:attribute:agenticEvidenceState", + "label": "Set", + "span": { + "startLine": 621, + "startColumn": 12, + "endLine": 621, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0057", + "kind": "resource_write", + "from": "step:agenticCheckIncidentsStep", + "to": "resource:attribute:agenticIncidentDays", + "label": "Set", + "span": { + "startLine": 618, + "startColumn": 12, + "endLine": 618, + "endColumn": 63 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0058", + "kind": "resource_write", + "from": "step:agenticCheckIncidentsStep", + "to": "resource:attribute:agenticIncidentStatus", + "label": "Set", + "span": { + "startLine": 615, + "startColumn": 12, + "endLine": 615, + "endColumn": 67 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0059", + "kind": "resource_write", + "from": "step:agenticDecisionStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 421, + "startColumn": 12, + "endLine": 421, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0060", + "kind": "resource_write", + "from": "step:agenticDecisionStep", + "to": "resource:attribute:agenticDecisionRounds", + "label": "Set", + "span": { + "startLine": 418, + "startColumn": 12, + "endLine": 418, + "endColumn": 52 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0061", + "kind": "resource_write", + "from": "step:agenticDecisionStep", + "to": "resource:attribute:agenticRationale", + "label": "Set", + "span": { + "startLine": 473, + "startColumn": 12, + "endLine": 473, + "endColumn": 48 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0062", + "kind": "resource_write", + "from": "step:agenticDecisionStep", + "to": "resource:attribute:agenticRecommendedAction", + "label": "Set", + "span": { + "startLine": 470, + "startColumn": 12, + "endLine": 470, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0063", + "kind": "resource_write", + "from": "step:agenticEmailFailedStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 1062, + "startColumn": 12, + "endLine": 1062, + "endColumn": 64 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0064", + "kind": "resource_write", + "from": "step:agenticEmailFailedStep", + "to": "resource:attribute:agenticEmailSent", + "label": "Set", + "span": { + "startLine": 1059, + "startColumn": 12, + "endLine": 1059, + "endColumn": 43 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0065", + "kind": "resource_write", + "from": "step:agenticGetPaymentStep", + "to": "resource:attribute:agenticPaymentAgeDays", + "label": "Set", + "span": { + "startLine": 537, + "startColumn": 12, + "endLine": 537, + "endColumn": 67 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0066", + "kind": "resource_write", + "from": "step:agenticGetPaymentStep", + "to": "resource:attribute:agenticPaymentAmount", + "label": "Set", + "span": { + "startLine": 540, + "startColumn": 12, + "endLine": 540, + "endColumn": 99 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0067", + "kind": "resource_write", + "from": "step:agenticGetPaymentStep", + "to": "resource:attribute:agenticPaymentStatus", + "label": "Set", + "span": { + "startLine": 534, + "startColumn": 12, + "endLine": 534, + "endColumn": 65 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0068", + "kind": "resource_write", + "from": "step:agenticGetSubscriptionStep", + "to": "resource:attribute:agenticSubscriptionStatus", + "label": "Set", + "span": { + "startLine": 513, + "startColumn": 12, + "endLine": 513, + "endColumn": 106 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0069", + "kind": "resource_write", + "from": "step:agenticGetSupportHistoryStep", + "to": "resource:attribute:agenticCancellationPending", + "label": "Set", + "span": { + "startLine": 594, + "startColumn": 12, + "endLine": 594, + "endColumn": 77 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0070", + "kind": "resource_write", + "from": "step:agenticGetSupportHistoryStep", + "to": "resource:attribute:agenticHistoryStatus", + "label": "Set", + "span": { + "startLine": 585, + "startColumn": 12, + "endLine": 585, + "endColumn": 65 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0071", + "kind": "resource_write", + "from": "step:agenticGetSupportHistoryStep", + "to": "resource:attribute:agenticPriorRefunds", + "label": "Set", + "span": { + "startLine": 591, + "startColumn": 12, + "endLine": 591, + "endColumn": 63 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0072", + "kind": "resource_write", + "from": "step:agenticGetSupportHistoryStep", + "to": "resource:attribute:agenticTenureYears", + "label": "Set", + "span": { + "startLine": 588, + "startColumn": 12, + "endLine": 588, + "endColumn": 61 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0073", + "kind": "resource_write", + "from": "step:agenticGetUsageStep", + "to": "resource:attribute:agenticUsagePercent", + "label": "Set", + "span": { + "startLine": 564, + "startColumn": 12, + "endLine": 564, + "endColumn": 63 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0074", + "kind": "resource_write", + "from": "step:agenticGetUsageStep", + "to": "resource:attribute:agenticUsageStatus", + "label": "Set", + "span": { + "startLine": 561, + "startColumn": 12, + "endLine": 561, + "endColumn": 61 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0075", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticBoundAction", + "label": "Set", + "span": { + "startLine": 679, + "startColumn": 12, + "endLine": 679, + "endColumn": 47 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0076", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 662, + "startColumn": 12, + "endLine": 662, + "endColumn": 46 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0077", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticGateEntries", + "label": "Set", + "span": { + "startLine": 671, + "startColumn": 13, + "endLine": 671, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0078", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticGateRequestKey", + "label": "Set", + "span": { + "startLine": 674, + "startColumn": 13, + "endLine": 674, + "endColumn": 102 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0079", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticGuardrailRule", + "label": "Set", + "span": { + "startLine": 659, + "startColumn": 12, + "endLine": 659, + "endColumn": 47 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0080", + "kind": "resource_write", + "from": "step:agenticGuardrailStep", + "to": "resource:attribute:agenticGuardrailVerdict", + "label": "Set", + "span": { + "startLine": 656, + "startColumn": 12, + "endLine": 656, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0081", + "kind": "resource_write", + "from": "step:agenticIssueRefundStep", + "to": "resource:attribute:agenticBillingKey", + "label": "Set", + "span": { + "startLine": 805, + "startColumn": 12, + "endLine": 805, + "endColumn": 43 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0082", + "kind": "resource_write", + "from": "step:agenticIssueRefundStep", + "to": "resource:attribute:agenticBillingOutcome", + "label": "Set", + "span": { + "startLine": 809, + "startColumn": 12, + "endLine": 809, + "endColumn": 51 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0083", + "kind": "resource_write", + "from": "step:agenticIssueRefundStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 814, + "startColumn": 13, + "endLine": 814, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0084", + "kind": "failure_transition", + "from": "step:agenticIssueRefundStep", + "to": "step:agenticBillingFailedStep", + "label": "Execute failure", + "span": { + "startLine": 796, + "startColumn": 19, + "endLine": 796, + "endColumn": 81 + }, + "metadata": { + "skipWaitFor": true + } + }, + { + "id": "edge:0085", + "kind": "resource_write", + "from": "step:agenticNonConvergenceStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 980, + "startColumn": 12, + "endLine": 980, + "endColumn": 60 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0086", + "kind": "resource_write", + "from": "step:agenticOfferAccountCreditStep", + "to": "resource:attribute:agenticBillingKey", + "label": "Set", + "span": { + "startLine": 840, + "startColumn": 12, + "endLine": 840, + "endColumn": 43 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0087", + "kind": "resource_write", + "from": "step:agenticOfferAccountCreditStep", + "to": "resource:attribute:agenticBillingOutcome", + "label": "Set", + "span": { + "startLine": 844, + "startColumn": 12, + "endLine": 844, + "endColumn": 51 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0088", + "kind": "resource_write", + "from": "step:agenticOfferAccountCreditStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 850, + "startColumn": 12, + "endLine": 850, + "endColumn": 54 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0089", + "kind": "resource_write", + "from": "step:agenticReCheckStep", + "to": "resource:attribute:agenticBoundAction", + "label": "Set", + "span": { + "startLine": 708, + "startColumn": 12, + "endLine": 708, + "endColumn": 58 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0090", + "kind": "resource_write", + "from": "step:agenticReCheckStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 703, + "startColumn": 13, + "endLine": 703, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0091", + "kind": "resource_write", + "from": "step:agenticReceiveRequestStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 387, + "startColumn": 13, + "endLine": 387, + "endColumn": 57 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0092", + "kind": "resource_write", + "from": "step:agenticReceiveRequestStep", + "to": "resource:attribute:agenticChargeReference", + "label": "Set", + "span": { + "startLine": 380, + "startColumn": 12, + "endLine": 380, + "endColumn": 62 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0093", + "kind": "resource_write", + "from": "step:agenticReceiveRequestStep", + "to": "resource:attribute:agenticInputEmail", + "label": "Set", + "span": { + "startLine": 377, + "startColumn": 12, + "endLine": 377, + "endColumn": 63 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0094", + "kind": "resource_write", + "from": "step:agenticReceiveRequestStep", + "to": "resource:attribute:agenticOperatorNote", + "label": "Set", + "span": { + "startLine": 383, + "startColumn": 12, + "endLine": 383, + "endColumn": 44 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0095", + "kind": "resource_write", + "from": "step:agenticRequestHumanApprovalStep", + "to": "resource:attribute:agenticManagerVerdict", + "label": "Set", + "span": { + "startLine": 752, + "startColumn": 12, + "endLine": 752, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0096", + "kind": "resource_write", + "from": "step:agenticSendCustomerMessageStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 960, + "startColumn": 13, + "endLine": 960, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0097", + "kind": "resource_write", + "from": "step:agenticSendCustomerMessageStep", + "to": "resource:attribute:agenticEmailSent", + "label": "Set", + "span": { + "startLine": 956, + "startColumn": 12, + "endLine": 956, + "endColumn": 44 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0098", + "kind": "failure_transition", + "from": "step:agenticSendCustomerMessageStep", + "to": "step:agenticEmailFailedStep", + "label": "Execute failure", + "span": { + "startLine": 931, + "startColumn": 42, + "endLine": 931, + "endColumn": 102 + }, + "metadata": { + "skipWaitFor": true + } + }, + { + "id": "edge:0099", + "kind": "resource_write", + "from": "step:agenticSubscriptionFailedStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 1040, + "startColumn": 12, + "endLine": 1040, + "endColumn": 66 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0100", + "kind": "resource_write", + "from": "step:agenticSubscriptionFailedStep", + "to": "resource:attribute:agenticSubscriptionApplied", + "label": "Set", + "span": { + "startLine": 1037, + "startColumn": 12, + "endLine": 1037, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0101", + "kind": "resource_write", + "from": "step:agenticVerifyBillingStep", + "to": "resource:attribute:agenticBillingOutcome", + "label": "Set", + "span": { + "startLine": 875, + "startColumn": 12, + "endLine": 875, + "endColumn": 51 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0102", + "kind": "resource_write", + "from": "step:agenticVerifyBillingStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 879, + "startColumn": 13, + "endLine": 879, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + } + ], + "diagnostics": [], + "groups": [ + { + "id": "intake", + "label": "Intake", + "stepIds": [ + "step:agenticReceiveRequestStep" + ] + }, + { + "id": "reasoning", + "label": "Reasoning", + "stepIds": [ + "step:agenticDecisionStep" + ] + }, + { + "id": "evidence", + "label": "Evidence", + "stepIds": [ + "step:agenticCheckIdentityStep", + "step:agenticGetSubscriptionStep", + "step:agenticGetPaymentStep", + "step:agenticGetUsageStep", + "step:agenticGetSupportHistoryStep", + "step:agenticCheckIncidentsStep" + ] + }, + { + "id": "control", + "label": "Control", + "stepIds": [ + "step:agenticGuardrailStep", + "step:agenticReCheckStep", + "step:agenticRequestHumanApprovalStep" + ] + }, + { + "id": "resolution", + "label": "Resolution", + "stepIds": [ + "step:agenticAuditIntentStep", + "step:agenticIssueRefundStep", + "step:agenticOfferAccountCreditStep", + "step:agenticVerifyBillingStep", + "step:agenticApplySubscriptionStep", + "step:agenticSendCustomerMessageStep" + ] + }, + { + "id": "failure", + "label": "Failure", + "stepIds": [ + "step:agenticNonConvergenceStep", + "step:agenticNotARefundStep", + "step:agenticBillingFailedStep", + "step:agenticSubscriptionFailedStep", + "step:agenticEmailFailedStep" + ] + }, + { + "id": "close", + "label": "Close", + "stepIds": [ + "step:agenticCloseCaseStep" + ] + } + ], + "v2": { + "indexedAttributes": [ + { + "attributeKey": "case-status", + "indexKey": "case-status", + "indexType": "keyword", + "valueType": "string", + "description": "Current case status" + } + ], + "summary": { + "rpcName": "GetDexSummary", + "fields": [ + { + "attributeKey": "in-charge-ref", + "valueType": "string", + "editable": false, + "description": "Charge reference" + }, + { + "attributeKey": "ev-payment-amount", + "valueType": "string", + "editable": false, + "description": "Payment amount" + }, + { + "attributeKey": "recommended-action", + "valueType": "string", + "editable": false, + "description": "Recommended action" + }, + { + "attributeKey": "guardrail-rule", + "valueType": "string", + "editable": false, + "description": "Guardrail rule" + } + ] + }, + "display": { + "rpcName": "GetDexDisplay", + "fields": [ + { + "attributeKey": "in-email", + "valueType": "string", + "editable": false, + "description": "Customer request" + }, + { + "attributeKey": "in-charge-ref", + "valueType": "string", + "editable": false, + "description": "Charge reference" + }, + { + "attributeKey": "evidence-state", + "valueType": "string", + "editable": false, + "description": "Evidence state" + }, + { + "attributeKey": "recommended-action", + "valueType": "string", + "editable": false, + "description": "Recommendation" + }, + { + "attributeKey": "recommendation-rationale", + "valueType": "string", + "editable": false, + "description": "Recommendation rationale" + }, + { + "attributeKey": "guardrail-verdict", + "valueType": "string", + "editable": false, + "description": "Guardrail verdict" + }, + { + "attributeKey": "guardrail-rule", + "valueType": "string", + "editable": false, + "description": "Guardrail rule" + }, + { + "attributeKey": "manager-verdict", + "valueType": "string", + "editable": false, + "description": "Manager verdict" + }, + { + "attributeKey": "gate-request-key", + "valueType": "string", + "editable": false, + "description": "Approval gate" + }, + { + "attributeKey": "billing-outcome", + "valueType": "string", + "editable": false, + "description": "Billing effect" + }, + { + "attributeKey": "subscription-applied", + "valueType": "string", + "editable": false, + "description": "Subscription effect" + }, + { + "attributeKey": "email-sent", + "valueType": "string", + "editable": false, + "description": "Customer message effect" + }, + { + "attributeKey": "operator-note", + "valueType": "string", + "editable": true, + "description": "Operator note" + } + ] + }, + "actions": [ + { + "rpcName": "ApproveRefund", + "label": "Approve", + "condition": { + "attributeKey": "case-status", + "operator": "in", + "values": [ + "awaiting-manager-rule", + "awaiting-manager-agent" + ] + }, + "input": { + "kind": "none" + } + }, + { + "rpcName": "RejectRefund", + "label": "Reject", + "condition": { + "attributeKey": "case-status", + "operator": "in", + "values": [ + "awaiting-manager-rule", + "awaiting-manager-agent" + ] + }, + "input": { + "kind": "object", + "fields": [ + { + "fieldName": "reason", + "valueType": "string", + "source": "user", + "required": true, + "description": "Rejection reason" + }, + { + "fieldName": "gateRequestKey", + "valueType": "string", + "source": "attribute", + "attributeKey": "gate-request-key", + "required": true, + "description": "Approval gate" + } + ] + } + } + ] + } +} diff --git a/docs/src/data/flow-definitions/customer-refund.json b/docs/src/data/flow-definitions/customer-refund.json new file mode 100644 index 000000000..bd8d553f3 --- /dev/null +++ b/docs/src/data/flow-definitions/customer-refund.json @@ -0,0 +1,937 @@ +{ + "schemaVersion": "2.0", + "valid": true, + "source": { + "language": "go", + "path": "examples/go/products/customer-refund/deterministic/workflow.go" + }, + "flow": { + "name": "CustomerRefundFlow", + "startStepId": "step:deterministicReceiveRequestStep", + "span": { + "startLine": 82, + "startColumn": 1, + "endLine": 92, + "endColumn": 2 + } + }, + "nodes": [ + { + "id": "decision-dispatch:step:deterministicCheckPolicyStep", + "kind": "decision_dispatch", + "name": "Decision", + "parentId": "step:deterministicCheckPolicyStep", + "phase": "execute", + "span": { + "startLine": 258, + "startColumn": 1, + "endLine": 289, + "endColumn": 2 + } + }, + { + "id": "decision:rpc:GetDexDisplay:180:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:GetDexDisplay", + "phase": "rpc", + "span": { + "startLine": 180, + "startColumn": 9, + "endLine": 188, + "endColumn": 4 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:GetDexSummary:134:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:GetDexSummary", + "phase": "rpc", + "span": { + "startLine": 134, + "startColumn": 9, + "endLine": 138, + "endColumn": 4 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:step:deterministicCheckOrderStep:246:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicCheckOrderStep", + "phase": "execute", + "span": { + "startLine": 246, + "startColumn": 9, + "endLine": 246, + "endColumn": 61 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicCheckPolicyStep:273:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicCheckPolicyStep", + "condition": "lookup != \"found\"", + "phase": "execute", + "span": { + "startLine": 273, + "startColumn": 10, + "endLine": 273, + "endColumn": 65 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicCheckPolicyStep:283:10", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicCheckPolicyStep", + "condition": "!(lookup != \"found\") and orderAgeDays \u003c= standardWindowDays", + "phase": "execute", + "span": { + "startLine": 283, + "startColumn": 10, + "endLine": 283, + "endColumn": 62 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicCheckPolicyStep:288:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicCheckPolicyStep", + "condition": "!(lookup != \"found\") and !(orderAgeDays \u003c= standardWindowDays)", + "phase": "execute", + "span": { + "startLine": 288, + "startColumn": 9, + "endLine": 288, + "endColumn": 60 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicCloseCaseStep:407:9", + "kind": "decision", + "name": "gracefulComplete", + "parentId": "step:deterministicCloseCaseStep", + "phase": "execute", + "span": { + "startLine": 407, + "startColumn": 9, + "endLine": 407, + "endColumn": 60 + }, + "decision": { + "type": "gracefulComplete" + } + }, + { + "id": "decision:step:deterministicDenyRefundStep:347:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicDenyRefundStep", + "phase": "execute", + "span": { + "startLine": 347, + "startColumn": 9, + "endLine": 347, + "endColumn": 64 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicIssueRefundStep:328:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicIssueRefundStep", + "phase": "execute", + "span": { + "startLine": 328, + "startColumn": 9, + "endLine": 328, + "endColumn": 64 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicNotifyCustomerStep:382:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicNotifyCustomerStep", + "phase": "execute", + "span": { + "startLine": 382, + "startColumn": 9, + "endLine": 382, + "endColumn": 59 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:deterministicReceiveRequestStep:218:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:deterministicReceiveRequestStep", + "phase": "execute", + "span": { + "startLine": 218, + "startColumn": 9, + "endLine": 218, + "endColumn": 60 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "resource:attribute:deterministicBillingOutcome", + "kind": "attribute", + "name": "billing-outcome", + "span": { + "startLine": 54, + "startColumn": 5, + "endLine": 54, + "endColumn": 81 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicCaseStatus", + "kind": "attribute", + "name": "case-status", + "span": { + "startLine": 61, + "startColumn": 5, + "endLine": 64, + "endColumn": 2 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicChargeReference", + "kind": "attribute", + "name": "charge-reference", + "span": { + "startLine": 44, + "startColumn": 5, + "endLine": 44, + "endColumn": 83 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicOperatorNote", + "kind": "attribute", + "name": "operator-note", + "span": { + "startLine": 58, + "startColumn": 5, + "endLine": 58, + "endColumn": 77 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicOrderAgeDays", + "kind": "attribute", + "name": "order-age-days", + "span": { + "startLine": 48, + "startColumn": 5, + "endLine": 48, + "endColumn": 77 + }, + "resource": { + "valueType": "int64" + } + }, + { + "id": "resource:attribute:deterministicOrderLookup", + "kind": "attribute", + "name": "order-lookup", + "span": { + "startLine": 50, + "startColumn": 5, + "endLine": 50, + "endColumn": 75 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicRecommendation", + "kind": "attribute", + "name": "recommended-action", + "span": { + "startLine": 56, + "startColumn": 5, + "endLine": 56, + "endColumn": 84 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicRefundAmount", + "kind": "attribute", + "name": "refund-amount", + "span": { + "startLine": 46, + "startColumn": 5, + "endLine": 46, + "endColumn": 77 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:deterministicRefundKey", + "kind": "attribute", + "name": "refund-key", + "span": { + "startLine": 52, + "startColumn": 5, + "endLine": 52, + "endColumn": 71 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "rpc:GetDexDisplay", + "kind": "rpc", + "name": "GetDexDisplay", + "span": { + "startLine": 148, + "startColumn": 1, + "endLine": 189, + "endColumn": 2 + } + }, + { + "id": "rpc:GetDexSummary", + "kind": "rpc", + "name": "GetDexSummary", + "span": { + "startLine": 118, + "startColumn": 1, + "endLine": 139, + "endColumn": 2 + } + }, + { + "id": "step:deterministicCheckOrderStep", + "kind": "step", + "name": "CheckOrderStep", + "phase": "execute", + "span": { + "startLine": 85, + "startColumn": 3, + "endLine": 85, + "endColumn": 48 + } + }, + { + "id": "step:deterministicCheckPolicyStep", + "kind": "step", + "name": "CheckPolicyStep", + "phase": "execute", + "span": { + "startLine": 86, + "startColumn": 3, + "endLine": 86, + "endColumn": 49 + } + }, + { + "id": "step:deterministicCloseCaseStep", + "kind": "step", + "name": "CloseCaseStep", + "phase": "execute", + "span": { + "startLine": 90, + "startColumn": 3, + "endLine": 90, + "endColumn": 47 + } + }, + { + "id": "step:deterministicDenyRefundStep", + "kind": "step", + "name": "DenyRefundStep", + "phase": "execute", + "span": { + "startLine": 88, + "startColumn": 3, + "endLine": 88, + "endColumn": 48 + } + }, + { + "id": "step:deterministicIssueRefundStep", + "kind": "step", + "name": "IssueRefundStep", + "phase": "execute", + "span": { + "startLine": 87, + "startColumn": 3, + "endLine": 87, + "endColumn": 70 + } + }, + { + "id": "step:deterministicNotifyCustomerStep", + "kind": "step", + "name": "NotifyCustomerStep", + "phase": "execute", + "span": { + "startLine": 89, + "startColumn": 3, + "endLine": 89, + "endColumn": 73 + } + }, + { + "id": "step:deterministicReceiveRequestStep", + "kind": "step", + "name": "ReceiveRequestStep", + "phase": "execute", + "start": true, + "span": { + "startLine": 84, + "startColumn": 3, + "endLine": 84, + "endColumn": 57 + } + } + ], + "edges": [ + { + "id": "edge:0001", + "kind": "transition", + "from": "decision:step:deterministicCheckOrderStep:246:9", + "to": "step:deterministicCheckPolicyStep", + "label": "GoTo", + "span": { + "startLine": 246, + "startColumn": 9, + "endLine": 246, + "endColumn": 61 + } + }, + { + "id": "edge:0002", + "kind": "transition", + "from": "decision:step:deterministicCheckPolicyStep:273:10", + "to": "step:deterministicNotifyCustomerStep", + "label": "GoTo", + "span": { + "startLine": 273, + "startColumn": 10, + "endLine": 273, + "endColumn": 65 + } + }, + { + "id": "edge:0003", + "kind": "transition", + "from": "decision:step:deterministicCheckPolicyStep:283:10", + "to": "step:deterministicIssueRefundStep", + "label": "GoTo", + "span": { + "startLine": 283, + "startColumn": 10, + "endLine": 283, + "endColumn": 62 + } + }, + { + "id": "edge:0004", + "kind": "transition", + "from": "decision:step:deterministicCheckPolicyStep:288:9", + "to": "step:deterministicDenyRefundStep", + "label": "GoTo", + "span": { + "startLine": 288, + "startColumn": 9, + "endLine": 288, + "endColumn": 60 + } + }, + { + "id": "edge:0005", + "kind": "transition", + "from": "decision:step:deterministicDenyRefundStep:347:9", + "to": "step:deterministicNotifyCustomerStep", + "label": "GoTo", + "span": { + "startLine": 347, + "startColumn": 9, + "endLine": 347, + "endColumn": 64 + } + }, + { + "id": "edge:0006", + "kind": "transition", + "from": "decision:step:deterministicIssueRefundStep:328:9", + "to": "step:deterministicNotifyCustomerStep", + "label": "GoTo", + "span": { + "startLine": 328, + "startColumn": 9, + "endLine": 328, + "endColumn": 64 + } + }, + { + "id": "edge:0007", + "kind": "transition", + "from": "decision:step:deterministicNotifyCustomerStep:382:9", + "to": "step:deterministicCloseCaseStep", + "label": "GoTo", + "span": { + "startLine": 382, + "startColumn": 9, + "endLine": 382, + "endColumn": 59 + } + }, + { + "id": "edge:0008", + "kind": "transition", + "from": "decision:step:deterministicReceiveRequestStep:218:9", + "to": "step:deterministicCheckOrderStep", + "label": "GoTo", + "span": { + "startLine": 218, + "startColumn": 9, + "endLine": 218, + "endColumn": 60 + } + }, + { + "id": "edge:0009", + "kind": "resource_read", + "from": "resource:attribute:deterministicCaseStatus", + "to": "step:deterministicCloseCaseStep", + "label": "Get", + "span": { + "startLine": 398, + "startColumn": 17, + "endLine": 398, + "endColumn": 49 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0010", + "kind": "resource_read", + "from": "resource:attribute:deterministicCaseStatus", + "to": "step:deterministicNotifyCustomerStep", + "label": "Get", + "span": { + "startLine": 364, + "startColumn": 17, + "endLine": 364, + "endColumn": 49 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0011", + "kind": "resource_read", + "from": "resource:attribute:deterministicOrderAgeDays", + "to": "step:deterministicCheckPolicyStep", + "label": "Get", + "span": { + "startLine": 275, + "startColumn": 23, + "endLine": 275, + "endColumn": 57 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0012", + "kind": "resource_read", + "from": "resource:attribute:deterministicOrderLookup", + "to": "step:deterministicCheckPolicyStep", + "label": "Get", + "span": { + "startLine": 262, + "startColumn": 17, + "endLine": 262, + "endColumn": 50 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0013", + "kind": "resource_write", + "from": "step:deterministicCheckOrderStep", + "to": "resource:attribute:deterministicCaseStatus", + "label": "Set", + "span": { + "startLine": 243, + "startColumn": 12, + "endLine": 243, + "endColumn": 64 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0014", + "kind": "resource_write", + "from": "step:deterministicCheckOrderStep", + "to": "resource:attribute:deterministicOrderAgeDays", + "label": "Set", + "span": { + "startLine": 237, + "startColumn": 19, + "endLine": 237, + "endColumn": 78 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0015", + "kind": "resource_write", + "from": "step:deterministicCheckOrderStep", + "to": "resource:attribute:deterministicOrderLookup", + "label": "Set", + "span": { + "startLine": 240, + "startColumn": 12, + "endLine": 240, + "endColumn": 53 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0016", + "kind": "resource_write", + "from": "step:deterministicCheckPolicyStep", + "to": "resource:attribute:deterministicCaseStatus", + "label": "Set", + "span": { + "startLine": 270, + "startColumn": 13, + "endLine": 270, + "endColumn": 66 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0017", + "kind": "resource_write", + "from": "step:deterministicCheckPolicyStep", + "to": "resource:attribute:deterministicRecommendation", + "label": "Set", + "span": { + "startLine": 267, + "startColumn": 13, + "endLine": 267, + "endColumn": 75 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0018", + "kind": "resource_write", + "from": "step:deterministicCloseCaseStep", + "to": "resource:attribute:deterministicCaseStatus", + "label": "Set", + "span": { + "startLine": 403, + "startColumn": 13, + "endLine": 403, + "endColumn": 61 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0019", + "kind": "resource_write", + "from": "step:deterministicDenyRefundStep", + "to": "resource:attribute:deterministicCaseStatus", + "label": "Set", + "span": { + "startLine": 344, + "startColumn": 12, + "endLine": 344, + "endColumn": 58 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0020", + "kind": "resource_write", + "from": "step:deterministicIssueRefundStep", + "to": "resource:attribute:deterministicBillingOutcome", + "label": "Set", + "span": { + "startLine": 316, + "startColumn": 12, + "endLine": 316, + "endColumn": 57 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0021", + "kind": "resource_write", + "from": "step:deterministicIssueRefundStep", + "to": "resource:attribute:deterministicCaseStatus", + "label": "Set", + "span": { + "startLine": 325, + "startColumn": 12, + "endLine": 325, + "endColumn": 52 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0022", + "kind": "resource_write", + "from": "step:deterministicIssueRefundStep", + "to": "resource:attribute:deterministicRefundKey", + "label": "Set", + "span": { + "startLine": 312, + "startColumn": 12, + "endLine": 312, + "endColumn": 48 + }, + "metadata": { + "phase": "execute" + } + } + ], + "diagnostics": [ + { + "severity": "warning", + "code": "unused_resource", + "message": "attribute charge-reference has no direct access in the source file", + "span": { + "startLine": 44, + "startColumn": 5, + "endLine": 44, + "endColumn": 83 + } + }, + { + "severity": "warning", + "code": "unused_resource", + "message": "attribute operator-note has no direct access in the source file", + "span": { + "startLine": 58, + "startColumn": 5, + "endLine": 58, + "endColumn": 77 + } + }, + { + "severity": "warning", + "code": "unused_resource", + "message": "attribute refund-amount has no direct access in the source file", + "span": { + "startLine": 46, + "startColumn": 5, + "endLine": 46, + "endColumn": 77 + } + } + ], + "groups": [ + { + "id": "intake", + "label": "Intake", + "stepIds": [ + "step:deterministicReceiveRequestStep" + ] + }, + { + "id": "evidence", + "label": "Evidence", + "stepIds": [ + "step:deterministicCheckOrderStep" + ] + }, + { + "id": "control", + "label": "Control", + "stepIds": [ + "step:deterministicCheckPolicyStep" + ] + }, + { + "id": "resolution", + "label": "Resolution", + "stepIds": [ + "step:deterministicIssueRefundStep", + "step:deterministicNotifyCustomerStep" + ] + }, + { + "id": "failure", + "label": "Failure", + "stepIds": [ + "step:deterministicDenyRefundStep" + ] + }, + { + "id": "close", + "label": "Close", + "stepIds": [ + "step:deterministicCloseCaseStep" + ] + } + ], + "v2": { + "indexedAttributes": [ + { + "attributeKey": "case-status", + "indexKey": "case-status", + "indexType": "keyword", + "valueType": "string", + "description": "Current case status" + } + ], + "summary": { + "rpcName": "GetDexSummary", + "fields": [ + { + "attributeKey": "charge-reference", + "valueType": "string", + "editable": false, + "description": "Charge reference" + }, + { + "attributeKey": "refund-amount", + "valueType": "string", + "editable": false, + "description": "Refund amount" + }, + { + "attributeKey": "recommended-action", + "valueType": "string", + "editable": false, + "description": "Recommended action" + } + ] + }, + "display": { + "rpcName": "GetDexDisplay", + "fields": [ + { + "attributeKey": "charge-reference", + "valueType": "string", + "editable": false, + "description": "Charge reference" + }, + { + "attributeKey": "refund-amount", + "valueType": "string", + "editable": false, + "description": "Requested amount" + }, + { + "attributeKey": "order-lookup", + "valueType": "string", + "editable": false, + "description": "Order evidence" + }, + { + "attributeKey": "order-age-days", + "valueType": "int64", + "editable": false, + "description": "Order age in days" + }, + { + "attributeKey": "recommended-action", + "valueType": "string", + "editable": false, + "description": "Policy recommendation" + }, + { + "attributeKey": "billing-outcome", + "valueType": "string", + "editable": false, + "description": "Billing outcome" + }, + { + "attributeKey": "operator-note", + "valueType": "string", + "editable": true, + "description": "Operator note" + } + ] + }, + "actions": [] + } +} diff --git a/examples/entity-store/docker-compose.yml b/examples/entity-store/docker-compose.yml index 178784a29..0f41d0202 100644 --- a/examples/entity-store/docker-compose.yml +++ b/examples/entity-store/docker-compose.yml @@ -25,6 +25,6 @@ services: timeout: 5s retries: 20 ports: - - "55432:5432" + - "${ENTITY_STORE_POSTGRES_PORT:-55432}:5432" volumes: - "./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" diff --git a/examples/go/README.md b/examples/go/README.md index 345780df3..50babc90d 100644 --- a/examples/go/README.md +++ b/examples/go/README.md @@ -130,6 +130,7 @@ details. - [User onboarding process](./products/signup) - [Job posting](./products/job-post) - [Deal DSL](./products/deal-dsl) (separate UI and `dex-deal-dsl` binary) +- [Customer refund](./products/customer-refund) (deterministic and agentic FDG 2.0 Flows) ## Patterns diff --git a/examples/go/cmd/server/dex/dex.go b/examples/go/cmd/server/dex/dex.go index 00b0bd466..cd4d71f4a 100644 --- a/examples/go/cmd/server/dex/dex.go +++ b/examples/go/cmd/server/dex/dex.go @@ -62,6 +62,7 @@ import ( primitivesubflow "github.com/superdurable/dex/examples/go/primitives/subflow" primitivetimer "github.com/superdurable/dex/examples/go/primitives/timer" primitivewaittypes "github.com/superdurable/dex/examples/go/primitives/wait-types" + customerrefund "github.com/superdurable/dex/examples/go/products/customer-refund" "github.com/superdurable/dex/examples/go/products/engagement" "github.com/superdurable/dex/examples/go/products/job-post" "github.com/superdurable/dex/examples/go/products/microservices" @@ -164,6 +165,7 @@ func NewRouter(client *sdk.Client) http.Handler { orderprocessing.RegisterRoutes(router, client, registry.OrderProcessing) signup.RegisterRoutes(router, client, registry.UserOnboarding) jobpost.RegisterRoutes(router, client, registry.JobPosting) + customerrefund.RegisterRoutes(router, client, registry.CustomerRefund, registry.AgenticRefund) patternspolling.RegisterRoutes(router, client, registry.PollingWithTimer, registry.BackoffPolling, registry.Iteration) interruptible.RegisterRoutes(router, client, registry.Interruptible) reminders.RegisterRoutes(router, client, registry.Reminder) diff --git a/examples/go/integ/customer_refund_test.go b/examples/go/integ/customer_refund_test.go new file mode 100644 index 000000000..cf2c2d3f2 --- /dev/null +++ b/examples/go/integ/customer_refund_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package integ + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/superdurable/dex/examples/go/products/customer-refund/agentic" + refundmodel "github.com/superdurable/dex/examples/go/products/customer-refund/model" + "github.com/superdurable/dex/examples/go/registry" + "github.com/superdurable/dex/sdk-go/dex" +) + +func TestDeterministicCustomerRefundOutcomes(t *testing.T) { + tests := []struct { + name string + caseID string + orderAgeDays int64 + wantRecommendation string + wantBillingOutcome any + }{ + {name: "eligible refund", orderAgeDays: 12, wantRecommendation: "refund", wantBillingOutcome: "confirmed"}, + {name: "expired denial", orderAgeDays: 31, wantRecommendation: "deny-outside-window", wantBillingOutcome: nil}, + {name: "missing order", caseID: "no-such-order", orderAgeDays: 4, wantRecommendation: "manual-order-follow-up", wantBillingOutcome: nil}, + {name: "declined provider", caseID: "declined", orderAgeDays: 4, wantRecommendation: "refund", wantBillingOutcome: "declined"}, + {name: "unknown provider", caseID: "unproven", orderAgeDays: 4, wantRecommendation: "refund", wantBillingOutcome: "unknown"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := integrationContext(t) + flowID := newFlowID(t, "customer-refund") + caseID := test.caseID + if caseID == "" { + caseID = flowID + } + _, err := integClient.StartFlow(ctx, registry.CustomerRefund, flowID, refundmodel.RefundCase{ + CaseID: caseID, Customer: "customer", CustomerNote: "refund", AmountCents: 4200, + OrderAgeDays: test.orderAgeDays, + }, dex.StartFlowOptions{}) + require.NoError(t, err) + require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) + + var display map[string]any + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.CustomerRefund.GetDexDisplay, nil, &display, + )) + require.Equal(t, test.wantRecommendation, display["recommended-action"]) + require.Equal(t, test.wantBillingOutcome, display["billing-outcome"]) + }) + } +} + +func TestAgenticCustomerRefundAutomaticAndUnknownOutcomes(t *testing.T) { + tests := []struct { + name string + caseID string + wantBillingOutcome string + }{ + {name: "automatic refund", wantBillingOutcome: "confirmed"}, + {name: "unknown effect", caseID: "unproven", wantBillingOutcome: "unknown"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := integrationContext(t) + flowID := newFlowID(t, "agentic-refund") + caseID := test.caseID + if caseID == "" { + caseID = flowID + } + _, err := integClient.StartFlow(ctx, registry.AgenticRefund, flowID, refundmodel.RefundCase{ + CaseID: caseID, Customer: "customer", CustomerNote: "refund", AmountCents: 4200, + OrderAgeDays: 8, + }, dex.StartFlowOptions{}) + require.NoError(t, err) + require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) + var display map[string]any + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.AgenticRefund.GetDexDisplay, nil, &display, + )) + require.Equal(t, test.wantBillingOutcome, display["billing-outcome"]) + }) + } +} + +func TestAgenticCustomerRefundApproveAndRejectActions(t *testing.T) { + t.Run("approve without input", func(t *testing.T) { + ctx := integrationContext(t) + flowID := newFlowID(t, "agentic-approve") + startAgenticEscalation(t, ctx, flowID, flowID, 1_400_000) + waitForAgenticGate(t, ctx, flowID) + + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.AgenticRefund.ApproveRefund, nil, nil, + )) + require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) + }) + + t.Run("reject validates gate snapshot", func(t *testing.T) { + ctx := integrationContext(t) + flowID := newFlowID(t, "agentic-reject") + startAgenticEscalation(t, ctx, flowID, flowID, 1_400_000) + gateRequestKey := waitForAgenticGate(t, ctx, flowID) + + err := integClient.InvokeRPC(ctx, flowID, registry.AgenticRefund.RejectRefund, agentic.RejectRefundInput{ + Reason: "duplicate request", GateRequestKey: "stale-gate", + }, nil) + require.ErrorContains(t, err, "approval gate changed") + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.AgenticRefund.RejectRefund, + agentic.RejectRefundInput{Reason: "duplicate request", GateRequestKey: gateRequestKey}, nil, + )) + require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) + }) +} + +func TestAgenticCustomerRefundEscalatesUnavailableEvidence(t *testing.T) { + ctx := integrationContext(t) + flowID := newFlowID(t, "agentic-evidence") + startAgenticEscalation(t, ctx, flowID, "agent-uncertain", 4200) + waitForAgenticGate(t, ctx, flowID) + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.AgenticRefund.ApproveRefund, nil, nil, + )) + require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) +} + +func startAgenticEscalation( + t *testing.T, + ctx context.Context, + flowID string, + caseID string, + amountCents int64, +) { + t.Helper() + _, err := integClient.StartFlow(ctx, registry.AgenticRefund, flowID, refundmodel.RefundCase{ + CaseID: caseID, Customer: "customer", CustomerNote: "review", AmountCents: amountCents, + OrderAgeDays: 8, + }, dex.StartFlowOptions{}) + require.NoError(t, err) +} + +func waitForAgenticGate(t *testing.T, ctx context.Context, flowID string) string { + t.Helper() + gateRequestKey := "" + var invokeErr error + require.Eventually(t, func() bool { + var display map[string]any + invokeErr = integClient.InvokeRPC(ctx, flowID, registry.AgenticRefund.GetDexDisplay, nil, &display) + if invokeErr != nil { + return false + } + gateRequestKey, _ = display["gate-request-key"].(string) + return gateRequestKey != "" + }, 30*time.Second, 200*time.Millisecond, "GetDexDisplay failed: %v", invokeErr) + return gateRequestKey +} diff --git a/examples/go/products/customer-refund/README.md b/examples/go/products/customer-refund/README.md new file mode 100644 index 000000000..c3851bbaf --- /dev/null +++ b/examples/go/products/customer-refund/README.md @@ -0,0 +1,29 @@ +# Customer refund example + +This product contains two Go Flows for the same customer-refund problem. + +- `deterministic/workflow.go` is a fixed seven-Step policy. It verifies the 30-day window, uses a persisted provider idempotency key, and preserves declined or unknown outcomes. +- `agentic/workflow.go` loops through durable evidence, records a recommendation, applies a guardrail, opens a keyed approval gate, and separates intent from idempotent effects. + +Both files are self-contained FDG 2.0 sources. Every Step declares `dex:group` +and a one-sentence `dex:explanation`. Generate their definitions from the +repository root: + +```bash +dexcli visualize examples/go/products/customer-refund/deterministic/workflow.go --schema-version 2.0 --json --out customer-refund-deterministic +dexcli visualize examples/go/products/customer-refund/agentic/workflow.go --schema-version 2.0 --json --out customer-refund-agentic +``` + +Start the example server and create runs with: + +```bash +curl -X POST http://127.0.0.1:8080/products/customer-refund/deterministic/start \ + -H 'content-type: application/json' \ + -d '{"case":{"caseId":"order-42","customer":"customer-1","customerNote":"Please refund this charge","amountCents":4200,"orderAgeDays":12}}' + +curl -X POST http://127.0.0.1:8080/products/customer-refund/agentic/start \ + -H 'content-type: application/json' \ + -d '{"case":{"caseId":"rule-escalation","customer":"customer-2","customerNote":"Please review this charge","amountCents":1400000,"orderAgeDays":8}}' +``` + +The agentic Flow exposes `ApproveRefund` as a no-input Action. `RejectRefund` asks for a reason and binds the hidden `gate-request-key` snapshot supplied by Dex Web. Both RPCs re-check the current status before publishing a verdict. diff --git a/examples/go/products/customer-refund/agentic/workflow.go b/examples/go/products/customer-refund/agentic/workflow.go new file mode 100644 index 000000000..ca730e7c2 --- /dev/null +++ b/examples/go/products/customer-refund/agentic/workflow.go @@ -0,0 +1,1208 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package agentic + +import ( + "errors" + "fmt" + "time" + + refundmodel "github.com/superdurable/dex/examples/go/products/customer-refund/model" + "github.com/superdurable/dex/sdk-go/dex" +) + +const ( + statusOpen = "open" + statusGathering = "gathering" + statusExecuting = "executing" + statusAwaitingManagerRule = "awaiting-manager-rule" + statusAwaitingManagerAgent = "awaiting-manager-agent" + statusResolved = "resolved" + statusDenied = "denied" + statusRefunded = "refunded" + statusCredited = "credited" + statusBusinessFailure = "business-failure" + statusOutcomeUnknown = "outcome-unknown" + statusCustomerUninformed = "customer-uninformed" + statusFollowUpSubscription = "follow-up-subscription" + statusNonConvergence = "non-convergence" + statusNotARefund = "not-a-refund" + actionIssueRefund = "IssueRefund" + actionOfferAccountCredit = "OfferAccountCredit" + actionRequestHumanApproval = "RequestHumanApproval" + managerThresholdCents = int64(1_000_000) + decisionRoundsBudget = int64(12) +) + +var agenticInputEmail = dex.DefineAttribute[string]("in-email") + +var agenticChargeReference = dex.DefineAttribute[string]("in-charge-ref") + +var agenticPaymentAmount = dex.DefineAttribute[string]("ev-payment-amount") + +var agenticIdentityStatus = dex.DefineAttribute[string]("ev-identity-status") + +var agenticSubscriptionStatus = dex.DefineAttribute[string]("ev-subscription-status") + +var agenticPaymentStatus = dex.DefineAttribute[string]("ev-payment-status") + +var agenticPaymentAgeDays = dex.DefineAttribute[int64]("ev-payment-age-days") + +var agenticUsageStatus = dex.DefineAttribute[string]("ev-usage-status") + +var agenticUsagePercent = dex.DefineAttribute[int64]("ev-usage-pct-of-allowance") + +var agenticHistoryStatus = dex.DefineAttribute[string]("ev-history-status") + +var agenticTenureYears = dex.DefineAttribute[int64]("ev-history-tenure-years") + +var agenticPriorRefunds = dex.DefineAttribute[int64]("ev-history-prior-refunds") + +var agenticCancellationPending = dex.DefineAttribute[bool]("ev-history-cancel-requested") + +var agenticIncidentStatus = dex.DefineAttribute[string]("ev-incident-status") + +var agenticIncidentDays = dex.DefineAttribute[int64]("ev-incident-days") + +var agenticEvidenceState = dex.DefineAttribute[string]("evidence-state") + +var agenticRecommendedAction = dex.DefineAttribute[string]("recommended-action") + +var agenticRationale = dex.DefineAttribute[string]("recommendation-rationale") + +var agenticDecisionRounds = dex.DefineAttribute[int64]("decision-rounds") + +var agenticGuardrailVerdict = dex.DefineAttribute[string]("guardrail-verdict") + +var agenticGuardrailRule = dex.DefineAttribute[string]("guardrail-rule") + +var agenticBoundAction = dex.DefineAttribute[string]("bound-action") + +var agenticManagerVerdict = dex.DefineAttribute[string]("manager-verdict") + +var agenticManagerRejectionReason = dex.DefineAttribute[string]("manager-rejection-reason") + +var agenticGateRequestKey = dex.DefineAttribute[string]("gate-request-key") + +var agenticGateEntries = dex.DefineAttribute[int64]("gate-entries") + +var agenticBillingKey = dex.DefineAttribute[string]("billing-key") + +var agenticBillingOutcome = dex.DefineAttribute[string]("billing-outcome") + +var agenticSubscriptionApplied = dex.DefineAttribute[string]("subscription-applied") + +var agenticEmailSent = dex.DefineAttribute[string]("email-sent") + +var agenticOperatorNote = dex.DefineAttribute[string]("operator-note") + +// dex:indexed-attribute value-type:string attribute-key:case-status description:"Current case status" index-type:keyword index-key:case-status +var agenticCaseStatus = dex.DefineAttribute[string]( + "case-status", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) + +var agenticManagerApproval = dex.DefineChannelMap[string]("manager-approval") + +type RejectRefundInput struct { + Reason string `json:"reason"` + GateRequestKey string `json:"gateRequestKey"` +} + +type AgenticCustomerRefundFlow struct { + dex.FlowDefaults + service refundmodel.Service +} + +func NewAgenticCustomerRefundFlow(service refundmodel.Service) *AgenticCustomerRefundFlow { + if service == nil { + panic("customer refund service is required") + } + return &AgenticCustomerRefundFlow{service: service} +} + +func (*AgenticCustomerRefundFlow) GetFlowType() string { + return "AgenticCustomerRefundFlow" +} + +func (flow *AgenticCustomerRefundFlow) GetSteps() []dex.StepDef { + return []dex.StepDef{ + dex.DefineStartStep(agenticReceiveRequestStep{}), + dex.DefineStep(agenticDecisionStep{}), + dex.DefineStep(agenticCheckIdentityStep{service: flow.service}), + dex.DefineStep(agenticGetSubscriptionStep{service: flow.service}), + dex.DefineStep(agenticGetPaymentStep{service: flow.service}), + dex.DefineStep(agenticGetUsageStep{service: flow.service}), + dex.DefineStep(agenticGetSupportHistoryStep{service: flow.service}), + dex.DefineStep(agenticCheckIncidentsStep{service: flow.service}), + dex.DefineStep(agenticGuardrailStep{}), + dex.DefineStep(agenticReCheckStep{}), + dex.DefineStep(agenticRequestHumanApprovalStep{}), + dex.DefineStep(agenticAuditIntentStep{}), + dex.DefineStep(agenticIssueRefundStep{service: flow.service}), + dex.DefineStep(agenticOfferAccountCreditStep{service: flow.service}), + dex.DefineStep(agenticVerifyBillingStep{service: flow.service}), + dex.DefineStep(agenticApplySubscriptionStep{service: flow.service}), + dex.DefineStep(agenticSendCustomerMessageStep{service: flow.service}), + dex.DefineStep(agenticNonConvergenceStep{}), + dex.DefineStep(agenticNotARefundStep{}), + dex.DefineStep(agenticBillingFailedStep{}), + dex.DefineStep(agenticSubscriptionFailedStep{}), + dex.DefineStep(agenticEmailFailedStep{}), + dex.DefineStep(agenticCloseCaseStep{}), + } +} + +func (flow *AgenticCustomerRefundFlow) GetRPCs() []dex.RPCDef { + actionOptions := &dex.RPCOptions{ + LockAttributes: []dex.AttributeLock{ + dex.LockAttribute(agenticCaseStatus), + dex.LockAttribute(agenticGateRequestKey), + }, + } + return []dex.RPCDef{ + dex.DefineRPC(flow.GetDexSummary, nil), + dex.DefineRPC(flow.GetDexDisplay, nil), + dex.DefineRPC(flow.ApproveRefund, actionOptions), + dex.DefineRPC(flow.RejectRefund, actionOptions), + } +} + +func (*AgenticCustomerRefundFlow) GetPersistenceSchema() dex.PersistenceSchema { + return dex.PersistenceSchema{ + Attributes: []dex.AttributeDef{ + agenticInputEmail, + agenticChargeReference, + agenticPaymentAmount, + agenticIdentityStatus, + agenticSubscriptionStatus, + agenticPaymentStatus, + agenticPaymentAgeDays, + agenticUsageStatus, + agenticUsagePercent, + agenticHistoryStatus, + agenticTenureYears, + agenticPriorRefunds, + agenticCancellationPending, + agenticIncidentStatus, + agenticIncidentDays, + agenticEvidenceState, + agenticRecommendedAction, + agenticRationale, + agenticDecisionRounds, + agenticGuardrailVerdict, + agenticGuardrailRule, + agenticBoundAction, + agenticManagerVerdict, + agenticManagerRejectionReason, + agenticGateRequestKey, + agenticGateEntries, + agenticBillingKey, + agenticBillingOutcome, + agenticSubscriptionApplied, + agenticEmailSent, + agenticOperatorNote, + agenticCaseStatus, + }, + Channels: []dex.ChannelDef{agenticManagerApproval}, + } +} + +// dex:field editable:false description:"Charge reference" value-type:string attribute-key:in-charge-ref +// dex:field attribute-key:ev-payment-amount value-type:string editable:false description:"Payment amount" +// dex:field attribute-key:recommended-action value-type:string editable:false description:"Recommended action" +// dex:field attribute-key:guardrail-rule value-type:string editable:false description:"Guardrail rule" +func (*AgenticCustomerRefundFlow) GetDexSummary( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + chargeReference, err := agenticOptionalDisplayAttribute(ctx, agenticChargeReference) + if err != nil { + return nil, err + } + paymentAmount, err := agenticOptionalDisplayAttribute(ctx, agenticPaymentAmount) + if err != nil { + return nil, err + } + recommendedAction, err := agenticOptionalDisplayAttribute(ctx, agenticRecommendedAction) + if err != nil { + return nil, err + } + guardrailRule, err := agenticOptionalDisplayAttribute(ctx, agenticGuardrailRule) + if err != nil { + return nil, err + } + return &dex.RPCResult[map[string]any]{Output: map[string]any{ + "in-charge-ref": chargeReference, + "ev-payment-amount": paymentAmount, + "recommended-action": recommendedAction, + "guardrail-rule": guardrailRule, + }}, nil +} + +// dex:field attribute-key:in-email value-type:string editable:false description:"Customer request" +// dex:field attribute-key:in-charge-ref value-type:string editable:false description:"Charge reference" +// dex:field attribute-key:evidence-state value-type:string editable:false description:"Evidence state" +// dex:field attribute-key:recommended-action value-type:string editable:false description:"Recommendation" +// dex:field attribute-key:recommendation-rationale value-type:string editable:false description:"Recommendation rationale" +// dex:field attribute-key:guardrail-verdict value-type:string editable:false description:"Guardrail verdict" +// dex:field attribute-key:guardrail-rule value-type:string editable:false description:"Guardrail rule" +// dex:field attribute-key:manager-verdict value-type:string editable:false description:"Manager verdict" +// dex:field attribute-key:gate-request-key value-type:string editable:false description:"Approval gate" +// dex:field attribute-key:billing-outcome value-type:string editable:false description:"Billing effect" +// dex:field attribute-key:subscription-applied value-type:string editable:false description:"Subscription effect" +// dex:field attribute-key:email-sent value-type:string editable:false description:"Customer message effect" +// dex:field attribute-key:operator-note value-type:string editable:true description:"Operator note" +func (*AgenticCustomerRefundFlow) GetDexDisplay( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + fields := []struct { + key string + attribute dex.Attribute[string] + }{ + {"in-email", agenticInputEmail}, + {"in-charge-ref", agenticChargeReference}, + {"evidence-state", agenticEvidenceState}, + {"recommended-action", agenticRecommendedAction}, + {"recommendation-rationale", agenticRationale}, + {"guardrail-verdict", agenticGuardrailVerdict}, + {"guardrail-rule", agenticGuardrailRule}, + {"manager-verdict", agenticManagerVerdict}, + {"gate-request-key", agenticGateRequestKey}, + {"billing-outcome", agenticBillingOutcome}, + {"subscription-applied", agenticSubscriptionApplied}, + {"email-sent", agenticEmailSent}, + {"operator-note", agenticOperatorNote}, + } + output := make(map[string]any, len(fields)) + for _, field := range fields { + value, err := agenticOptionalDisplayAttribute(ctx, field.attribute) + if err != nil { + return nil, err + } + output[field.key] = value + } + return &dex.RPCResult[map[string]any]{Output: map[string]any{ + "in-email": output["in-email"], + "in-charge-ref": output["in-charge-ref"], + "evidence-state": output["evidence-state"], + "recommended-action": output["recommended-action"], + "recommendation-rationale": output["recommendation-rationale"], + "guardrail-verdict": output["guardrail-verdict"], + "guardrail-rule": output["guardrail-rule"], + "manager-verdict": output["manager-verdict"], + "gate-request-key": output["gate-request-key"], + "billing-outcome": output["billing-outcome"], + "subscription-applied": output["subscription-applied"], + "email-sent": output["email-sent"], + "operator-note": output["operator-note"], + }}, nil +} + +// dex:action action-label:"Approve" +// dex:when values:["awaiting-manager-rule","awaiting-manager-agent"] operator:in attribute-key:case-status +func (*AgenticCustomerRefundFlow) ApproveRefund( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[dex.None], error) { + gateRequestKey, err := agenticValidateOpenGate(ctx) + if err != nil { + return nil, err + } + if err := agenticManagerApproval.Publish(ctx, gateRequestKey, "approve"); err != nil { + return nil, err + } + return &dex.RPCResult[dex.None]{}, nil +} + +// dex:action action-label:"Reject" +// dex:when attribute-key:case-status operator:in values:["awaiting-manager-rule","awaiting-manager-agent"] +// dex:input field-name:reason value-type:string source:user required:true description:"Rejection reason" +// dex:input description:"Approval gate" required:true source:attribute attribute-key:gate-request-key value-type:string field-name:gateRequestKey +func (*AgenticCustomerRefundFlow) RejectRefund( + ctx dex.Context, + input RejectRefundInput, +) (*dex.RPCResult[dex.None], error) { + gateRequestKey, err := agenticValidateOpenGate(ctx) + if err != nil { + return nil, err + } + if input.GateRequestKey != gateRequestKey { + return nil, fmt.Errorf("approval gate changed; refresh the case before rejecting") + } + if input.Reason == "" { + return nil, fmt.Errorf("rejection reason is required") + } + if err := agenticManagerRejectionReason.Set(ctx, input.Reason); err != nil { + return nil, err + } + if err := agenticManagerApproval.Publish(ctx, gateRequestKey, "reject"); err != nil { + return nil, err + } + return &dex.RPCResult[dex.None]{}, nil +} + +// dex:group group-id:intake group-label:"Intake" +// dex:explanation text:"Store the inbound refund request and open or reject the case." +type agenticReceiveRequestStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticReceiveRequestStep) GetStepType() string { + return "ReceiveRequestStep" +} + +func (agenticReceiveRequestStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticInputEmail.Set(ctx, refundCase.CustomerNote); err != nil { + return nil, err + } + if err := agenticChargeReference.Set(ctx, refundCase.CaseID); err != nil { + return nil, err + } + if err := agenticOperatorNote.Set(ctx, ""); err != nil { + return nil, err + } + if refundCase.CaseID == "" { + if err := agenticCaseStatus.Set(ctx, statusNotARefund); err != nil { + return nil, err + } + return dex.GoTo(agenticNotARefundStep{}, refundCase), nil + } + if err := agenticCaseStatus.Set(ctx, statusOpen); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:reasoning group-label:"Reasoning" +// dex:explanation text:"Choose the next capability from gathered evidence and guardrails." +type agenticDecisionStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticDecisionStep) GetStepType() string { + return "AgentDecisionStep" +} + +func (agenticDecisionStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + rounds, _, err := agenticOptionalAttribute(ctx, agenticDecisionRounds) + if err != nil { + return nil, err + } + if rounds >= decisionRoundsBudget { + return dex.GoTo(agenticNonConvergenceStep{}, refundCase), nil + } + if err := agenticDecisionRounds.Set(ctx, rounds+1); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusGathering); err != nil { + return nil, err + } + _, hasIdentity, err := agenticOptionalAttribute(ctx, agenticIdentityStatus) + if err != nil { + return nil, err + } + if !hasIdentity { + return dex.GoTo(agenticCheckIdentityStep{}, refundCase), nil + } + _, hasSubscription, err := agenticOptionalAttribute(ctx, agenticSubscriptionStatus) + if err != nil { + return nil, err + } + if !hasSubscription { + return dex.GoTo(agenticGetSubscriptionStep{}, refundCase), nil + } + _, hasPayment, err := agenticOptionalAttribute(ctx, agenticPaymentStatus) + if err != nil { + return nil, err + } + if !hasPayment { + return dex.GoTo(agenticGetPaymentStep{}, refundCase), nil + } + _, hasUsage, err := agenticOptionalAttribute(ctx, agenticUsageStatus) + if err != nil { + return nil, err + } + if !hasUsage { + return dex.GoTo(agenticGetUsageStep{}, refundCase), nil + } + _, hasHistory, err := agenticOptionalAttribute(ctx, agenticHistoryStatus) + if err != nil { + return nil, err + } + if !hasHistory { + return dex.GoTo(agenticGetSupportHistoryStep{}, refundCase), nil + } + _, hasIncidents, err := agenticOptionalAttribute(ctx, agenticIncidentStatus) + if err != nil { + return nil, err + } + if !hasIncidents { + return dex.GoTo(agenticCheckIncidentsStep{}, refundCase), nil + } + action, rationale, err := agenticChooseAction(ctx, refundCase) + if err != nil { + return nil, err + } + if err := agenticRecommendedAction.Set(ctx, action); err != nil { + return nil, err + } + if err := agenticRationale.Set(ctx, rationale); err != nil { + return nil, err + } + return dex.GoTo(agenticGuardrailStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Verify the customer identity before collecting further evidence." +type agenticCheckIdentityStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticCheckIdentityStep) GetStepType() string { + return "CheckIdentityStep" +} + +func (step agenticCheckIdentityStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticIdentityStatus.Set(ctx, step.service.LookupEvidence(refundCase).IdentityStatus); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Load the customer's subscription details for the case." +type agenticGetSubscriptionStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticGetSubscriptionStep) GetStepType() string { + return "GetSubscriptionStep" +} + +func (step agenticGetSubscriptionStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticSubscriptionStatus.Set(ctx, step.service.LookupEvidence(refundCase).SubscriptionStatus); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Load the payment and charge evidence for the refund." +type agenticGetPaymentStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticGetPaymentStep) GetStepType() string { + return "GetPaymentStep" +} + +func (step agenticGetPaymentStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + evidence := step.service.LookupEvidence(refundCase) + if err := agenticPaymentStatus.Set(ctx, evidence.PaymentStatus); err != nil { + return nil, err + } + if err := agenticPaymentAgeDays.Set(ctx, refundCase.OrderAgeDays); err != nil { + return nil, err + } + if err := agenticPaymentAmount.Set(ctx, fmt.Sprintf("%.2f", float64(refundCase.AmountCents)/100)); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Load usage signals that may support or deny a refund." +type agenticGetUsageStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticGetUsageStep) GetStepType() string { + return "GetUsageStep" +} + +func (step agenticGetUsageStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + evidence := step.service.LookupEvidence(refundCase) + if err := agenticUsageStatus.Set(ctx, evidence.UsageStatus); err != nil { + return nil, err + } + if err := agenticUsagePercent.Set(ctx, evidence.UsagePercent); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Load prior support history for this customer." +type agenticGetSupportHistoryStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticGetSupportHistoryStep) GetStepType() string { + return "GetSupportHistoryStep" +} + +func (step agenticGetSupportHistoryStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + evidence := step.service.LookupEvidence(refundCase) + if err := agenticHistoryStatus.Set(ctx, evidence.HistoryStatus); err != nil { + return nil, err + } + if err := agenticTenureYears.Set(ctx, evidence.TenureYears); err != nil { + return nil, err + } + if err := agenticPriorRefunds.Set(ctx, evidence.PriorRefunds); err != nil { + return nil, err + } + if err := agenticCancellationPending.Set(ctx, evidence.CancellationPending); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Check for active incidents that affect refund policy." +type agenticCheckIncidentsStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticCheckIncidentsStep) GetStepType() string { + return "CheckIncidentsStep" +} + +func (step agenticCheckIncidentsStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + evidence := step.service.LookupEvidence(refundCase) + if err := agenticIncidentStatus.Set(ctx, evidence.IncidentStatus); err != nil { + return nil, err + } + if err := agenticIncidentDays.Set(ctx, evidence.IncidentDays); err != nil { + return nil, err + } + if err := agenticEvidenceState.Set(ctx, "complete"); err != nil { + return nil, err + } + return dex.GoTo(agenticDecisionStep{}, refundCase), nil +} + +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Apply refund guardrails and set the recommended action." +type agenticGuardrailStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticGuardrailStep) GetStepType() string { + return "GuardrailStep" +} + +func (agenticGuardrailStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + action, err := agenticRecommendedAction.Get(ctx) + if err != nil { + return nil, err + } + verdict := "auto" + rule := "standard-policy" + status := statusExecuting + if action == actionRequestHumanApproval { + verdict = "escalate-agent" + rule = "agent-uncertainty" + status = statusAwaitingManagerAgent + } else if refundCase.AmountCents > managerThresholdCents || refundCase.CaseID == "rule-escalation" || refundCase.CaseID == "incident" { + verdict = "escalate-rule" + rule = "manager-approval-required" + status = statusAwaitingManagerRule + } + if err := agenticGuardrailVerdict.Set(ctx, verdict); err != nil { + return nil, err + } + if err := agenticGuardrailRule.Set(ctx, rule); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, status); err != nil { + return nil, err + } + if verdict != "auto" { + gateEntries, _, getErr := agenticOptionalAttribute(ctx, agenticGateEntries) + if getErr != nil { + return nil, getErr + } + gateEntries++ + if err := agenticGateEntries.Set(ctx, gateEntries); err != nil { + return nil, err + } + if err := agenticGateRequestKey.Set(ctx, fmt.Sprintf("%s:gate:%d", refundCase.CaseID, gateEntries)); err != nil { + return nil, err + } + return dex.GoTo(agenticRequestHumanApprovalStep{}, refundCase), nil + } + if err := agenticBoundAction.Set(ctx, action); err != nil { + return nil, err + } + return dex.GoTo(agenticAuditIntentStep{}, refundCase), nil +} + +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Re-check evidence after a capability returns to the decision loop." +type agenticReCheckStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticReCheckStep) GetStepType() string { + return "ReCheckStep" +} + +func (agenticReCheckStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + verdict, err := agenticManagerVerdict.Get(ctx) + if err != nil { + return nil, err + } + if verdict != "approve" { + if err := agenticCaseStatus.Set(ctx, statusDenied); err != nil { + return nil, err + } + return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil + } + if err := agenticBoundAction.Set(ctx, actionIssueRefund); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusExecuting); err != nil { + return nil, err + } + return dex.GoTo(agenticAuditIntentStep{}, refundCase), nil +} + +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Ask a human to approve or reject the recommended refund action." +type agenticRequestHumanApprovalStep struct { + dex.StepDefaults +} + +func (agenticRequestHumanApprovalStep) GetStepType() string { + return "RequestHumanApprovalStep" +} + +func (agenticRequestHumanApprovalStep) WaitFor( + ctx dex.Context, + _ refundmodel.RefundCase, +) (*dex.Wait, error) { + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return nil, err + } + return dex.Until(agenticManagerApproval.ForOne(gateRequestKey)), nil +} + +func (agenticRequestHumanApprovalStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return nil, err + } + verdicts, err := agenticManagerApproval.GetConditionResults(ctx, gateRequestKey) + if err != nil { + return nil, err + } + if len(verdicts) != 1 { + return nil, fmt.Errorf("approval gate expected one verdict") + } + if err := agenticManagerVerdict.Set(ctx, verdicts[0]); err != nil { + return nil, err + } + return dex.GoTo(agenticReCheckStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Record the approved refund intent before billing changes." +type agenticAuditIntentStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticAuditIntentStep) GetStepType() string { + return "AuditIntentStep" +} + +func (agenticAuditIntentStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + action, err := agenticBoundAction.Get(ctx) + if err != nil { + return nil, err + } + if action == actionOfferAccountCredit { + return dex.GoTo(agenticOfferAccountCreditStep{}, refundCase), nil + } + return dex.GoTo(agenticIssueRefundStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Issue the refund through billing." +type agenticIssueRefundStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticIssueRefundStep) GetStepType() string { + return "IssueRefundStep" +} + +func (agenticIssueRefundStep) GetStepOptions() *dex.StepOptions { + return &dex.StepOptions{ + ExecuteRetry: &dex.RetryPolicy{ + InitialInterval: time.Second, BackoffCoefficient: 2, MaximumInterval: 10 * time.Second, MaximumAttempts: 4, + }, + ExecuteFailure: dex.ProceedToOnExecuteFailure(agenticBillingFailedStep{}, nil), + } +} + +func (step agenticIssueRefundStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + key := refundCase.CaseID + ":refund" + if err := agenticBillingKey.Set(ctx, key); err != nil { + return nil, err + } + outcome := step.service.IssueRefund(key, refundCase) + if err := agenticBillingOutcome.Set(ctx, outcome); err != nil { + return nil, err + } + switch outcome { + case refundmodel.BillingConfirmed: + if err := agenticCaseStatus.Set(ctx, statusRefunded); err != nil { + return nil, err + } + return dex.GoTo(agenticApplySubscriptionStep{}, refundCase), nil + case refundmodel.BillingUnknown: + return dex.GoTo(agenticVerifyBillingStep{}, refundCase), nil + default: + return dex.GoTo(agenticBillingFailedStep{}, refundCase), nil + } +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Offer account credit instead of a cash refund." +type agenticOfferAccountCreditStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticOfferAccountCreditStep) GetStepType() string { + return "OfferAccountCreditStep" +} + +func (step agenticOfferAccountCreditStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + key := refundCase.CaseID + ":credit" + if err := agenticBillingKey.Set(ctx, key); err != nil { + return nil, err + } + outcome := step.service.IssueCredit(key, refundCase) + if err := agenticBillingOutcome.Set(ctx, outcome); err != nil { + return nil, err + } + if outcome != refundmodel.BillingConfirmed { + return dex.GoTo(agenticBillingFailedStep{}, refundCase), nil + } + if err := agenticCaseStatus.Set(ctx, statusCredited); err != nil { + return nil, err + } + return dex.GoTo(agenticApplySubscriptionStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Verify the billing outcome after a refund or credit." +type agenticVerifyBillingStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticVerifyBillingStep) GetStepType() string { + return "VerifyBillingStep" +} + +func (step agenticVerifyBillingStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + key, err := agenticBillingKey.Get(ctx) + if err != nil { + return nil, err + } + outcome := step.service.LookupBillingOutcome(key) + if err := agenticBillingOutcome.Set(ctx, outcome); err != nil { + return nil, err + } + if outcome == refundmodel.BillingConfirmed { + if err := agenticCaseStatus.Set(ctx, statusRefunded); err != nil { + return nil, err + } + return dex.GoTo(agenticApplySubscriptionStep{}, refundCase), nil + } + if outcome == refundmodel.BillingUnknown { + if err := agenticCaseStatus.Set(ctx, statusOutcomeUnknown); err != nil { + return nil, err + } + return dex.GoTo(agenticCloseCaseStep{}, refundCase), nil + } + return dex.GoTo(agenticBillingFailedStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Apply any subscription change required by the resolution." +type agenticApplySubscriptionStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticApplySubscriptionStep) GetStepType() string { + return "ApplySubscriptionStep" +} + +func (agenticApplySubscriptionStep) GetStepOptions() *dex.StepOptions { + return &dex.StepOptions{ExecuteFailure: dex.ProceedToOnExecuteFailure(agenticSubscriptionFailedStep{}, nil)} +} + +func (step agenticApplySubscriptionStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := step.service.ApplySubscriptionState(refundCase); err != nil { + return nil, err + } + if err := agenticSubscriptionApplied.Set(ctx, "yes"); err != nil { + return nil, err + } + return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Send the customer the resolution message." +type agenticSendCustomerMessageStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticSendCustomerMessageStep) GetStepType() string { + return "SendCustomerMessageStep" +} + +func (agenticSendCustomerMessageStep) GetStepOptions() *dex.StepOptions { + return &dex.StepOptions{ExecuteFailure: dex.ProceedToOnExecuteFailure(agenticEmailFailedStep{}, nil)} +} + +func (step agenticSendCustomerMessageStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + status, err := agenticCaseStatus.Get(ctx) + if err != nil { + return nil, err + } + message := "We have finished reviewing your request." + switch status { + case statusRefunded: + message = "Your refund has been issued." + case statusCredited: + message = "We applied account credit." + case statusDenied: + message = "We cannot approve this refund." + case statusBusinessFailure: + message = "We could not process a refund on this charge." + } + if err := step.service.SendCustomerMessage(refundCase, message); err != nil { + return nil, err + } + if err := agenticEmailSent.Set(ctx, "yes"); err != nil { + return nil, err + } + if status != statusDenied && status != statusBusinessFailure { + if err := agenticCaseStatus.Set(ctx, statusResolved); err != nil { + return nil, err + } + } + return dex.GoTo(agenticCloseCaseStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Close the loop when the agent cannot converge on an action." +type agenticNonConvergenceStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticNonConvergenceStep) GetStepType() string { + return "NonConvergenceStep" +} + +func (agenticNonConvergenceStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticCaseStatus.Set(ctx, statusNonConvergence); err != nil { + return nil, err + } + return dex.GoTo(agenticCloseCaseStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Mark the case as not a refund request." +type agenticNotARefundStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticNotARefundStep) GetStepType() string { + return "NotARefundStep" +} + +func (agenticNotARefundStep) Execute( + _ dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + return dex.GoTo(agenticCloseCaseStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Handle a billing failure during refund or credit." +type agenticBillingFailedStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticBillingFailedStep) GetStepType() string { + return "BillingFailedStep" +} + +func (agenticBillingFailedStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticBillingOutcome.Set(ctx, refundmodel.BillingDeclined); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusBusinessFailure); err != nil { + return nil, err + } + return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Handle a subscription update failure after a resolution." +type agenticSubscriptionFailedStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticSubscriptionFailedStep) GetStepType() string { + return "SubscriptionFailedStep" +} + +func (agenticSubscriptionFailedStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticSubscriptionApplied.Set(ctx, "no"); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusFollowUpSubscription); err != nil { + return nil, err + } + return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Handle a failure sending the customer resolution message." +type agenticEmailFailedStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticEmailFailedStep) GetStepType() string { + return "EmailFailedStep" +} + +func (agenticEmailFailedStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := agenticEmailSent.Set(ctx, "no"); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusCustomerUninformed); err != nil { + return nil, err + } + return dex.GoTo(agenticCloseCaseStep{}, refundCase), nil +} + +// dex:group group-id:close group-label:"Close" +// dex:explanation text:"Close the refund case once resolution is final." +type agenticCloseCaseStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (agenticCloseCaseStep) GetStepType() string { + return "CloseCaseStep" +} + +func (agenticCloseCaseStep) Execute( + _ dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + return dex.GracefulComplete("refund:" + refundCase.CaseID), nil +} + +func agenticChooseAction( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (string, string, error) { + identityStatus, err := agenticIdentityStatus.Get(ctx) + if err != nil { + return "", "", err + } + usageStatus, err := agenticUsageStatus.Get(ctx) + if err != nil { + return "", "", err + } + usagePercent, err := agenticUsagePercent.Get(ctx) + if err != nil { + return "", "", err + } + priorRefunds, err := agenticPriorRefunds.Get(ctx) + if err != nil { + return "", "", err + } + cancellationPending, err := agenticCancellationPending.Get(ctx) + if err != nil { + return "", "", err + } + incidentStatus, err := agenticIncidentStatus.Get(ctx) + if err != nil { + return "", "", err + } + if identityStatus == "unavailable" || usageStatus == "unavailable" { + return actionRequestHumanApproval, "material evidence is unavailable", nil + } + if cancellationPending { + return actionIssueRefund, "the cancellation request was not actioned", nil + } + if incidentStatus == "present" { + return actionIssueRefund, "a documented incident requires manager judgment", nil + } + if refundCase.OrderAgeDays <= 30 && usageStatus == "confirmed-empty" { + return actionIssueRefund, "inside the refund window with no recorded usage", nil + } + if usagePercent >= 80 && priorRefunds > 0 { + return actionOfferAccountCredit, "heavy usage and a prior refund favor account credit", nil + } + if refundCase.OrderAgeDays > 30 { + return actionOfferAccountCredit, "outside the standard refund window", nil + } + return actionIssueRefund, "available evidence supports a refund", nil +} + +func agenticValidateOpenGate(ctx dex.Context) (string, error) { + status, err := agenticCaseStatus.Get(ctx) + if err != nil { + return "", err + } + if status != statusAwaitingManagerRule && status != statusAwaitingManagerAgent { + return "", fmt.Errorf("refund is not awaiting manager action") + } + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return "", err + } + if gateRequestKey == "" { + return "", fmt.Errorf("approval gate is missing") + } + return gateRequestKey, nil +} + +func agenticOptionalAttribute[T any]( + ctx dex.Context, + attribute dex.Attribute[T], +) (T, bool, error) { + value, err := attribute.Get(ctx) + if err == nil { + return value, true, nil + } + var notFound *dex.AttributeNotFoundError + if errors.As(err, ¬Found) { + var zero T + return zero, false, nil + } + var zero T + return zero, false, err +} + +func agenticOptionalDisplayAttribute[T any](ctx dex.Context, attribute dex.Attribute[T]) (any, error) { + value, found, err := agenticOptionalAttribute(ctx, attribute) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + return value, nil +} + +var ( + _ dex.Flow = (*AgenticCustomerRefundFlow)(nil) + _ dex.RPC[dex.None, map[string]any] = (*AgenticCustomerRefundFlow)(nil).GetDexSummary + _ dex.RPC[dex.None, map[string]any] = (*AgenticCustomerRefundFlow)(nil).GetDexDisplay + _ dex.RPC[dex.None, dex.None] = (*AgenticCustomerRefundFlow)(nil).ApproveRefund + _ dex.RPC[RejectRefundInput, dex.None] = (*AgenticCustomerRefundFlow)(nil).RejectRefund +) diff --git a/examples/go/products/customer-refund/controller.go b/examples/go/products/customer-refund/controller.go new file mode 100644 index 000000000..d6d80d8a7 --- /dev/null +++ b/examples/go/products/customer-refund/controller.go @@ -0,0 +1,88 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package customerrefund + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superdurable/dex/examples/go/products/customer-refund/agentic" + "github.com/superdurable/dex/examples/go/products/customer-refund/deterministic" + refundmodel "github.com/superdurable/dex/examples/go/products/customer-refund/model" + "github.com/superdurable/dex/examples/go/server/httputil" + "github.com/superdurable/dex/sdk-go/dex" +) + +type startRefundRequest struct { + FlowID string `json:"flowId"` + Case refundmodel.RefundCase `json:"case"` +} + +type controller struct { + client *dex.Client + deterministicFlow *deterministic.CustomerRefundFlow + agenticFlow *agentic.AgenticCustomerRefundFlow +} + +func RegisterRoutes( + router gin.IRouter, + client *dex.Client, + deterministicFlow *deterministic.CustomerRefundFlow, + agenticFlow *agentic.AgenticCustomerRefundFlow, +) { + controller := &controller{ + client: client, deterministicFlow: deterministicFlow, agenticFlow: agenticFlow, + } + group := router.Group("/products/customer-refund") + group.POST("/deterministic/start", controller.startDeterministic) + group.POST("/agentic/start", controller.startAgentic) +} + +func (controller *controller) startDeterministic(request *gin.Context) { + controller.start(request, controller.deterministicFlow) +} + +func (controller *controller) startAgentic(request *gin.Context) { + controller.start(request, controller.agenticFlow) +} + +func (controller *controller) start(request *gin.Context, flow dex.Flow) { + var body startRefundRequest + if err := request.ShouldBindJSON(&body); err != nil { + request.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + flowID := body.FlowID + if flowID == "" { + flowID = httputil.NewFlowID("customer-refund") + } + if body.Case.CaseID == "" { + body.Case.CaseID = flowID + } + runID, err := controller.client.StartFlow( + request.Request.Context(), flow, flowID, body.Case, dex.StartFlowOptions{}, + ) + if err != nil { + httputil.Respond(request, nil, err) + return + } + httputil.Respond(request, gin.H{"flowID": flowID, "runID": runID}, nil) +} diff --git a/examples/go/products/customer-refund/deterministic/workflow.go b/examples/go/products/customer-refund/deterministic/workflow.go new file mode 100644 index 000000000..eba502175 --- /dev/null +++ b/examples/go/products/customer-refund/deterministic/workflow.go @@ -0,0 +1,440 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package deterministic + +import ( + "errors" + "fmt" + "time" + + refundmodel "github.com/superdurable/dex/examples/go/products/customer-refund/model" + "github.com/superdurable/dex/sdk-go/dex" +) + +const ( + statusReceived = "received" + statusOrderChecked = "order-checked" + statusOrderNotFound = "order-not-found" + statusRefunded = "refunded" + statusBillingDeclined = "billing-declined" + statusBillingUnconfirmed = "billing-unconfirmed" + statusDenied = "denied" + statusResolved = "resolved" + standardWindowDays = int64(30) +) + +var deterministicChargeReference = dex.DefineAttribute[string]("charge-reference") + +var deterministicRefundAmount = dex.DefineAttribute[string]("refund-amount") + +var deterministicOrderAgeDays = dex.DefineAttribute[int64]("order-age-days") + +var deterministicOrderLookup = dex.DefineAttribute[string]("order-lookup") + +var deterministicRefundKey = dex.DefineAttribute[string]("refund-key") + +var deterministicBillingOutcome = dex.DefineAttribute[string]("billing-outcome") + +var deterministicRecommendation = dex.DefineAttribute[string]("recommended-action") + +var deterministicOperatorNote = dex.DefineAttribute[string]("operator-note") + +// dex:indexed-attribute attribute-key:case-status index-key:case-status index-type:keyword value-type:string description:"Current case status" +var deterministicCaseStatus = dex.DefineAttribute[string]( + "case-status", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}), +) + +type CustomerRefundFlow struct { + dex.FlowDefaults + service refundmodel.Service +} + +func NewCustomerRefundFlow(service refundmodel.Service) *CustomerRefundFlow { + if service == nil { + panic("customer refund service is required") + } + return &CustomerRefundFlow{service: service} +} + +func (*CustomerRefundFlow) GetFlowType() string { + return "CustomerRefundFlow" +} + +func (flow *CustomerRefundFlow) GetSteps() []dex.StepDef { + return []dex.StepDef{ + dex.DefineStartStep(deterministicReceiveRequestStep{}), + dex.DefineStep(deterministicCheckOrderStep{}), + dex.DefineStep(deterministicCheckPolicyStep{}), + dex.DefineStep(deterministicIssueRefundStep{service: flow.service}), + dex.DefineStep(deterministicDenyRefundStep{}), + dex.DefineStep(deterministicNotifyCustomerStep{service: flow.service}), + dex.DefineStep(deterministicCloseCaseStep{}), + } +} + +func (flow *CustomerRefundFlow) GetRPCs() []dex.RPCDef { + return []dex.RPCDef{ + dex.DefineRPC(flow.GetDexSummary, nil), + dex.DefineRPC(flow.GetDexDisplay, nil), + } +} + +func (*CustomerRefundFlow) GetPersistenceSchema() dex.PersistenceSchema { + return dex.PersistenceSchema{Attributes: []dex.AttributeDef{ + deterministicChargeReference, + deterministicRefundAmount, + deterministicOrderAgeDays, + deterministicOrderLookup, + deterministicRefundKey, + deterministicBillingOutcome, + deterministicRecommendation, + deterministicOperatorNote, + deterministicCaseStatus, + }} +} + +// dex:field description:"Charge reference" editable:false attribute-key:charge-reference value-type:string +// dex:field attribute-key:refund-amount value-type:string editable:false description:"Refund amount" +// dex:field attribute-key:recommended-action value-type:string editable:false description:"Recommended action" +func (*CustomerRefundFlow) GetDexSummary( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + chargeReference, err := deterministicOptionalAttribute(ctx, deterministicChargeReference) + if err != nil { + return nil, err + } + refundAmount, err := deterministicOptionalAttribute(ctx, deterministicRefundAmount) + if err != nil { + return nil, err + } + recommendation, err := deterministicOptionalAttribute(ctx, deterministicRecommendation) + if err != nil { + return nil, err + } + return &dex.RPCResult[map[string]any]{Output: map[string]any{ + "charge-reference": chargeReference, + "refund-amount": refundAmount, + "recommended-action": recommendation, + }}, nil +} + +// dex:field attribute-key:charge-reference value-type:string editable:false description:"Charge reference" +// dex:field attribute-key:refund-amount value-type:string editable:false description:"Requested amount" +// dex:field attribute-key:order-lookup value-type:string editable:false description:"Order evidence" +// dex:field attribute-key:order-age-days value-type:int64 editable:false description:"Order age in days" +// dex:field attribute-key:recommended-action value-type:string editable:false description:"Policy recommendation" +// dex:field attribute-key:billing-outcome value-type:string editable:false description:"Billing outcome" +// dex:field attribute-key:operator-note value-type:string editable:true description:"Operator note" +func (*CustomerRefundFlow) GetDexDisplay( + ctx dex.Context, + _ dex.None, +) (*dex.RPCResult[map[string]any], error) { + chargeReference, err := deterministicOptionalAttribute(ctx, deterministicChargeReference) + if err != nil { + return nil, err + } + refundAmount, err := deterministicOptionalAttribute(ctx, deterministicRefundAmount) + if err != nil { + return nil, err + } + orderLookup, err := deterministicOptionalAttribute(ctx, deterministicOrderLookup) + if err != nil { + return nil, err + } + orderAgeDays, err := deterministicOptionalAttribute(ctx, deterministicOrderAgeDays) + if err != nil { + return nil, err + } + recommendation, err := deterministicOptionalAttribute(ctx, deterministicRecommendation) + if err != nil { + return nil, err + } + billingOutcome, err := deterministicOptionalAttribute(ctx, deterministicBillingOutcome) + if err != nil { + return nil, err + } + operatorNote, err := deterministicOptionalAttribute(ctx, deterministicOperatorNote) + if err != nil { + return nil, err + } + return &dex.RPCResult[map[string]any]{Output: map[string]any{ + "charge-reference": chargeReference, + "refund-amount": refundAmount, + "order-lookup": orderLookup, + "order-age-days": orderAgeDays, + "recommended-action": recommendation, + "billing-outcome": billingOutcome, + "operator-note": operatorNote, + }}, nil +} + +// dex:group group-label:"Intake" group-id:intake +// dex:explanation text:"Store the inbound refund request and start the case." +type deterministicReceiveRequestStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (deterministicReceiveRequestStep) GetStepType() string { + return "ReceiveRequestStep" +} + +func (deterministicReceiveRequestStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + writes := []struct { + attribute dex.Attribute[string] + value string + }{ + {deterministicChargeReference, refundCase.CaseID}, + {deterministicRefundAmount, fmt.Sprintf("%.2f", float64(refundCase.AmountCents)/100)}, + {deterministicOperatorNote, ""}, + {deterministicCaseStatus, statusReceived}, + } + for _, write := range writes { + if err := write.attribute.Set(ctx, write.value); err != nil { + return nil, err + } + } + return dex.GoTo(deterministicCheckOrderStep{}, refundCase), nil +} + +// dex:group group-id:evidence group-label:"Evidence" +// dex:explanation text:"Check the order and charge evidence for the refund." +type deterministicCheckOrderStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (deterministicCheckOrderStep) GetStepType() string { + return "CheckOrderStep" +} + +func (deterministicCheckOrderStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + lookup := "found" + if refundCase.CaseID == "no-such-order" { + lookup = "missing" + } else if err := deterministicOrderAgeDays.Set(ctx, refundCase.OrderAgeDays); err != nil { + return nil, err + } + if err := deterministicOrderLookup.Set(ctx, lookup); err != nil { + return nil, err + } + if err := deterministicCaseStatus.Set(ctx, statusOrderChecked); err != nil { + return nil, err + } + return dex.GoTo(deterministicCheckPolicyStep{}, refundCase), nil +} + +// dex:group group-id:control group-label:"Control" +// dex:explanation text:"Evaluate refund policy and choose approve or deny." +type deterministicCheckPolicyStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (deterministicCheckPolicyStep) GetStepType() string { + return "CheckPolicyStep" +} + +func (deterministicCheckPolicyStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + lookup, err := deterministicOrderLookup.Get(ctx) + if err != nil { + return nil, err + } + if lookup != "found" { + if err := deterministicRecommendation.Set(ctx, "manual-order-follow-up"); err != nil { + return nil, err + } + if err := deterministicCaseStatus.Set(ctx, statusOrderNotFound); err != nil { + return nil, err + } + return dex.GoTo(deterministicNotifyCustomerStep{}, refundCase), nil + } + orderAgeDays, err := deterministicOrderAgeDays.Get(ctx) + if err != nil { + return nil, err + } + if orderAgeDays <= standardWindowDays { + if err := deterministicRecommendation.Set(ctx, "refund"); err != nil { + return nil, err + } + return dex.GoTo(deterministicIssueRefundStep{}, refundCase), nil + } + if err := deterministicRecommendation.Set(ctx, "deny-outside-window"); err != nil { + return nil, err + } + return dex.GoTo(deterministicDenyRefundStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Issue the refund through billing." +type deterministicIssueRefundStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (deterministicIssueRefundStep) GetStepType() string { + return "IssueRefundStep" +} + +func (deterministicIssueRefundStep) GetStepOptions() *dex.StepOptions { + return &dex.StepOptions{ExecuteRetry: &dex.RetryPolicy{ + InitialInterval: time.Second, BackoffCoefficient: 2, MaximumInterval: 10 * time.Second, MaximumAttempts: 4, + }} +} + +func (step deterministicIssueRefundStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + key := refundCase.CaseID + ":refund" + if err := deterministicRefundKey.Set(ctx, key); err != nil { + return nil, err + } + outcome := step.service.IssueRefund(key, refundCase) + if err := deterministicBillingOutcome.Set(ctx, outcome); err != nil { + return nil, err + } + status := statusBillingUnconfirmed + if outcome == refundmodel.BillingConfirmed { + status = statusRefunded + } else if outcome == refundmodel.BillingDeclined { + status = statusBillingDeclined + } + if err := deterministicCaseStatus.Set(ctx, status); err != nil { + return nil, err + } + return dex.GoTo(deterministicNotifyCustomerStep{}, refundCase), nil +} + +// dex:group group-id:failure group-label:"Failure" +// dex:explanation text:"Record a policy denial for the refund request." +type deterministicDenyRefundStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (deterministicDenyRefundStep) GetStepType() string { + return "DenyRefundStep" +} + +func (deterministicDenyRefundStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + if err := deterministicCaseStatus.Set(ctx, statusDenied); err != nil { + return nil, err + } + return dex.GoTo(deterministicNotifyCustomerStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Notify the customer of the refund decision." +type deterministicNotifyCustomerStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (deterministicNotifyCustomerStep) GetStepType() string { + return "NotifyCustomerStep" +} + +func (step deterministicNotifyCustomerStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + status, err := deterministicCaseStatus.Get(ctx) + if err != nil { + return nil, err + } + message := "We are still confirming this refund and will write again." + switch status { + case statusRefunded: + message = "Your refund is on its way." + case statusDenied: + message = "Your order falls outside the 30-day refund window." + case statusOrderNotFound: + message = "We could not find the order this request refers to." + case statusBillingDeclined: + message = "The payment provider declined this refund." + } + if err := step.service.SendCustomerMessage(refundCase, message); err != nil { + return nil, err + } + return dex.GoTo(deterministicCloseCaseStep{}, refundCase), nil +} + +// dex:group group-id:close group-label:"Close" +// dex:explanation text:"Close the refund case after notification." +type deterministicCloseCaseStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] +} + +func (deterministicCloseCaseStep) GetStepType() string { + return "CloseCaseStep" +} + +func (deterministicCloseCaseStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + status, err := deterministicCaseStatus.Get(ctx) + if err != nil { + return nil, err + } + if status != statusOrderNotFound && status != statusDenied && status != statusBillingDeclined && status != statusBillingUnconfirmed { + if err := deterministicCaseStatus.Set(ctx, statusResolved); err != nil { + return nil, err + } + } + return dex.GracefulComplete("refund:" + refundCase.CaseID), nil +} + +func deterministicOptionalAttribute[T any](ctx dex.Context, attribute dex.Attribute[T]) (any, error) { + value, err := attribute.Get(ctx) + if err == nil { + return value, nil + } + var notFound *dex.AttributeNotFoundError + if errors.As(err, ¬Found) { + return nil, nil + } + return nil, err +} + +var ( + _ dex.Flow = (*CustomerRefundFlow)(nil) + _ dex.RPC[dex.None, map[string]any] = (*CustomerRefundFlow)(nil).GetDexSummary + _ dex.RPC[dex.None, map[string]any] = (*CustomerRefundFlow)(nil).GetDexDisplay + _ dex.Step[refundmodel.RefundCase] = deterministicReceiveRequestStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicCheckOrderStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicCheckPolicyStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicIssueRefundStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicDenyRefundStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicNotifyCustomerStep{} + _ dex.Step[refundmodel.RefundCase] = deterministicCloseCaseStep{} +) diff --git a/examples/go/products/customer-refund/model/service.go b/examples/go/products/customer-refund/model/service.go new file mode 100644 index 000000000..0a1a98fcb --- /dev/null +++ b/examples/go/products/customer-refund/model/service.go @@ -0,0 +1,188 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package model + +import ( + "fmt" + "sync" +) + +const ( + BillingConfirmed = "confirmed" + BillingDeclined = "declined" + BillingUnknown = "unknown" +) + +type RefundCase struct { + CaseID string `json:"caseId"` + Customer string `json:"customer"` + CustomerNote string `json:"customerNote"` + AmountCents int64 `json:"amountCents"` + OrderAgeDays int64 `json:"orderAgeDays"` +} + +type Evidence struct { + IdentityStatus string + SubscriptionStatus string + PaymentStatus string + UsageStatus string + HistoryStatus string + IncidentStatus string + UsagePercent int64 + TenureYears int64 + PriorRefunds int64 + CancellationPending bool + IncidentDays int64 +} + +type Service interface { + LookupEvidence(refundCase RefundCase) Evidence + IssueRefund(idempotencyKey string, refundCase RefundCase) string + IssueCredit(idempotencyKey string, refundCase RefundCase) string + LookupBillingOutcome(idempotencyKey string) string + ApplySubscriptionState(refundCase RefundCase) error + SendCustomerMessage(refundCase RefundCase, message string) error +} + +type FakeService struct { + mutex sync.Mutex + settled map[string]string + messages map[string][]string + refundCalls map[string]int + creditCalls map[string]int + subscriptions map[string]int +} + +func NewFakeService() *FakeService { + return &FakeService{ + settled: make(map[string]string), + messages: make(map[string][]string), + refundCalls: make(map[string]int), + creditCalls: make(map[string]int), + subscriptions: make(map[string]int), + } +} + +func (*FakeService) LookupEvidence(refundCase RefundCase) Evidence { + evidence := Evidence{ + IdentityStatus: "present", SubscriptionStatus: "present", PaymentStatus: "present", + UsageStatus: "confirmed-empty", HistoryStatus: "present", IncidentStatus: "confirmed-empty", + TenureYears: 2, + } + switch refundCase.CaseID { + case "identity-unavailable", "agent-uncertain": + evidence.IdentityStatus = "unavailable" + case "heavy-repeat": + evidence.UsageStatus = "present" + evidence.UsagePercent = 92 + evidence.PriorRefunds = 1 + case "missed-cancellation": + evidence.CancellationPending = true + case "incident": + evidence.IncidentStatus = "present" + evidence.IncidentDays = 6 + case "evidence-unavailable": + evidence.UsageStatus = "unavailable" + } + if refundCase.OrderAgeDays > 14 && evidence.UsageStatus == "confirmed-empty" { + evidence.UsageStatus = "present" + evidence.UsagePercent = 71 + } + if refundCase.Customer == "long-tenure" { + evidence.TenureYears = 5 + } + return evidence +} + +func (service *FakeService) IssueRefund(idempotencyKey string, refundCase RefundCase) string { + service.mutex.Lock() + defer service.mutex.Unlock() + service.refundCalls[idempotencyKey]++ + if settled, found := service.settled[idempotencyKey]; found { + return settled + } + switch refundCase.CaseID { + case "declined": + service.settled[idempotencyKey] = BillingDeclined + return BillingDeclined + case "unproven": + return BillingUnknown + default: + service.settled[idempotencyKey] = BillingConfirmed + return BillingConfirmed + } +} + +func (service *FakeService) IssueCredit(idempotencyKey string, refundCase RefundCase) string { + service.mutex.Lock() + defer service.mutex.Unlock() + service.creditCalls[idempotencyKey]++ + if settled, found := service.settled[idempotencyKey]; found { + return settled + } + if refundCase.CaseID == "declined" { + service.settled[idempotencyKey] = BillingDeclined + return BillingDeclined + } + service.settled[idempotencyKey] = BillingConfirmed + return BillingConfirmed +} + +func (service *FakeService) LookupBillingOutcome(idempotencyKey string) string { + service.mutex.Lock() + defer service.mutex.Unlock() + if settled, found := service.settled[idempotencyKey]; found { + return settled + } + return BillingUnknown +} + +func (service *FakeService) ApplySubscriptionState(refundCase RefundCase) error { + service.mutex.Lock() + defer service.mutex.Unlock() + service.subscriptions[refundCase.CaseID]++ + if refundCase.CaseID == "subscription-failure" { + return fmt.Errorf("subscription provider rejected update") + } + return nil +} + +func (service *FakeService) SendCustomerMessage(refundCase RefundCase, message string) error { + service.mutex.Lock() + defer service.mutex.Unlock() + if refundCase.CaseID == "email-failure" { + return fmt.Errorf("email provider rejected message") + } + service.messages[refundCase.CaseID] = append(service.messages[refundCase.CaseID], message) + return nil +} + +func (service *FakeService) RefundCalls(idempotencyKey string) int { + service.mutex.Lock() + defer service.mutex.Unlock() + return service.refundCalls[idempotencyKey] +} + +func (service *FakeService) Messages(caseID string) []string { + service.mutex.Lock() + defer service.mutex.Unlock() + return append([]string(nil), service.messages[caseID]...) +} diff --git a/examples/go/products/customer-refund/model/service_test.go b/examples/go/products/customer-refund/model/service_test.go new file mode 100644 index 000000000..a22797fd3 --- /dev/null +++ b/examples/go/products/customer-refund/model/service_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2022-2026 Super Durable, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFakeServiceMakesRefundEffectsIdempotent(t *testing.T) { + service := NewFakeService() + refundCase := RefundCase{CaseID: "order-42", AmountCents: 4200} + + first := service.IssueRefund("refund-key", refundCase) + second := service.IssueRefund("refund-key", refundCase) + + require.Equal(t, BillingConfirmed, first) + require.Equal(t, first, second) + require.Equal(t, 2, service.RefundCalls("refund-key")) +} + +func TestFakeServicePreservesUnknownAndDeclinedOutcomes(t *testing.T) { + service := NewFakeService() + + require.Equal(t, BillingDeclined, service.IssueRefund("declined-key", RefundCase{CaseID: "declined"})) + require.Equal(t, BillingDeclined, service.LookupBillingOutcome("declined-key")) + require.Equal(t, BillingUnknown, service.IssueRefund("unknown-key", RefundCase{CaseID: "unproven"})) + require.Equal(t, BillingUnknown, service.LookupBillingOutcome("unknown-key")) +} diff --git a/examples/go/registry/registry.go b/examples/go/registry/registry.go index b809cb6e6..3895a366b 100644 --- a/examples/go/registry/registry.go +++ b/examples/go/registry/registry.go @@ -53,6 +53,9 @@ import ( "github.com/superdurable/dex/examples/go/primitives/subflow" "github.com/superdurable/dex/examples/go/primitives/timer" "github.com/superdurable/dex/examples/go/primitives/wait-types" + "github.com/superdurable/dex/examples/go/products/customer-refund/agentic" + "github.com/superdurable/dex/examples/go/products/customer-refund/deterministic" + refundmodel "github.com/superdurable/dex/examples/go/products/customer-refund/model" "github.com/superdurable/dex/examples/go/products/engagement" "github.com/superdurable/dex/examples/go/products/job-post" "github.com/superdurable/dex/examples/go/products/microservices" @@ -78,6 +81,8 @@ var ( Subscription *subscription.SubscriptionFlow UserOnboarding *signup.UserOnboardingFlow JobPosting *jobpost.JobPostingFlow + CustomerRefund *deterministic.CustomerRefundFlow + AgenticRefund *agentic.AgenticCustomerRefundFlow CronSchedule *cron.CronScheduleFlow PollingWithTimer *patternspolling.PollingWithTimerFlow @@ -143,6 +148,9 @@ func New(applicationSvc service.MyService, getClient ClientProvider) []dex.Flow Subscription = subscription.NewSubscriptionFlow(applicationService) UserOnboarding = signup.NewUserOnboardingFlow(applicationService) JobPosting = jobpost.NewJobPostingFlow(applicationService) + refundService := refundmodel.NewFakeService() + CustomerRefund = deterministic.NewCustomerRefundFlow(refundService) + AgenticRefund = agentic.NewAgenticCustomerRefundFlow(refundService) CronSchedule = cron.NewCronScheduleFlow() PollingWithTimer = patternspolling.NewPollingWithTimerFlow() @@ -201,6 +209,8 @@ func Flows(additional ...dex.Flow) []dex.Flow { Subscription, UserOnboarding, JobPosting, + CustomerRefund, + AgenticRefund, CronSchedule, PollingWithTimer, BackoffPolling, diff --git a/examples/go/run-e2e-tests.sh b/examples/go/run-e2e-tests.sh index c74d8da57..5ede27641 100755 --- a/examples/go/run-e2e-tests.sh +++ b/examples/go/run-e2e-tests.sh @@ -42,6 +42,7 @@ web_port="${DEX_EXAMPLES_WEB_PORT:-19901}" deal_dsl_dex_port="${DEX_EXAMPLES_DEAL_DSL_DEX_PORT:-19803}" deal_dsl_web_port="${DEX_EXAMPLES_DEAL_DSL_WEB_PORT:-19903}" postgres_port="${DEX_EXAMPLES_POSTGRES_PORT:-19432}" +entity_store_postgres_port="${DEX_EXAMPLES_ENTITY_STORE_POSTGRES_PORT:-55432}" default_dex_address="127.0.0.1:${dex_port}" deal_dsl_dex_address="127.0.0.1:${deal_dsl_dex_port}" postgres_url="postgres://deal_dsl:deal_dsl@127.0.0.1:${postgres_port}/deal_dsl?sslmode=disable" @@ -78,7 +79,7 @@ cleanup() { fi fi if $entity_store_started; then - if ! docker compose -p "$entity_store_project" \ + if ! ENTITY_STORE_POSTGRES_PORT="$entity_store_postgres_port" docker compose -p "$entity_store_project" \ -f "$entity_store_dir/docker-compose.yml" down --volumes >>"$log_file" 2>&1; then echo "failed to stop the Go examples entity store" >&2 fi @@ -93,10 +94,14 @@ cleanup() { } trap cleanup EXIT -docker compose -p "$entity_store_project" \ +ENTITY_STORE_POSTGRES_PORT="$entity_store_postgres_port" docker compose -p "$entity_store_project" \ -f "$entity_store_dir/docker-compose.yml" up --detach --wait entity_store_started=true +entity_store_config="$test_dir/entity-store.yaml" +sed "s/localhost:55432/localhost:${entity_store_postgres_port}/" \ + "$entity_store_dir/attribute-store.yaml" >"$entity_store_config" + if [[ ! -f "$repo_root/web/assets/dist/index.html" ]]; then ( cd "$repo_root/web" @@ -111,7 +116,7 @@ fi ) "$binary_dir/dexcli" dev \ - -attribute-store-config "$entity_store_dir/attribute-store.yaml" \ + -attribute-store-config "$entity_store_config" \ -bind-address 127.0.0.1 \ -dex-port "$dex_port" \ -web-port "$web_port" \ @@ -164,7 +169,7 @@ DEAL_DSL_POSTGRES_PORT="$postgres_port" docker compose \ deal_dsl_started=true "$binary_dir/dexcli" dev \ - -attribute-store-config "$entity_store_dir/attribute-store.yaml" \ + -attribute-store-config "$entity_store_config" \ -bind-address 127.0.0.1 \ -dex-port "$deal_dsl_dex_port" \ -web-port "$deal_dsl_web_port" \ diff --git a/examples/java/build.gradle b/examples/java/build.gradle index 66753a7c4..a1bffd368 100644 --- a/examples/java/build.gradle +++ b/examples/java/build.gradle @@ -23,7 +23,7 @@ repositories { dependencies { implementation "org.springframework.boot:spring-boot-starter-web" - implementation "io.superdurable:dex-sdk:0.7.0" + implementation "io.superdurable:dex-sdk:0.9.0" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8" diff --git a/examples/java/src/main/java/io/superdurable/dex/primitives/step/README.md b/examples/java/src/main/java/io/superdurable/dex/primitives/step/README.md index b0977e1d1..278d433a5 100644 --- a/examples/java/src/main/java/io/superdurable/dex/primitives/step/README.md +++ b/examples/java/src/main/java/io/superdurable/dex/primitives/step/README.md @@ -16,7 +16,7 @@ http://localhost:8080/retrying-failure/start?workflowId=java-retrying-failure-1 Then open the Flow in Dex Web: ```text -http://localhost:8802/flows/java-retrying-failure-1 +http://localhost:8802/v1/flows/java-retrying-failure-1 ``` Stop the demonstration when finished: diff --git a/examples/playground/README.md b/examples/playground/README.md index 6e5ef99c2..5aa3b6df3 100644 --- a/examples/playground/README.md +++ b/examples/playground/README.md @@ -60,8 +60,8 @@ example server allows CORS so the browser can call the APIs directly. After a flow ID is known, the page links to Dex Web: -- current run: `{dexWeb}/flows/{flowId}` -- specific run: `{dexWeb}/flows/{flowId}/{runId}` -- search: `{dexWeb}/?q=WorkflowId="{flowId}"` +- current run: `{dexWeb}/v1/flows/{flowId}` +- specific run: `{dexWeb}/v1/flows/{flowId}/{runId}` +- search: `{dexWeb}/v1/flows?q=WorkflowId="{flowId}"` Those routes come from `web/app/App.tsx`. diff --git a/examples/playground/app.js b/examples/playground/app.js index cc1b789d4..2f888b190 100644 --- a/examples/playground/app.js +++ b/examples/playground/app.js @@ -91,9 +91,9 @@ function dexWebFlowURL(flowId, runId) { return ""; } if (runId) { - return `${base}/flows/${encodeURIComponent(flowId)}/${encodeURIComponent(runId)}`; + return `${base}/v1/flows/${encodeURIComponent(flowId)}/${encodeURIComponent(runId)}`; } - return `${base}/flows/${encodeURIComponent(flowId)}`; + return `${base}/v1/flows/${encodeURIComponent(flowId)}`; } function dexWebSearchURL(flowId) { @@ -101,7 +101,7 @@ function dexWebSearchURL(flowId) { if (!flowId || !base) { return ""; } - return `${base}/?q=${encodeURIComponent(`WorkflowId="${flowId}"`)}`; + return `${base}/v1/flows?q=${encodeURIComponent(`WorkflowId="${flowId}"`)}`; } function fieldValue(form, field, example) { diff --git a/packages/flow-definition-renderer/README.md b/packages/flow-definition-renderer/README.md index 0021a5edb..b93dd02fc 100644 --- a/packages/flow-definition-renderer/README.md +++ b/packages/flow-definition-renderer/README.md @@ -1,9 +1,10 @@ # Flow Definition renderer -This private package owns the Flow Definition Graph v1 TypeScript contract, -compound layout, React renderer, and renderer styles. Dex Web and the product -documentation import the same package so checked-in examples cannot drift from -the interactive Flow Rendering page. +This private package owns both Flow Definition Graph TypeScript contracts and +renderers. `FlowDefinitionGraphView` renders Version 1 with its compound layout. +`ProcessCanvasView` renders Version 2 with ordered group bands, collapsed or +expanded Step cards, top-down or left-right layout, and selection details. Dex +Web and product documentation import the same package. Consumers must provide React 19, React Flow, and Dagre. The repository uses a local file dependency and preserves symlinks in Vite, Docusaurus, and diff --git a/packages/flow-definition-renderer/src/ProcessCanvas.tsx b/packages/flow-definition-renderer/src/ProcessCanvas.tsx new file mode 100644 index 000000000..869ee04de --- /dev/null +++ b/packages/flow-definition-renderer/src/ProcessCanvas.tsx @@ -0,0 +1,368 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useEffect, useMemo, useState } from 'react'; +import { + Background, + Controls, + Handle, + MarkerType, + Position, + ReactFlow, + type Edge, + type Node, + type NodeProps, + type NodeTypes, + type ReactFlowInstance, +} from '@xyflow/react'; +import type { + FlowDefinitionEdge, + FlowDefinitionGraph, + FlowDefinitionGroup, + FlowDefinitionNode, +} from './types'; + +type ProcessCanvasDirection = 'tb' | 'lr'; +type ProcessCanvasDetail = 'collapsed' | 'expanded'; + +interface ProcessStepData extends Record { + definition: FlowDefinitionNode; + detail: ProcessCanvasDetail; + direction: ProcessCanvasDirection; + waitDescriptions: string[]; + destinationNames: string[]; +} + +interface ProcessGroupData extends Record { + group: FlowDefinitionGroup; + hue: number; +} + +export interface ProcessCanvasScene { + nodes: Array>; + edges: Edge[]; +} + +const processNodeTypes: NodeTypes = { + processStep: ProcessStepNode, + processGroup: ProcessGroupNode, +}; + +export function ProcessCanvasView({ graph }: { graph: FlowDefinitionGraph }) { + const [detail, setDetail] = useState('collapsed'); + const [direction, setDirection] = useState('tb'); + const [selectedID, setSelectedID] = useState(''); + const [flowInstance, setFlowInstance] = useState(null); + const scene = useMemo( + () => buildProcessCanvasScene(graph, detail, direction), + [detail, direction, graph], + ); + const selectedStep = graph.nodes.find((node) => node.id === selectedID && node.kind === 'step'); + const selectedGroup = graph.groups?.find((group) => `group:${group.id}` === selectedID); + + useEffect(() => { + if (!flowInstance || scene.nodes.length === 0) return; + void flowInstance.fitView({ duration: 180, maxZoom: 1.15, minZoom: 0.16, padding: 0.08 }); + }, [detail, direction, flowInstance, scene.nodes.length]); + + return ( +
+
+
+

Process Canvas · FDG 2.0

+

{graph.flow.name}

+

{graph.source.language} · {graph.source.path}

+
+
+
+ {(['collapsed', 'expanded'] as const).map((option) => ( + + ))} +
+
+ {(['tb', 'lr'] as const).map((option) => ( + + ))} +
+
+
+
+ setSelectedID(node.id)} + onPaneClick={() => setSelectedID('')} + proOptions={{ hideAttribution: true }} + > + + + +
+ {(selectedStep || selectedGroup) && ( +
+ {selectedStep && } + {selectedGroup && ( + <> + Group + {selectedGroup.label} +

{selectedGroup.stepIds.length} registered Steps

+ + )} +
+ )} + {graph.diagnostics.length > 0 && ( +
+ {graph.diagnostics.map((diagnostic, index) => ( +
+ {diagnostic.code} + {diagnostic.message} +
+ ))} +
+ )} +
+ ); +} + +export function buildProcessCanvasScene( + graph: FlowDefinitionGraph, + detail: ProcessCanvasDetail = 'collapsed', + direction: ProcessCanvasDirection = 'tb', +): ProcessCanvasScene { + const definitions = Array.isArray(graph.nodes) ? graph.nodes : []; + const definitionsByID = new Map(definitions.map((definition) => [definition.id, definition])); + const steps = definitions.filter((definition) => definition.kind === 'step'); + const stepsByID = new Map(steps.map((step) => [step.id, step])); + const assigned = new Set(); + const protocolGroups = Array.isArray(graph.groups) ? graph.groups : []; + const groups = protocolGroups.map((group) => { + const stepIds: string[] = []; + if (Array.isArray(group.stepIds)) { + for (const stepID of group.stepIds) { + if (typeof stepID !== 'string' || !stepsByID.has(stepID) || assigned.has(stepID)) continue; + assigned.add(stepID); + stepIds.push(stepID); + } + } + return { + id: safeText(group.id), + label: safeText(group.label) || safeText(group.id), + stepIds, + }; + }).filter((group) => group.id !== '' && group.stepIds.length > 0); + const ungrouped = steps.filter((step) => !assigned.has(step.id)).map((step) => step.id); + if (ungrouped.length > 0) { + groups.push({ id: 'ungrouped', label: 'Ungrouped', stepIds: ungrouped }); + } + + const nodes: Array> = []; + let groupCursor = 36; + groups.forEach((group, groupIndex) => { + const stepWidth = detail === 'expanded' ? 284 : 226; + const stepHeight = detail === 'expanded' ? 172 : 92; + const acrossCount = Math.min(3, Math.max(1, group.stepIds.length)); + const alongCount = Math.ceil(group.stepIds.length / acrossCount); + const bandWidth = direction === 'tb' + ? acrossCount * stepWidth + (acrossCount - 1) * 28 + 72 + : alongCount * stepWidth + (alongCount - 1) * 38 + 72; + const bandHeight = direction === 'tb' + ? alongCount * stepHeight + (alongCount - 1) * 34 + 92 + : acrossCount * stepHeight + (acrossCount - 1) * 24 + 92; + const bandX = direction === 'tb' ? 48 : groupCursor; + const bandY = direction === 'tb' ? groupCursor : 48; + nodes.push({ + id: `group:${group.id}`, + type: 'processGroup', + position: { x: bandX, y: bandY }, + style: { width: bandWidth, height: bandHeight, zIndex: 0 }, + data: { group, hue: groupIndex % 6 }, + selectable: true, + zIndex: 0, + }); + group.stepIds.forEach((stepID, stepIndex) => { + const row = Math.floor(stepIndex / acrossCount); + const column = stepIndex % acrossCount; + const localX = direction === 'tb' + ? 36 + column * (stepWidth + 28) + : 36 + row * (stepWidth + 38); + const localY = direction === 'tb' + ? 56 + row * (stepHeight + 34) + : 56 + column * (stepHeight + 24); + const position = { x: bandX + localX, y: bandY + localY, width: stepWidth, height: stepHeight }; + const step = stepsByID.get(stepID)!; + nodes.push({ + id: stepID, + type: 'processStep', + position: { x: position.x, y: position.y }, + style: { width: stepWidth, height: stepHeight, zIndex: 2 }, + data: { + definition: step, + detail, + direction, + waitDescriptions: stepWaitDescriptions(step, definitions), + destinationNames: stepDestinations(step, graph.edges, definitionsByID), + }, + zIndex: 2, + }); + }); + groupCursor += (direction === 'tb' ? bandHeight : bandWidth) + 34; + }); + + const edges = controlTopologyEdges(graph.edges, definitionsByID, stepsByID).map((edge) => ({ + id: edge.id, + source: edge.from, + target: edge.to, + sourceHandle: direction === 'tb' ? 'vertical-source' : 'horizontal-source', + targetHandle: direction === 'tb' ? 'vertical-target' : 'horizontal-target', + type: 'smoothstep', + markerEnd: { + type: MarkerType.ArrowClosed, + color: edge.kind === 'failure_transition' ? '#b84a4a' : '#4d7661', + height: 17, + width: 17, + }, + label: edge.count > 1 ? `${edge.count} paths` : undefined, + style: { + stroke: edge.kind === 'failure_transition' ? '#b84a4a' : '#4d7661', + strokeDasharray: edge.kind === 'failure_transition' ? '7 5' : undefined, + strokeWidth: edge.kind === 'failure_transition' ? 2.4 : 2, + }, + zIndex: 1, + })); + return { nodes, edges }; +} + +function controlTopologyEdges( + edges: FlowDefinitionEdge[], + definitionsByID: Map, + stepsByID: Map, +): Array<{ id: string; from: string; to: string; kind: string; count: number }> { + const merged = new Map(); + for (const edge of Array.isArray(edges) ? edges : []) { + if (edge.kind !== 'transition' && edge.kind !== 'failure_transition') continue; + const from = stepOwnerID(edge.from, definitionsByID); + const to = stepOwnerID(edge.to, definitionsByID); + if (!stepsByID.has(from) || !stepsByID.has(to)) continue; + const key = `${from}:${to}:${edge.kind}`; + const existing = merged.get(key); + if (existing) existing.count += 1; + else merged.set(key, { id: edge.id || key, from, to, kind: edge.kind, count: 1 }); + } + return [...merged.values()]; +} + +function stepOwnerID(id: string, definitionsByID: Map): string { + let current = definitionsByID.get(id); + const visited = new Set(); + while (current && !visited.has(current.id)) { + if (current.kind === 'step') return current.id; + visited.add(current.id); + current = current.parentId ? definitionsByID.get(current.parentId) : undefined; + } + return id; +} + +function stepWaitDescriptions(step: FlowDefinitionNode, definitions: FlowDefinitionNode[]): string[] { + return definitions + .filter((definition) => definition.kind === 'wait' && definition.parentId === step.id) + .flatMap((definition) => { + const conditions = Array.isArray(definition.wait?.conditions) ? definition.wait.conditions : []; + if (conditions.length === 0) return ['Unresolved wait condition']; + return conditions.map((condition) => `${condition.kind}: ${safeText(condition.label)}`); + }); +} + +function stepDestinations( + step: FlowDefinitionNode, + edges: FlowDefinitionEdge[], + definitionsByID: Map, +): string[] { + const names = new Set(); + for (const edge of Array.isArray(edges) ? edges : []) { + if (edge.kind !== 'transition' && edge.kind !== 'failure_transition') continue; + if (stepOwnerID(edge.from, definitionsByID) !== step.id) continue; + const target = definitionsByID.get(stepOwnerID(edge.to, definitionsByID)); + if (target) names.add(safeText(target.name)); + } + return [...names]; +} + +function ProcessStepNode({ data }: NodeProps) { + const step = data as ProcessStepData; + const direction = step.direction; + return ( +
+ + +
+ {step.definition.start ? 'Start' : 'Step'} + {safeText(step.definition.name)} +
+ {safeText(step.definition.id).replace(/^step:/, '')} + {step.detail === 'expanded' && ( +
+ Waits for{step.waitDescriptions[0] ?? 'Nothing'} + Then goes to{step.destinationNames.join(', ') || 'Flow completion'} +
+ )} + + +