feat: implement environment management features

- Added environment creation and editing pages with forms for name and description.
- Integrated environment listing with options to edit and delete environments.
- Updated agent detail page to manage environments followed by agents.
- Enhanced API to support environment operations including listing, creating, updating, and deleting environments.
- Refactored related components and state management for improved clarity and functionality.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 1766bdf497
commit 6b1a13ecdc
19 changed files with 1377 additions and 341 deletions
+72 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
restate "github.com/restatedev/sdk-go"
@@ -17,6 +18,13 @@ import (
// It pauses the workflow using a Restate Awakeable and blocks until an external
// caller resolves it via POST /api/executions/{id}/resume.
//
// Node params (all optional):
// - method "ui" (default) | "quorum" | "auto" — "auto" emits on output 0
// immediately without pausing. quorum N>1 is surfaced in the reviewer
// context; strict N-approver enforcement is a follow-up.
// - minApprovers — for method "quorum".
// - timeoutSeconds — reject automatically after that long.
//
// Two outputs:
// - Output 0: approved — items flow with human-supplied data merged in
// - Output 1: rejected — items flow with rejection_reason field
@@ -39,6 +47,27 @@ func (e *ApprovalExecutor) Execute(
inputItems = []models.Item{{}}
}
// Resolve the effective approval policy from node params.
method := strParam(node.Parameters, "method", "")
if method == "" {
method = "ui"
}
minApprovers := intParam(node.Parameters, "minApprovers", 1)
timeoutSeconds := intParam(node.Parameters, "timeoutSeconds", 0)
// Auto method: don't pause at all — emit straight to the approved output.
if method == "auto" {
slog.InfoContext(ctx, "approval_auto", slog.String("node", node.Name))
var out []models.Item
for _, item := range inputItems {
m := copyItem(item)
m["approved"] = true
m["approval_method"] = "auto"
out = append(out, m)
}
return map[int][]models.Item{0: out}, nil
}
// Approval requires Restate for durable blocking.
raw := durability.RestateCtxFromContext(ctx)
rctx, ok := raw.(restate.WorkflowContext)
@@ -69,6 +98,13 @@ func (e *ApprovalExecutor) Execute(
if reason, ok := resolved["reason"].(string); ok && reason != "" {
approvalCtx["reason"] = reason
}
approvalCtx["method"] = method
if method == "quorum" {
approvalCtx["minApprovers"] = minApprovers
}
if timeoutSeconds > 0 {
approvalCtx["timeoutSeconds"] = timeoutSeconds
}
if len(inputItems) == 1 {
approvalCtx["inputs"] = map[string]any(inputItems[0])
} else if len(inputItems) > 1 {
@@ -111,10 +147,29 @@ func (e *ApprovalExecutor) Execute(
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)
// Block on the Awakeable. Durable across crashes / restarts. If a
// timeout is set, race it against a durable Restate timer — whichever
// fires first wins; on timeout we route to the rejected output.
var approvalData map[string]any
timedOut := false
if timeoutSeconds > 0 {
selector := restate.Select(rctx, awakeable, restate.After(rctx, time.Duration(timeoutSeconds)*time.Second))
switch winner := selector.Select(); winner {
case awakeable:
d, err := awakeable.Result()
if err != nil {
return nil, fmt.Errorf("approval node %q: %w", node.Name, err)
}
approvalData = d
default:
timedOut = true
}
} else {
d, err := awakeable.Result()
if err != nil {
return nil, fmt.Errorf("approval node %q: %w", node.Name, err)
}
approvalData = d
}
// Clear pending markers now that we've resumed.
@@ -122,6 +177,19 @@ func (e *ApprovalExecutor) Execute(
restate.Clear(rctx, "pending_approval_id")
restate.Clear(rctx, "pending_approval_context")
if timedOut {
slog.InfoContext(ctx, "approval_timed_out",
slog.String("node", node.Name), slog.Int("timeout_seconds", timeoutSeconds))
var rejected []models.Item
for _, item := range inputItems {
r := copyItem(item)
r["approved"] = false
r["rejection_reason"] = fmt.Sprintf("approval timed out after %ds", timeoutSeconds)
rejected = append(rejected, r)
}
return map[int][]models.Item{1: rejected}, nil
}
slog.InfoContext(ctx, "workflow_resumed",
slog.String("node", node.Name),
)
+24 -18
View File
@@ -44,16 +44,6 @@ func (e *DeployExecutor) Execute(ctx context.Context, node models.NodeDef, input
}
logger := engine.NodeLoggerFromContext(ctx)
target := strings.ToLower(strParam(node.Parameters, "target", "agentcore"))
switch target {
case "agentcore":
// supported below
case "k8s", "kubernetes", "vertex", "":
return nil, fmt.Errorf("deploy: target %q not yet implemented (only \"agentcore\" today)", target)
default:
return nil, fmt.Errorf("deploy: unknown target %q", target)
}
trigger := firstItem(inputs)
agentID, _ := trigger["agentId"].(string)
if agentID == "" {
@@ -63,18 +53,33 @@ func (e *DeployExecutor) Execute(ctx context.Context, node models.NodeDef, input
if err != nil {
return nil, fmt.Errorf("deploy: load agent %q: %w", agentID, err)
}
credName := strParam(node.Parameters, "credentialName", "aws")
// envName is purely informational here (recorded in the summary).
envName, _ := trigger["environment"].(string)
// target: node param → "agentcore".
target := strings.ToLower(strParam(node.Parameters, "target", ""))
if target == "" {
target = "agentcore"
}
switch target {
case "agentcore":
// supported below
case "k8s", "kubernetes", "vertex":
return nil, fmt.Errorf("deploy: target %q not yet implemented (only \"agentcore\" today)", target)
default:
return nil, fmt.Errorf("deploy: unknown target %q", target)
}
// credentialName: node param → "aws".
credName := strParam(node.Parameters, "credentialName", "")
if credName == "" {
credName = "aws"
}
cred, credScope, err := e.lookupCredential(ctx, a, credName)
if err != nil {
return nil, fmt.Errorf("deploy: %w", err)
}
logger.Log(fmt.Sprintf("[deploy] using %s credential %q", credScope, credName))
if cred.Type != storage.CredentialAWS {
return nil, fmt.Errorf("deploy: credential %q is type %q; target=agentcore needs an aws credential", credName, cred.Type)
}
if cred.AwsRegion == "" || cred.AwsAccountID == "" || cred.AwsCrossAccountRoleArn == "" {
return nil, fmt.Errorf("deploy: aws credential %q is missing region / accountId / crossAccountRoleArn", credName)
}
logger.Log(fmt.Sprintf("[deploy] target=%s credential=%s (%s)", target, credName, credScope))
image := resolveDeployImage(node.Parameters, inputs)
if image == "" {
@@ -143,6 +148,7 @@ func (e *DeployExecutor) Execute(ctx context.Context, node models.NodeDef, input
summary := map[string]any{
"target": "agentcore",
"environment": envName,
"agentId": agentID,
"agentName": a.Name,
"credentialName": cred.Name,