diff --git a/Makefile b/Makefile index 4360e7744..b1069062f 100644 --- a/Makefile +++ b/Makefile @@ -51,3 +51,11 @@ lint: .PHONY: test-quiet test-quiet: go test ./... | grep -v "\[no test files\]" | grep -v "\(cached\)" + +.PHONY: install-goimports +install-goimports: + $ go install golang.org/x/tools/cmd/goimports@latest + +.PHONY: workflow-playground +workflow-playground: install-goimports + go run ./pkg/workflows/sdk/cmd/playground diff --git a/pkg/workflows/sdk/cmd/playground/main.go b/pkg/workflows/sdk/cmd/playground/main.go new file mode 100644 index 000000000..de18efe00 --- /dev/null +++ b/pkg/workflows/sdk/cmd/playground/main.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" +) + +var port = flag.String("port", ":8080", "port to run on") + +//go:embed www +var www embed.FS + +func main() { + flag.Parse() + + files, err := fs.Sub(www, "www") + if err != nil { + log.Fatalln("Failed to strip filesystem prefix www/:", err) + } + http.Handle("/", http.FileServerFS(files)) + http.Handle("/format-chart", http.HandlerFunc(formatChart)) + if err := http.ListenAndServe(*port, nil); err != nil { + log.Fatalln(err) + } +} + +func formatChart(w http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + dir, err := writeFiles(req.Context(), req.Body) + if err != nil { + log.Println("Failed to write files:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + formatted, err := os.ReadFile(filepath.Join(dir, "workflow.go")) + if err != nil { + log.Println("Failed to read workflow file:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp := struct { + Chart string `json:"chart"` + Workflow string `json:"workflow"` + Error string `json:"error"` + }{Workflow: string(formatted)} + + cmd := exec.CommandContext(req.Context(), "go", "run", "main.go", "workflow.go") + cmd.Dir = dir + chart, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + err = fmt.Errorf("%s: %s", exitErr.ProcessState, exitErr.Stderr) + } + log.Println("Failed to format chart:", err) + resp.Error = err.Error() + } else { + resp.Chart = string(chart) + log.Println("Formatted chart:", resp.Chart) + } + + e := json.NewEncoder(w) + err = e.Encode(resp) + if err != nil { + log.Println("Failed to write JSON response:", err) + } +} + +func writeFiles(ctx context.Context, req io.Reader) (dir string, err error) { + dir, err = os.MkdirTemp("", "format-chart") + if err != nil { + err = fmt.Errorf("failed to create temp dir: %s", err) + return + } + + b, err := io.ReadAll(req) + if err != nil { + err = fmt.Errorf("failed to read request: %s", err) + return + } + + err = os.WriteFile(filepath.Join(dir, "workflow.go"), b, 0666) + if err != nil { + err = fmt.Errorf("failed to write workflow.go: %s", err) + return + } + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0666) + if err != nil { + err = fmt.Errorf("failed to write main.go: %s", err) + return + } + err = os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0666) + if err != nil { + err = fmt.Errorf("failed to write go.mod: %s", err) + return + } + wd, err := os.Getwd() + if err != nil { + err = fmt.Errorf("failed to get working directory: %s", err) + return + } + var goWork bytes.Buffer + fmt.Fprintf(&goWork, `go 1.23 + +use %s`, wd) + err = os.WriteFile(filepath.Join(dir, "go.work"), goWork.Bytes(), 0666) + if err != nil { + err = fmt.Errorf("failed to write go.work: %s", err) + return + } + + cmd := exec.CommandContext(ctx, "goimports", "-w", "workflow.go") + cmd.Dir = dir + _, err = cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + err = fmt.Errorf("%s: %s", exitErr.ProcessState, exitErr.Stderr) + } + log.Println("Failed to run goimports:", err) + } + + return +} + +const mainGo = `package main + +import ( + "fmt" + "log" +) + +func main() { + spec, err := buildWorkflow().Spec() + if err != nil { + log.Fatalln("Failed to get spec:", err) + } + chart, err := spec.FormatChart() + if err != nil { + log.Fatalln("Failed to format chart:", err) + } + fmt.Println(chart) +} +` + +const goMod = `module example.workflow/builder + +go 1.23 + +require github.com/smartcontractkit/chainlink-common v0.0.0-00010101000000-000000000000 +` diff --git a/pkg/workflows/sdk/cmd/playground/www/index.html b/pkg/workflows/sdk/cmd/playground/www/index.html new file mode 100644 index 000000000..7ab12b3db --- /dev/null +++ b/pkg/workflows/sdk/cmd/playground/www/index.html @@ -0,0 +1,180 @@ + + + + + +
+
+
+
+package main
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/basictrigger"
+	ocr3 "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/ocr3cap"
+	"github.com/smartcontractkit/chainlink-common/pkg/capabilities/targets/chainwriter"
+	"github.com/smartcontractkit/chainlink-common/pkg/capabilities/triggers/streams"
+	"github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil"
+	"github.com/smartcontractkit/chainlink-common/pkg/workflows/sdk"
+)
+
+func buildWorkflow() *sdk.WorkflowSpecFactory {
+	w := sdk.NewWorkflowSpecFactory(sdk.NewWorkflowParams{
+		Owner: "your name", Name: "workflow name",
+	})
+	trigger := basictrigger.TriggerConfig{
+		Name:   "trigger",
+		Number: 100,
+	}.New(w)
+
+	foo := sdk.Compute1(w, "get-foo", sdk.Compute1Inputs[string]{
+		Arg0: trigger.CoolOutput(),
+	}, func(runtime sdk.Runtime, s string) (int64, error) {
+		resp, err := http.Get("https://foo.com/" + s)
+		if err != nil {
+			return -1, fmt.Errorf("failed to get data from foo.com: %w", err)
+		}
+		defer resp.Body.Close()
+		b, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return -1, fmt.Errorf("failed to read response from foo.com: %w", err)
+		}
+		return strconv.ParseInt(string(b), 10, 64)
+	})
+	bar := sdk.Compute1(w, "get-bar", sdk.Compute1Inputs[string]{
+		Arg0: trigger.CoolOutput(),
+	}, func(runtime sdk.Runtime, s string) (int64, error) {
+		resp, err := http.Get("https://bar.io/api/" + s)
+		if err != nil {
+			return -1, fmt.Errorf("failed to get data from bar.io: %w", err)
+		}
+		defer resp.Body.Close()
+		b, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return -1, fmt.Errorf("failed to read response from bar.io: %w", err)
+		}
+		return strconv.ParseInt(string(b), 10, 64)
+	})
+	baz := sdk.Compute1(w, "get-baz", sdk.Compute1Inputs[string]{
+		Arg0: trigger.CoolOutput(),
+	}, func(runtime sdk.Runtime, s string) (int64, error) {
+		query := url.Values{"id": []string{s}}.Encode()
+		resp, err := http.Get("https://baz.com/v2/path/to/thing?" + query)
+		if err != nil {
+			return -1, fmt.Errorf("failed to get data from baz.com/v2: %w", err)
+		}
+		defer resp.Body.Close()
+		b, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return -1, fmt.Errorf("failed to read response from baz.com/v2: %w", err)
+		}
+		return strconv.ParseInt(string(b), 10, 64)
+	})
+
+	compute := sdk.Compute3(w, "compute", sdk.Compute3Inputs[int64, int64, int64]{
+		Arg0: foo.Value(),
+		Arg1: bar.Value(),
+		Arg2: baz.Value(),
+	}, func(runtime sdk.Runtime, foo, bar, baz int64) ([]streams.Feed, error) {
+		val, err := mathutil.Median(foo, bar, baz)
+		if err != nil {
+			return nil, fmt.Errorf("failed to calculate median: %w", err)
+		}
+		return []streams.Feed{{
+			Metadata: streams.SignersMetadata{},
+			Payload: []streams.FeedReport{
+				{FullReport: []byte(strconv.FormatInt(val, 10))},
+			},
+			Timestamp: 0,
+		}}, nil
+	})
+
+	consensus := ocr3.DataFeedsConsensusConfig{}.New(w, "consensus", ocr3.DataFeedsConsensusInput{
+		Observations: compute.Value(),
+	})
+
+	chainwriter.TargetConfig{
+		Address: "0xfakeaddr",
+	}.New(w, "id", chainwriter.TargetInput{
+		SignedReport: consensus,
+	})
+	return w
+}
+            
+ + +
+
+
+
+flowchart + + trigger[\"trigger
trigger
(basic-test-trigger[at]1.0.0)"/] + + compute["compute
action
(custom_compute[at]1.0.0)"] + get-bar -- Value --> compute + get-baz -- Value --> compute + get-foo -- Value --> compute + + get-bar["get-bar
action
(custom_compute[at]1.0.0)"] + trigger -- cool_output --> get-bar + + get-baz["get-baz
action
(custom_compute[at]1.0.0)"] + trigger -- cool_output --> get-baz + + get-foo["get-foo
action
(custom_compute[at]1.0.0)"] + trigger -- cool_output --> get-foo + + consensus[["consensus
consensus
(offchain_reporting[at]1.0.0)"]] + compute -- Value --> consensus + + unnamed6[/"target
(id)"\] + consensus --> unnamed6 +
+
+ + + \ No newline at end of file diff --git a/pkg/workflows/sdk/workflow_spec_test.go b/pkg/workflows/sdk/workflow_spec_test.go index 7ae8d9ecb..204c969b5 100644 --- a/pkg/workflows/sdk/workflow_spec_test.go +++ b/pkg/workflows/sdk/workflow_spec_test.go @@ -67,13 +67,15 @@ func TestWorkflowSpecFormatChart(t *testing.T) { }{ {"notstreamssepolia", notStreamSepoliaWorkflowSpec}, {"serial", serialWorkflowSpec}, + {"parallel", parallelWorkflowSpec}, {"parallel_serialized", parallelSerializedWorkflowSpec}, + {"builder_parallel", buildSimpleWorkflowSpec( - sdk.NewWorkflowSpecFactory(sdk.NewWorkflowParams{Owner: "test", Name: "parallel"}), + sdk.NewWorkflowSpecFactory(sdk.NewWorkflowParams{Name: "parallel"}), ).MustSpec(t)}, {"builder_serial", buildSimpleWorkflowSpec( - sdk.NewSerialWorkflowSpecFactory(sdk.NewWorkflowParams{Owner: "test", Name: "serial"}), + sdk.NewSerialWorkflowSpecFactory(sdk.NewWorkflowParams{Name: "serial"}), ).MustSpec(t)}, } { t.Run(tt.name, func(t *testing.T) {