commit 2e94e6bdf6c3e9dcaac86e7dfca98c65f0634cfd Author: Shreyas Kapale Date: Fri May 8 01:23:27 2026 +0530 feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..831e112 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.git +.gitignore +.dockerignore +.DS_Store +.idea +.vscode + +# Local Go binaries +bin/ +flow +*.test +coverage.out + +# Local databases +*.db +*.db-shm +*.db-wal + +# Frontend build artifacts (rebuilt inside the web build stage) +web/node_modules/ +web/.next/ +web/out/ +web/dist/ + +# Secrets / env +.env +.env.local +.env.*.local + +# Docs / examples don't need to be in build context +docs/ +README.md +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3957a02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +/bin/ +/flow +/*.db +/*.db-shm +/*.db-wal +.env +.env.local +coverage.out +*.test +.DS_Store +.idea/ +.vscode/ + +# frontend +web/node_modules/ +web/.next/ +web/out/* +!web/out/.gitkeep +web/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbeb43d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# --- web build stage --- +FROM node:22-alpine AS web +WORKDIR /app +COPY web/package.json web/package-lock.json* ./web/ +RUN cd web && (test -f package-lock.json && npm ci --no-fund --no-audit || npm install --no-fund --no-audit) +COPY web ./web +RUN cd web && npm run build + +# --- go build stage --- +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web /app/web/out ./web/out +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/flow ./cmd/flow + +# --- runtime stage --- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=build /out/flow /usr/local/bin/flow +EXPOSE 8080 9080 +ENTRYPOINT ["/usr/local/bin/flow"] +CMD ["serve"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c56f4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Lyzr AI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22265b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: build run serve test vet tidy clean web web-dev dev + +BINARY := bin/flow + +build: web + go build -o $(BINARY) ./cmd/flow + +# Build only the Go binary, expecting web/dist to already exist. +build-go: + go build -o $(BINARY) ./cmd/flow + +web: + cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run build + +web-dev: + cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run dev + +# `make dev` runs the Vite dev server (auto-installs deps). +# In another terminal run `make serve` to start the Go API on :8080; +# the Vite proxy forwards /api/* requests to it. +dev: web-dev + +serve: build-go + ./$(BINARY) serve + +run: build + ./$(BINARY) + +test: + go test ./... -count=1 -race + +vet: + go vet ./... + +tidy: + go mod tidy + +clean: + rm -rf bin/ web/dist web/node_modules *.db *.db-shm *.db-wal coverage.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..e078d27 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# flow + +A lightweight, durable, n8n-compatible workflow engine in Go. + +- Single Go binary, single SQLite file — no external services required for the default install +- n8n DSL compatible: paste exported workflow JSON and run it +- Durable execution with crash-safe journal +- First-class human-in-the-loop approvals +- Postgres backend opt-in for multi-instance deployments +- Embeddable as a Go library (`import "github.com/lyzrai/flow/pkg/engine"`) + +## Status + +Pre-v0.1. Workflow engine extraction in progress. Not yet usable. + +## Quickstart + +```sh +# build +make build + +# run an example workflow +./bin/flow run examples/hello.json +``` + +## License + +Apache 2.0 diff --git a/cmd/flow/main.go b/cmd/flow/main.go new file mode 100644 index 0000000..38a82a4 --- /dev/null +++ b/cmd/flow/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/lyzrai/flow/pkg/api" + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/executors" + "github.com/lyzrai/flow/pkg/orchestrator" + "github.com/lyzrai/flow/web" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(2) + } + + switch os.Args[1] { + case "run": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: flow run ") + os.Exit(2) + } + os.Exit(runWorkflow(os.Args[2])) + case "serve": + os.Exit(serve()) + case "version": + fmt.Println("flow v0.0.1-dev") + default: + printUsage() + os.Exit(2) + } +} + +func printUsage() { + fmt.Fprintln(os.Stderr, `flow — durable n8n-compatible workflow engine + +usage: + flow run execute a workflow file once and print outputs + flow serve start the HTTP server (UI + API) on :8080 + flow version print version + +env vars (for `+"`flow serve`"+`): + FLOW_ADDR HTTP listen address (default :8080) + RESTATE_INGRESS_URL Restate ingress URL (default http://localhost:8081) + RESTATE_ADMIN_URL Restate admin URL (default http://localhost:9070) + RESTATE_SERVICE_ADDR Restate service-endpoint listen addr (default :9080) + RESTATE_DEPLOYMENT_URI How Restate reaches this service (default http://localhost:9080)`) +} + +func runWorkflow(path string) int { + data, err := os.ReadFile(path) + if err != nil { + slog.Error("failed to read workflow file", slog.String("path", path), slog.Any("error", err)) + return 1 + } + + wf, err := engine.ParseWorkflow(data) + if err != nil { + slog.Error("failed to parse workflow", slog.Any("error", err)) + return 1 + } + + executors.RegisterAll() + + ctx := context.Background() + result, err := engine.RunWorkflow(ctx, wf, nil, executors.BuildLookup()) + if err != nil { + slog.Error("workflow run failed", slog.Any("error", err)) + return 1 + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + slog.Error("failed to marshal result", slog.Any("error", err)) + return 1 + } + fmt.Println(string(out)) + return 0 +} + +func serve() int { + executors.RegisterAll() + lookup := executors.BuildLookup() + + addr := envOr("FLOW_ADDR", ":8080") + ingressURL := envOr("RESTATE_INGRESS_URL", "http://localhost:8081") + adminURL := envOr("RESTATE_ADMIN_URL", "http://localhost:9070") + serviceAddr := envOr("RESTATE_SERVICE_ADDR", ":9080") + deployURI := envOr("RESTATE_DEPLOYMENT_URI", "http://localhost"+serviceAddr) + + slog.Info("checking restate", + slog.String("ingress", ingressURL), + slog.String("admin", adminURL), + ) + if err := orchestrator.HealthCheckRestate(ingressURL); err != nil { + slog.Error("restate not reachable, exiting", + slog.String("hint", "run `docker compose up restate` (or set RESTATE_INGRESS_URL=)"), + slog.Any("error", err), + ) + return 1 + } + + orch := orchestrator.NewRestateOrchestrator(ingressURL) + + // Start the Restate service endpoint that Restate calls back into. + go startRestateService(serviceAddr, lookup) + + // Auto-register with Restate admin so callbacks land on us. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := orchestrator.RegisterDeploymentWithRetry(ctx, adminURL, deployURI, 30, 2*time.Second); err != nil { + slog.Error("restate deployment registration failed", + slog.String("admin", adminURL), + slog.String("deploy_uri", deployURI), + slog.Any("error", err), + ) + return + } + slog.Info("registered with restate", slog.String("deploy_uri", deployURI)) + }() + + // HTTP server (UI + API). + srv := &http.Server{ + Addr: addr, + Handler: api.NewServer(api.ServerDeps{ + Assets: web.Dist(), + Orchestrator: orch, + RestateIngressURL: orch.IngressURL(), + }), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + slog.Info("flow listening", slog.String("addr", addr)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server failed", slog.Any("error", err)) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + slog.Info("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("forced shutdown", slog.Any("error", err)) + return 1 + } + return 0 +} + +func startRestateService(addr string, lookup engine.ExecutorLookup) { + rs := orchestrator.NewRestateServer(lookup, nil, nil) + slog.Info("starting restate service endpoint", slog.String("addr", addr)) + if err := rs.Start(context.Background(), addr); err != nil { + slog.Error("restate service failed", slog.Any("error", err)) + os.Exit(1) + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..990e8f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + restate: + image: docker.io/restatedev/restate:latest + ports: + - "8081:8080" # ingress + - "9070:9070" # admin + + flow: + build: . + ports: + - "8080:8080" # UI + API + - "9080:9080" # restate callback + environment: + - RESTATE_INGRESS_URL=http://restate:8080 + - RESTATE_ADMIN_URL=http://restate:9070 + - RESTATE_DEPLOYMENT_URI=http://flow:9080 + depends_on: + - restate diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a5334a --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/lyzrai/flow + +go 1.24.5 + +require ( + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c + github.com/google/uuid v1.6.0 + github.com/restatedev/sdk-go v0.23.0 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/text v0.3.8 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b19c57 --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1 h1:dOYG7LS/WK00RWZc8XGgcUTlTxpp3mKhdR2Q9z9HbXM= +github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/restatedev/sdk-go v0.23.0 h1:Eewh6n/YZUfA7In5ZiAIA6smLyssynsBHVijUguNpvc= +github.com/restatedev/sdk-go v0.23.0/go.mod h1:2G757yGe0Ihwcb+Z/HZUscQ0g3PFTyueO0f8qlqxWDo= +github.com/restatedev/sdk-go v0.24.0 h1:SZN633U7Jb7AbhWwSOMVPz5Fw9fwWHcGlcr0BF4PMDM= +github.com/restatedev/sdk-go v0.24.0/go.mod h1:2G757yGe0Ihwcb+Z/HZUscQ0g3PFTyueO0f8qlqxWDo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/api/server.go b/pkg/api/server.go new file mode 100644 index 0000000..620754f --- /dev/null +++ b/pkg/api/server.go @@ -0,0 +1,485 @@ +// Package api provides the HTTP server for flow. +// +// It serves: +// - /api/health — readiness probe +// - /api/workflows — workflow CRUD (in-memory placeholder until storage lands) +// - / — embedded SPA (any non-/api path returns index.html) +// +// The router is intentionally std-library only at this stage; we'll lift in a +// proper router (chi or gin) when the API surface grows. +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" + "github.com/lyzrai/flow/pkg/orchestrator" +) + +// FlowSummary is the list-shape returned to the dashboard. +type FlowSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` + NodeCount int `json:"nodeCount"` + Status string `json:"status,omitempty"` +} + +// ServerDeps groups the construction-time dependencies of the HTTP server. +// Assets is the embedded UI bundle; Orchestrator drives durable execution; +// RestateIngressURL is used to resolve Awakeables from the resume handler. +type ServerDeps struct { + Assets fs.FS + Orchestrator orchestrator.Orchestrator + RestateIngressURL string +} + +// Server is a thin HTTP server that bundles API + embedded SPA. +type Server struct { + mux *http.ServeMux + orch orchestrator.Orchestrator + restateIngres string + + mu sync.Mutex + flows map[string]storedFlow // in-memory placeholder; storage layer lands next +} + +// Definition is held as raw n8n-format JSON so we don't lose connection +// shape on round-trip. We parse on read for validation + node count. +type storedFlow struct { + ID string `json:"id"` + Name string `json:"name"` + Definition json.RawMessage `json:"definition"` + UpdatedAt time.Time `json:"updatedAt"` + NodeCount int `json:"nodeCount"` +} + +// NewServer constructs a Server with API routes installed and the SPA mounted +// at "/" using deps.Assets. deps.Orchestrator may be nil — execute routes will +// then return 503. +func NewServer(deps ServerDeps) *Server { + s := &Server{ + mux: http.NewServeMux(), + orch: deps.Orchestrator, + restateIngres: deps.RestateIngressURL, + flows: make(map[string]storedFlow), + } + s.routes(deps.Assets) + return s +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } + +func (s *Server) routes(assets fs.FS) { + // API + s.mux.HandleFunc("GET /api/health", s.handleHealth) + s.mux.HandleFunc("GET /api/workflows", s.handleListFlows) + s.mux.HandleFunc("POST /api/workflows", s.handleCreateFlow) + s.mux.HandleFunc("GET /api/workflows/{id}", s.handleGetFlow) + s.mux.HandleFunc("PUT /api/workflows/{id}", s.handleUpdateFlow) + s.mux.HandleFunc("DELETE /api/workflows/{id}", s.handleDeleteFlow) + s.mux.HandleFunc("POST /api/workflows/execute", s.handleExecuteWorkflow) + s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) + s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution) + + // SPA: any path not starting with /api falls through to the embedded bundle. + s.mux.Handle("/", spaHandler(assets)) +} + +// --- handlers ------------------------------------------------------------- + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleListFlows(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]FlowSummary, 0, len(s.flows)) + for _, f := range s.flows { + out = append(out, FlowSummary{ + ID: f.ID, + Name: f.Name, + UpdatedAt: f.UpdatedAt, + NodeCount: f.NodeCount, + Status: "draft", + }) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCreateFlow(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Definition json.RawMessage `json:"definition"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + count, parsedName, err := analyzeDefinition(body.Definition) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + id := newID() + f := storedFlow{ + ID: id, + Name: firstNonEmpty(body.Name, parsedName, "Untitled flow"), + Definition: body.Definition, + UpdatedAt: time.Now().UTC(), + NodeCount: count, + } + s.mu.Lock() + s.flows[id] = f + s.mu.Unlock() + writeJSON(w, http.StatusCreated, map[string]string{"id": id}) +} + +func (s *Server) handleGetFlow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s.mu.Lock() + f, ok := s.flows[id] + s.mu.Unlock() + if !ok { + writeError(w, http.StatusNotFound, errors.New("flow not found")) + return + } + writeJSON(w, http.StatusOK, f) +} + +func (s *Server) handleUpdateFlow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + Name *string `json:"name"` + Definition json.RawMessage `json:"definition"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.flows[id] + if !ok { + writeError(w, http.StatusNotFound, errors.New("flow not found")) + return + } + if body.Name != nil { + f.Name = *body.Name + } + if len(body.Definition) > 0 { + count, _, err := analyzeDefinition(body.Definition) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + f.Definition = body.Definition + f.NodeCount = count + } + f.UpdatedAt = time.Now().UTC() + s.flows[id] = f + w.WriteHeader(http.StatusNoContent) +} + +// analyzeDefinition tries to parse the provided JSON as a flow workflow. +// Save-time is intentionally lenient — drafts with no trigger or no nodes are +// allowed. Strict validation happens at run time. We still surface obviously +// malformed JSON as a 400. +func analyzeDefinition(raw json.RawMessage) (int, string, error) { + if len(raw) == 0 { + return 0, "", nil + } + var probe struct { + Name string `json:"name"` + Nodes []map[string]any `json:"nodes"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return 0, "", fmt.Errorf("definition is not valid JSON: %w", err) + } + // Best-effort full parse. Failures are fine at draft time. + if _, err := engine.ParseWorkflow(raw); err != nil { + _ = err // intentionally swallow; draft state. + } + return len(probe.Nodes), probe.Name, nil +} + +func (s *Server) handleDeleteFlow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s.mu.Lock() + delete(s.flows, id) + s.mu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + +// handleExecuteWorkflow accepts either an inline workflow definition or a +// stored workflow ID and submits it to the orchestrator asynchronously. +// POST /api/workflows/execute +// { "workflow": , "input": [...] } | { "workflow_id": "...", "input": [...] } +// Returns 202 with { execution_id, status } so the UI can route to the run- +// detail page and stream events. +func (s *Server) handleExecuteWorkflow(w http.ResponseWriter, r *http.Request) { + if s.orch == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured")) + return + } + + rawBody, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + wfJSON, input, err := s.parseExecuteRequest(rawBody) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + wf, err := engine.ParseWorkflow(wfJSON) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("parse workflow: %w", err)) + return + } + + apiKey := r.Header.Get("X-API-Key") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + execID, err := s.orch.RunAsync(ctx, &orchestrator.RunRequest{ + RequestMeta: orchestrator.RequestMeta{APIKey: apiKey}, + Workflow: wf, + TriggerData: input, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("submit execution: %w", err)) + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "execution_id": execID, + "status": "running", + }) +} + +// handleResumeExecution resolves a Restate Awakeable so a paused workflow +// continues. Body: { "awakeable_id": "...", "data": } +// +// `data` is forwarded as-is to the Awakeable; the Approval node interprets +// `{"approved": bool, "reason"?: string, ...}`. +func (s *Server) handleResumeExecution(w http.ResponseWriter, r *http.Request) { + if s.restateIngres == "" { + writeError(w, http.StatusServiceUnavailable, errors.New("resume requires restate ingress URL")) + return + } + var body struct { + AwakeableID string `json:"awakeable_id"` + Data json.RawMessage `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if body.AwakeableID == "" { + writeError(w, http.StatusBadRequest, errors.New("awakeable_id is required")) + return + } + payload := body.Data + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + + url := strings.TrimRight(s.restateIngres, "/") + "/restate/awakeables/" + body.AwakeableID + "/resolve" + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, strings.NewReader(string(payload))) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + writeError(w, http.StatusBadGateway, fmt.Errorf("resume call failed: %w", err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + writeError(w, resp.StatusCode, fmt.Errorf("restate returned %d: %s", resp.StatusCode, string(respBody))) + return + } + + slog.InfoContext(r.Context(), "workflow_resumed_via_api", + slog.String("execution_id", r.PathValue("id")), + slog.String("awakeable_id", body.AwakeableID), + ) + writeJSON(w, http.StatusOK, map[string]string{"message": "workflow resumed"}) +} + +func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { + if s.orch == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured")) + return + } + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, errors.New("execution id required")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + status, err := s.orch.GetExecution(ctx, id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +// parseExecuteRequest accepts the structured ExecuteRequest shape: +// { "workflow": , "input": [{...}, ...] } +// { "workflow_id": "", "input": [...] } +// Either path returns the raw n8n-format workflow JSON ready for ParseWorkflow. +func (s *Server) parseExecuteRequest(body []byte) (json.RawMessage, []models.Item, error) { + var probe struct { + Workflow json.RawMessage `json:"workflow"` + WorkflowID string `json:"workflow_id"` + Input []models.Item `json:"input"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return nil, nil, fmt.Errorf("invalid request body: %w", err) + } + + input := probe.Input + if len(input) == 0 { + input = []models.Item{{}} + } + + if probe.WorkflowID != "" { + s.mu.Lock() + f, ok := s.flows[probe.WorkflowID] + s.mu.Unlock() + if !ok { + return nil, nil, fmt.Errorf("workflow %q not found", probe.WorkflowID) + } + return f.Definition, input, nil + } + if len(probe.Workflow) > 0 && string(probe.Workflow) != "null" { + return probe.Workflow, input, nil + } + return nil, nil, errors.New("either workflow or workflow_id is required") +} + +// --- SPA ----------------------------------------------------------------- + +// spaHandler serves static assets from fsys; falls back to index.html for any +// non-asset GET so client-side routing works on refresh. +func spaHandler(fsys fs.FS) http.Handler { + if fsys == nil { + // Frontend not built; render a small notice instead of a blank page. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeError(w, http.StatusNotFound, errors.New("frontend not built")) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(noBundleHTML)) + }) + } + + files := http.FS(fsys) + fileServer := http.FileServer(files) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeError(w, http.StatusNotFound, errors.New("not found")) + return + } + // If the requested asset exists, serve it. Otherwise fall back to index.html. + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + serveIndex(w, r, fsys) + return + } + if f, err := fsys.Open(path); err == nil { + _ = f.Close() + // Long cache for fingerprinted assets, no-cache for index.html. + if strings.HasPrefix(path, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + fileServer.ServeHTTP(w, r) + return + } + serveIndex(w, r, fsys) + }) +} + +func serveIndex(w http.ResponseWriter, _ *http.Request, fsys fs.FS) { + data, err := fs.ReadFile(fsys, "index.html") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) +} + +const noBundleHTML = ` +flow + +
+

flow — frontend not built

+

Run cd web && npm install && npm run build and rebuild the binary to ship the UI.

