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,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
|
||||
}
|
||||
Reference in New Issue
Block a user