From 57cedc81057388aaef898975c34f6e80232355fb Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Fri, 18 Sep 2026 15:51:19 -0700 Subject: [PATCH 01/17] Add Flow Visualization 2.0 supervision --- cli/README.md | 16 +- .../visualization-v2-invalid/go.mod | 7 + .../visualization-v2-invalid/workflow.go | 131 + cli/internal/command/visualize.go | 21 +- .../command/visualize_v2_integ_test.go | 174 + cli/internal/flowviz/analyze.go | 48 +- cli/internal/flowviz/go_analyzer.go | 14 +- cli/internal/flowviz/model.go | 69 +- cli/internal/flowviz/v2_directives.go | 1230 ++++++ .../flow-definition-graph.v2.schema.json | 196 + docs/content/production/supervision.mdx | 65 + .../content/references/flow-visualization.mdx | 10 +- docs/content/use-cases/customer-refund.mdx | 48 + docs/content/use-cases/index.mdx | 1 + .../current/production/supervision.mdx | 65 + .../current/references/flow-visualization.mdx | 10 +- .../current/use-cases/customer-refund.mdx | 48 + .../current/use-cases/index.mdx | 1 + docs/scripts/generate-flow-definitions.sh | 19 +- docs/sidebars.ts | 2 + .../components/DocsFlowDefinitionGraph.tsx | 5 +- .../customer-refund-agentic.json | 3407 +++++++++++++++++ .../flow-definitions/customer-refund.json | 937 +++++ examples/entity-store/docker-compose.yml | 2 +- examples/go/README.md | 1 + examples/go/cmd/server/dex/dex.go | 2 + examples/go/integ/customer_refund_test.go | 177 + .../go/products/customer-refund/README.md | 27 + .../customer-refund/agentic/workflow.go | 1185 ++++++ .../go/products/customer-refund/controller.go | 88 + .../customer-refund/deterministic/workflow.go | 433 +++ .../products/customer-refund/model/service.go | 188 + .../customer-refund/model/service_test.go | 48 + examples/go/registry/registry.go | 10 + examples/go/run-e2e-tests.sh | 13 +- packages/flow-definition-renderer/README.md | 9 +- .../src/ProcessCanvas.tsx | 368 ++ .../flow-definition-renderer/src/index.ts | 11 + .../flow-definition-renderer/src/styles.css | 241 ++ .../flow-definition-renderer/src/types.ts | 82 +- web/README.md | 20 + web/api/handlers.go | 1 + web/api/supervision.go | 1008 +++++ web/api/supervision_integ_test.go | 512 +++ web/app/App.tsx | 33 +- web/app/components/AppHeader.tsx | 16 +- web/app/globals.css | 277 ++ web/app/rendering/FlowRenderingPage.tsx | 17 +- web/app/rendering/ProcessCanvas.test.ts | 93 + web/app/supervision/SupervisionPages.test.ts | 122 + web/app/supervision/SupervisionPages.tsx | 528 +++ web/app/supervision/SupervisionProvider.tsx | 59 + web/flow_rendering.go | 290 +- web/flow_rendering_v2_test.go | 106 + web/lib/types.ts | 40 + web/server.go | 1 + 56 files changed, 12461 insertions(+), 71 deletions(-) create mode 100644 cli/internal/command/testfixtures/visualization-v2-invalid/go.mod create mode 100644 cli/internal/command/testfixtures/visualization-v2-invalid/workflow.go create mode 100644 cli/internal/command/visualize_v2_integ_test.go create mode 100644 cli/internal/flowviz/v2_directives.go create mode 100644 cli/schema/flow-definition-graph.v2.schema.json create mode 100644 docs/content/production/supervision.mdx create mode 100644 docs/content/use-cases/customer-refund.mdx create mode 100644 docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/supervision.mdx create mode 100644 docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/use-cases/customer-refund.mdx create mode 100644 docs/src/data/flow-definitions/customer-refund-agentic.json create mode 100644 docs/src/data/flow-definitions/customer-refund.json create mode 100644 examples/go/integ/customer_refund_test.go create mode 100644 examples/go/products/customer-refund/README.md create mode 100644 examples/go/products/customer-refund/agentic/workflow.go create mode 100644 examples/go/products/customer-refund/controller.go create mode 100644 examples/go/products/customer-refund/deterministic/workflow.go create mode 100644 examples/go/products/customer-refund/model/service.go create mode 100644 examples/go/products/customer-refund/model/service_test.go create mode 100644 packages/flow-definition-renderer/src/ProcessCanvas.tsx create mode 100644 web/api/supervision.go create mode 100644 web/api/supervision_integ_test.go create mode 100644 web/app/rendering/ProcessCanvas.test.ts create mode 100644 web/app/supervision/SupervisionPages.test.ts create mode 100644 web/app/supervision/SupervisionPages.tsx create mode 100644 web/app/supervision/SupervisionProvider.tsx create mode 100644 web/flow_rendering_v2_test.go diff --git a/cli/README.md b/cli/README.md index 39894ce8a..8656c8170 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,23 @@ 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 Supervision contract. +The contract declares Indexed Attributes, the fixed `GetDexSummary` and +`GetDexDisplay` RPCs, editable Display fields, and conditional Action RPCs. +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..27b1beb0d 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") } @@ -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_v2_integ_test.go b/cli/internal/command/visualize_v2_integ_test.go new file mode 100644 index 000000000..dc721ee62 --- /dev/null +++ b/cli/internal/command/visualize_v2_integ_test.go @@ -0,0 +1,174 @@ +// 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.Supervision) + require.Equal(t, test.wantGroupIDs, v2GroupIDs(graph.Groups)) + require.Equal(t, test.wantSummaryFields, v2ViewFieldKeys(graph.Supervision.Summary.Fields)) + require.Equal(t, test.wantActionRPCNames, v2ActionRPCNames(graph.Supervision.Actions)) + require.Equal(t, "case-status", graph.Supervision.IndexedAttributes[0].AttributeKey) + require.Equal(t, "case-status", graph.Supervision.IndexedAttributes[0].IndexKey) + require.Equal(t, "keyword", graph.Supervision.IndexedAttributes[0].IndexType) + + 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.Supervision.Actions[1].Input.Fields)) + require.Equal(t, []string{"user", "attribute"}, v2ActionInputSources(graph.Supervision.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, `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), `"supervision": {`) +} + +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..56c5beaba 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.Supervision == nil { + graph.Supervision = &Supervision{ + 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"` + Supervision *Supervision `json:"supervision"` + }{Graph: graph, Groups: graph.Groups, Supervision: graph.Supervision} + } + 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..8a9c1a74d 100644 --- a/cli/internal/flowviz/model.go +++ b/cli/internal/flowviz/model.go @@ -14,7 +14,10 @@ import ( "strings" ) -const SchemaVersion = "1.0" +const ( + SchemaVersionV1 = "1.0" + SchemaVersionV2 = "2.0" +) type Graph struct { SchemaVersion string `json:"schemaVersion"` @@ -24,6 +27,68 @@ type Graph struct { Nodes []Node `json:"nodes"` Edges []Edge `json:"edges"` Diagnostics []Diagnostic `json:"diagnostics"` + Groups []StepGroup `json:"groups,omitempty"` + Supervision *Supervision `json:"supervision,omitempty"` +} + +type StepGroup struct { + ID string `json:"id"` + Label string `json:"label"` + StepIDs []string `json:"stepIds"` +} + +type Supervision 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..7a7986dd5 --- /dev/null +++ b/cli/internal/flowviz/v2_directives.go @@ -0,0 +1,1230 @@ +// 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() + 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.Supervision = &Supervision{ + 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) 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..eeb729177 --- /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", + "supervision" + ], + "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"} + }, + "supervision": {"$ref": "#/$defs/supervision"} + }, + "$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} + } + }, + "supervision": { + "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/supervision.mdx b/docs/content/production/supervision.mdx new file mode 100644 index 000000000..01e9aeaa1 --- /dev/null +++ b/docs/content/production/supervision.mdx @@ -0,0 +1,65 @@ +--- +title: Supervision +--- + +# Supervision + +Dex Web enables Supervision when its Flow rendering directory contains at least one valid Go Flow Definition Graph 2.0 file. Supervision is then the default workspace. Operators can switch between **Supervision** and **Operations** in the upper-left corner. Operations keeps the existing run search, history, state, and rendering pages. + +Generate a definition and provide its directory when Dex starts: + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 --json --out ./build/refund +dexcli dev --flow-rendering-dir ./build +``` + +Version 1 remains the default and supports Go and Python. Version 2 currently supports Go only. Version 1 and Version 2 definitions 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 for diagnostics but does not enable supervision. + +## 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: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. 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 list page 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 opens the Flow ID, 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. Historical runs remain in Operations. + +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 detail page'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/references/flow-visualization.mdx b/docs/content/references/flow-visualization.mdx index 6812bd4f3..4da19c530 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 Process Canvas and the Supervision contract: + +```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 @@ -89,6 +95,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 a Supervision 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 [Supervision](/production/supervision) 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 +107,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 supervision 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..34bcc7fa7 --- /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 Process Canvas and Dex Web Supervision. + +## 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 [Supervision](/production/supervision) for the protocol and operator workflow. diff --git a/docs/content/use-cases/index.mdx b/docs/content/use-cases/index.mdx index 723017990..15c2a6f65 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 with supervision ## Related example diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/supervision.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/supervision.mdx new file mode 100644 index 000000000..8b378b6dd --- /dev/null +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/production/supervision.mdx @@ -0,0 +1,65 @@ +--- +title: Supervision +--- + +# Supervision + +当 Flow rendering directory 中至少包含一个有效的 Go Flow Definition Graph 2.0 文件时,Dex Web 会启用 Supervision,并把它设为默认 workspace。Operator 可以在左上角切换 **Supervision** 与 **Operations**。Operations 继续提供已有的 run 搜索、历史、状态和 rendering 页面。 + +生成 definition,并在 Dex 启动时提供目录: + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 --json --out ./build/refund +dexcli dev --flow-rendering-dir ./build +``` + +Version 1 仍是默认版本,并支持 Go 与 Python。Version 2 第一版只支持 Go。Version 1 与 Version 2 definition 可以共存。Dex Web 会在启动时拒绝格式错误的 Version 2 文件,以及同一 Flow type 的多个有效 Version 2 definition。Analyzer 输出中的 **valid: false** 文件仍可用于查看 diagnostic,但不会启用 supervision。 + +## 具名 directive + +Version 2 directive 使用具名值。同一行内的参数位置没有语义。包含空格的字符串使用 JSON 双引号,多值使用 JSON array。 + + + + +```go +// dex:group group-id:control group-label:"Control" +// 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 声明。Group 顺序由第一个成员在 **GetSteps** 中的注册位置决定,组内 Step 顺序也使用 **GetSteps**。 + +## 搜索与 Summary + +Indexed Attribute 声明绑定 Dex Attribute key、物理搜索 index key、index type、应用 value type 和 description。Analyzer 会把每个值与 Go 定义和 SDK 计算出的有效 index 配置比较。 + +列表页每次搜索一个 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,而不是 run ID。**GetDexDisplay**、Attribute 读取、编辑和 Action 都省略 run ID,因此 Dex 会定位 current run。Continue-as-New 后,刷新同一个 URL 会自动指向新 run。历史 run 继续在 Operations 中查看。 + +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 的字段保持隐藏,并使用详情页的 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/references/flow-visualization.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/references/flow-visualization.mdx index b335c0186..d95659ef2 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,以启用 Process Canvas 与 Supervision contract: + +```bash +dexcli visualize ./refund_flow.go --schema-version 2.0 +``` + 该命令会打开本地的 **Flow Rendering** 页面,并持续提供图形直到按下 Ctrl+C。需要 Flow Definition Graph 文件时使用 **--json**。未指定 **--out** 时,JSON 会写入标准输出;使用 **--out** 可以选择文件前缀: ```bash @@ -89,6 +95,8 @@ Go 分析需要本地 Go toolchain,输入必须属于能够完成类型检查 首版不支持单文件中的多个 Flow、反射、动态 class、Dex 通配符导入、基于 getattr 的目标、monkeypatch,以及逃逸出处理方法的 movement 集合。TypeScript、Java、Rust 和递归多文件分析也不在首版范围内。 +Version 2.0 第一版只支持 Go。它保留所有 Version 1 字段,并要求 Step group 与 Supervision contract 和 Flow 位于同一文件。同一行内的具名 directive 参数可以任意排列。多条 directive 行保留源码顺序,并成为 UI 顺序。未知、重复、缺失或非法参数都会产生 blocking diagnostic。Indexed Attribute、Summary、Display 与 Action 规则见 [Supervision](/production/supervision)。 + ## Unknown 节点和诊断 不支持的动态控制流不会被静默省略。输出会包含 Unknown 节点和错误诊断。默认 renderer 仍会显示部分图。使用 **--json** 时,命令会写出部分 JSON,并以状态码 1 退出。 @@ -99,6 +107,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 与 supervision 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..abcb604d3 --- /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 元数据,供 Process Canvas 与 Dex Web Supervision 使用。 + +## 确定性策略 + +**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}), +) +``` + + + + +协议与操作流程见 [Supervision](/production/supervision)。 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..24a42ff98 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/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..a01cc3d9a 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/supervision', '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..8440377a6 --- /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" + ] + } + ], + "supervision": { + "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..569d9fa65 --- /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" + ] + } + ], + "supervision": { + "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..08700fdd6 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 supervision](./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..2eac5d65d --- /dev/null +++ b/examples/go/products/customer-refund/README.md @@ -0,0 +1,27 @@ +# Customer refund supervision 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. 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..cb549fb14 --- /dev/null +++ b/examples/go/products/customer-refund/agentic/workflow.go @@ -0,0 +1,1185 @@ +// 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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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" +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..650011bfb --- /dev/null +++ b/examples/go/products/customer-refund/deterministic/workflow.go @@ -0,0 +1,433 @@ +// 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 +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" +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" +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" +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" +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" +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" +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/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'} +
+ )} + + +