+
` + +// --- helpers -------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("failed to write json response", slog.Any("error", err)) + } +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func newID() string { + // Compact ULID-ish without pulling a dep: timestamp + crypto random. + now := time.Now().UTC().UnixNano() + return strings.ReplaceAll(time.Unix(0, now).Format("20060102T150405.000000000"), ".", "") +} diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go new file mode 100644 index 0000000..a4dc432 --- /dev/null +++ b/pkg/api/server_test.go @@ -0,0 +1,357 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lyzrai/flow/pkg/models" + "github.com/lyzrai/flow/pkg/orchestrator" +) + +func TestHealth_returns200(t *testing.T) { + srv := NewServer(ServerDeps{}) + r := httptest.NewRequest(http.MethodGet, "/api/health", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d want 200", w.Code) + } + var body map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("body: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("body: got %v", body) + } +} + +func TestWorkflowCRUD_roundTrip(t *testing.T) { + srv := NewServer(ServerDeps{}) + + // Create + wf := map[string]any{ + "name": "round-trip", + "definition": map[string]any{ + "name": "round-trip", + "nodes": []any{ + map[string]any{ + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.manualTrigger", + "parameters": map[string]any{}, + "position": []any{0, 0}, + }, + }, + "connections": map[string]any{}, + }, + } + body, _ := json.Marshal(wf) + r := httptest.NewRequest(http.MethodPost, "/api/workflows", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusCreated { + t.Fatalf("create: got %d (%s)", w.Code, w.Body.String()) + } + var created struct{ ID string `json:"id"` } + _ = json.Unmarshal(w.Body.Bytes(), &created) + if created.ID == "" { + t.Fatal("create: missing id") + } + + // List + r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("list: got %d", w.Code) + } + var list []FlowSummary + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("list body: %v", err) + } + if len(list) != 1 || list[0].ID != created.ID { + t.Fatalf("list: got %+v", list) + } + if list[0].NodeCount != 1 { + t.Fatalf("nodeCount: expected 1, got %d", list[0].NodeCount) + } + if list[0].Name != "round-trip" { + t.Fatalf("name: got %q", list[0].Name) + } + + // Get + r = httptest.NewRequest(http.MethodGet, "/api/workflows/"+created.ID, nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("get: got %d", w.Code) + } + + // Update name only + upd, _ := json.Marshal(map[string]any{"name": "renamed"}) + r = httptest.NewRequest(http.MethodPut, "/api/workflows/"+created.ID, bytes.NewReader(upd)) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("update: got %d (%s)", w.Code, w.Body.String()) + } + + // Verify rename via list + r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + _ = json.Unmarshal(w.Body.Bytes(), &list) + if list[0].Name != "renamed" { + t.Fatalf("rename: got %q", list[0].Name) + } + + // Delete + r = httptest.NewRequest(http.MethodDelete, "/api/workflows/"+created.ID, nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("delete: got %d", w.Code) + } + + // Empty list + r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + _ = json.Unmarshal(w.Body.Bytes(), &list) + if len(list) != 0 { + t.Fatalf("expected empty list after delete, got %+v", list) + } +} + +func TestExecute_requiresOrchestrator(t *testing.T) { + srv := NewServer(ServerDeps{}) + body, _ := json.Marshal(map[string]any{ + "workflow": map[string]any{ + "name": "x", + "nodes": []any{map[string]any{"id": "1", "name": "T", "type": "n8n-nodes-base.manualTrigger", "parameters": map[string]any{}, "position": []any{0, 0}}}, + "connections": map[string]any{}, + }, + }) + r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("got %d, want 503 when orchestrator nil (body=%s)", w.Code, w.Body.String()) + } +} + +// stubOrch is an Orchestrator that records the last submitted workflow. +type stubOrch struct { + lastWorkflow *models.WorkflowDefinition + lastTrigger []models.Item + execID string + statusOut *orchestrator.ExecutionStatus + statusErr error +} + +func (s *stubOrch) Run(_ context.Context, req *orchestrator.RunRequest) (string, *models.ExecutionResult, error) { + s.lastWorkflow = req.Workflow + s.lastTrigger = req.TriggerData + return s.execID, nil, nil +} +func (s *stubOrch) RunAsync(_ context.Context, req *orchestrator.RunRequest) (string, error) { + s.lastWorkflow = req.Workflow + s.lastTrigger = req.TriggerData + return s.execID, nil +} +func (s *stubOrch) GetExecution(_ context.Context, _ string) (*orchestrator.ExecutionStatus, error) { + if s.statusErr != nil { + return nil, s.statusErr + } + return s.statusOut, nil +} + +func TestExecute_inlineWorkflow_callsOrchestrator(t *testing.T) { + orch := &stubOrch{execID: "exec-123"} + srv := NewServer(ServerDeps{Orchestrator: orch}) + + body := []byte(`{"workflow":{"name":"x","nodes":[{"id":"1","name":"T","type":"n8n-nodes-base.manualTrigger","parameters":{},"position":[0,0]}],"connections":{}},"input":[{"k":"v"}]}`) + r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + + if w.Code != http.StatusAccepted { + t.Fatalf("got %d (%s), want 202", w.Code, w.Body.String()) + } + var resp map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["execution_id"] != "exec-123" { + t.Fatalf("execution_id: got %q", resp["execution_id"]) + } + if orch.lastWorkflow == nil { + t.Fatal("orchestrator was not called") + } + if orch.lastWorkflow.Name != "x" { + t.Fatalf("workflow name not parsed: %+v", orch.lastWorkflow) + } + if len(orch.lastTrigger) != 1 || orch.lastTrigger[0]["k"] != "v" { + t.Fatalf("trigger data not threaded: %+v", orch.lastTrigger) + } +} + +func TestExecute_byWorkflowID(t *testing.T) { + orch := &stubOrch{execID: "exec-7"} + srv := NewServer(ServerDeps{Orchestrator: orch}) + + // Pre-create a workflow. + body, _ := json.Marshal(map[string]any{ + "name": "stored", + "definition": map[string]any{ + "name": "stored", + "nodes": []any{map[string]any{"id": "1", "name": "T", "type": "n8n-nodes-base.manualTrigger", "parameters": map[string]any{}, "position": []any{0, 0}}}, + "connections": map[string]any{}, + }, + }) + r := httptest.NewRequest(http.MethodPost, "/api/workflows", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + var created struct{ ID string `json:"id"` } + _ = json.Unmarshal(w.Body.Bytes(), &created) + + exec, _ := json.Marshal(map[string]any{"workflow_id": created.ID}) + r = httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(exec)) + w = httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusAccepted { + t.Fatalf("got %d (%s)", w.Code, w.Body.String()) + } + if orch.lastWorkflow == nil || orch.lastWorkflow.Name != "stored" { + t.Fatalf("by-id execute did not load stored workflow: %+v", orch.lastWorkflow) + } +} + +func TestExecute_missing_workflow_400(t *testing.T) { + srv := NewServer(ServerDeps{Orchestrator: &stubOrch{}}) + body := []byte(`{}`) + r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("got %d", w.Code) + } +} + +func TestGetExecution_returnsOrchStatus(t *testing.T) { + want := &orchestrator.ExecutionStatus{ExecutionID: "abc", Status: "success"} + srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusOut: want}}) + + r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("got %d (%s)", w.Code, w.Body.String()) + } + var got orchestrator.ExecutionStatus + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got.ExecutionID != "abc" || got.Status != "success" { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestGetExecution_notFound(t *testing.T) { + srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusErr: errors.New("nope")}}) + r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("got %d", w.Code) + } +} + +func TestResume_proxiesToRestate(t *testing.T) { + got := struct { + path string + body []byte + contentType string + }{} + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.path = r.URL.Path + got.contentType = r.Header.Get("Content-Type") + got.body, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer stub.Close() + + srv := NewServer(ServerDeps{RestateIngressURL: stub.URL}) + body, _ := json.Marshal(map[string]any{ + "awakeable_id": "sign_xyz", + "data": map[string]any{"approved": true}, + }) + r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("resume: got %d (%s)", w.Code, w.Body.String()) + } + if got.path != "/restate/awakeables/sign_xyz/resolve" { + t.Errorf("proxied path: got %q", got.path) + } + if !strings.Contains(string(got.body), `"approved":true`) { + t.Errorf("proxied body: got %q", string(got.body)) + } + if got.contentType != "application/json" { + t.Errorf("content-type: got %q", got.contentType) + } +} + +func TestResume_requiresAwakeableID(t *testing.T) { + srv := NewServer(ServerDeps{RestateIngressURL: "http://localhost"}) + body, _ := json.Marshal(map[string]any{"awakeable_id": "", "data": map[string]any{}}) + r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("got %d", w.Code) + } +} + +func TestResume_unwiredIngress503(t *testing.T) { + srv := NewServer(ServerDeps{}) // no RestateIngressURL + body, _ := json.Marshal(map[string]any{"awakeable_id": "sign_x", "data": map[string]any{}}) + r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("got %d", w.Code) + } +} + +func TestSPA_fallsBackToIndexForUnknownRoutes(t *testing.T) { + // Without any Assets, SPA handler returns the no-bundle notice. + srv := NewServer(ServerDeps{}) + for _, path := range []string{"/", "/flows/abc", "/runs/123"} { + r := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Errorf("%s: got %d", path, w.Code) + } + if !strings.Contains(w.Body.String(), "html") { + t.Errorf("%s: expected html body", path) + } + } +} + +func TestUnknownAPIRoute_404(t *testing.T) { + srv := NewServer(ServerDeps{}) + r := httptest.NewRequest(http.MethodGet, "/api/does-not-exist", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("got %d", w.Code) + } +} diff --git a/pkg/durability/ctx.go b/pkg/durability/ctx.go new file mode 100644 index 0000000..154937b --- /dev/null +++ b/pkg/durability/ctx.go @@ -0,0 +1,187 @@ +// Package durability defines the journal abstraction the engine uses to +// turn each meaningful step (node execution, sub-workflow call, approval wait) +// into a replayable journal entry. +// +// Two implementations ship: RestateDurableCtx for production durability and +// DirectCtx for tests / library-mode embedding without a Restate process. +package durability + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// RetryPolicy configures per-step retry behavior. In Restate this maps to +// native per-Run retry options. In DirectCtx (tests) it retries in-process. +type RetryPolicy struct { + MaxAttempts int + InitialInterval time.Duration +} + +// DurableCtx abstracts over journaled execution. +type DurableCtx interface { + // Run executes fn as a named journaled step. On replay (after crash/restart), + // completed steps return cached results without re-execution. + // The returned value must be JSON-serializable. + Run(name string, fn func(ctx context.Context) (any, error)) (any, error) + + // RunWithRetry is like Run but applies a per-step retry policy. + RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) +} + +type durableCtxKey struct{} + +// WithDurableCtx attaches a DurableCtx to a context.Context. +func WithDurableCtx(ctx context.Context, dctx DurableCtx) context.Context { + return context.WithValue(ctx, durableCtxKey{}, dctx) +} + +// FromContext extracts a DurableCtx from the context, if present. +func FromContext(ctx context.Context) (DurableCtx, bool) { + dctx, ok := ctx.Value(durableCtxKey{}).(DurableCtx) + return dctx, ok && dctx != nil +} + +// --- Restate context threading --- +// Separate from DurableCtx because some executors need the raw Restate context +// for features not available through the DurableCtx abstraction (e.g., Awakeables). + +type restateCtxKey struct{} + +// WithRestateCtx attaches a raw Restate WorkflowContext to a Go context. +// Used by the ApprovalExecutor to create Awakeables. +func WithRestateCtx(ctx context.Context, rctx any) context.Context { + return context.WithValue(ctx, restateCtxKey{}, rctx) +} + +// RestateCtxFromContext extracts the raw Restate WorkflowContext. +// Returns nil if not running inside Restate. +func RestateCtxFromContext(ctx context.Context) any { + return ctx.Value(restateCtxKey{}) +} + +// ScopedDurableCtx wraps a DurableCtx and prefixes all step names. +// Used when a sub-workflow runs inside a parent durable workflow — the prefix +// avoids step name collisions between parent and child. +type ScopedDurableCtx struct { + Inner DurableCtx + Prefix string +} + +func (s *ScopedDurableCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) { + return s.Inner.Run(s.Prefix+name, fn) +} + +func (s *ScopedDurableCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) { + return s.Inner.RunWithRetry(s.Prefix+name, policy, fn) +} + +// RunAs executes a durable step and JSON-decodes the result into a concrete type T. +// Restate's `restate.Run` returns map[string]interface{} on replay instead of the +// original struct type; RunAs handles that round-trip transparently. +func RunAs[T any](dctx DurableCtx, name string, fn func(ctx context.Context) (T, error)) (T, error) { + raw, err := dctx.Run(name, func(ctx context.Context) (any, error) { + return fn(ctx) + }) + if err != nil { + var zero T + return zero, err + } + if typed, ok := raw.(T); ok { + return typed, nil + } + jsonBytes, err := json.Marshal(raw) + if err != nil { + var zero T + return zero, fmt.Errorf("durability RunAs %q: marshal: %w", name, err) + } + var result T + if err := json.Unmarshal(jsonBytes, &result); err != nil { + var zero T + return zero, fmt.Errorf("durability RunAs %q: unmarshal into %T: %w", name, result, err) + } + return result, nil +} + +// RunAsWithRetry is like RunAs but applies a per-step retry policy. +func RunAsWithRetry[T any](dctx DurableCtx, name string, policy RetryPolicy, fn func(ctx context.Context) (T, error)) (T, error) { + raw, err := dctx.RunWithRetry(name, policy, func(ctx context.Context) (any, error) { + return fn(ctx) + }) + if err != nil { + var zero T + return zero, err + } + if typed, ok := raw.(T); ok { + return typed, nil + } + jsonBytes, err := json.Marshal(raw) + if err != nil { + var zero T + return zero, fmt.Errorf("durability RunAsWithRetry %q: marshal: %w", name, err) + } + var result T + if err := json.Unmarshal(jsonBytes, &result); err != nil { + var zero T + return zero, fmt.Errorf("durability RunAsWithRetry %q: unmarshal into %T: %w", name, result, err) + } + return result, nil +} + +// --- Approval persistence interface --- + +// ApprovalCreator persists a HITL approval row. Implemented by the storage layer. +// Defined here to avoid a cycle from executors → storage. +type ApprovalCreator interface { + CreateFromRecord(ctx context.Context, a *ApprovalRecord) error +} + +// ApprovalRecord is the data needed to persist a pending approval. +type ApprovalRecord struct { + ID string + ExecutionID string + NodeName string + AwakeableID string + Status string + InputData map[string]any + APIKey string +} + +type approvalCreatorKey struct{} +type executionIDKey struct{} +type apiKeyCtxKey struct{} + +// WithApprovalCreator attaches an ApprovalCreator to a context. +func WithApprovalCreator(ctx context.Context, c ApprovalCreator) context.Context { + return context.WithValue(ctx, approvalCreatorKey{}, c) +} + +// ApprovalCreatorFromContext extracts the ApprovalCreator, or nil. +func ApprovalCreatorFromContext(ctx context.Context) ApprovalCreator { + c, _ := ctx.Value(approvalCreatorKey{}).(ApprovalCreator) + return c +} + +// WithExecutionID attaches the workflow execution ID to a context. +func WithExecutionID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, executionIDKey{}, id) +} + +// ExecutionIDFromContext extracts the execution ID, or "". +func ExecutionIDFromContext(ctx context.Context) string { + s, _ := ctx.Value(executionIDKey{}).(string) + return s +} + +// WithAPIKey attaches the API key to a context. +func WithAPIKey(ctx context.Context, key string) context.Context { + return context.WithValue(ctx, apiKeyCtxKey{}, key) +} + +// APIKeyFromContext extracts the API key, or "". +func APIKeyFromContext(ctx context.Context) string { + s, _ := ctx.Value(apiKeyCtxKey{}).(string) + return s +} diff --git a/pkg/durability/direct.go b/pkg/durability/direct.go new file mode 100644 index 0000000..52d3735 --- /dev/null +++ b/pkg/durability/direct.go @@ -0,0 +1,62 @@ +package durability + +import ( + "context" + "log/slog" + "time" +) + +// DirectCtx executes functions inline without journaling. +// Used in tests and for library-mode embedding (e.g., governor importing flow's +// engine but not running a Restate process). +type DirectCtx struct { + Ctx context.Context +} + +func (d *DirectCtx) ctx() context.Context { + if d.Ctx != nil { + return d.Ctx + } + return context.Background() +} + +func (d *DirectCtx) Run(_ string, fn func(ctx context.Context) (any, error)) (any, error) { + return fn(d.ctx()) +} + +func (d *DirectCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) { + maxAttempts := policy.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = 1 + } + wait := policy.InitialInterval + if wait <= 0 { + wait = time.Second + } + + ctx := d.ctx() + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + result, err := fn(ctx) + if err == nil { + if attempt > 0 { + slog.InfoContext(ctx, "retry_succeeded", + slog.String("step", name), + slog.Int("attempt", attempt+1), + ) + } + return result, nil + } + lastErr = err + if attempt < maxAttempts-1 { + slog.WarnContext(ctx, "retry", + slog.String("step", name), + slog.Int("attempt", attempt+1), + slog.Int("max", maxAttempts), + slog.Any("error", err), + ) + time.Sleep(wait) + } + } + return nil, lastErr +} diff --git a/pkg/durability/direct_test.go b/pkg/durability/direct_test.go new file mode 100644 index 0000000..d4a89a7 --- /dev/null +++ b/pkg/durability/direct_test.go @@ -0,0 +1,87 @@ +package durability + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestDirectCtx_Run_passesThrough(t *testing.T) { + d := &DirectCtx{} + out, err := d.Run("step", func(_ context.Context) (any, error) { + return 42, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != 42 { + t.Fatalf("expected 42, got %v", out) + } +} + +func TestDirectCtx_RunWithRetry_succeedsOnSecondAttempt(t *testing.T) { + d := &DirectCtx{} + calls := 0 + out, err := d.RunWithRetry("step", RetryPolicy{MaxAttempts: 3, InitialInterval: time.Millisecond}, func(_ context.Context) (any, error) { + calls++ + if calls < 2 { + return nil, errors.New("flaky") + } + return "ok", nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 calls, got %d", calls) + } + if out != "ok" { + t.Fatalf("expected ok, got %v", out) + } +} + +func TestDirectCtx_RunWithRetry_givesUpAfterMaxAttempts(t *testing.T) { + d := &DirectCtx{} + calls := 0 + _, err := d.RunWithRetry("step", RetryPolicy{MaxAttempts: 3, InitialInterval: time.Millisecond}, func(_ context.Context) (any, error) { + calls++ + return nil, errors.New("permanent") + }) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } +} + +func TestDirectCtx_RunWithRetry_zeroAttemptsDefaultsToOne(t *testing.T) { + d := &DirectCtx{} + calls := 0 + _, err := d.RunWithRetry("step", RetryPolicy{}, func(_ context.Context) (any, error) { + calls++ + return nil, errors.New("nope") + }) + if err == nil { + t.Fatal("expected error") + } + if calls != 1 { + t.Fatalf("expected single attempt, got %d", calls) + } +} + +func TestWithDurableCtx_roundTrip(t *testing.T) { + d := &DirectCtx{} + ctx := WithDurableCtx(context.Background(), d) + got, ok := FromContext(ctx) + if !ok || got != d { + t.Fatal("DurableCtx round-trip failed") + } +} + +func TestFromContext_nilCtx(t *testing.T) { + if _, ok := FromContext(context.Background()); ok { + t.Fatal("FromContext should be false on a bare context") + } +} diff --git a/pkg/durability/errors.go b/pkg/durability/errors.go new file mode 100644 index 0000000..89b0fef --- /dev/null +++ b/pkg/durability/errors.go @@ -0,0 +1,45 @@ +package durability + +import "errors" + +// TerminalClassifier is implemented by errors that know whether they are permanent. +// RestateDurableCtx checks this interface: errors marked terminal are wrapped with +// restate.TerminalError so Restate stops retrying immediately. +type TerminalClassifier interface { + IsTerminal() bool +} + +// PermanentError marks an error as terminal for durable execution. +// Use this to wrap errors that should never be retried (e.g., resource not found, +// invalid configuration) without importing Restate. +type PermanentError struct{ Err error } + +func (e *PermanentError) Error() string { return e.Err.Error() } +func (e *PermanentError) Unwrap() error { return e.Err } +func (e *PermanentError) IsTerminal() bool { return true } + +// RetryableError marks an error as retryable for durable execution. +// By default all errors are terminal (no retry). Wrap with this to allow +// Restate to retry (e.g., transient network errors, rate limits). +type RetryableError struct{ Err error } + +func (e *RetryableError) Error() string { return e.Err.Error() } +func (e *RetryableError) Unwrap() error { return e.Err } +func (e *RetryableError) IsTerminal() bool { return false } + +// IsTerminalError checks whether err should be treated as terminal. +// Returns true unless the error (or any in its chain) is explicitly marked +// retryable via RetryableError. +func IsTerminalError(err error) bool { + var tc TerminalClassifier + if errors.As(err, &tc) { + return tc.IsTerminal() + } + return true +} + +// IsRetryableError checks whether err is explicitly marked as retryable. +func IsRetryableError(err error) bool { + var tc TerminalClassifier + return errors.As(err, &tc) && !tc.IsTerminal() +} diff --git a/pkg/durability/errors_test.go b/pkg/durability/errors_test.go new file mode 100644 index 0000000..b6a0815 --- /dev/null +++ b/pkg/durability/errors_test.go @@ -0,0 +1,55 @@ +package durability + +import ( + "errors" + "fmt" + "testing" +) + +func TestIsTerminalError_default(t *testing.T) { + if !IsTerminalError(errors.New("plain")) { + t.Fatal("plain errors should default to terminal (no retry)") + } +} + +func TestPermanentError_isTerminal(t *testing.T) { + err := &PermanentError{Err: errors.New("nope")} + if !IsTerminalError(err) { + t.Fatal("PermanentError must be terminal") + } + if IsRetryableError(err) { + t.Fatal("PermanentError must not be retryable") + } +} + +func TestRetryableError_isNotTerminal(t *testing.T) { + err := &RetryableError{Err: errors.New("transient")} + if IsTerminalError(err) { + t.Fatal("RetryableError must not be terminal") + } + if !IsRetryableError(err) { + t.Fatal("RetryableError must be retryable") + } +} + +func TestRetryableError_unwrappedThroughFmt(t *testing.T) { + inner := errors.New("network down") + wrapped := fmt.Errorf("step bar: %w", &RetryableError{Err: inner}) + if IsTerminalError(wrapped) { + t.Fatal("retryable classification must survive fmt.Errorf wrapping") + } + if !IsRetryableError(wrapped) { + t.Fatal("retryable classification must survive fmt.Errorf wrapping") + } +} + +func TestPermanentError_unwrap(t *testing.T) { + inner := errors.New("missing") + err := &PermanentError{Err: inner} + if !errors.Is(err, inner) { + t.Fatal("PermanentError must unwrap to inner") + } + if err.Error() != "missing" { + t.Fatalf("unexpected message: %q", err.Error()) + } +} diff --git a/pkg/durability/restate.go b/pkg/durability/restate.go new file mode 100644 index 0000000..32966bc --- /dev/null +++ b/pkg/durability/restate.go @@ -0,0 +1,50 @@ +package durability + +import ( + "context" + + restate "github.com/restatedev/sdk-go" +) + +// RestateDurableCtx wraps a Restate Context to provide durable step execution. +// Each Run() call is journaled by Restate — on crash/restart, completed steps +// return cached results without re-execution. +// Works with any Restate context type (Context, ObjectContext, WorkflowContext). +type RestateDurableCtx struct { + Rctx restate.Context +} + +func (d *RestateDurableCtx) run(name string, fn func(ctx context.Context) (any, error), opts ...restate.RunOption) (any, error) { + allOpts := append([]restate.RunOption{restate.WithName(name)}, opts...) + result, err := restate.Run(d.Rctx, func(rc restate.RunContext) (any, error) { + result, err := fn(rc) + if err != nil && !IsRetryableError(err) { + // Default: all errors are terminal unless explicitly wrapped + // in RetryableError by the caller. + return result, restate.TerminalError(err) + } + return result, err + }, allOpts...) + // On replay, Restate preserves terminal-ness (restate.IsTerminalError) but + // strips Go error types. Re-wrap as PermanentError so callers can use + // durability.IsTerminalError without parsing error strings. + if err != nil && restate.IsTerminalError(err) && !IsTerminalError(err) { + return result, &PermanentError{Err: err} + } + return result, err +} + +func (d *RestateDurableCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) { + return d.run(name, fn) +} + +func (d *RestateDurableCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) { + var opts []restate.RunOption + if policy.MaxAttempts > 0 { + opts = append(opts, restate.WithMaxRetryAttempts(uint(policy.MaxAttempts))) + } + if policy.InitialInterval > 0 { + opts = append(opts, restate.WithInitialRetryInterval(policy.InitialInterval)) + } + return d.run(name, fn, opts...) +} diff --git a/pkg/durability/runas_test.go b/pkg/durability/runas_test.go new file mode 100644 index 0000000..fe3e7f2 --- /dev/null +++ b/pkg/durability/runas_test.go @@ -0,0 +1,92 @@ +package durability + +import ( + "context" + "errors" + "testing" +) + +type sample struct { + Name string `json:"name"` + Count int `json:"count"` +} + +// fakeReplayCtx returns map[string]any from Run instead of the original type, +// the way Restate behaves on journal replay. Verifies RunAs handles the +// JSON round-trip transparently. +type fakeReplayCtx struct{} + +func (fakeReplayCtx) Run(_ string, fn func(ctx context.Context) (any, error)) (any, error) { + v, err := fn(context.Background()) + if err != nil { + return nil, err + } + // Simulate JSON round-trip ala Restate replay: encode then decode into map. + // Real Restate does this via its journal serialization. + if s, ok := v.(sample); ok { + return map[string]any{"name": s.Name, "count": float64(s.Count)}, nil + } + return v, nil +} + +func (fakeReplayCtx) RunWithRetry(name string, _ RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) { + return fakeReplayCtx{}.Run(name, fn) +} + +func TestRunAs_directPath(t *testing.T) { + d := &DirectCtx{} + got, err := RunAs[sample](d, "step", func(_ context.Context) (sample, error) { + return sample{Name: "alice", Count: 7}, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "alice" || got.Count != 7 { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestRunAs_replayPath(t *testing.T) { + got, err := RunAs[sample](fakeReplayCtx{}, "step", func(_ context.Context) (sample, error) { + return sample{Name: "bob", Count: 9}, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "bob" || got.Count != 9 { + t.Fatalf("RunAs failed to round-trip via JSON: %+v", got) + } +} + +func TestRunAs_propagatesErrors(t *testing.T) { + d := &DirectCtx{} + _, err := RunAs[sample](d, "step", func(_ context.Context) (sample, error) { + return sample{}, errors.New("boom") + }) + if err == nil || err.Error() != "boom" { + t.Fatalf("expected boom, got %v", err) + } +} + +func TestScopedDurableCtx_prefixesStepName(t *testing.T) { + captured := "" + rec := &recordingCtx{onRun: func(name string) { captured = name }} + scoped := &ScopedDurableCtx{Inner: rec, Prefix: "iter:0/"} + _, _ = scoped.Run("foo", func(_ context.Context) (any, error) { return nil, nil }) + if captured != "iter:0/foo" { + t.Fatalf("expected prefixed step name, got %q", captured) + } +} + +type recordingCtx struct{ onRun func(string) } + +func (r *recordingCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) { + if r.onRun != nil { + r.onRun(name) + } + return fn(context.Background()) +} + +func (r *recordingCtx) RunWithRetry(name string, _ RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) { + return r.Run(name, fn) +} diff --git a/pkg/engine/context.go b/pkg/engine/context.go new file mode 100644 index 0000000..91f35e2 --- /dev/null +++ b/pkg/engine/context.go @@ -0,0 +1,111 @@ +package engine + +import ( + "github.com/lyzrai/flow/pkg/models" +) + +// ExecutionContext is the data bus that carries items between nodes during execution. +type ExecutionContext struct { + nodeOutputs map[string]map[int][]models.Item + + Workflow *models.WorkflowDefinition + DAG *DAG + Lookup ExecutorLookup + + // Events is an optional channel for streaming execution events (SSE). + // When non-nil, the runner and executors emit events as execution progresses. + Events chan<- ExecutionEvent + + // delegatedNodes tracks nodes claimed as delegate tools by an upstream node. + // Reserved for governance/sub-agent extensions; unused in the base engine. + delegatedNodes map[string]bool +} + +// MarkDelegated marks a node as claimed by an upstream node. +func (c *ExecutionContext) MarkDelegated(nodeName string) { + if c.delegatedNodes == nil { + c.delegatedNodes = make(map[string]bool) + } + c.delegatedNodes[nodeName] = true +} + +// IsDelegated returns true if the node has been claimed. +func (c *ExecutionContext) IsDelegated(nodeName string) bool { + return c.delegatedNodes[nodeName] +} + +// NewExecutionContext creates a new execution context for a workflow run. +func NewExecutionContext(wf *models.WorkflowDefinition, dag *DAG) *ExecutionContext { + return &ExecutionContext{ + nodeOutputs: make(map[string]map[int][]models.Item), + Workflow: wf, + DAG: dag, + } +} + +// NewStreamingExecutionContext creates a context with an event channel for SSE streaming. +func NewStreamingExecutionContext(wf *models.WorkflowDefinition, dag *DAG, events chan<- ExecutionEvent) *ExecutionContext { + return &ExecutionContext{ + nodeOutputs: make(map[string]map[int][]models.Item), + Workflow: wf, + DAG: dag, + Events: events, + } +} + +// Emit sends an event if streaming is enabled. +func (c *ExecutionContext) Emit(event ExecutionEvent) { + if c.Events != nil { + c.Events <- event + } +} + +// IsStreaming returns true if this context has a streaming event channel. +func (c *ExecutionContext) IsStreaming() bool { + return c.Events != nil +} + +// SetOutput stores the output items of a node at a specific output index. +func (c *ExecutionContext) SetOutput(nodeName string, outputIndex int, items []models.Item) { + if c.nodeOutputs[nodeName] == nil { + c.nodeOutputs[nodeName] = make(map[int][]models.Item) + } + c.nodeOutputs[nodeName][outputIndex] = items +} + +// GetNodeOutput retrieves the output items of a specific node and output index. +func (c *ExecutionContext) GetNodeOutput(nodeName string, outputIndex int) []models.Item { + if outputs, ok := c.nodeOutputs[nodeName]; ok { + return outputs[outputIndex] + } + return nil +} + +// GatherInputs collects all inputs for a given node by looking at incoming edges. +// Returns a slice where each element corresponds to an input index. +func (c *ExecutionContext) GatherInputs(nodeName string) [][]models.Item { + inEdges := c.DAG.InEdges[nodeName] + if len(inEdges) == 0 { + return nil + } + + maxIdx := 0 + for _, edge := range inEdges { + if edge.TargetInputIndex > maxIdx { + maxIdx = edge.TargetInputIndex + } + } + + inputs := make([][]models.Item, maxIdx+1) + for _, edge := range inEdges { + sourceItems := c.GetNodeOutput(edge.Target, edge.SourceOutputIndex) + inputs[edge.TargetInputIndex] = append(inputs[edge.TargetInputIndex], sourceItems...) + } + + return inputs +} + +// AllOutputs returns all node outputs for building the execution result. +func (c *ExecutionContext) AllOutputs() map[string]map[int][]models.Item { + return c.nodeOutputs +} diff --git a/pkg/engine/dag.go b/pkg/engine/dag.go new file mode 100644 index 0000000..7c1ba6c --- /dev/null +++ b/pkg/engine/dag.go @@ -0,0 +1,123 @@ +package engine + +import ( + "fmt" + "sort" + + "github.com/lyzrai/flow/pkg/models" +) + +// Edge represents a directed connection between nodes in the DAG. +type Edge struct { + Target string + SourceOutputIndex int + TargetInputIndex int +} + +// DAG is the directed acyclic graph built from a workflow's nodes and connections. +type DAG struct { + Nodes map[string]models.NodeDef + Adjacency map[string][]Edge + InEdges map[string][]Edge + InDegree map[string]int + Connections []models.ConnectionDef +} + +// BuildDAG constructs a DAG from a WorkflowDefinition. +func BuildDAG(wf *models.WorkflowDefinition) (*DAG, error) { + dag := &DAG{ + Nodes: make(map[string]models.NodeDef, len(wf.Nodes)), + Adjacency: make(map[string][]Edge), + InEdges: make(map[string][]Edge), + InDegree: make(map[string]int), + Connections: wf.Connections, + } + + for _, node := range wf.Nodes { + if _, exists := dag.Nodes[node.Name]; exists { + return nil, fmt.Errorf("duplicate node name %q", node.Name) + } + dag.Nodes[node.Name] = node + dag.InDegree[node.Name] = 0 + } + + for _, conn := range wf.Connections { + edge := Edge{ + Target: conn.TargetNode, + SourceOutputIndex: conn.SourceOutputIndex, + TargetInputIndex: conn.TargetInputIndex, + } + dag.Adjacency[conn.SourceNode] = append(dag.Adjacency[conn.SourceNode], edge) + + inEdge := Edge{ + Target: conn.SourceNode, + SourceOutputIndex: conn.SourceOutputIndex, + TargetInputIndex: conn.TargetInputIndex, + } + dag.InEdges[conn.TargetNode] = append(dag.InEdges[conn.TargetNode], inEdge) + + dag.InDegree[conn.TargetNode]++ + } + + return dag, nil +} + +// TopologicalSort returns nodes in execution order using Kahn's algorithm. +// Returns an error if a cycle is detected. +func (d *DAG) TopologicalSort() ([]string, error) { + inDegree := make(map[string]int, len(d.InDegree)) + for k, v := range d.InDegree { + inDegree[k] = v + } + + var queue []string + for name, deg := range inDegree { + if deg == 0 { + queue = append(queue, name) + } + } + sort.Strings(queue) + + var order []string + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + order = append(order, current) + + for _, edge := range d.Adjacency[current] { + inDegree[edge.Target]-- + if inDegree[edge.Target] == 0 { + queue = append(queue, edge.Target) + } + } + } + + if len(order) != len(d.Nodes) { + return nil, fmt.Errorf("cycle detected in workflow graph: sorted %d of %d nodes", len(order), len(d.Nodes)) + } + + return order, nil +} + +// HasApprovalNode returns true if any node in the workflow is a waitForApproval node. +// Used by the runner to gate workflows that need durable HITL. +func HasApprovalNode(wf *models.WorkflowDefinition) bool { + for _, node := range wf.Nodes { + if node.Type == "flow-nodes-base.waitForApproval" { + return true + } + } + return false +} + +// GetStartNodes returns nodes with no incoming connections. +func (d *DAG) GetStartNodes() []string { + var starts []string + for name, deg := range d.InDegree { + if deg == 0 { + starts = append(starts, name) + } + } + return starts +} diff --git a/pkg/engine/dag_test.go b/pkg/engine/dag_test.go new file mode 100644 index 0000000..a5c33f3 --- /dev/null +++ b/pkg/engine/dag_test.go @@ -0,0 +1,130 @@ +package engine + +import ( + "reflect" + "sort" + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +func TestBuildDAG_populatesEdgesAndDegrees(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "A", Type: "flow-nodes-base.set"}, + {ID: "3", Name: "B", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "A", SourceOutputIndex: 0, TargetInputIndex: 0}, + {SourceNode: "A", TargetNode: "B", SourceOutputIndex: 0, TargetInputIndex: 0}, + }, + } + dag, err := BuildDAG(wf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dag.InDegree["T"] != 0 || dag.InDegree["A"] != 1 || dag.InDegree["B"] != 1 { + t.Errorf("indegrees: %+v", dag.InDegree) + } + if len(dag.Adjacency["T"]) != 1 || dag.Adjacency["T"][0].Target != "A" { + t.Errorf("T adjacency: %+v", dag.Adjacency["T"]) + } + if len(dag.InEdges["B"]) != 1 || dag.InEdges["B"][0].Target != "A" { + t.Errorf("B in-edges: %+v", dag.InEdges["B"]) + } +} + +func TestBuildDAG_rejectsDuplicateNodeNames(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "X", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "X", Type: "flow-nodes-base.set"}, + }, + } + if _, err := BuildDAG(wf); err == nil { + t.Fatal("expected duplicate-name error") + } +} + +func TestTopologicalSort_orderRespectsDependencies(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "A", Type: "flow-nodes-base.set"}, + {ID: "3", Name: "B", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "A"}, + {SourceNode: "A", TargetNode: "B"}, + }, + } + dag, _ := BuildDAG(wf) + order, err := dag.TopologicalSort() + if err != nil { + t.Fatalf("unexpected: %v", err) + } + idx := map[string]int{} + for i, n := range order { + idx[n] = i + } + if !(idx["T"] < idx["A"] && idx["A"] < idx["B"]) { + t.Fatalf("unexpected order %v", order) + } +} + +func TestTopologicalSort_detectsCycle(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "A", Type: "flow-nodes-base.set"}, + {ID: "2", Name: "B", Type: "flow-nodes-base.set"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "A", TargetNode: "B"}, + {SourceNode: "B", TargetNode: "A"}, + }, + } + dag, _ := BuildDAG(wf) + if _, err := dag.TopologicalSort(); err == nil { + t.Fatal("expected cycle error") + } +} + +func TestHasApprovalNode(t *testing.T) { + with := &models.WorkflowDefinition{Nodes: []models.NodeDef{{Type: "flow-nodes-base.waitForApproval"}}} + without := &models.WorkflowDefinition{Nodes: []models.NodeDef{{Type: "flow-nodes-base.set"}}} + if !HasApprovalNode(with) { + t.Error("expected true") + } + if HasApprovalNode(without) { + t.Error("expected false") + } +} + +func TestGetStartNodes(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "T1", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "T2", Type: "flow-nodes-base.trigger"}, + {ID: "3", Name: "Mid", Type: "flow-nodes-base.set"}, + }, + Connections: []models.ConnectionDef{{SourceNode: "T1", TargetNode: "Mid"}}, + } + dag, _ := BuildDAG(wf) + got := dag.GetStartNodes() + sort.Strings(got) + want := []string{"Mid", "T1", "T2"} + // Mid has incoming edge so it should be excluded; T2 has no incoming so it's a start. + want = []string{"T1", "T2"} + // recompute filter + filtered := []string{} + for _, n := range got { + if n != "Mid" { + filtered = append(filtered, n) + } + } + sort.Strings(filtered) + if !reflect.DeepEqual(filtered, want) { + t.Fatalf("starts: got %v want %v", filtered, want) + } +} diff --git a/pkg/engine/emitter.go b/pkg/engine/emitter.go new file mode 100644 index 0000000..4f43a86 --- /dev/null +++ b/pkg/engine/emitter.go @@ -0,0 +1,28 @@ +package engine + +import "context" + +type emitterKey struct{} + +// Emitter publishes execution events for a given execution ID. +// Implemented by execevents.MemoryBus (default) and external backends (Postgres LISTEN/NOTIFY, Redis Streams). +type Emitter interface { + Emit(ctx context.Context, execID string, e ExecutionEvent) +} + +// WithEmitter returns a new context carrying the given Emitter. +func WithEmitter(ctx context.Context, e Emitter) context.Context { + return context.WithValue(ctx, emitterKey{}, e) +} + +// EmitterFromContext returns the Emitter stored in ctx, or a no-op emitter. +func EmitterFromContext(ctx context.Context) Emitter { + if e, ok := ctx.Value(emitterKey{}).(Emitter); ok && e != nil { + return e + } + return noopEmitter{} +} + +type noopEmitter struct{} + +func (noopEmitter) Emit(_ context.Context, _ string, _ ExecutionEvent) {} diff --git a/pkg/engine/events.go b/pkg/engine/events.go new file mode 100644 index 0000000..c480195 --- /dev/null +++ b/pkg/engine/events.go @@ -0,0 +1,26 @@ +package engine + +// EventType identifies the kind of streaming event emitted during workflow execution. +type EventType string + +const ( + EventNodeStarted EventType = "node_started" + EventNodeCompleted EventType = "node_completed" + EventNodeError EventType = "node_error" + EventToken EventType = "token" + EventToolCallDelta EventType = "tool_call_delta" + EventBusFull EventType = "bus_full" + EventDone EventType = "done" +) + +// ExecutionEvent is a streaming event emitted during workflow execution. +type ExecutionEvent struct { + Type EventType `json:"type"` + Node string `json:"node,omitempty"` + NodeType string `json:"node_type,omitempty"` + Content string `json:"content,omitempty"` + Status string `json:"status,omitempty"` + Outputs map[string]any `json:"outputs,omitempty"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} diff --git a/pkg/engine/expressions.go b/pkg/engine/expressions.go new file mode 100644 index 0000000..277d082 --- /dev/null +++ b/pkg/engine/expressions.go @@ -0,0 +1,316 @@ +package engine + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/dop251/goja" + + "github.com/lyzrai/flow/pkg/models" +) + +var ( + exprPattern = regexp.MustCompile(`\{\{\s*(.*?)\s*\}\}`) + + jsRefDollarParen = regexp.MustCompile(`\$\(['"](.*?)['"]\)(?:\.item)?\.json(?:\.([a-zA-Z0-9_.]+))?`) + jsRefDollarNode = regexp.MustCompile(`\$node\[['"]([^'"]+)['"]\](?:\.json(?:\.([a-zA-Z0-9_.]+))?)?`) + jsRefDollarJSON = regexp.MustCompile(`\$json(?:\.([a-zA-Z0-9_.]+))?`) + + pureRefDollarParen = regexp.MustCompile(`^\$\(['"](.*?)['"]\)(?:\.item)?\.json(?:\.[a-zA-Z0-9_.]+)?$`) + pureRefDollarNode = regexp.MustCompile(`^\$node\[['"].*?['"]\](?:\.json(?:\.[a-zA-Z0-9_.]+)?)?$`) + pureRefDollarJSON = regexp.MustCompile(`^\$json(?:\.[a-zA-Z0-9_.]+)?$`) +) + +// ResolveExpressions recursively walks a parameters map and resolves any +// {{ ... }} expressions found in string values. +func ResolveExpressions(params map[string]any, ctx *ExecutionContext, currentNode string) map[string]any { + result := make(map[string]any, len(params)) + for k, v := range params { + result[k] = resolveValue(v, ctx, currentNode) + } + return result +} + +func resolveValue(v any, ctx *ExecutionContext, currentNode string) any { + switch val := v.(type) { + case string: + return resolveString(val, ctx, currentNode) + case map[string]any: + resolved := make(map[string]any, len(val)) + for k, inner := range val { + resolved[k] = resolveValue(inner, ctx, currentNode) + } + return resolved + case []any: + resolved := make([]any, len(val)) + for i, inner := range val { + resolved[i] = resolveValue(inner, ctx, currentNode) + } + return resolved + default: + return v + } +} + +func resolveString(s string, ctx *ExecutionContext, currentNode string) any { + s = strings.TrimPrefix(s, "=") + + if matches := exprPattern.FindAllStringIndex(s, -1); len(matches) == 1 && matches[0][0] == 0 && matches[0][1] == len(s) { + inner := exprPattern.FindStringSubmatch(s)[1] + return evaluateExpression(inner, ctx, currentNode) + } + + return exprPattern.ReplaceAllStringFunc(s, func(match string) string { + inner := exprPattern.FindStringSubmatch(match)[1] + result := evaluateExpression(inner, ctx, currentNode) + return fmt.Sprintf("%v", result) + }) +} + +// evaluateExpression evaluates a single expression against the execution context. +// +// Supported forms: +// +// $json.field.path → current node's first input item +// $json → entire first input item +// $input.item.json.field → same as $json.field +// $node["Name"].json.field → output of a named node +// $('Name').json.field → n8n shorthand for $node["Name"] +func evaluateExpression(expr string, ctx *ExecutionContext, currentNode string) any { + expr = strings.TrimSpace(expr) + + if strings.HasPrefix(expr, "$json") { + if pureRefDollarJSON.MatchString(expr) { + if expr == "$json" { + return getCurrentInputItem(ctx, currentNode) + } + fieldPath := expr[len("$json."):] + return resolveFromInput(ctx, currentNode, fieldPath) + } + return evalJSExpression(expr, ctx, currentNode) + } + + if strings.HasPrefix(expr, "$input.item.json.") { + fieldPath := expr[len("$input.item.json."):] + return resolveFromInput(ctx, currentNode, fieldPath) + } + if expr == "$input.item.json" { + return getCurrentInputItem(ctx, currentNode) + } + + if strings.HasPrefix(expr, "$(") { + if pureRefDollarParen.MatchString(expr) { + return resolveDollarParenReference(expr, ctx) + } + return evalJSExpression(expr, ctx, currentNode) + } + + if strings.HasPrefix(expr, "$node[") { + if pureRefDollarNode.MatchString(expr) { + return resolveNodeReference(expr, ctx) + } + return evalJSExpression(expr, ctx, currentNode) + } + + return evalJSExpression(expr, ctx, currentNode) +} + +func evalJSExpression(expr string, ctx *ExecutionContext, currentNode string) any { + var currentItem models.Item + if inputs := ctx.GatherInputs(currentNode); len(inputs) > 0 && len(inputs[0]) > 0 { + currentItem = inputs[0][0] + } + + substituted := jsRefDollarParen.ReplaceAllStringFunc(expr, func(match string) string { + m := jsRefDollarParen.FindStringSubmatch(match) + nodeName, fieldPath := m[1], m[2] + items := ctx.GetNodeOutput(nodeName, 0) + if len(items) == 0 { + return "null" + } + var v any + if fieldPath == "" { + v = items[0] + } else { + v = traverseField(items[0], fieldPath) + } + return toJSONLiteral(v) + }) + + substituted = jsRefDollarNode.ReplaceAllStringFunc(substituted, func(match string) string { + m := jsRefDollarNode.FindStringSubmatch(match) + nodeName, fieldPath := m[1], m[2] + items := ctx.GetNodeOutput(nodeName, 0) + if len(items) == 0 { + return "null" + } + var v any + if fieldPath == "" { + v = items[0] + } else { + v = traverseField(items[0], fieldPath) + } + return toJSONLiteral(v) + }) + + substituted = jsRefDollarJSON.ReplaceAllStringFunc(substituted, func(match string) string { + m := jsRefDollarJSON.FindStringSubmatch(match) + fieldPath := m[1] + if currentItem == nil { + return "null" + } + var v any + if fieldPath == "" { + v = currentItem + } else { + v = traverseField(currentItem, fieldPath) + } + return toJSONLiteral(v) + }) + + vm := goja.New() + val, err := vm.RunString(substituted) + if err != nil { + return "{{ " + expr + " }}" + } + return val.Export() +} + +func toJSONLiteral(v any) string { + if v == nil { + return "null" + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%q", fmt.Sprintf("%v", v)) + } + return string(b) +} + +func getCurrentInputItem(ctx *ExecutionContext, currentNode string) any { + inputs := ctx.GatherInputs(currentNode) + if len(inputs) > 0 && len(inputs[0]) > 0 { + return inputs[0][0] + } + return nil +} + +func resolveFromInput(ctx *ExecutionContext, currentNode string, fieldPath string) any { + inputs := ctx.GatherInputs(currentNode) + if len(inputs) == 0 || len(inputs[0]) == 0 { + return nil + } + item := inputs[0][0] + return traverseField(item, fieldPath) +} + +func resolveDollarParenReference(expr string, ctx *ExecutionContext) any { + nameStart := strings.Index(expr, "(") + if nameStart == -1 { + return nil + } + + rest := expr[nameStart+1:] + if len(rest) == 0 { + return nil + } + quote := rest[0:1] + if quote != "'" && quote != `"` { + return nil + } + + endQuote := strings.Index(rest[1:], quote) + if endQuote == -1 { + return nil + } + nodeName := rest[1 : 1+endQuote] + + items := ctx.GetNodeOutput(nodeName, 0) + if len(items) == 0 { + return nil + } + + closeParen := strings.Index(expr, ")") + if closeParen == -1 || closeParen >= len(expr)-1 { + return items[0] + } + + after := expr[closeParen+1:] + after = strings.TrimPrefix(after, ".item") + + if !strings.HasPrefix(after, ".json") { + return items[0] + } + after = after[len(".json"):] + + if after == "" { + return items[0] + } + if strings.HasPrefix(after, ".") { + fieldPath := after[1:] + return traverseField(items[0], fieldPath) + } + + return items[0] +} + +func resolveNodeReference(expr string, ctx *ExecutionContext) any { + start := strings.Index(expr, `"`) + if start == -1 { + start = strings.Index(expr, `'`) + } + if start == -1 { + return nil + } + + quote := expr[start : start+1] + end := strings.Index(expr[start+1:], quote) + if end == -1 { + return nil + } + nodeName := expr[start+1 : start+1+end] + + items := ctx.GetNodeOutput(nodeName, 0) + if len(items) == 0 { + return nil + } + + rest := expr[start+1+end:] + jsonIdx := strings.Index(rest, ".json") + if jsonIdx == -1 { + return items[0] + } + + after := rest[jsonIdx+len(".json"):] + if after == "" || after == "." { + return items[0] + } + if strings.HasPrefix(after, ".") { + fieldPath := after[1:] + return traverseField(items[0], fieldPath) + } + + return items[0] +} + +func traverseField(item models.Item, fieldPath string) any { + if fieldPath == "" { + return item + } + + parts := strings.Split(fieldPath, ".") + var current any = item + + for _, part := range parts { + switch m := current.(type) { + case map[string]any: + current = m[part] + default: + return nil + } + } + + return current +} diff --git a/pkg/engine/expressions_test.go b/pkg/engine/expressions_test.go new file mode 100644 index 0000000..945c905 --- /dev/null +++ b/pkg/engine/expressions_test.go @@ -0,0 +1,129 @@ +package engine + +import ( + "reflect" + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +// helper: build a context with a single upstream node Producer that has output 0. +func ctxWith(produced models.Item) *ExecutionContext { + wf := &models.WorkflowDefinition{Nodes: []models.NodeDef{{Name: "Producer"}, {Name: "Consumer"}}} + dag, _ := BuildDAG(&models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "Producer", Type: "flow-nodes-base.set"}, + {ID: "2", Name: "Consumer", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "Producer", TargetNode: "Consumer", SourceOutputIndex: 0, TargetInputIndex: 0}, + }, + }) + c := NewExecutionContext(wf, dag) + c.SetOutput("Producer", 0, []models.Item{produced}) + return c +} + +func TestResolveString_dollarJsonField(t *testing.T) { + c := ctxWith(models.Item{"name": "ada", "n": float64(42)}) + got := resolveString("={{ $json.name }}", c, "Consumer") + if got != "ada" { + t.Fatalf("got %v want ada", got) + } +} + +func TestResolveString_dollarJsonNumberPreservesType(t *testing.T) { + c := ctxWith(models.Item{"n": float64(42)}) + got := resolveString("={{ $json.n }}", c, "Consumer") + if got != float64(42) { + t.Fatalf("got %T %v, want float64 42", got, got) + } +} + +func TestResolveString_dollarParenNodeReference(t *testing.T) { + c := ctxWith(models.Item{"k": "v"}) + got := resolveString(`={{ $('Producer').json.k }}`, c, "Consumer") + if got != "v" { + t.Fatalf("got %v want v", got) + } +} + +func TestResolveString_dollarNodeBracketReference(t *testing.T) { + c := ctxWith(models.Item{"k": "v"}) + got := resolveString(`={{ $node["Producer"].json.k }}`, c, "Consumer") + if got != "v" { + t.Fatalf("got %v want v", got) + } +} + +func TestResolveString_interpolation(t *testing.T) { + c := ctxWith(models.Item{"name": "ada"}) + got := resolveString("hi {{ $json.name }}", c, "Consumer") + if got != "hi ada" { + t.Fatalf("got %q want %q", got, "hi ada") + } +} + +func TestResolveString_jsArithmetic(t *testing.T) { + c := ctxWith(models.Item{"n": float64(7)}) + got := resolveString("={{ $json.n + 3 }}", c, "Consumer") + // goja returns int64 for integer arithmetic. + if got != int64(10) && got != float64(10) { + t.Fatalf("got %T %v want 10", got, got) + } +} + +func TestResolveString_unknownField_returnsNil(t *testing.T) { + c := ctxWith(models.Item{"k": "v"}) + got := resolveString("={{ $json.missing }}", c, "Consumer") + if got != nil { + t.Fatalf("got %v, want nil for missing field", got) + } +} + +func TestResolveExpressions_recursive(t *testing.T) { + c := ctxWith(models.Item{"name": "ada", "age": float64(7)}) + in := map[string]any{ + "top": "={{ $json.name }}", + "nested": map[string]any{ + "k": "={{ $json.age }}", + }, + "list": []any{"={{ $json.name }}", "static"}, + } + got := ResolveExpressions(in, c, "Consumer") + wantNested := map[string]any{"k": float64(7)} + if !reflect.DeepEqual(got["nested"], wantNested) { + t.Errorf("nested: got %+v want %+v", got["nested"], wantNested) + } + wantList := []any{"ada", "static"} + if !reflect.DeepEqual(got["list"], wantList) { + t.Errorf("list: got %+v want %+v", got["list"], wantList) + } + if got["top"] != "ada" { + t.Errorf("top: got %v", got["top"]) + } +} + +func TestTraverseField_dotPath(t *testing.T) { + item := models.Item{ + "user": map[string]any{"name": "ada", "addr": map[string]any{"city": "London"}}, + } + if got := traverseField(item, "user.name"); got != "ada" { + t.Errorf("got %v", got) + } + if got := traverseField(item, "user.addr.city"); got != "London" { + t.Errorf("got %v", got) + } + if got := traverseField(item, "user.addr.zip"); got != nil { + t.Errorf("expected nil for missing path, got %v", got) + } +} + +func TestResolveString_jsEvalFailureReturnsExpressionLiteral(t *testing.T) { + c := ctxWith(models.Item{}) + got := resolveString("={{ this is not js }}", c, "Consumer") + // Failure path returns "{{ expr }}" so the broken expression is visible at runtime. + if got != "{{ this is not js }}" { + t.Fatalf("got %v", got) + } +} diff --git a/pkg/engine/parser.go b/pkg/engine/parser.go new file mode 100644 index 0000000..c8b2fef --- /dev/null +++ b/pkg/engine/parser.go @@ -0,0 +1,159 @@ +package engine + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/lyzrai/flow/pkg/models" +) + +// n8nWorkflowJSON mirrors the raw n8n export format for unmarshalling. +type n8nWorkflowJSON struct { + Name string `json:"name"` + Nodes []models.NodeDef `json:"nodes"` + Connections map[string]struct { + Main []json.RawMessage `json:"main"` + } `json:"connections"` + Settings map[string]any `json:"settings"` +} + +type connectionTarget struct { + Node string `json:"node"` + Type string `json:"type"` + Index int `json:"index"` +} + +// n8nToFlow maps specific n8n node types to flow-native equivalents. +// Unmapped types are translated automatically via prefix replacement. +var n8nToFlow = map[string]string{ + // All triggers → single flow trigger type + "n8n-nodes-base.webhook": "flow-nodes-base.trigger", + "n8n-nodes-base.scheduleTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.manualTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.formTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.activationTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.errorTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.executeWorkflowTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.n8nTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.sseTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.workflowTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.localFileTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.emailReadImap": "flow-nodes-base.trigger", + "n8n-nodes-base.rssFeedReadTrigger": "flow-nodes-base.trigger", + "n8n-nodes-base.evaluationTrigger": "flow-nodes-base.trigger", + + "n8n-nodes-base.start": "flow-nodes-base.trigger", + "n8n-nodes-base.cron": "flow-nodes-base.trigger", + "n8n-nodes-base.interval": "flow-nodes-base.trigger", + + "n8n-nodes-base.function": "flow-nodes-base.code", + "n8n-nodes-base.functionItem": "flow-nodes-base.code", + + "n8n-nodes-base.spreadsheetFile": "flow-nodes-base.convertToFile", + "n8n-nodes-base.moveBinaryData": "flow-nodes-base.convertToFile", + "n8n-nodes-base.readBinaryFile": "flow-nodes-base.readWriteFile", + "n8n-nodes-base.readBinaryFiles": "flow-nodes-base.readWriteFile", + "n8n-nodes-base.writeBinaryFile": "flow-nodes-base.readWriteFile", + "n8n-nodes-base.readPdf": "flow-nodes-base.extractFromFile", + "n8n-nodes-base.htmlExtract": "flow-nodes-base.html", + "n8n-nodes-base.transform": "flow-nodes-base.set", + "n8n-nodes-base.noop": "flow-nodes-base.noOp", + + "n8n-nodes-langchain.chatTrigger": "flow-nodes-base.trigger", + "n8n-nodes-langchain.mcpTrigger": "flow-nodes-base.trigger", +} + +// TranslateNodeType converts an n8n node type string to its flow-native equivalent. +// Types already using the flow-nodes-base prefix are returned as-is. +func TranslateNodeType(n8nType string) string { + if strings.HasPrefix(n8nType, "flow-nodes-base.") { + return n8nType + } + + cleaned := strings.TrimPrefix(n8nType, "@n8n/") + + if flowType, ok := n8nToFlow[cleaned]; ok { + return flowType + } + + if strings.HasPrefix(cleaned, "n8n-nodes-base.") { + return strings.Replace(cleaned, "n8n-nodes-base.", "flow-nodes-base.", 1) + } + if strings.HasPrefix(cleaned, "n8n-nodes-langchain.") { + return strings.Replace(cleaned, "n8n-nodes-langchain.", "flow-nodes-base.", 1) + } + + return cleaned +} + +// ParseWorkflow parses raw n8n workflow JSON bytes into a WorkflowDefinition. +func ParseWorkflow(data []byte) (*models.WorkflowDefinition, error) { + var raw n8nWorkflowJSON + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal workflow JSON: %w", err) + } + + if len(raw.Nodes) == 0 { + return nil, fmt.Errorf("workflow has no nodes") + } + + for i := range raw.Nodes { + raw.Nodes[i].Type = TranslateNodeType(raw.Nodes[i].Type) + } + + triggerCount := 0 + for _, n := range raw.Nodes { + if n.Type == "flow-nodes-base.trigger" { + triggerCount++ + } + } + if triggerCount == 0 { + return nil, fmt.Errorf("workflow must have exactly one trigger node (found 0)") + } + if triggerCount > 1 { + return nil, fmt.Errorf("workflow must have exactly one trigger node (found %d)", triggerCount) + } + + nodeNames := make(map[string]bool, len(raw.Nodes)) + for _, n := range raw.Nodes { + nodeNames[n.Name] = true + } + + var connections []models.ConnectionDef + + for sourceName, connData := range raw.Connections { + if !nodeNames[sourceName] { + continue + } + + for outputIndex, rawTargets := range connData.Main { + var targets []connectionTarget + if err := json.Unmarshal(rawTargets, &targets); err != nil { + continue + } + + for _, t := range targets { + if t.Node == "" { + continue + } + if !nodeNames[t.Node] { + continue + } + connections = append(connections, models.ConnectionDef{ + SourceNode: sourceName, + SourceOutputIndex: outputIndex, + TargetNode: t.Node, + TargetInputIndex: t.Index, + }) + } + } + } + + return &models.WorkflowDefinition{ + Name: raw.Name, + Nodes: raw.Nodes, + Connections: connections, + Settings: raw.Settings, + }, nil +} diff --git a/pkg/engine/parser_test.go b/pkg/engine/parser_test.go new file mode 100644 index 0000000..8eb15f1 --- /dev/null +++ b/pkg/engine/parser_test.go @@ -0,0 +1,95 @@ +package engine + +import "testing" + +func TestTranslateNodeType(t *testing.T) { + cases := []struct { + in, want string + }{ + {"n8n-nodes-base.manualTrigger", "flow-nodes-base.trigger"}, + {"n8n-nodes-base.webhook", "flow-nodes-base.trigger"}, + {"n8n-nodes-base.scheduleTrigger", "flow-nodes-base.trigger"}, + {"n8n-nodes-base.cron", "flow-nodes-base.trigger"}, + {"n8n-nodes-base.set", "flow-nodes-base.set"}, + {"n8n-nodes-base.function", "flow-nodes-base.code"}, + {"n8n-nodes-base.functionItem", "flow-nodes-base.code"}, + {"n8n-nodes-base.noop", "flow-nodes-base.noOp"}, + {"n8n-nodes-base.if", "flow-nodes-base.if"}, + {"@n8n/n8n-nodes-base.set", "flow-nodes-base.set"}, + {"flow-nodes-base.set", "flow-nodes-base.set"}, + {"unknown.type", "unknown.type"}, + } + for _, c := range cases { + got := TranslateNodeType(c.in) + if got != c.want { + t.Errorf("TranslateNodeType(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseWorkflow_RequiresExactlyOneTrigger(t *testing.T) { + noTrigger := []byte(`{"name":"x","nodes":[{"id":"1","name":"a","type":"n8n-nodes-base.set","parameters":{}}],"connections":{}}`) + if _, err := ParseWorkflow(noTrigger); err == nil { + t.Fatal("expected error when no trigger node, got nil") + } + + twoTriggers := []byte(`{"name":"x","nodes":[ + {"id":"1","name":"a","type":"n8n-nodes-base.manualTrigger","parameters":{}}, + {"id":"2","name":"b","type":"n8n-nodes-base.webhook","parameters":{}} + ],"connections":{}}`) + if _, err := ParseWorkflow(twoTriggers); err == nil { + t.Fatal("expected error when two trigger nodes, got nil") + } + + one := []byte(`{"name":"x","nodes":[{"id":"1","name":"a","type":"n8n-nodes-base.manualTrigger","parameters":{}}],"connections":{}}`) + wf, err := ParseWorkflow(one) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if wf.Nodes[0].Type != "flow-nodes-base.trigger" { + t.Errorf("trigger not translated: got %q", wf.Nodes[0].Type) + } +} + +func TestParseWorkflow_BuildsConnections(t *testing.T) { + data := []byte(`{ + "name":"x", + "nodes":[ + {"id":"1","name":"a","type":"n8n-nodes-base.manualTrigger","parameters":{}}, + {"id":"2","name":"b","type":"n8n-nodes-base.set","parameters":{}} + ], + "connections":{ + "a":{"main":[[{"node":"b","type":"main","index":0}]]} + } + }`) + wf, err := ParseWorkflow(data) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if len(wf.Connections) != 1 { + t.Fatalf("expected 1 connection, got %d", len(wf.Connections)) + } + c := wf.Connections[0] + if c.SourceNode != "a" || c.TargetNode != "b" || c.SourceOutputIndex != 0 || c.TargetInputIndex != 0 { + t.Errorf("unexpected connection: %+v", c) + } +} + +func TestParseWorkflow_SkipsConnectionsToMissingNodes(t *testing.T) { + data := []byte(`{ + "name":"x", + "nodes":[ + {"id":"1","name":"a","type":"n8n-nodes-base.manualTrigger","parameters":{}} + ], + "connections":{ + "a":{"main":[[{"node":"missing","type":"main","index":0}]]} + } + }`) + wf, err := ParseWorkflow(data) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if len(wf.Connections) != 0 { + t.Fatalf("expected dangling connections to be dropped, got %d", len(wf.Connections)) + } +} diff --git a/pkg/engine/runner.go b/pkg/engine/runner.go new file mode 100644 index 0000000..127bdaa --- /dev/null +++ b/pkg/engine/runner.go @@ -0,0 +1,151 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lyzrai/flow/pkg/models" +) + +// terminalError is unwrapped by the runner to halt execution. +// Executors and durability layers can wrap their errors with TerminalError +// to signal "do not retry, do not forward inputs, stop the workflow". +type terminalError struct{ Err error } + +func (e *terminalError) Error() string { return e.Err.Error() } +func (e *terminalError) Unwrap() error { return e.Err } + +// TerminalError marks an error as non-retryable. +func TerminalError(err error) error { + if err == nil { + return nil + } + return &terminalError{Err: err} +} + +// IsTerminalError returns true if err (or any wrapped error) is terminal. +func IsTerminalError(err error) bool { + var te *terminalError + return errors.As(err, &te) +} + +// RunWorkflow builds the DAG and executes nodes in topological order. +func RunWorkflow(ctx context.Context, wf *models.WorkflowDefinition, triggerData []models.Item, lookup ExecutorLookup) (*models.ExecutionResult, error) { + dag, err := BuildDAG(wf) + if err != nil { + return nil, fmt.Errorf("failed to build DAG: %w", err) + } + + order, err := dag.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort DAG: %w", err) + } + + slog.InfoContext(ctx, "workflow_started", + slog.String("workflow", wf.Name), + slog.Int("node_count", len(order)), + ) + + execCtx := NewExecutionContext(wf, dag) + execCtx.Lookup = lookup + + var execErrors []string + + for _, nodeName := range order { + if execCtx.IsDelegated(nodeName) { + continue + } + + node := dag.Nodes[nodeName] + + var inputs [][]models.Item + if node.Type == "flow-nodes-base.trigger" && triggerData != nil { + inputs = [][]models.Item{triggerData} + } else { + inputs = execCtx.GatherInputs(nodeName) + } + + // Skip non-trigger root nodes (orphaned — no path from trigger). + if node.Type != "flow-nodes-base.trigger" && len(dag.InEdges[nodeName]) == 0 { + slog.InfoContext(ctx, "node_skipped_no_trigger_path", + slog.String("node", nodeName), + slog.String("type", node.Type), + ) + continue + } + + // Skip nodes that have upstream connections but received no items. + if len(dag.InEdges[nodeName]) > 0 && allInputsEmpty(inputs) { + slog.InfoContext(ctx, "node_skipped_no_input", + slog.String("node", nodeName), + slog.String("type", node.Type), + ) + continue + } + + resolvedParams := ResolveExpressions(node.Parameters, execCtx, nodeName) + node.Parameters = resolvedParams + + executorFn, err := lookup(node.Type) + if err != nil { + slog.ErrorContext(ctx, "node_skipped", + slog.String("node", nodeName), + slog.String("type", node.Type), + slog.String("error", err.Error()), + ) + execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nodeName, err)) + forwardInputs(execCtx, nodeName, inputs) + continue + } + + start := time.Now() + outputs, err := executeWithRetry(ctx, executorFn, node, inputs, execCtx) + elapsed := time.Since(start) + + if err != nil { + slog.ErrorContext(ctx, "node_failed", + slog.String("node", nodeName), + slog.String("type", node.Type), + slog.String("error", err.Error()), + slog.Float64("duration_ms", float64(elapsed.Microseconds())/1000.0), + ) + if IsTerminalError(err) { + return nil, fmt.Errorf("node %q: %w", nodeName, err) + } + execErrors = append(execErrors, fmt.Sprintf("node %q execution failed: %v", nodeName, err)) + forwardInputs(execCtx, nodeName, inputs) + continue + } + + slog.InfoContext(ctx, "node_executed", + slog.String("node", nodeName), + slog.String("type", node.Type), + slog.Float64("duration_ms", float64(elapsed.Microseconds())/1000.0), + ) + + for outIdx, items := range outputs { + execCtx.SetOutput(nodeName, outIdx, items) + } + } + + status := "success" + if len(execErrors) > 0 { + status = "partial_error" + } + + slog.InfoContext(ctx, "workflow_completed", + slog.String("workflow", wf.Name), + slog.String("status", status), + slog.Int("errors", len(execErrors)), + ) + + return &models.ExecutionResult{ + Status: status, + Outputs: getTerminalOutputs(execCtx, dag), + NodeOutputs: execCtx.AllOutputs(), + Errors: execErrors, + }, nil +} diff --git a/pkg/engine/runner_helpers.go b/pkg/engine/runner_helpers.go new file mode 100644 index 0000000..52527f9 --- /dev/null +++ b/pkg/engine/runner_helpers.go @@ -0,0 +1,113 @@ +package engine + +import ( + "context" + "log/slog" + "time" + + "github.com/lyzrai/flow/pkg/models" +) + +// retryableNodeTypes lists node types where retry makes sense (I/O, external calls). +// Deterministic nodes (If, Set, Merge, etc.) never retry. +var retryableNodeTypes = map[string]bool{ + "flow-nodes-base.httpRequest": true, + "flow-nodes-base.code": true, + "flow-nodes-base.executeWorkflow": true, +} + +// executeWithRetry wraps an executor call with optional retry logic from node.Settings. +// Only applies to retryable node types. Deterministic nodes skip retry. +func executeWithRetry(ctx context.Context, executorFn NodeExecutorFunc, node models.NodeDef, inputs [][]models.Item, execCtx *ExecutionContext) (map[int][]models.Item, error) { + retryOnFail, _ := node.Settings["retryOnFail"].(bool) + if !retryOnFail || !retryableNodeTypes[node.Type] { + return executorFn(ctx, node, inputs, execCtx) + } + + maxTries := 3 + if v, ok := node.Settings["maxTries"].(float64); ok && v > 0 { + maxTries = int(v) + } + waitMs := 1000 + if v, ok := node.Settings["waitBetweenTries"].(float64); ok && v > 0 { + waitMs = int(v) + } + + var lastErr error + for attempt := 0; attempt <= maxTries; attempt++ { + outputs, err := executorFn(ctx, node, inputs, execCtx) + if err == nil { + if attempt > 0 { + slog.InfoContext(ctx, "node_retry_succeeded", + slog.String("node", node.Name), + slog.Int("attempt", attempt+1), + ) + } + return outputs, nil + } + lastErr = err + if attempt < maxTries { + slog.WarnContext(ctx, "node_retry", + slog.String("node", node.Name), + slog.Int("attempt", attempt+1), + slog.Int("max", maxTries+1), + slog.Any("error", err), + ) + time.Sleep(time.Duration(waitMs) * time.Millisecond) + } + } + return nil, lastErr +} + +// allInputsEmpty returns true if all input slots are empty. +func allInputsEmpty(inputs [][]models.Item) bool { + for _, group := range inputs { + if len(group) > 0 { + return false + } + } + return true +} + +// forwardInputs stores the node's inputs as its outputs so downstream nodes +// aren't starved when an executor fails or is missing. +func forwardInputs(ctx *ExecutionContext, nodeName string, inputs [][]models.Item) { + var all []models.Item + for _, input := range inputs { + all = append(all, input...) + } + if len(all) > 0 { + ctx.SetOutput(nodeName, 0, all) + } +} + +func getTerminalOutputs(ctx *ExecutionContext, dag *DAG) map[string]map[int][]models.Item { + result := make(map[string]map[int][]models.Item) + allOutputs := ctx.AllOutputs() + + for nodeName := range dag.Nodes { + if ctx.IsDelegated(nodeName) { + continue + } + if isEffectiveTerminal(ctx, dag, nodeName) { + if out, ok := allOutputs[nodeName]; ok { + result[nodeName] = out + } + } + } + + return result +} + +func isEffectiveTerminal(ctx *ExecutionContext, dag *DAG, nodeName string) bool { + edges := dag.Adjacency[nodeName] + if len(edges) == 0 { + return true + } + for _, edge := range edges { + if !ctx.IsDelegated(edge.Target) { + return false + } + } + return true +} diff --git a/pkg/engine/runner_helpers_test.go b/pkg/engine/runner_helpers_test.go new file mode 100644 index 0000000..acac6a9 --- /dev/null +++ b/pkg/engine/runner_helpers_test.go @@ -0,0 +1,147 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +func TestExecuteWithRetry_skipsRetryForNonRetryableNode(t *testing.T) { + calls := 0 + exec := func(_ context.Context, _ models.NodeDef, _ [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + calls++ + return nil, errors.New("boom") + } + node := models.NodeDef{ + Type: "flow-nodes-base.set", // not in retryableNodeTypes + Settings: map[string]any{ + "retryOnFail": true, + "maxTries": float64(3), + "waitBetweenTries": float64(1), + }, + } + _, err := executeWithRetry(context.Background(), exec, node, nil, nil) + if err == nil { + t.Fatal("expected error") + } + if calls != 1 { + t.Fatalf("expected 1 call (retry disabled), got %d", calls) + } +} + +func TestExecuteWithRetry_retriesRetryableNode(t *testing.T) { + calls := 0 + exec := func(_ context.Context, _ models.NodeDef, _ [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + calls++ + if calls < 3 { + return nil, errors.New("flaky") + } + return map[int][]models.Item{0: {{"ok": true}}}, nil + } + node := models.NodeDef{ + Type: "flow-nodes-base.httpRequest", + Settings: map[string]any{ + "retryOnFail": true, + "maxTries": float64(5), + "waitBetweenTries": float64(1), + }, + } + got, err := executeWithRetry(context.Background(), exec, node, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } + if got[0][0]["ok"] != true { + t.Fatalf("unexpected output: %+v", got) + } +} + +func TestExecuteWithRetry_noRetryWhenFlagOff(t *testing.T) { + calls := 0 + exec := func(_ context.Context, _ models.NodeDef, _ [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + calls++ + return nil, errors.New("boom") + } + node := models.NodeDef{ + Type: "flow-nodes-base.httpRequest", + // No Settings → retryOnFail defaults false. + } + _, _ = executeWithRetry(context.Background(), exec, node, nil, nil) + if calls != 1 { + t.Fatalf("expected 1 call, got %d", calls) + } +} + +func TestForwardInputs_storesFlattenedItemsOnOutput0(t *testing.T) { + wf := &models.WorkflowDefinition{Nodes: []models.NodeDef{{Name: "X"}}} + dag, _ := BuildDAG(wf) + c := NewExecutionContext(wf, dag) + + forwardInputs(c, "X", [][]models.Item{{{"a": 1}}, {{"b": 2}}}) + got := c.GetNodeOutput("X", 0) + if len(got) != 2 { + t.Fatalf("expected 2 forwarded items, got %d", len(got)) + } +} + +func TestGetTerminalOutputs_excludesNodesWithDownstream(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "A"}, + {ID: "2", Name: "B"}, + {ID: "3", Name: "C"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "A", TargetNode: "B"}, + {SourceNode: "B", TargetNode: "C"}, + }, + } + dag, _ := BuildDAG(wf) + c := NewExecutionContext(wf, dag) + c.SetOutput("A", 0, []models.Item{{"a": 1}}) + c.SetOutput("B", 0, []models.Item{{"b": 1}}) + c.SetOutput("C", 0, []models.Item{{"c": 1}}) + + got := getTerminalOutputs(c, dag) + if _, ok := got["C"]; !ok { + t.Fatal("C should be terminal") + } + if _, ok := got["A"]; ok { + t.Fatal("A should not be terminal") + } + if _, ok := got["B"]; ok { + t.Fatal("B should not be terminal") + } +} + +func TestExecutionContext_GatherInputs(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{{ID: "1", Name: "A"}, {ID: "2", Name: "B"}}, + Connections: []models.ConnectionDef{ + {SourceNode: "A", SourceOutputIndex: 0, TargetNode: "B", TargetInputIndex: 0}, + }, + } + dag, _ := BuildDAG(wf) + c := NewExecutionContext(wf, dag) + c.SetOutput("A", 0, []models.Item{{"k": "v"}}) + + inputs := c.GatherInputs("B") + if len(inputs) != 1 || len(inputs[0]) != 1 || inputs[0][0]["k"] != "v" { + t.Fatalf("unexpected gather: %+v", inputs) + } +} + +func TestExecutionContext_DelegationFlag(t *testing.T) { + c := &ExecutionContext{} + if c.IsDelegated("X") { + t.Fatal("default false") + } + c.MarkDelegated("X") + if !c.IsDelegated("X") { + t.Fatal("expected true after Mark") + } +} diff --git a/pkg/engine/runner_test.go b/pkg/engine/runner_test.go new file mode 100644 index 0000000..6411bee --- /dev/null +++ b/pkg/engine/runner_test.go @@ -0,0 +1,137 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +// passThrough returns input items as-is on output 0; emits 1 item if no inputs. +func passThroughLookup() ExecutorLookup { + return func(_ string) (NodeExecutorFunc, error) { + return func(_ context.Context, _ models.NodeDef, inputs [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + var items []models.Item + for _, in := range inputs { + items = append(items, in...) + } + if len(items) == 0 { + items = []models.Item{{}} + } + return map[int][]models.Item{0: items}, nil + }, nil + } +} + +func twoNodeWorkflow() *models.WorkflowDefinition { + return &models.WorkflowDefinition{ + Name: "two-node", + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger", Parameters: map[string]any{}}, + {ID: "2", Name: "M", Type: "flow-nodes-base.set", Parameters: map[string]any{}}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", SourceOutputIndex: 0, TargetNode: "M", TargetInputIndex: 0}, + }, + } +} + +func TestRunWorkflow_executesAllNodes(t *testing.T) { + wf := twoNodeWorkflow() + result, err := RunWorkflow(context.Background(), wf, []models.Item{{"hi": "there"}}, passThroughLookup()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Status != "success" { + t.Fatalf("status: %q (errors=%v)", result.Status, result.Errors) + } + if len(result.NodeOutputs["T"][0]) == 0 { + t.Fatal("expected trigger to emit items") + } + // M is a leaf — its outputs should appear under terminal Outputs. + if _, ok := result.Outputs["M"]; !ok { + t.Fatalf("expected M as terminal output, got %v", result.Outputs) + } +} + +func TestRunWorkflow_recordsErrors_partial(t *testing.T) { + wf := twoNodeWorkflow() + calls := 0 + failingLookup := func(_ string) (NodeExecutorFunc, error) { + return func(_ context.Context, node models.NodeDef, _ [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + calls++ + if node.Name == "M" { + return nil, errors.New("simulated") + } + return map[int][]models.Item{0: {{}}}, nil + }, nil + } + result, err := RunWorkflow(context.Background(), wf, nil, failingLookup) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Status != "partial_error" { + t.Fatalf("expected partial_error, got %q", result.Status) + } + if len(result.Errors) == 0 { + t.Fatal("expected error message recorded") + } +} + +func TestRunWorkflow_terminalErrorHaltsRun(t *testing.T) { + wf := twoNodeWorkflow() + failingLookup := func(_ string) (NodeExecutorFunc, error) { + return func(_ context.Context, node models.NodeDef, _ [][]models.Item, _ *ExecutionContext) (map[int][]models.Item, error) { + if node.Name == "M" { + return nil, TerminalError(errors.New("fatal")) + } + return map[int][]models.Item{0: {{}}}, nil + }, nil + } + _, err := RunWorkflow(context.Background(), wf, nil, failingLookup) + if err == nil { + t.Fatal("expected error from terminal failure") + } + if !IsTerminalError(err) { + t.Fatalf("expected terminal classification, got %v", err) + } +} + +func TestRunWorkflow_unknownNodeTypeContinues(t *testing.T) { + wf := &models.WorkflowDefinition{ + Name: "unknown", + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger", Parameters: map[string]any{}}, + {ID: "2", Name: "X", Type: "flow-nodes-base.does-not-exist", Parameters: map[string]any{}}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "X"}, + }, + } + lookup := func(nodeType string) (NodeExecutorFunc, error) { + if nodeType == "flow-nodes-base.does-not-exist" { + return nil, errors.New("not registered") + } + return passThroughLookup()(nodeType) + } + result, err := RunWorkflow(context.Background(), wf, nil, lookup) + if err != nil { + t.Fatalf("unknown node should not abort the run: %v", err) + } + if result.Status != "partial_error" { + t.Fatalf("status: %q", result.Status) + } +} + +func TestAllInputsEmpty_helper(t *testing.T) { + if !allInputsEmpty(nil) { + t.Error("nil should be empty") + } + if !allInputsEmpty([][]models.Item{{}, {}}) { + t.Error("empty groups should be empty") + } + if allInputsEmpty([][]models.Item{{{"k": 1}}}) { + t.Error("non-empty group should not be empty") + } +} diff --git a/pkg/engine/subgraph.go b/pkg/engine/subgraph.go new file mode 100644 index 0000000..76ce4fd --- /dev/null +++ b/pkg/engine/subgraph.go @@ -0,0 +1,109 @@ +package engine + +import ( + "fmt" + "sort" +) + +// LoopBody holds the body node names in topological order for inline execution. +type LoopBody struct { + Order []string + Nodes map[string]bool + TerminalNodes []string +} + +// GetLoopBody identifies the loop body nodes and returns them in topological +// order for inline execution within the parent ExecutionContext. +func GetLoopBody(dag *DAG, loopNodeName string) (*LoopBody, error) { + var entryNodes []string + for _, edge := range dag.Adjacency[loopNodeName] { + if edge.SourceOutputIndex == 0 { + entryNodes = append(entryNodes, edge.Target) + } + } + if len(entryNodes) == 0 { + return nil, fmt.Errorf("loop node %q has no body (no output 0 connections)", loopNodeName) + } + + continuationNodes := make(map[string]bool) + for _, edge := range dag.Adjacency[loopNodeName] { + if edge.SourceOutputIndex == 1 { + continuationNodes[edge.Target] = true + } + } + + bodyNodes := make(map[string]bool) + queue := make([]string, len(entryNodes)) + copy(queue, entryNodes) + for _, n := range entryNodes { + bodyNodes[n] = true + } + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, edge := range dag.Adjacency[current] { + next := edge.Target + if next == loopNodeName || continuationNodes[next] || bodyNodes[next] { + continue + } + bodyNodes[next] = true + queue = append(queue, next) + } + } + + inDegree := make(map[string]int, len(bodyNodes)) + for name := range bodyNodes { + inDegree[name] = 0 + } + for name := range bodyNodes { + for _, edge := range dag.Adjacency[name] { + if bodyNodes[edge.Target] { + inDegree[edge.Target]++ + } + } + } + + var topoQueue []string + for name, deg := range inDegree { + if deg == 0 { + topoQueue = append(topoQueue, name) + } + } + sort.Strings(topoQueue) + + var order []string + for len(topoQueue) > 0 { + current := topoQueue[0] + topoQueue = topoQueue[1:] + order = append(order, current) + for _, edge := range dag.Adjacency[current] { + if bodyNodes[edge.Target] { + inDegree[edge.Target]-- + if inDegree[edge.Target] == 0 { + topoQueue = append(topoQueue, edge.Target) + } + } + } + } + + var terminalNodes []string + for name := range bodyNodes { + isTerminal := true + for _, edge := range dag.Adjacency[name] { + if bodyNodes[edge.Target] { + isTerminal = false + break + } + } + if isTerminal { + terminalNodes = append(terminalNodes, name) + } + } + sort.Strings(terminalNodes) + + return &LoopBody{ + Order: order, + Nodes: bodyNodes, + TerminalNodes: terminalNodes, + }, nil +} diff --git a/pkg/engine/subgraph_test.go b/pkg/engine/subgraph_test.go new file mode 100644 index 0000000..362d357 --- /dev/null +++ b/pkg/engine/subgraph_test.go @@ -0,0 +1,78 @@ +package engine + +import ( + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +// Loop has two outputs: 0 → body entry, 1 → continuation. +// Layout: +// +// Trigger → Loop → (out0) Body1 → Body2 +// └→ (out1) After +func TestGetLoopBody_topologicalAndTerminal(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "Loop", Type: "flow-nodes-base.splitInBatches"}, + {ID: "3", Name: "Body1", Type: "flow-nodes-base.set"}, + {ID: "4", Name: "Body2", Type: "flow-nodes-base.noOp"}, + {ID: "5", Name: "After", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "Loop"}, + {SourceNode: "Loop", SourceOutputIndex: 0, TargetNode: "Body1"}, + {SourceNode: "Body1", TargetNode: "Body2"}, + {SourceNode: "Loop", SourceOutputIndex: 1, TargetNode: "After"}, + }, + } + dag, err := BuildDAG(wf) + if err != nil { + t.Fatalf("build dag: %v", err) + } + body, err := GetLoopBody(dag, "Loop") + if err != nil { + t.Fatalf("body: %v", err) + } + if len(body.Order) != 2 { + t.Fatalf("expected 2 body nodes, got %v", body.Order) + } + // Body1 must come before Body2 in topo order. + idx := map[string]int{} + for i, n := range body.Order { + idx[n] = i + } + if !(idx["Body1"] < idx["Body2"]) { + t.Errorf("expected Body1 before Body2, got %v", body.Order) + } + if !body.Nodes["Body1"] || !body.Nodes["Body2"] { + t.Errorf("body nodes set: %v", body.Nodes) + } + if body.Nodes["After"] { + t.Error("After should NOT be in body (it's behind output 1)") + } + // Body2 has no successors inside the body → terminal. + if len(body.TerminalNodes) != 1 || body.TerminalNodes[0] != "Body2" { + t.Errorf("terminals: %v", body.TerminalNodes) + } +} + +func TestGetLoopBody_returnsErrorWithNoBody(t *testing.T) { + wf := &models.WorkflowDefinition{ + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "Loop", Type: "flow-nodes-base.splitInBatches"}, + {ID: "3", Name: "After", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "Loop"}, + // Only output 1 wired — no body. + {SourceNode: "Loop", SourceOutputIndex: 1, TargetNode: "After"}, + }, + } + dag, _ := BuildDAG(wf) + if _, err := GetLoopBody(dag, "Loop"); err == nil { + t.Fatal("expected error when loop has no body") + } +} diff --git a/pkg/engine/types.go b/pkg/engine/types.go new file mode 100644 index 0000000..20d9773 --- /dev/null +++ b/pkg/engine/types.go @@ -0,0 +1,15 @@ +package engine + +import ( + "context" + + "github.com/lyzrai/flow/pkg/models" +) + +// NodeExecutorFunc is the signature for executing a single node. +// Each registered node type provides one of these. +type NodeExecutorFunc func(ctx context.Context, node models.NodeDef, inputs [][]models.Item, execCtx *ExecutionContext) (map[int][]models.Item, error) + +// ExecutorLookup resolves a node type string to a node executor function. +// The runner uses this to dispatch each node to its registered handler. +type ExecutorLookup func(nodeType string) (NodeExecutorFunc, error) diff --git a/pkg/executors/approval.go b/pkg/executors/approval.go new file mode 100644 index 0000000..86a946e --- /dev/null +++ b/pkg/executors/approval.go @@ -0,0 +1,150 @@ +package executors + +import ( + "context" + "fmt" + "log/slog" + + "github.com/google/uuid" + restate "github.com/restatedev/sdk-go" + + "github.com/lyzrai/flow/pkg/durability" + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// ApprovalExecutor implements a human-in-the-loop approval node. +// It pauses the workflow using a Restate Awakeable and blocks until an external +// caller resolves it via POST /api/executions/{id}/resume. +// +// Two outputs: +// - Output 0: approved — items flow with human-supplied data merged in +// - Output 1: rejected — items flow with rejection_reason field +// +// The caller resolves with {"approved": true, ...data} or +// {"approved": false, "reason": "..."}. +type ApprovalExecutor struct{} + +func (e *ApprovalExecutor) Execute( + ctx context.Context, + node models.NodeDef, + inputs [][]models.Item, + execCtx *engine.ExecutionContext, +) (map[int][]models.Item, error) { + var inputItems []models.Item + for _, input := range inputs { + inputItems = append(inputItems, input...) + } + if len(inputItems) == 0 { + inputItems = []models.Item{{}} + } + + // Approval requires Restate for durable blocking. + raw := durability.RestateCtxFromContext(ctx) + rctx, ok := raw.(restate.WorkflowContext) + if !ok { + return nil, &durability.PermanentError{Err: fmt.Errorf( + "approval node %q requires Restate for durable execution", node.Name, + )} + } + + // Create the Awakeable — the workflow sleeps here until it's resolved. + awakeable := restate.Awakeable[map[string]any](rctx) + awakeableID := awakeable.Id() + + // Surface pending state so GET /api/executions/:id can return it. + restate.Set(rctx, "pending_approval_node", node.Name) + restate.Set(rctx, "pending_approval_id", awakeableID) + + // Approval context for the reviewer UI: resolved message + input data. + approvalCtx := map[string]any{} + if execCtx != nil { + resolved := engine.ResolveExpressions(node.Parameters, execCtx, node.Name) + if msg, ok := resolved["message"].(string); ok && msg != "" { + approvalCtx["message"] = msg + } + } else if msg, ok := node.Parameters["message"].(string); ok && msg != "" { + approvalCtx["message"] = msg + } + if len(inputItems) == 1 { + approvalCtx["inputs"] = map[string]any(inputItems[0]) + } else if len(inputItems) > 1 { + items := make([]map[string]any, len(inputItems)) + for i, item := range inputItems { + items[i] = map[string]any(item) + } + approvalCtx["inputs"] = items + } + if len(approvalCtx) > 0 { + restate.Set(rctx, "pending_approval_context", approvalCtx) + } + + // Optionally persist to a side store so other channels (Slack, email) can + // resolve the approval too. No-op when no creator is wired in this build. + if creator := durability.ApprovalCreatorFromContext(ctx); creator != nil { + inputMap := make(map[string]any, len(inputItems)) + for i, item := range inputItems { + inputMap[fmt.Sprintf("item_%d", i)] = map[string]any(item) + } + record := &durability.ApprovalRecord{ + ID: uuid.New().String(), + ExecutionID: durability.ExecutionIDFromContext(ctx), + NodeName: node.Name, + AwakeableID: awakeableID, + Status: "pending", + InputData: inputMap, + APIKey: durability.APIKeyFromContext(ctx), + } + if err := creator.CreateFromRecord(ctx, record); err != nil { + slog.WarnContext(ctx, "failed to persist approval, continuing", + slog.String("node", node.Name), + slog.String("error", err.Error()), + ) + } + } + + slog.InfoContext(ctx, "workflow_paused_for_approval", + slog.String("node", node.Name), + slog.String("awakeable_id", awakeableID), + ) + + // Block on the Awakeable. Durable across crashes / restarts. + approvalData, err := awakeable.Result() + if err != nil { + return nil, fmt.Errorf("approval node %q: %w", node.Name, err) + } + + // Clear pending markers now that we've resumed. + restate.Clear(rctx, "pending_approval_node") + restate.Clear(rctx, "pending_approval_id") + restate.Clear(rctx, "pending_approval_context") + + slog.InfoContext(ctx, "workflow_resumed", + slog.String("node", node.Name), + ) + + // Route based on the approved field. + approved, _ := approvalData["approved"].(bool) + + if approved { + var out []models.Item + for _, item := range inputItems { + merged := copyItem(item) + for k, v := range approvalData { + merged[k] = v + } + out = append(out, merged) + } + return map[int][]models.Item{0: out}, nil + } + + reason, _ := approvalData["reason"].(string) + var rejected []models.Item + for _, item := range inputItems { + r := copyItem(item) + r["rejection_reason"] = reason + r["approved"] = false + rejected = append(rejected, r) + } + return map[int][]models.Item{1: rejected}, nil +} diff --git a/pkg/executors/executor.go b/pkg/executors/executor.go new file mode 100644 index 0000000..5b257f6 --- /dev/null +++ b/pkg/executors/executor.go @@ -0,0 +1,17 @@ +package executors + +import ( + "context" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// NodeExecutor is the interface that all node type executors implement. +type NodeExecutor interface { + // Execute runs the node logic. + // inputs[i] holds the items received on input port i. + // Returns map[outputIndex]items. Single-output nodes return {0: items}. + // Branching nodes (If, Switch) route items to multiple output indices. + Execute(ctx context.Context, node models.NodeDef, inputs [][]models.Item, execCtx *engine.ExecutionContext) (map[int][]models.Item, error) +} diff --git a/pkg/executors/executors_test.go b/pkg/executors/executors_test.go new file mode 100644 index 0000000..4686d3d --- /dev/null +++ b/pkg/executors/executors_test.go @@ -0,0 +1,173 @@ +package executors + +import ( + "context" + "errors" + "testing" + + "github.com/lyzrai/flow/pkg/durability" + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +func TestTrigger_emitsAtLeastOneItemWhenInputEmpty(t *testing.T) { + out, err := (&TriggerExecutor{}).Execute(context.Background(), + models.NodeDef{Name: "t", Type: "flow-nodes-base.trigger"}, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out[0]) != 1 { + t.Fatalf("expected 1 item on output 0, got %d", len(out[0])) + } +} + +func TestTrigger_passesThroughInputItems(t *testing.T) { + in := [][]models.Item{{{"a": 1}, {"a": 2}}} + out, err := (&TriggerExecutor{}).Execute(context.Background(), + models.NodeDef{Name: "t", Type: "flow-nodes-base.trigger"}, in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out[0]) != 2 { + t.Fatalf("expected 2 items, got %d", len(out[0])) + } +} + +func TestNoOp_passesAllItemsThrough(t *testing.T) { + in := [][]models.Item{{{"x": "a"}}, {{"x": "b"}}} + out, err := (&NoOpExecutor{}).Execute(context.Background(), models.NodeDef{}, in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out[0]) != 2 { + t.Fatalf("expected 2 items, got %d", len(out[0])) + } +} + +func TestSet_v3Assignments(t *testing.T) { + node := models.NodeDef{Parameters: map[string]any{ + "assignments": map[string]any{ + "assignments": []any{ + map[string]any{"name": "greeting", "value": "hi", "type": "string"}, + map[string]any{"name": "version", "value": float64(2), "type": "number"}, + }, + }, + }} + in := [][]models.Item{{{"existing": true}}} + out, err := (&SetExecutor{}).Execute(context.Background(), node, in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out[0]) != 1 { + t.Fatalf("expected 1 item, got %d", len(out[0])) + } + got := out[0][0] + if got["greeting"] != "hi" || got["version"] != float64(2) || got["existing"] != true { + t.Fatalf("unexpected merged item: %+v", got) + } +} + +func TestSet_v3FieldsValuesShape(t *testing.T) { + node := models.NodeDef{Parameters: map[string]any{ + "fields": map[string]any{ + "values": []any{ + map[string]any{"name": "country", "stringValue": "IN"}, + map[string]any{"name": "rank", "numberValue": float64(7)}, + }, + }, + }} + in := [][]models.Item{{{}}} + out, err := (&SetExecutor{}).Execute(context.Background(), node, in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := out[0][0] + if got["country"] != "IN" || got["rank"] != float64(7) { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestSet_legacyValuesShape(t *testing.T) { + node := models.NodeDef{Parameters: map[string]any{ + "values": map[string]any{ + "string": []any{ + map[string]any{"name": "env", "value": "prod"}, + }, + "number": []any{ + map[string]any{"name": "port", "value": float64(8080)}, + }, + }, + }} + in := [][]models.Item{{{}}} + out, err := (&SetExecutor{}).Execute(context.Background(), node, in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := out[0][0] + if got["env"] != "prod" || got["port"] != float64(8080) { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestSet_doesNotMutateInputItem(t *testing.T) { + original := models.Item{"k": "v"} + node := models.NodeDef{Parameters: map[string]any{ + "assignments": map[string]any{ + "assignments": []any{map[string]any{"name": "k", "value": "v2"}}, + }, + }} + in := [][]models.Item{{original}} + out, _ := (&SetExecutor{}).Execute(context.Background(), node, in, nil) + if out[0][0]["k"] != "v2" { + t.Fatalf("expected output to be overridden, got %v", out[0][0]["k"]) + } + if original["k"] != "v" { + t.Fatal("Set must not mutate the input item in place") + } +} + +// Approval node refuses to run without a Restate context, returning a +// PermanentError so the engine doesn't retry. +func TestApproval_requiresRestateContext(t *testing.T) { + node := models.NodeDef{Name: "Approve", Type: "flow-nodes-base.waitForApproval"} + in := [][]models.Item{{{}}} + + _, err := (&ApprovalExecutor{}).Execute(context.Background(), node, in, &engine.ExecutionContext{}) + if err == nil { + t.Fatal("expected approval node to refuse running outside Restate") + } + var perm *durability.PermanentError + if !errors.As(err, &perm) { + t.Fatalf("expected PermanentError so engine doesn't retry, got %T", err) + } +} + +func TestRegistry_RegisterAll_registersExpectedTypes(t *testing.T) { + RegisterAll() + for _, want := range []string{ + "flow-nodes-base.trigger", + "flow-nodes-base.noOp", + "flow-nodes-base.set", + "flow-nodes-base.waitForApproval", + } { + if _, err := Get(want); err != nil { + t.Errorf("missing executor for %q: %v", want, err) + } + } +} + +func TestBuildLookup_returnsFunctioningExecutor(t *testing.T) { + RegisterAll() + lookup := BuildLookup() + fn, err := lookup("flow-nodes-base.trigger") + if err != nil { + t.Fatalf("lookup miss: %v", err) + } + out, err := fn(context.Background(), models.NodeDef{Type: "flow-nodes-base.trigger"}, nil, nil) + if err != nil { + t.Fatalf("trigger execution failed: %v", err) + } + if len(out[0]) != 1 { + t.Fatalf("trigger should emit 1 item, got %d", len(out[0])) + } +} diff --git a/pkg/executors/noop.go b/pkg/executors/noop.go new file mode 100644 index 0000000..e2da04c --- /dev/null +++ b/pkg/executors/noop.go @@ -0,0 +1,19 @@ +package executors + +import ( + "context" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// NoOpExecutor passes input items through unchanged. +type NoOpExecutor struct{} + +func (e *NoOpExecutor) Execute(_ context.Context, _ models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) { + var items []models.Item + for _, input := range inputs { + items = append(items, input...) + } + return map[int][]models.Item{0: items}, nil +} diff --git a/pkg/executors/registry.go b/pkg/executors/registry.go new file mode 100644 index 0000000..5f86bb2 --- /dev/null +++ b/pkg/executors/registry.go @@ -0,0 +1,64 @@ +package executors + +import ( + "context" + "fmt" + "sync" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +var ( + registry = map[string]NodeExecutor{} + registryMu sync.RWMutex +) + +// Register adds a node executor for a given flow node type. +func Register(nodeType string, executor NodeExecutor) { + registryMu.Lock() + defer registryMu.Unlock() + registry[nodeType] = executor +} + +// Get returns the executor for a flow-native node type. +func Get(nodeType string) (NodeExecutor, error) { + registryMu.RLock() + defer registryMu.RUnlock() + if e, ok := registry[nodeType]; ok { + return e, nil + } + return nil, fmt.Errorf("executor not implemented for node type %q", nodeType) +} + +// RegistryDeps holds optional dependencies for executors that need external access. +type RegistryDeps struct { + WorkflowLoader WorkflowLoaderFunc +} + +// WorkflowLoaderFunc loads a workflow definition by ID from storage. +type WorkflowLoaderFunc func(ctx context.Context, id string) (*models.WorkflowDefinition, error) + +// RegisterAll registers the v0.1 primitive executors. +func RegisterAll(deps ...RegistryDeps) { + // Control flow / data primitives + Register("flow-nodes-base.trigger", &TriggerExecutor{}) + Register("flow-nodes-base.noOp", &NoOpExecutor{}) + Register("flow-nodes-base.set", &SetExecutor{}) + + // Human-in-the-loop + Register("flow-nodes-base.waitForApproval", &ApprovalExecutor{}) +} + +// BuildLookup creates an ExecutorLookup from the registered executors. +func BuildLookup() engine.ExecutorLookup { + return func(nodeType string) (engine.NodeExecutorFunc, error) { + exec, err := Get(nodeType) + if err != nil { + return nil, err + } + return func(ctx context.Context, node models.NodeDef, inputs [][]models.Item, execCtx *engine.ExecutionContext) (map[int][]models.Item, error) { + return exec.Execute(ctx, node, inputs, execCtx) + }, nil + } +} diff --git a/pkg/executors/set.go b/pkg/executors/set.go new file mode 100644 index 0000000..3e912f7 --- /dev/null +++ b/pkg/executors/set.go @@ -0,0 +1,105 @@ +package executors + +import ( + "context" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// SetExecutor adds, modifies, or removes fields on each input item. +// Supports n8n v1/v2 (parameters.values.{string,number,boolean}) and v3 +// (parameters.assignments.assignments / parameters.fields.values) layouts. +type SetExecutor struct{} + +func (e *SetExecutor) Execute(_ context.Context, node models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) { + var inputItems []models.Item + for _, input := range inputs { + inputItems = append(inputItems, input...) + } + + assignments := getAssignments(node.Parameters) + + var result []models.Item + for _, item := range inputItems { + newItem := copyItem(item) + for _, a := range assignments { + newItem[a.name] = a.value + } + result = append(result, newItem) + } + + return map[int][]models.Item{0: result}, nil +} + +type assignment struct { + name string + value any +} + +func getAssignments(params map[string]any) []assignment { + var result []assignment + + if assignmentsObj, ok := params["assignments"].(map[string]any); ok { + if list, ok := assignmentsObj["assignments"].([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + name, _ := m["name"].(string) + value := m["value"] + if name != "" { + result = append(result, assignment{name: name, value: value}) + } + } + } + return result + } + } + + if fieldsObj, ok := params["fields"].(map[string]any); ok { + if list, ok := fieldsObj["values"].([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + name, _ := m["name"].(string) + if name == "" { + continue + } + for _, vKey := range []string{"stringValue", "numberValue", "booleanValue", "value"} { + if v, exists := m[vKey]; exists { + result = append(result, assignment{name: name, value: v}) + break + } + } + } + } + if len(result) > 0 { + return result + } + } + } + + if values, ok := params["values"].(map[string]any); ok { + for _, typeName := range []string{"string", "number", "boolean"} { + if list, ok := values[typeName].([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + name, _ := m["name"].(string) + value := m["value"] + if name != "" { + result = append(result, assignment{name: name, value: value}) + } + } + } + } + } + } + + return result +} + +func copyItem(item models.Item) models.Item { + cp := make(models.Item, len(item)) + for k, v := range item { + cp[k] = v + } + return cp +} diff --git a/pkg/executors/trigger.go b/pkg/executors/trigger.go new file mode 100644 index 0000000..7353565 --- /dev/null +++ b/pkg/executors/trigger.go @@ -0,0 +1,23 @@ +package executors + +import ( + "context" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// TriggerExecutor is a universal trigger node that passes through the trigger data +// injected by the runner. All n8n trigger types are mapped to this single executor. +type TriggerExecutor struct{} + +func (e *TriggerExecutor) Execute(_ context.Context, _ models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) { + var items []models.Item + for _, input := range inputs { + items = append(items, input...) + } + if len(items) == 0 { + items = []models.Item{{}} + } + return map[int][]models.Item{0: items}, nil +} diff --git a/pkg/models/execution.go b/pkg/models/execution.go new file mode 100644 index 0000000..42f238d --- /dev/null +++ b/pkg/models/execution.go @@ -0,0 +1,16 @@ +package models + +// Item is a single data item flowing between nodes. +type Item = map[string]any + +// NodeOutput holds the outputs of a single node, keyed by output index. +type NodeOutput = map[int][]Item + +// ExecutionResult is the final result of a workflow execution. +type ExecutionResult struct { + ExecutionID string `json:"execution_id"` + Status string `json:"status"` + Outputs map[string]map[int][]Item `json:"outputs"` + NodeOutputs map[string]map[int][]Item `json:"node_outputs"` + Errors []string `json:"errors"` +} diff --git a/pkg/models/workflow.go b/pkg/models/workflow.go new file mode 100644 index 0000000..26df644 --- /dev/null +++ b/pkg/models/workflow.go @@ -0,0 +1,29 @@ +package models + +// NodeDef represents a single node in an n8n-compatible workflow. +type NodeDef struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + TypeVersion float64 `json:"typeVersion"` + Parameters map[string]any `json:"parameters"` + Credentials map[string]any `json:"credentials,omitempty"` + Settings map[string]any `json:"settings,omitempty"` + Position [2]float64 `json:"position"` +} + +// ConnectionDef represents a directed edge between two nodes. +type ConnectionDef struct { + SourceNode string + SourceOutputIndex int + TargetNode string + TargetInputIndex int +} + +// WorkflowDefinition is the parsed internal representation of a workflow. +type WorkflowDefinition struct { + Name string `json:"name"` + Nodes []NodeDef `json:"nodes"` + Connections []ConnectionDef + Settings map[string]any `json:"settings"` +} diff --git a/pkg/orchestrator/admin.go b/pkg/orchestrator/admin.go new file mode 100644 index 0000000..3ee2a38 --- /dev/null +++ b/pkg/orchestrator/admin.go @@ -0,0 +1,73 @@ +package orchestrator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// HealthCheckRestate verifies the Restate ingress is reachable. The flow +// binary fails fast at startup if this returns an error. +func HealthCheckRestate(ingressURL string) error { + client := &http.Client{Timeout: 3 * time.Second} + r, err := client.Get(ingressURL + "/restate/health") + if err != nil { + return fmt.Errorf("restate ingress unreachable at %s: %w", ingressURL, err) + } + defer r.Body.Close() + if r.StatusCode != http.StatusOK { + return fmt.Errorf("restate ingress returned %d at %s", r.StatusCode, ingressURL) + } + return nil +} + +// RegisterDeployment registers this service with the Restate admin API so +// Restate knows where to call back for the WorkflowExecutor handler. +// +// adminURL — Restate admin endpoint (default http://localhost:9070) +// deployURI — how Restate reaches this service (e.g. http://flow:9080) +func RegisterDeployment(ctx context.Context, adminURL, deployURI string) error { + body, _ := json.Marshal(map[string]any{"uri": deployURI, "force": true}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, adminURL+"/deployments", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build admin request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + r, err := client.Do(req) + if err != nil { + return fmt.Errorf("restate admin call failed: %w", err) + } + defer r.Body.Close() + if r.StatusCode >= 300 { + respBody, _ := io.ReadAll(r.Body) + return fmt.Errorf("restate admin returned %d: %s", r.StatusCode, string(respBody)) + } + return nil +} + +// RegisterDeploymentWithRetry retries registration until it succeeds or ctx +// is cancelled. Used at startup because the Restate cluster may take a moment +// to become ready relative to flow. +func RegisterDeploymentWithRetry(ctx context.Context, adminURL, deployURI string, maxAttempts int, interval time.Duration) error { + var last error + for i := 0; i < maxAttempts; i++ { + if err := RegisterDeployment(ctx, adminURL, deployURI); err == nil { + return nil + } else { + last = err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + } + return fmt.Errorf("registration failed after %d attempts: %w", maxAttempts, last) +} diff --git a/pkg/orchestrator/admin_test.go b/pkg/orchestrator/admin_test.go new file mode 100644 index 0000000..04080d0 --- /dev/null +++ b/pkg/orchestrator/admin_test.go @@ -0,0 +1,139 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestHealthCheckRestate_okWhen200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/restate/health" { + t.Errorf("path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := HealthCheckRestate(srv.URL); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestHealthCheckRestate_failsOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + if err := HealthCheckRestate(srv.URL); err == nil { + t.Fatal("expected error on 503") + } +} + +func TestHealthCheckRestate_failsWhenUnreachable(t *testing.T) { + if err := HealthCheckRestate("http://127.0.0.1:1"); err == nil { + t.Fatal("expected unreachable error") + } +} + +func TestRegisterDeployment_postsExpectedBody(t *testing.T) { + var seen struct { + path string + body []byte + contentType string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.path = r.URL.Path + seen.contentType = r.Header.Get("Content-Type") + buf := make([]byte, 256) + n, _ := r.Body.Read(buf) + seen.body = buf[:n] + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + if err := RegisterDeployment(context.Background(), srv.URL, "http://flow:9080"); err != nil { + t.Fatalf("unexpected: %v", err) + } + if seen.path != "/deployments" { + t.Errorf("path: %q", seen.path) + } + if seen.contentType != "application/json" { + t.Errorf("content-type: %q", seen.contentType) + } + var body map[string]any + if err := json.Unmarshal(seen.body, &body); err != nil { + t.Fatalf("body json: %v", err) + } + if body["uri"] != "http://flow:9080" || body["force"] != true { + t.Errorf("body: %+v", body) + } +} + +func TestRegisterDeployment_returnsErrorOn5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("oops")) + })) + defer srv.Close() + + if err := RegisterDeployment(context.Background(), srv.URL, "http://flow:9080"); err == nil { + t.Fatal("expected error") + } +} + +func TestRegisterDeploymentWithRetry_succeedsAfterFailures(t *testing.T) { + var attempts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&attempts, 1) + if n < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + if err := RegisterDeploymentWithRetry(context.Background(), srv.URL, "http://flow:9080", 5, 5*time.Millisecond); err != nil { + t.Fatalf("expected success after retries, got %v", err) + } + if atomic.LoadInt32(&attempts) != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } +} + +func TestRegisterDeploymentWithRetry_givesUpAfterMaxAttempts(t *testing.T) { + var attempts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&attempts, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + err := RegisterDeploymentWithRetry(context.Background(), srv.URL, "http://flow:9080", 3, 1*time.Millisecond) + if err == nil { + t.Fatal("expected failure") + } + if got := atomic.LoadInt32(&attempts); got != 3 { + t.Fatalf("attempts: got %d, want 3", got) + } +} + +func TestRegisterDeploymentWithRetry_respectsContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + if err := RegisterDeploymentWithRetry(ctx, srv.URL, "http://flow:9080", 100, 50*time.Millisecond); err == nil { + t.Fatal("expected error from cancelled context") + } +} diff --git a/pkg/orchestrator/restate_client.go b/pkg/orchestrator/restate_client.go new file mode 100644 index 0000000..b4f9641 --- /dev/null +++ b/pkg/orchestrator/restate_client.go @@ -0,0 +1,116 @@ +package orchestrator + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + restate "github.com/restatedev/sdk-go" + "github.com/restatedev/sdk-go/ingress" + + "github.com/lyzrai/flow/pkg/models" +) + +// RestateOrchestrator submits workflows to a Restate server via its HTTP ingress. +// Each node in the workflow becomes a journaled step that survives crashes. +type RestateOrchestrator struct { + restateURL string +} + +// NewRestateOrchestrator constructs an orchestrator pointed at restateURL +// (the Restate ingress URL, e.g. http://localhost:8081). +func NewRestateOrchestrator(restateURL string) *RestateOrchestrator { + return &RestateOrchestrator{restateURL: restateURL} +} + +// IngressURL returns the Restate ingress URL for external API calls +// (e.g., awakeable resolution from the resume HTTP handler). +func (o *RestateOrchestrator) IngressURL() string { return o.restateURL } + +// Run submits a workflow synchronously and blocks until it finishes. +func (o *RestateOrchestrator) Run(ctx context.Context, req *RunRequest) (string, *models.ExecutionResult, error) { + client := ingress.NewClient(o.restateURL) + workflowID := generateWorkflowID() + + wfReq := &WorkflowRequest{ + RequestMeta: req.RequestMeta, + Workflow: req.Workflow, + TriggerData: req.TriggerData, + } + + result, err := ingress.Workflow[*WorkflowRequest, *models.ExecutionResult]( + client, "WorkflowExecutor", workflowID, "Run", + ).Request(ctx, wfReq) + if err != nil { + return workflowID, nil, fmt.Errorf("workflow execution failed: %w", err) + } + + result.ExecutionID = workflowID + return workflowID, result, nil +} + +// RunAsync submits a workflow and returns immediately with an execution ID. +func (o *RestateOrchestrator) RunAsync(ctx context.Context, req *RunRequest) (string, error) { + client := ingress.NewClient(o.restateURL) + workflowID := generateWorkflowID() + + wfReq := &WorkflowRequest{ + RequestMeta: req.RequestMeta, + Workflow: req.Workflow, + TriggerData: req.TriggerData, + } + + _, err := ingress.Workflow[*WorkflowRequest, *models.ExecutionResult]( + client, "WorkflowExecutor", workflowID, "Run", + ).Send(ctx, wfReq) + if err != nil { + return "", fmt.Errorf("async workflow submission failed: %w", err) + } + + return workflowID, nil +} + +// GetExecution returns the live status of a workflow execution. +// While running, it also probes for a pending approval via the shared handler. +func (o *RestateOrchestrator) GetExecution(ctx context.Context, executionID string) (*ExecutionStatus, error) { + client := ingress.NewClient(o.restateURL) + + handle := ingress.WorkflowHandle[*models.ExecutionResult](client, "WorkflowExecutor", executionID) + + result, err := handle.Output(ctx) + if err != nil { + var notReady *ingress.InvocationNotReadyError + if errors.As(err, ¬Ready) { + status := &ExecutionStatus{ExecutionID: executionID, Status: "running"} + approval, aErr := ingress.Workflow[restate.Void, *PendingApproval]( + client, "WorkflowExecutor", executionID, "GetPendingApproval", + ).Request(ctx, restate.Void{}) + if aErr == nil && approval != nil && approval.AwakeableID != "" { + status.Status = "waiting_for_approval" + status.PendingApproval = approval + } + return status, nil + } + var notFound *ingress.InvocationNotFoundError + if errors.As(err, ¬Found) { + return nil, fmt.Errorf("execution %q not found", executionID) + } + return nil, fmt.Errorf("failed to get execution status: %w", err) + } + + return &ExecutionStatus{ + ExecutionID: executionID, + Status: result.Status, + Outputs: result.Outputs, + NodeOutputs: result.NodeOutputs, + Errors: result.Errors, + }, nil +} + +func generateWorkflowID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/pkg/orchestrator/service.go b/pkg/orchestrator/service.go new file mode 100644 index 0000000..a92f70a --- /dev/null +++ b/pkg/orchestrator/service.go @@ -0,0 +1,171 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + + restate "github.com/restatedev/sdk-go" + "github.com/restatedev/sdk-go/server" + + "github.com/lyzrai/flow/pkg/durability" + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// WorkflowRequest is the JSON-serializable payload Restate persists for replay. +type WorkflowRequest struct { + RequestMeta + Workflow *models.WorkflowDefinition `json:"workflow"` + TriggerData []models.Item `json:"trigger_data"` +} + +// nodeStepResult round-trips node outputs through the Restate journal as JSON. +// We use string-keyed outputs because JSON can't natively encode int keys. +type nodeStepResult struct { + Outputs map[string][]models.Item `json:"outputs"` +} + +func toStepResult(outputs map[int][]models.Item) nodeStepResult { + m := make(map[string][]models.Item, len(outputs)) + for idx, items := range outputs { + m[strconv.Itoa(idx)] = items + } + return nodeStepResult{Outputs: m} +} + +func fromStepResult(result nodeStepResult) map[int][]models.Item { + m := make(map[int][]models.Item, len(result.Outputs)) + for key, items := range result.Outputs { + idx, _ := strconv.Atoi(key) + m[idx] = items + } + return m +} + +// WorkflowService is the Restate workflow handler that provides durable execution. +// Each node execution is wrapped in restate.Run() for automatic journaling. +type WorkflowService struct { + lookup engine.ExecutorLookup + approvalCreator durability.ApprovalCreator + execStore ExecutionCompleter +} + +// NewWorkflowService constructs the Restate-side handler. approvalCreator and +// execStore are optional — pass nil if not yet wired. +func NewWorkflowService(lookup engine.ExecutorLookup, approvalCreator durability.ApprovalCreator, execStore ExecutionCompleter) *WorkflowService { + return &WorkflowService{lookup: lookup, approvalCreator: approvalCreator, execStore: execStore} +} + +// ServiceName is required by the Restate SDK. It's the service identifier +// used in ingress URLs and admin registration. +func (w *WorkflowService) ServiceName() string { return "WorkflowExecutor" } + +// Run is the Restate workflow handler. It walks the DAG and wraps each node +// execution in restate.Run() for durability — on crash/restart, completed steps +// replay from the journal without re-execution. +func (w *WorkflowService) Run(ctx restate.WorkflowContext, req WorkflowRequest) (*models.ExecutionResult, error) { + dctx := &durability.RestateDurableCtx{Rctx: ctx} + enrichedCtx := durability.WithDurableCtx(ctx, dctx) + enrichedCtx = durability.WithRestateCtx(enrichedCtx, ctx) + enrichedCtx = durability.WithExecutionID(enrichedCtx, restate.Key(ctx)) + enrichedCtx = durability.WithAPIKey(enrichedCtx, req.APIKey) + if w.approvalCreator != nil { + enrichedCtx = durability.WithApprovalCreator(enrichedCtx, w.approvalCreator) + } + + result, err := walkDurable(enrichedCtx, req.Workflow, req.TriggerData, w.lookup, restateNodeRunner(ctx)) + if result == nil { + result = &models.ExecutionResult{Status: "failed"} + } + result.ExecutionID = restate.Key(ctx) + + // Persist terminal state if a store is wired so list queries reflect + // completion without polling the per-execution endpoint. + if w.execStore != nil { + w.persistTerminal(enrichedCtx, result) + } + + if err != nil && durability.IsTerminalError(err) { + return result, restate.TerminalError(err) + } + return result, err +} + +// GetPendingApproval is a shared (non-blocking) handler that returns the +// pending approval state set by the Approval node. Called by the HTTP API +// to surface HITL state. +func (w *WorkflowService) GetPendingApproval(ctx restate.WorkflowSharedContext, _ restate.Void) (*PendingApproval, error) { + node, err := restate.Get[string](ctx, "pending_approval_node") + if err != nil || node == "" { + return nil, nil + } + awakeableID, _ := restate.Get[string](ctx, "pending_approval_id") + approvalCtx, _ := restate.Get[map[string]any](ctx, "pending_approval_context") + return &PendingApproval{Node: node, AwakeableID: awakeableID, Context: approvalCtx}, nil +} + +// restateNodeRunner returns a NodeRunner that wraps each node execution in +// restate.Run() for durable journaling. +func restateNodeRunner(rctx restate.WorkflowContext) NodeRunner { + return func(ctx context.Context, stepID string, executeFn func(ctx context.Context) (map[int][]models.Item, error)) (map[int][]models.Item, error) { + result, err := restate.Run(rctx, func(runCtx restate.RunContext) (nodeStepResult, error) { + outputs, err := executeFn(ctx) + if err != nil { + if durability.IsTerminalError(err) { + return nodeStepResult{}, restate.TerminalError(err) + } + return nodeStepResult{}, err + } + return toStepResult(outputs), nil + }, restate.WithName(stepID)) + if err != nil { + return nil, fmt.Errorf("restate step %q failed: %w", stepID, err) + } + return fromStepResult(result), nil + } +} + +func (w *WorkflowService) persistTerminal(ctx context.Context, result *models.ExecutionResult) { + var outputsJSON, nodeOutputsJSON json.RawMessage + if result.Outputs != nil { + outputsJSON, _ = json.Marshal(result.Outputs) + } + if result.NodeOutputs != nil { + nodeOutputsJSON, _ = json.Marshal(result.NodeOutputs) + } + errMsg := "" + if len(result.Errors) > 0 { + errMsg = result.Errors[0] + } + if cErr := w.execStore.Complete(ctx, result.ExecutionID, result.Status, outputsJSON, nodeOutputsJSON, errMsg); cErr != nil { + slog.WarnContext(ctx, "failed to persist workflow completion", + slog.String("execution_id", result.ExecutionID), + slog.Any("error", cErr), + ) + } +} + +// defaultRetryPolicy caps Restate's default infinite retries to a sensible +// limit so a persistently failing step doesn't loop forever. +var defaultRetryPolicy = restate.WithInvocationRetryPolicy( + restate.WithMaxAttempts(10), + restate.KillOnMaxAttempts(), +) + +// NewRestateServer constructs a Restate server endpoint with the WorkflowExecutor +// handler bound. extraServices lets callers register additional Restate handlers +// (for governance, audit, etc.) without modifying this package. +func NewRestateServer(lookup engine.ExecutorLookup, approvalCreator durability.ApprovalCreator, execStore ExecutionCompleter, extraServices ...any) *server.Restate { + wfSvc := NewWorkflowService(lookup, approvalCreator, execStore) + + rs := server.NewRestate(). + Bind(restate.Reflect(wfSvc, defaultRetryPolicy)) + + for _, svc := range extraServices { + rs = rs.Bind(restate.Reflect(svc, defaultRetryPolicy)) + } + return rs +} diff --git a/pkg/orchestrator/service_test.go b/pkg/orchestrator/service_test.go new file mode 100644 index 0000000..ecaffc4 --- /dev/null +++ b/pkg/orchestrator/service_test.go @@ -0,0 +1,39 @@ +package orchestrator + +import ( + "reflect" + "testing" + + "github.com/lyzrai/flow/pkg/models" +) + +func TestStepResult_roundTrip(t *testing.T) { + src := map[int][]models.Item{ + 0: {{"a": 1}, {"a": 2}}, + 1: {{"b": "x"}}, + } + got := fromStepResult(toStepResult(src)) + if !reflect.DeepEqual(got, src) { + t.Fatalf("round trip mismatch:\n got: %+v\n src: %+v", got, src) + } +} + +func TestStepResult_emptyInput(t *testing.T) { + got := fromStepResult(toStepResult(map[int][]models.Item{})) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +func TestToStepResult_keysAreStringIndexed(t *testing.T) { + got := toStepResult(map[int][]models.Item{0: {{"k": 1}}, 2: {{"k": 2}}}) + if _, ok := got.Outputs["0"]; !ok { + t.Error("missing key 0") + } + if _, ok := got.Outputs["2"]; !ok { + t.Error("missing key 2") + } + if _, ok := got.Outputs["1"]; ok { + t.Error("did not expect key 1 (skipped index)") + } +} diff --git a/pkg/orchestrator/types.go b/pkg/orchestrator/types.go new file mode 100644 index 0000000..5829dd2 --- /dev/null +++ b/pkg/orchestrator/types.go @@ -0,0 +1,73 @@ +// Package orchestrator coordinates durable workflow execution. +// +// The default implementation is RestateOrchestrator (talks to Restate Server +// over its HTTP ingress). The interface stays pluggable so an in-memory or +// Temporal-backed implementation can be slotted in for tests / alternative +// deployments. +package orchestrator + +import ( + "context" + "encoding/json" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// RequestMeta carries identity fields that survive serialization to Restate +// and back. Embedded in every request struct. +type RequestMeta struct { + APIKey string `json:"api_key,omitempty"` + OrgID string `json:"org_id,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` +} + +// RunRequest contains everything needed to execute a workflow. +type RunRequest struct { + RequestMeta + Workflow *models.WorkflowDefinition + TriggerData []models.Item + Lookup engine.ExecutorLookup +} + +// PendingApproval describes a workflow paused on a HITL Approval node. +type PendingApproval struct { + Node string `json:"node"` + AwakeableID string `json:"awakeable_id"` + Context map[string]any `json:"context,omitempty"` +} + +// ExecutionStatus represents the current state of a workflow execution. +type ExecutionStatus struct { + ExecutionID string `json:"execution_id"` + Status string `json:"status"` + Outputs map[string]map[int][]models.Item `json:"outputs,omitempty"` + NodeOutputs map[string]map[int][]models.Item `json:"node_outputs,omitempty"` + Errors []string `json:"errors,omitempty"` + PendingApproval *PendingApproval `json:"pending_approval,omitempty"` +} + +// Orchestrator coordinates workflow execution. Implementations control the +// durability and scheduling model (in-memory, Restate, Temporal, etc.). +type Orchestrator interface { + // Run executes a workflow and blocks until completion. + Run(ctx context.Context, req *RunRequest) (executionID string, result *models.ExecutionResult, err error) + + // RunAsync starts a workflow execution and returns immediately with an + // execution ID. Status is observed via GetExecution / event stream. + RunAsync(ctx context.Context, req *RunRequest) (executionID string, err error) + + // GetExecution retrieves the status and result of a workflow execution. + GetExecution(ctx context.Context, executionID string) (*ExecutionStatus, error) +} + +// NodeRunner is called for each node during DAG execution. The orchestrator +// controls HOW the node runs (directly, via restate.Run, etc.). +// stepID is the node name, used as a durable step identifier. +type NodeRunner func(ctx context.Context, stepID string, executeFn func(ctx context.Context) (map[int][]models.Item, error)) (map[int][]models.Item, error) + +// ExecutionCompleter persists terminal execution state to the database. +// Optional — if nil, terminal state is not persisted. +type ExecutionCompleter interface { + Complete(ctx context.Context, id, status string, outputs, nodeOutputs json.RawMessage, errMsg string) error +} diff --git a/pkg/orchestrator/walk.go b/pkg/orchestrator/walk.go new file mode 100644 index 0000000..c3ecadb --- /dev/null +++ b/pkg/orchestrator/walk.go @@ -0,0 +1,124 @@ +package orchestrator + +import ( + "context" + "fmt" + "log/slog" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +// walkDurable executes a workflow's DAG with each node wrapped in a NodeRunner +// (typically restate.Run for journaling). Sequential topological order — the +// reactive ready-queue with futures is a v0.2 thing. +func walkDurable( + ctx context.Context, + wf *models.WorkflowDefinition, + triggerData []models.Item, + lookup engine.ExecutorLookup, + runNode NodeRunner, +) (*models.ExecutionResult, error) { + dag, err := engine.BuildDAG(wf) + if err != nil { + wrapped := fmt.Errorf("failed to build DAG: %w", err) + return &models.ExecutionResult{Status: "failed", Errors: []string{wrapped.Error()}}, wrapped + } + + order, err := dag.TopologicalSort() + if err != nil { + wrapped := fmt.Errorf("failed to sort DAG: %w", err) + return &models.ExecutionResult{Status: "failed", Errors: []string{wrapped.Error()}}, wrapped + } + + slog.InfoContext(ctx, "workflow_started_durable", + slog.String("workflow", wf.Name), + slog.Int("node_count", len(order)), + ) + + execCtx := engine.NewExecutionContext(wf, dag) + execCtx.Lookup = lookup + + var execErrors []string + + for _, nodeName := range order { + node := dag.Nodes[nodeName] + + var inputs [][]models.Item + if node.Type == "flow-nodes-base.trigger" && triggerData != nil { + inputs = [][]models.Item{triggerData} + } else { + inputs = execCtx.GatherInputs(nodeName) + } + + // Skip orphan non-trigger nodes (no path from a trigger). + if node.Type != "flow-nodes-base.trigger" && len(dag.InEdges[nodeName]) == 0 { + continue + } + + // Skip nodes whose inputs were routed elsewhere (Switch/If false branch). + if len(dag.InEdges[nodeName]) > 0 && allInputsEmpty(inputs) { + continue + } + + resolvedParams := engine.ResolveExpressions(node.Parameters, execCtx, nodeName) + node.Parameters = resolvedParams + + executorFn, err := lookup(node.Type) + if err != nil { + slog.WarnContext(ctx, "node_skipped_unknown_type", + slog.String("node", nodeName), + slog.String("type", node.Type), + ) + execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nodeName, err)) + continue + } + + // Capture loop-locals for the closure. + nm, nd, in, ex := nodeName, node, inputs, executorFn + outputs, runErr := runNode(ctx, "node:"+nm, func(c context.Context) (map[int][]models.Item, error) { + return ex(c, nd, in, execCtx) + }) + if runErr != nil { + execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nm, runErr)) + continue + } + for outIdx, items := range outputs { + execCtx.SetOutput(nm, outIdx, items) + } + } + + status := "success" + if len(execErrors) > 0 { + status = "partial_error" + } + + return &models.ExecutionResult{ + Status: status, + Outputs: terminalOutputs(execCtx, dag), + NodeOutputs: execCtx.AllOutputs(), + Errors: execErrors, + }, nil +} + +func allInputsEmpty(inputs [][]models.Item) bool { + for _, group := range inputs { + if len(group) > 0 { + return false + } + } + return true +} + +func terminalOutputs(ctx *engine.ExecutionContext, dag *engine.DAG) map[string]map[int][]models.Item { + result := make(map[string]map[int][]models.Item) + all := ctx.AllOutputs() + for nodeName := range dag.Nodes { + if len(dag.Adjacency[nodeName]) == 0 { + if out, ok := all[nodeName]; ok { + result[nodeName] = out + } + } + } + return result +} diff --git a/pkg/orchestrator/walk_test.go b/pkg/orchestrator/walk_test.go new file mode 100644 index 0000000..6d1c8fe --- /dev/null +++ b/pkg/orchestrator/walk_test.go @@ -0,0 +1,147 @@ +package orchestrator + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/lyzrai/flow/pkg/engine" + "github.com/lyzrai/flow/pkg/models" +) + +func newTestWorkflow() *models.WorkflowDefinition { + return &models.WorkflowDefinition{ + Name: "test", + Nodes: []models.NodeDef{ + {ID: "1", Name: "Trigger", Type: "flow-nodes-base.trigger", Parameters: map[string]any{}}, + {ID: "2", Name: "Noop", Type: "flow-nodes-base.noOp", Parameters: map[string]any{}}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "Trigger", SourceOutputIndex: 0, TargetNode: "Noop", TargetInputIndex: 0}, + }, + } +} + +// fakeRunner records each step name and forwards execution. +func fakeRunner(steps *[]string) NodeRunner { + return func(ctx context.Context, stepID string, fn func(context.Context) (map[int][]models.Item, error)) (map[int][]models.Item, error) { + *steps = append(*steps, stepID) + return fn(ctx) + } +} + +func passThroughLookup() engine.ExecutorLookup { + return func(_ string) (engine.NodeExecutorFunc, error) { + return func(_ context.Context, _ models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) { + var items []models.Item + for _, in := range inputs { + items = append(items, in...) + } + if len(items) == 0 { + items = []models.Item{{}} + } + return map[int][]models.Item{0: items}, nil + }, nil + } +} + +func TestWalkDurable_runsNodesViaNodeRunner(t *testing.T) { + wf := newTestWorkflow() + var steps []string + result, err := walkDurable(context.Background(), wf, []models.Item{{"in": "x"}}, passThroughLookup(), fakeRunner(&steps)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Status != "success" { + t.Fatalf("expected success, got %q (errors=%v)", result.Status, result.Errors) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps via NodeRunner, got %d: %v", len(steps), steps) + } + for _, want := range []string{"node:Trigger", "node:Noop"} { + found := false + for _, s := range steps { + if s == want { + found = true + break + } + } + if !found { + t.Errorf("missing expected step %q in %v", want, steps) + } + } +} + +func TestWalkDurable_collectsTerminalOutputs(t *testing.T) { + wf := newTestWorkflow() + steps := []string{} + result, err := walkDurable(context.Background(), wf, []models.Item{{"hi": "world"}}, passThroughLookup(), fakeRunner(&steps)) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + // Noop has no outgoing edges → it's the terminal node. + if _, ok := result.Outputs["Noop"]; !ok { + t.Fatalf("expected terminal output for Noop, got %v", result.Outputs) + } + if _, ok := result.Outputs["Trigger"]; ok { + t.Fatalf("Trigger should not be a terminal output (has downstream edge)") + } +} + +func TestWalkDurable_recordsExecErrors(t *testing.T) { + wf := newTestWorkflow() + calls := int32(0) + + failingLookup := func(_ string) (engine.NodeExecutorFunc, error) { + return func(_ context.Context, node models.NodeDef, _ [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) { + n := atomic.AddInt32(&calls, 1) + if n == 2 { + return nil, errors.New("simulated failure") + } + return map[int][]models.Item{0: {{"ok": true}}}, nil + }, nil + } + + steps := []string{} + result, err := walkDurable(context.Background(), wf, []models.Item{{}}, failingLookup, fakeRunner(&steps)) + if err != nil { + t.Fatalf("walk should not return error for non-terminal failures, got %v", err) + } + if result.Status != "partial_error" { + t.Fatalf("expected partial_error, got %q", result.Status) + } + if len(result.Errors) == 0 { + t.Fatal("expected error message recorded") + } +} + +func TestWalkDurable_failsOnCycle(t *testing.T) { + // Two non-trigger nodes pointing at each other = cycle. + wf := &models.WorkflowDefinition{ + Name: "cycle", + Nodes: []models.NodeDef{ + {ID: "1", Name: "T", Type: "flow-nodes-base.trigger"}, + {ID: "2", Name: "A", Type: "flow-nodes-base.noOp"}, + {ID: "3", Name: "B", Type: "flow-nodes-base.noOp"}, + }, + Connections: []models.ConnectionDef{ + {SourceNode: "T", TargetNode: "A"}, + {SourceNode: "A", TargetNode: "B"}, + {SourceNode: "B", TargetNode: "A"}, + }, + } + steps := []string{} + _, err := walkDurable(context.Background(), wf, nil, passThroughLookup(), fakeRunner(&steps)) + if err == nil { + t.Fatal("expected cycle detection error") + } +} + +func TestRequestMeta_isSerializable(t *testing.T) { + // Smoke check: types compile and zero values work. + r := RunRequest{RequestMeta: RequestMeta{APIKey: "k", OrgID: "o"}} + if r.RequestMeta.APIKey != "k" || r.RequestMeta.OrgID != "o" { + t.Fatal("RequestMeta fields lost on assignment") + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..20ae5f9 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next +dist +# Note: out/ is the static-export target embedded by Go (web/embed.go). +# We ignore everything inside but keep the directory + .gitkeep so the +# Go embed compiles before the npm build has run. +/out/* +!/out/.gitkeep +.env*.local +.DS_Store +next-env.d.ts +*.tsbuildinfo diff --git a/web/app/executions/view/page.tsx b/web/app/executions/view/page.tsx new file mode 100644 index 0000000..4e982e9 --- /dev/null +++ b/web/app/executions/view/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { RefreshCw, Send } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api, type ExecutionStatus } from "@/lib/api"; + +export default function ExecutionPage() { + return ( + Loading…}> + + + ); +} + +function statusVariant(s?: string) { + switch ((s || "").toLowerCase()) { + case "success": + case "completed": + return "success" as const; + case "running": + case "pending": + return "secondary" as const; + case "failed": + case "error": + return "destructive" as const; + case "waiting": + case "paused": + return "warning" as const; + default: + return "outline" as const; + } +} + +function ExecutionView() { + const params = useSearchParams(); + const id = params.get("id") ?? ""; + + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [polling, setPolling] = useState(true); + + // resume form + const [awakeable, setAwakeable] = useState(""); + const [data, setData] = useState(`{"approved": true}`); + const [resuming, setResuming] = useState(false); + + async function refresh() { + if (!id) return; + try { + const s = await api.getExecution(id); + setStatus(s); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "load failed"); + } + } + + useEffect(() => { + refresh(); + if (!polling) return; + const t = setInterval(() => { + refresh(); + }, 2000); + return () => clearInterval(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, polling]); + + async function onResume() { + setResuming(true); + setError(null); + try { + let payload: unknown = {}; + if (data.trim()) payload = JSON.parse(data); + await api.resumeExecution(id, { awakeable_id: awakeable, data: payload }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "resume failed"); + } finally { + setResuming(false); + } + } + + if (!id) { + return ( +
+ Missing id query param. +
+ ); + } + + const s = (status?.status as string) || "unknown"; + const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase()); + + return ( +
+
+ + +
+ +
+
+

Execution

+ {s} + {!terminal && polling && ( + polling every 2s… + )} +
+

{id}

+
+ + {error && ( + + {error} + + )} + +
+ + + Status + Live snapshot from the orchestrator + + +
+              {status ? JSON.stringify(status, null, 2) : "Loading…"}
+            
+
+
+ + + + Resume + + Resolve a Restate awakeable to continue a paused workflow. + + + +
+ + setAwakeable(e.target.value)} + placeholder="awk_…" + className="font-mono text-xs" + /> +
+
+ +