feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup

This commit is contained in:
Shreyas Kapale
2026-05-13 22:15:08 +05:30
committed by patel-lyzr
commit 2e94e6bdf6
84 changed files with 11063 additions and 0 deletions
+33
View File
@@ -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
+19
View File
@@ -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/
+24
View File
@@ -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"]
+190
View File
@@ -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.
+39
View File
@@ -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
+28
View File
@@ -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
+181
View File
@@ -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 <workflow.json>")
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 <workflow.json> 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
}
+18
View File
@@ -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
+28
View File
@@ -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
)
+65
View File
@@ -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=
+485
View File
@@ -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": <n8n-format JSON>, "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": <any> }
//
// `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": <n8n-JSON object>, "input": [{...}, ...] }
// { "workflow_id": "<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 = `<!doctype html>
<html><head><title>flow</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{max-width:420px;padding:32px;border:1px solid #334155;border-radius:12px;background:#1e293b}
h1{margin:0 0 8px;font-size:18px}
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:12px}
</style></head>
<body><div class="card">
<h1>flow — frontend not built</h1>
<p>Run <code>cd web && npm install && npm run build</code> and rebuild the binary to ship the UI.</p>
</div></body></html>`
// --- 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"), ".", "")
}
+357
View File
@@ -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)
}
}
+187
View File
@@ -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
}
+62
View File
@@ -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
}
+87
View File
@@ -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")
}
}
+45
View File
@@ -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()
}
+55
View File
@@ -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())
}
}
+50
View File
@@ -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...)
}
+92
View File
@@ -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)
}
+111
View File
@@ -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
}
+123
View File
@@ -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
}
+130
View File
@@ -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)
}
}
+28
View File
@@ -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) {}
+26
View File
@@ -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"`
}
+316
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+159
View File
@@ -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
}
+95
View File
@@ -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))
}
}
+151
View File
@@ -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
}
+113
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+137
View File
@@ -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")
}
}
+109
View File
@@ -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
}
+78
View File
@@ -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")
}
}
+15
View File
@@ -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)
+150
View File
@@ -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
}
+17
View File
@@ -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)
}
+173
View File
@@ -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]))
}
}
+19
View File
@@ -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
}
+64
View File
@@ -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
}
}
+105
View File
@@ -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
}
+23
View File
@@ -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
}
+16
View File
@@ -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"`
}
+29
View File
@@ -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"`
}
+73
View File
@@ -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)
}
+139
View File
@@ -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")
}
}
+116
View File
@@ -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, &notReady) {
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, &notFound) {
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)
}
+171
View File
@@ -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
}
+39
View File
@@ -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)")
}
}
+73
View File
@@ -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
}
+124
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+12
View File
@@ -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
+195
View File
@@ -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 (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<ExecutionView />
</Suspense>
);
}
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<ExecutionStatus | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
const s = (status?.status as string) || "unknown";
const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase());
return (
<div className="space-y-6">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPolling((p) => !p)}
>
{polling ? "Stop polling" : "Resume polling"}
</Button>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw />
Refresh
</Button>
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-semibold tracking-tight">Execution</h1>
<Badge variant={statusVariant(s)}>{s}</Badge>
{!terminal && polling && (
<span className="text-xs text-muted-foreground">polling every 2s</span>
)}
</div>
<p className="mt-1 font-mono text-xs text-muted-foreground">{id}</p>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Live snapshot from the orchestrator</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted/30 p-4 text-xs">
{status ? JSON.stringify(status, null, 2) : "Loading…"}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resume</CardTitle>
<CardDescription>
Resolve a Restate awakeable to continue a paused workflow.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="awakeable">Awakeable ID</Label>
<Input
id="awakeable"
value={awakeable}
onChange={(e) => setAwakeable(e.target.value)}
placeholder="awk_…"
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="data">Resolution data (JSON)</Label>
<Textarea
id="data"
rows={6}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<Button
className="w-full"
onClick={onResume}
disabled={resuming || !awakeable}
>
<Send />
{resuming ? "Sending…" : "Resume"}
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
const sample = `{
"name": "Hello",
"nodes": [
{
"id": "1",
"name": "When clicked",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"parameters": {}
},
{
"id": "2",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [200, 0],
"parameters": { "values": { "string": [{ "name": "msg", "value": "hello" }] } }
}
],
"connections": {
"When clicked": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] }
}
}`;
export default function NewFlowPage() {
const router = useRouter();
const [name, setName] = useState("");
const [definition, setDefinition] = useState(sample);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function onSave() {
setSaving(true);
setError(null);
try {
let parsed: unknown;
try {
parsed = JSON.parse(definition);
} catch (e) {
throw new Error(`Definition must be valid JSON: ${(e as Error).message}`);
}
const { id } = await api.createFlow({ name, definition: parsed });
router.push(`/flows/view/?id=${encodeURIComponent(id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">New flow</h1>
<p className="mt-1 text-sm text-muted-foreground">
Paste an n8n workflow export, or start from the sample below.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>
n8n-format JSON. Drafts are validated leniently; strict validation runs on
execute.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My flow (optional — uses workflow.name if blank)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={20}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Link href="/">
<Button variant="ghost">Cancel</Button>
</Link>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save flow"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Play, Save, Trash2 } 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 StoredFlow } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export default function FlowDetailPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<FlowDetail />
</Suspense>
);
}
function FlowDetail() {
const router = useRouter();
const params = useSearchParams();
const id = params.get("id") ?? "";
const [flow, setFlow] = useState<StoredFlow | null>(null);
const [name, setName] = useState("");
const [definition, setDefinition] = useState("");
const [input, setInput] = useState("[{}]");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [lastExecId, setLastExecId] = useState<string | null>(null);
async function load() {
if (!id) return;
try {
const f = await api.getFlow(id);
setFlow(f);
setName(f.name);
setDefinition(JSON.stringify(f.definition, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function onSave() {
setSaving(true);
setError(null);
try {
const parsed = JSON.parse(definition);
await api.updateFlow(id, { name, definition: parsed });
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
async function onRun() {
setRunning(true);
setError(null);
try {
let parsedInput: unknown[] | undefined;
if (input.trim()) {
const v = JSON.parse(input);
if (!Array.isArray(v)) throw new Error("Input must be a JSON array");
parsedInput = v;
}
const res = await api.executeWorkflow({ workflow_id: id, input: parsedInput });
setLastExecId(res.execution_id);
router.push(`/executions/view/?id=${encodeURIComponent(res.execution_id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "run failed");
} finally {
setRunning(false);
}
}
async function onDelete() {
if (!confirm("Delete this flow? This cannot be undone.")) return;
try {
await api.deleteFlow(id);
router.push("/");
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
}
}
if (!id) {
return (
<div className="text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
return (
<div className="space-y-6">
{flow && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span className="font-mono">{flow.id}</span>
<span>·</span>
<span>updated {formatDate(flow.updatedAt)}</span>
<Badge variant="secondary">{flow.nodeCount} nodes</Badge>
</div>
)}
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{name || "Untitled flow"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit, save, and run this workflow against the orchestrator.
</p>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>n8n-format JSON</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={24}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onDelete}>
<Trash2 />
Delete
</Button>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save"}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Run</CardTitle>
<CardDescription>Submit to the orchestrator</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={8}
value={input}
onChange={(e) => setInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-xs text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button className="w-full" onClick={onRun} disabled={running}>
<Play />
{running ? "Submitting…" : "Run flow"}
</Button>
{lastExecId && (
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{lastExecId}
</Link>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.6rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
html,
body {
@apply bg-background text-foreground antialiased;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Breadcrumbs } from "@/components/breadcrumbs";
export const metadata: Metadata = {
title: "flow",
description: "Durable n8n-compatible workflow engine",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<SidebarProvider
style={{ "--sidebar-width": "17rem" } as React.CSSProperties}
>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-4 backdrop-blur">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumbs />
</header>
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
</body>
</html>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus, RefreshCw, Trash2, Activity } 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 { api, type FlowSummary } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export default function DashboardPage() {
const [flows, setFlows] = useState<FlowSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [health, setHealth] = useState<"ok" | "down" | "checking">("checking");
async function load() {
try {
const list = await api.listFlows();
list.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
setFlows(list);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "failed to load");
}
}
useEffect(() => {
load();
api.health()
.then(() => setHealth("ok"))
.catch(() => setHealth("down"));
}, []);
async function onDelete(id: string) {
if (!confirm("Delete this flow?")) return;
try {
await api.deleteFlow(id);
await load();
} catch (e) {
alert(e instanceof Error ? e.message : "delete failed");
}
}
return (
<div className="space-y-8">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Flows</h1>
<p className="mt-1 text-sm text-muted-foreground">
Durable, n8n-compatible workflows. Paste exported JSON to import.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={
health === "ok"
? "h-2 w-2 rounded-full bg-emerald-500"
: health === "down"
? "h-2 w-2 rounded-full bg-rose-500"
: "h-2 w-2 rounded-full bg-amber-500"
}
/>
<span>API {health}</span>
</div>
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw />
Refresh
</Button>
<Link href="/flows/new">
<Button size="sm">
<Plus />
New flow
</Button>
</Link>
</div>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
{flows === null ? (
<SkeletonGrid />
) : flows.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{flows.map((f) => (
<Card key={f.id} className="group transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{f.name || "Untitled flow"}</CardTitle>
<CardDescription className="mt-1 truncate font-mono text-[11px]">
{f.id}
</CardDescription>
</div>
<Badge variant="secondary">{f.status || "draft"}</Badge>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>
<span className="font-medium text-foreground">{f.nodeCount}</span> nodes
</span>
<span>·</span>
<span>{formatDate(f.updatedAt)}</span>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Link href={`/flows/view/?id=${encodeURIComponent(f.id)}`}>
<Button size="sm" variant="ghost">
<Activity />
Open
</Button>
</Link>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(f.id)}
aria-label="Delete flow"
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
function SkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-1/3 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
);
}
function EmptyState() {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<div className="rounded-full bg-muted p-3">
<Plus className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">No flows yet</p>
<p className="text-sm text-muted-foreground">
Import an n8n workflow JSON to get started.
</p>
</div>
<Link href="/flows/new">
<Button size="sm">Create your first flow</Button>
</Link>
</CardContent>
</Card>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Workflow,
LayoutGrid,
PlusCircle,
Activity,
BookOpen,
ExternalLink,
Github,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
} from "@/components/ui/sidebar";
type NavItem = {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
match?: (pathname: string) => boolean;
};
const primary: NavItem[] = [
{
title: "Flows",
href: "/",
icon: LayoutGrid,
match: (p) => p === "/" || p.startsWith("/flows/view"),
},
{
title: "New flow",
href: "/flows/new",
icon: PlusCircle,
match: (p) => p.startsWith("/flows/new"),
},
{
title: "Executions",
href: "/executions/view",
icon: Activity,
match: (p) => p.startsWith("/executions"),
},
];
const docs = [
{
title: "n8n compatibility",
href: "https://github.com/lyzrai/flow#n8n-compatible",
external: true,
},
{
title: "Durability model",
href: "https://github.com/lyzrai/flow#durability",
external: true,
},
{
title: "Embed as Go library",
href: "https://github.com/lyzrai/flow#embedding",
external: true,
},
];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname() || "/";
return (
<Sidebar variant="floating" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">flow</span>
<span className="text-xs text-muted-foreground">
pre-v0.1 · durable workflows
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu className="gap-1">
{primary.map((item) => {
const Icon = item.icon;
const active = item.match
? item.match(pathname)
: pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={active}>
<Link href={item.href} className="font-medium">
<Icon className="size-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupLabel>
<BookOpen className="mr-1 size-3.5" />
Reference
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="font-medium" disabled>
Documentation
</SidebarMenuButton>
<SidebarMenuSub className="ml-0 border-l-0 px-1.5">
{docs.map((d) => (
<SidebarMenuSubItem key={d.href}>
<SidebarMenuSubButton asChild>
<a href={d.href} target="_blank" rel="noreferrer">
<span className="truncate">{d.title}</span>
{d.external && (
<ExternalLink className="ml-auto size-3 opacity-60" />
)}
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a
href="https://github.com/lyzrai/flow"
target="_blank"
rel="noreferrer"
>
<Github className="size-4" />
<span>GitHub</span>
<ExternalLink className="ml-auto size-3 opacity-60" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
export function Breadcrumbs() {
return (
<Suspense fallback={<div className="h-4" />}>
<BreadcrumbsInner />
</Suspense>
);
}
function BreadcrumbsInner() {
const pathname = usePathname() || "/";
const params = useSearchParams();
const crumbs = derive(pathname, params);
return (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={`${c.label}-${i}`} className="contents">
<BreadcrumbItem className={i === 0 ? "hidden md:block" : undefined}>
{isLast || !c.href ? (
<BreadcrumbPage>{c.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={c.href}>{c.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && (
<BreadcrumbSeparator
className={i === 0 ? "hidden md:block" : undefined}
/>
)}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
function derive(
pathname: string,
params: URLSearchParams | null
): { label: string; href?: string }[] {
const id = params?.get("id");
if (pathname === "/" || pathname === "") {
return [{ label: "flow", href: "/" }, { label: "Flows" }];
}
if (pathname.startsWith("/flows/new")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: "New" },
];
}
if (pathname.startsWith("/flows/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: id ? `Flow ${id.slice(0, 8)}` : "Flow" },
];
}
if (pathname.startsWith("/executions/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Executions" },
{ label: id ? `${id.slice(0, 12)}` : "Run" },
];
}
return [{ label: "flow", href: "/" }];
}
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-ring",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
warning:
"border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-400",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+140
View File
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+773
View File
@@ -0,0 +1,773 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 font-mono",
className
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+49
View File
@@ -0,0 +1,49 @@
// Package web ships the embedded SPA bundle that the flow HTTP server serves.
//
// The Next.js app under this directory builds a static export into ./out
// (Next's default for `output: "export"`). At Go build time we embed the
// contents of ./out as an io/fs.FS via Dist().
//
// To compile this package the out/ directory must exist and contain at least
// one file. Run `cd web && npm install && npm run build` before `go build`.
// A placeholder is committed so a fresh checkout compiles without requiring
// the npm build first; running the npm build populates the directory.
package web
import (
"embed"
"io/fs"
)
//go:embed all:out
var distFS embed.FS
// Dist returns the embedded SPA bundle rooted at the out/ directory.
// Returns nil if the bundle is empty (unbuilt) — the API server then renders
// a small "frontend not built" notice.
func Dist() fs.FS {
sub, err := fs.Sub(distFS, "out")
if err != nil {
return nil
}
// Treat an empty bundle (only the placeholder marker) as nil so the
// server falls back to the no-bundle notice instead of serving a stub.
if isEmpty(sub) {
return nil
}
return sub
}
func isEmpty(fsys fs.FS) bool {
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
return true
}
for _, e := range entries {
if e.Name() == ".gitkeep" {
continue
}
return false
}
return true
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+83
View File
@@ -0,0 +1,83 @@
// API client for the Go flow server. Static export → calls go to same origin.
export type FlowSummary = {
id: string;
name: string;
description?: string;
updatedAt: string;
nodeCount: number;
status?: string;
};
export type StoredFlow = {
id: string;
name: string;
definition: unknown;
updatedAt: string;
nodeCount: number;
};
export type ExecutionStatus = {
execution_id?: string;
status?: string;
[k: string]: unknown;
};
const base = ""; // same-origin
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const body = await res.json();
if (body?.error) msg = body.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
health: () => fetch(`${base}/api/health`).then(handle<{ status: string }>),
listFlows: () => fetch(`${base}/api/workflows`).then(handle<FlowSummary[]>),
getFlow: (id: string) => fetch(`${base}/api/workflows/${id}`).then(handle<StoredFlow>),
createFlow: (body: { name: string; definition: unknown }) =>
fetch(`${base}/api/workflows`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ id: string }>),
updateFlow: (id: string, body: { name?: string; definition?: unknown }) =>
fetch(`${base}/api/workflows/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<void>),
deleteFlow: (id: string) =>
fetch(`${base}/api/workflows/${id}`, { method: "DELETE" }).then(handle<void>),
executeWorkflow: (body: { workflow_id?: string; workflow?: unknown; input?: unknown[] }) =>
fetch(`${base}/api/workflows/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ execution_id: string; status: string }>),
getExecution: (id: string) =>
fetch(`${base}/api/executions/${id}`).then(handle<ExecutionStatus>),
resumeExecution: (id: string, body: { awakeable_id: string; data?: unknown }) =>
fetch(`${base}/api/executions/${id}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ message: string }>),
};
+12
View File
@@ -0,0 +1,12 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(value: string | Date) {
const d = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
// Static export writes to ./out (Go embed reads from there).
// Leave distDir at the default ".next" so build artifacts and the
// export target don't collide.
images: { unoptimized: true },
trailingSlash: true,
reactStrictMode: true,
};
export default nextConfig;
+2897
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "flow-web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "15.1.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+93
View File
@@ -0,0 +1,93 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: '1.5rem',
screens: {
'2xl': '1320px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [animate],
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "dist"]
}