mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"), ".", "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
restate "github.com/restatedev/sdk-go"
|
||||
"github.com/restatedev/sdk-go/ingress"
|
||||
|
||||
"github.com/lyzrai/flow/pkg/models"
|
||||
)
|
||||
|
||||
// RestateOrchestrator submits workflows to a Restate server via its HTTP ingress.
|
||||
// Each node in the workflow becomes a journaled step that survives crashes.
|
||||
type RestateOrchestrator struct {
|
||||
restateURL string
|
||||
}
|
||||
|
||||
// NewRestateOrchestrator constructs an orchestrator pointed at restateURL
|
||||
// (the Restate ingress URL, e.g. http://localhost:8081).
|
||||
func NewRestateOrchestrator(restateURL string) *RestateOrchestrator {
|
||||
return &RestateOrchestrator{restateURL: restateURL}
|
||||
}
|
||||
|
||||
// IngressURL returns the Restate ingress URL for external API calls
|
||||
// (e.g., awakeable resolution from the resume HTTP handler).
|
||||
func (o *RestateOrchestrator) IngressURL() string { return o.restateURL }
|
||||
|
||||
// Run submits a workflow synchronously and blocks until it finishes.
|
||||
func (o *RestateOrchestrator) Run(ctx context.Context, req *RunRequest) (string, *models.ExecutionResult, error) {
|
||||
client := ingress.NewClient(o.restateURL)
|
||||
workflowID := generateWorkflowID()
|
||||
|
||||
wfReq := &WorkflowRequest{
|
||||
RequestMeta: req.RequestMeta,
|
||||
Workflow: req.Workflow,
|
||||
TriggerData: req.TriggerData,
|
||||
}
|
||||
|
||||
result, err := ingress.Workflow[*WorkflowRequest, *models.ExecutionResult](
|
||||
client, "WorkflowExecutor", workflowID, "Run",
|
||||
).Request(ctx, wfReq)
|
||||
if err != nil {
|
||||
return workflowID, nil, fmt.Errorf("workflow execution failed: %w", err)
|
||||
}
|
||||
|
||||
result.ExecutionID = workflowID
|
||||
return workflowID, result, nil
|
||||
}
|
||||
|
||||
// RunAsync submits a workflow and returns immediately with an execution ID.
|
||||
func (o *RestateOrchestrator) RunAsync(ctx context.Context, req *RunRequest) (string, error) {
|
||||
client := ingress.NewClient(o.restateURL)
|
||||
workflowID := generateWorkflowID()
|
||||
|
||||
wfReq := &WorkflowRequest{
|
||||
RequestMeta: req.RequestMeta,
|
||||
Workflow: req.Workflow,
|
||||
TriggerData: req.TriggerData,
|
||||
}
|
||||
|
||||
_, err := ingress.Workflow[*WorkflowRequest, *models.ExecutionResult](
|
||||
client, "WorkflowExecutor", workflowID, "Run",
|
||||
).Send(ctx, wfReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("async workflow submission failed: %w", err)
|
||||
}
|
||||
|
||||
return workflowID, nil
|
||||
}
|
||||
|
||||
// GetExecution returns the live status of a workflow execution.
|
||||
// While running, it also probes for a pending approval via the shared handler.
|
||||
func (o *RestateOrchestrator) GetExecution(ctx context.Context, executionID string) (*ExecutionStatus, error) {
|
||||
client := ingress.NewClient(o.restateURL)
|
||||
|
||||
handle := ingress.WorkflowHandle[*models.ExecutionResult](client, "WorkflowExecutor", executionID)
|
||||
|
||||
result, err := handle.Output(ctx)
|
||||
if err != nil {
|
||||
var notReady *ingress.InvocationNotReadyError
|
||||
if errors.As(err, ¬Ready) {
|
||||
status := &ExecutionStatus{ExecutionID: executionID, Status: "running"}
|
||||
approval, aErr := ingress.Workflow[restate.Void, *PendingApproval](
|
||||
client, "WorkflowExecutor", executionID, "GetPendingApproval",
|
||||
).Request(ctx, restate.Void{})
|
||||
if aErr == nil && approval != nil && approval.AwakeableID != "" {
|
||||
status.Status = "waiting_for_approval"
|
||||
status.PendingApproval = approval
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
var notFound *ingress.InvocationNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return nil, fmt.Errorf("execution %q not found", executionID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get execution status: %w", err)
|
||||
}
|
||||
|
||||
return &ExecutionStatus{
|
||||
ExecutionID: executionID,
|
||||
Status: result.Status,
|
||||
Outputs: result.Outputs,
|
||||
NodeOutputs: result.NodeOutputs,
|
||||
Errors: result.Errors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateWorkflowID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "/" }];
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }>),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+2897
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user