Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pkg/workflows/sdk/cmd/playground: add workflow playground #822

Draft
wants to merge 1 commit into
base: workflow-builder-serial
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
166 changes: 166 additions & 0 deletions pkg/workflows/sdk/cmd/playground/main.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure on line 34 in pkg/workflows/sdk/cmd/playground/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G114: Use of net/http serve function that has no support for setting timeouts (gosec)
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)

Check failure on line 97 in pkg/workflows/sdk/cmd/playground/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G306: Expect WriteFile permissions to be 0600 or less (gosec)
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)

Check failure on line 102 in pkg/workflows/sdk/cmd/playground/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G306: Expect WriteFile permissions to be 0600 or less (gosec)
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)

Check failure on line 107 in pkg/workflows/sdk/cmd/playground/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G306: Expect WriteFile permissions to be 0600 or less (gosec)
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
`
180 changes: 180 additions & 0 deletions pkg/workflows/sdk/cmd/playground/www/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<html>
<head>
<style>

</style>
</head>
<body>
<div style="display: flex; flex-direction: row; height: 100%;">
<div style="flex-grow: 1">
<form id="form" style="display: flex; flex-direction: column; height: 100%;">
<pre contentEditable="true" id="code" style="font-size: 10px; tab-size:20px; flex-grow: 2; overflow-y: scroll; padding: 10px;">
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
}
</pre>
<!-- TODO checkbox for auto-render?-->
<button type="submit">Render</button>
<div id="message"></div>
</form>
</div>
<div id="chart" class="mermaid" style="flex-grow: 1">
flowchart

trigger[\"<b>trigger</b><br>trigger<br><i>(basic-test-trigger[at]1.0.0)</i>"/]

compute["<b>compute</b><br>action<br><i>(custom_compute[at]1.0.0)</i>"]
get-bar -- Value --> compute
get-baz -- Value --> compute
get-foo -- Value --> compute

get-bar["<b>get-bar</b><br>action<br><i>(custom_compute[at]1.0.0)</i>"]
trigger -- cool_output --> get-bar

get-baz["<b>get-baz</b><br>action<br><i>(custom_compute[at]1.0.0)</i>"]
trigger -- cool_output --> get-baz

get-foo["<b>get-foo</b><br>action<br><i>(custom_compute[at]1.0.0)</i>"]
trigger -- cool_output --> get-foo

consensus[["<b>consensus</b><br>consensus<br><i>(offchain_reporting[at]1.0.0)</i>"]]
compute -- Value --> consensus

unnamed6[/"target<br><i>(id)</i>"\]
consensus --> unnamed6
</div>
</div>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';

let form = document.getElementById('form');
form.addEventListener('submit', (e) => {
e.preventDefault();

let code = document.getElementById('code').innerText;
let message = document.getElementById('message');
let chart = document.getElementById('chart');
message.innerText = '';
chart.innerHTML = '';

fetch('/format-chart', {
method: 'PUT',
body: code,
}).then(response => response.json()
).then(data => {
if (data.error) {
throw data.error;
}
let code = document.getElementById('code')
code.innerText = data.workflow;

return data.chart;
}).then(data => mermaid.render('mermaid', data)).then(data => {
let chart = document.getElementById('chart');
chart.innerHTML = data.svg;
}).catch(error => {
let message = document.getElementById('message');
let chart = document.getElementById('chart');
message.innerText = error;
chart.innerText = '';
});
});
</script>
</body>
</html>
6 changes: 4 additions & 2 deletions pkg/workflows/sdk/workflow_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading