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
+1
View File
@@ -219,6 +219,7 @@ func serve() int {
Runs: mongo.Runs(), Runs: mongo.Runs(),
Agents: mongo.Agents(), Agents: mongo.Agents(),
Credentials: mongo.Credentials(), Credentials: mongo.Credentials(),
Environments: mongo.Environments(),
Events: eventBus, Events: eventBus,
Logs: logs, Logs: logs,
}), }),
+138 -125
View File
@@ -58,7 +58,7 @@ type Agent struct {
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty"` WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty"`
AuthStatus storage.AuthStatus `json:"authStatus,omitempty"` AuthStatus storage.AuthStatus `json:"authStatus,omitempty"`
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty"` AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty"`
AttachedPipelines []string `json:"attachedPipelines,omitempty"` Environments []string `json:"environments,omitempty"`
Credentials []PublicCredential `json:"credentials,omitempty"` Credentials []PublicCredential `json:"credentials,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
@@ -108,7 +108,7 @@ func (s *Server) publicAgent(a *storage.Agent) Agent {
WebhookInstalledAt: a.WebhookInstalledAt, WebhookInstalledAt: a.WebhookInstalledAt,
AuthStatus: a.AuthStatus, AuthStatus: a.AuthStatus,
AuthCheckedAt: a.AuthCheckedAt, AuthCheckedAt: a.AuthCheckedAt,
AttachedPipelines: a.AttachedPipelines, Environments: a.Environments,
Credentials: creds, Credentials: creds,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
@@ -342,27 +342,31 @@ func (s *Server) handleUninstallWebhook(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, s.publicAgent(a)) writeJSON(w, http.StatusOK, s.publicAgent(a))
} }
// --- pipeline attachments ------------------------------------------------ // --- environment subscriptions -------------------------------------------
func (s *Server) handleAttachPipeline(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentFollowEnv(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
pipelineID := r.PathValue("pipelineId") envName := r.PathValue("envName")
a, err := s.agents.Get(r.Context(), id) a, err := s.agents.Get(r.Context(), id)
if err != nil { if err != nil {
writeStorageErr(w, err, "agent not found") writeStorageErr(w, err, "agent not found")
return return
} }
if _, err := s.pipelines.Get(r.Context(), pipelineID); err != nil { if s.environments == nil {
writeStorageErr(w, err, "pipeline not found") writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return return
} }
for _, existing := range a.AttachedPipelines { if _, err := s.environments.GetByName(r.Context(), envName); err != nil {
if existing == pipelineID { writeStorageErr(w, err, "environment not found")
writeJSON(w, http.StatusOK, s.publicAgent(a)) // already attached return
}
for _, e := range a.Environments {
if strings.EqualFold(e, envName) {
writeJSON(w, http.StatusOK, s.publicAgent(a)) // already following
return return
} }
} }
a.AttachedPipelines = append(a.AttachedPipelines, pipelineID) a.Environments = append(a.Environments, envName)
a.UpdatedAt = time.Now().UTC() a.UpdatedAt = time.Now().UTC()
if err := s.agents.Update(r.Context(), a); err != nil { if err := s.agents.Update(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)
@@ -371,21 +375,21 @@ func (s *Server) handleAttachPipeline(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.publicAgent(a)) writeJSON(w, http.StatusOK, s.publicAgent(a))
} }
func (s *Server) handleDetachPipeline(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentUnfollowEnv(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
pipelineID := r.PathValue("pipelineId") envName := r.PathValue("envName")
a, err := s.agents.Get(r.Context(), id) a, err := s.agents.Get(r.Context(), id)
if err != nil { if err != nil {
writeStorageErr(w, err, "agent not found") writeStorageErr(w, err, "agent not found")
return return
} }
out := a.AttachedPipelines[:0] out := a.Environments[:0]
for _, p := range a.AttachedPipelines { for _, e := range a.Environments {
if p != pipelineID { if !strings.EqualFold(e, envName) {
out = append(out, p) out = append(out, e)
} }
} }
a.AttachedPipelines = out a.Environments = out
a.UpdatedAt = time.Now().UTC() a.UpdatedAt = time.Now().UTC()
if err := s.agents.Update(r.Context(), a); err != nil { if err := s.agents.Update(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)
@@ -396,8 +400,8 @@ func (s *Server) handleDetachPipeline(w http.ResponseWriter, r *http.Request) {
// --- manual trigger ------------------------------------------------------ // --- manual trigger ------------------------------------------------------
// handleTriggerAgent dispatches a run on each attached pipeline. Trigger // handleTriggerAgent dispatches runs across the agent's followed
// data describes who/what triggered the run (manual / webhook / etc.). // environments. Trigger data describes who/what triggered the run.
func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
if s.orch == nil { if s.orch == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured")) writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
@@ -409,8 +413,8 @@ func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
writeStorageErr(w, err, "agent not found") writeStorageErr(w, err, "agent not found")
return return
} }
if len(a.AttachedPipelines) == 0 { if len(a.Environments) == 0 {
writeError(w, http.StatusBadRequest, errors.New("agent has no attached pipelines")) writeError(w, http.StatusBadRequest, errors.New("agent follows no environments"))
return return
} }
triggerData := []map[string]any{{ triggerData := []map[string]any{{
@@ -437,140 +441,149 @@ func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
}) })
} }
// dispatchAgent runs each attached pipeline asynchronously. Returns the // dispatchAgent fans out across the agent's followed environments. For
// execution IDs collected and a per-pipeline failure list so the caller // each env it iterates the env's pipelines, applies the per-pipeline
// can surface skip reasons to the user instead of silently returning []. // branch filter (for push triggers), stamps the env name + agent info
// into the trigger payload, and submits the run. Returns the execution
// IDs and a per-(env,pipeline) failure list so callers can surface skips.
func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger any) ([]string, []DispatchFailure, error) { func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger any) ([]string, []DispatchFailure, error) {
triggerJSON, _ := json.Marshal(trigger) // pushedBranch is set only for github_push triggers; it gates which
var triggerItems []models.Item // pipelines run (a pipeline's Trigger.fromBranch must match, or be a
if items, ok := trigger.([]map[string]any); ok { // wildcard). Manual triggers run every pipeline in every followed env.
for _, m := range items {
triggerItems = append(triggerItems, models.Item(m))
}
}
// Per-pipeline branch filter — only applies when the trigger came from a
// git push event. Manual triggers fan out to every attached pipeline so
// you can still kick a run from the UI without first hand-editing every
// Trigger node. The filter compares the pushed branch against the
// pipeline's Trigger node `fromBranch` — wildcard ("*", empty) on either
// side disables the filter for that pipeline.
pushedBranch := "" pushedBranch := ""
isPush := false isPush := false
source := ""
if items, ok := trigger.([]map[string]any); ok && len(items) > 0 { if items, ok := trigger.([]map[string]any); ok && len(items) > 0 {
if src, _ := items[0]["source"].(string); src == "github_push" { if src, _ := items[0]["source"].(string); src != "" {
isPush = true source = src
if b, _ := items[0]["branch"].(string); b != "" { if src == "github_push" {
pushedBranch = b isPush = true
} else if b, _ := items[0]["ref"].(string); b != "" { if b, _ := items[0]["branch"].(string); b != "" {
pushedBranch = b pushedBranch = b
} else if b, _ := items[0]["ref"].(string); b != "" {
pushedBranch = b
}
} }
} }
} }
out := make([]string, 0, len(a.AttachedPipelines)) var out []string
var failures []DispatchFailure var failures []DispatchFailure
for _, pid := range a.AttachedPipelines {
p, err := s.pipelines.Get(ctx, pid) for _, envName := range a.Environments {
env, err := s.environments.GetByName(ctx, envName)
if err != nil { if err != nil {
slog.WarnContext(ctx, "agent_dispatch_pipeline_missing", slog.WarnContext(ctx, "agent_dispatch_env_missing",
slog.String("agent_id", a.ID), slog.String("agent_id", a.ID), slog.String("env", envName), slog.Any("error", err))
slog.String("pipeline_id", pid),
slog.Any("error", err),
)
failures = append(failures, DispatchFailure{ failures = append(failures, DispatchFailure{
PipelineID: pid, Reason: "pipeline not found", Error: err.Error(), Environment: envName, Reason: "environment not found", Error: err.Error(),
}) })
continue continue
} }
wf, err := engine.ParseWorkflow(p.Definition) for _, pid := range env.PipelineIDs {
if err != nil { p, err := s.pipelines.Get(ctx, pid)
slog.WarnContext(ctx, "agent_dispatch_parse_failed", if err != nil {
slog.String("pipeline_id", pid),
slog.Any("error", err),
)
failures = append(failures, DispatchFailure{
PipelineID: pid, Reason: "parse failed", Error: err.Error(),
})
continue
}
if isPush {
triggerBranch := pipelineTriggerBranch(wf)
if triggerBranch != "" && triggerBranch != "*" && triggerBranch != pushedBranch {
slog.InfoContext(ctx, "agent_dispatch_branch_filtered",
slog.String("pipeline_id", pid),
slog.String("pushed", pushedBranch),
slog.String("trigger_branch", triggerBranch),
)
failures = append(failures, DispatchFailure{ failures = append(failures, DispatchFailure{
PipelineID: pid, Environment: envName, PipelineID: pid, Reason: "pipeline not found", Error: err.Error(),
Reason: "branch filtered",
Error: fmt.Sprintf("trigger fromBranch=%q ≠ pushed=%q", triggerBranch, pushedBranch),
}) })
continue continue
} }
} wf, err := engine.ParseWorkflow(p.Definition)
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second) if err != nil {
execID, err := s.orch.RunAsync(runCtx, &orchestrator.RunRequest{ failures = append(failures, DispatchFailure{
RequestMeta: orchestrator.RequestMeta{WorkflowID: pid}, Environment: envName, PipelineID: pid, Reason: "parse failed", Error: err.Error(),
Workflow: wf, })
TriggerData: triggerItems, continue
}) }
cancel() if isPush {
if err != nil { tb := pipelineTriggerBranch(wf)
slog.WarnContext(ctx, "agent_dispatch_failed", if tb != "" && tb != "*" && tb != pushedBranch {
slog.String("pipeline_id", pid), failures = append(failures, DispatchFailure{
slog.Any("error", err), Environment: envName, PipelineID: pid, Reason: "branch filtered",
) Error: fmt.Sprintf("trigger fromBranch=%q ≠ pushed=%q", tb, pushedBranch),
failures = append(failures, DispatchFailure{ })
PipelineID: pid, Reason: "orchestrator submit", Error: err.Error(), continue
}) }
continue
}
// Hook the log archiver onto the new execution so per-node lines
// land in MinIO when each node finishes.
startLogArchiver(context.Background(), s.logs, s.events, execID)
// Broadcast so /runs etc. light up without polling. We extract
// `source` from the trigger payload (manual / github_push).
source := ""
if items, ok := trigger.([]map[string]any); ok && len(items) > 0 {
if s, _ := items[0]["source"].(string); s != "" {
source = s
} }
}
s.runsBus.Publish(RunCreatedEvent{
Type: "run_created",
ExecutionID: execID,
PipelineID: pid,
PipelineName: p.Name,
AgentID: a.ID,
Source: source,
StartedAt: time.Now().UTC(),
})
if s.runs != nil { // Per-(env, pipeline) trigger payload: clone the base items and
_ = s.runs.Insert(ctx, &storage.Run{ // stamp the env name so downstream nodes (Deploy / Approval) can
ID: execID, // inherit env defaults.
perRunItems := stampTriggerEnv(trigger, envName)
triggerJSON, _ := json.Marshal(perRunItems)
triggerItems := make([]models.Item, 0, len(perRunItems))
for _, m := range perRunItems {
triggerItems = append(triggerItems, models.Item(m))
}
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
execID, err := s.orch.RunAsync(runCtx, &orchestrator.RunRequest{
RequestMeta: orchestrator.RequestMeta{WorkflowID: pid},
Workflow: wf,
TriggerData: triggerItems,
})
cancel()
if err != nil {
failures = append(failures, DispatchFailure{
Environment: envName, PipelineID: pid, Reason: "orchestrator submit", Error: err.Error(),
})
continue
}
startLogArchiver(context.Background(), s.logs, s.events, execID)
s.runsBus.Publish(RunCreatedEvent{
Type: "run_created",
ExecutionID: execID,
PipelineID: pid, PipelineID: pid,
PipelineName: p.Name, PipelineName: p.Name,
Status: "running", AgentID: a.ID,
Environment: envName,
Source: source,
StartedAt: time.Now().UTC(), StartedAt: time.Now().UTC(),
TriggerData: triggerJSON,
}) })
if s.runs != nil {
_ = s.runs.Insert(ctx, &storage.Run{
ID: execID,
PipelineID: pid,
PipelineName: p.Name,
Status: "running",
StartedAt: time.Now().UTC(),
TriggerData: triggerJSON,
})
}
out = append(out, execID)
} }
out = append(out, execID)
} }
return out, failures, nil return out, failures, nil
} }
// DispatchFailure describes why a single attached pipeline was skipped at // stampTriggerEnv returns a copy of the trigger items (each a map) with
// dispatch time. We surface these to the API caller so trigger failures // `environment` set to envName. The base items are not mutated so the
// same trigger payload can be reused across environments.
func stampTriggerEnv(trigger any, envName string) []map[string]any {
items, ok := trigger.([]map[string]any)
if !ok || len(items) == 0 {
return []map[string]any{{"environment": envName}}
}
out := make([]map[string]any, 0, len(items))
for _, m := range items {
cp := make(map[string]any, len(m)+1)
for k, v := range m {
cp[k] = v
}
cp["environment"] = envName
out = append(out, cp)
}
return out
}
// DispatchFailure describes why a single (environment, pipeline) pair was
// skipped at dispatch time. Surfaced to API callers so trigger failures
// don't appear as silent no-ops. // don't appear as silent no-ops.
type DispatchFailure struct { type DispatchFailure struct {
PipelineID string `json:"pipelineId"` Environment string `json:"environment,omitempty"`
Reason string `json:"reason"` PipelineID string `json:"pipelineId,omitempty"`
Error string `json:"error,omitempty"` Reason string `json:"reason"`
Error string `json:"error,omitempty"`
} }
// --- webhook receiver ---------------------------------------------------- // --- webhook receiver ----------------------------------------------------
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/lyzrai/flow/pkg/storage"
)
// Environments API — global, named deploy stages. An environment is a
// sequencing container: it owns an ordered list of pipelines (the
// promotion sequence) plus a description. Per-deploy config lives on the
// nodes, not here. Agents subscribe to environments (see agents.go);
// dispatch fans out across the followed envs' pipelines.
//
// Routes:
// GET /api/environments
// POST /api/environments
// GET /api/environments/{name}
// PUT /api/environments/{name} (name + description)
// DELETE /api/environments/{name}
// POST /api/environments/{name}/pipelines/{pipelineId} (bring a pipeline in — appends)
// DELETE /api/environments/{name}/pipelines/{pipelineId} (remove a pipeline)
// PUT /api/environments/{name}/pipelines (reorder; body {"pipelineIds":[...]})
// environmentBody is the create/update wire shape. PipelineIDs are
// managed via the /pipelines sub-routes.
type environmentBody struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
func (b *environmentBody) normalize() (storage.Environment, error) {
name := strings.TrimSpace(b.Name)
if name == "" {
return storage.Environment{}, errors.New("name is required")
}
now := time.Now().UTC()
return storage.Environment{
Name: name,
Description: strings.TrimSpace(b.Description),
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *Server) handleListEnvironments(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
list, err := s.environments.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []*storage.Environment{}
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleGetEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
e, err := s.environments.GetByName(r.Context(), r.PathValue("name"))
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
writeJSON(w, http.StatusOK, e)
}
func (s *Server) handleCreateEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
var body environmentBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
env, err := body.normalize()
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
env.ID = newID()
if err := s.environments.Create(r.Context(), &env); err != nil {
if errors.Is(err, storage.ErrAlreadyExists) {
writeError(w, http.StatusConflict, fmt.Errorf("environment %q already exists", env.Name))
return
}
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, &env)
}
func (s *Server) handleUpdateEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
existing, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
var body environmentBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if body.Name == "" {
body.Name = name
}
updated, err := body.normalize()
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Name is immutable through PUT; carry forward identity + pipeline set.
updated.Name = existing.Name
updated.ID = existing.ID
updated.PipelineIDs = existing.PipelineIDs
updated.CreatedAt = existing.CreatedAt
updated.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), &updated); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, &updated)
}
func (s *Server) handleDeleteEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
if err := s.environments.Delete(r.Context(), r.PathValue("name")); err != nil {
writeStorageErr(w, err, "environment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleEnvAddPipeline(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
pipelineID := r.PathValue("pipelineId")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
if _, err := s.pipelines.Get(r.Context(), pipelineID); err != nil {
writeStorageErr(w, err, "pipeline not found")
return
}
if env.HasPipeline(pipelineID) {
writeJSON(w, http.StatusOK, env) // already in this env
return
}
env.PipelineIDs = append(env.PipelineIDs, pipelineID)
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, env)
}
func (s *Server) handleEnvRemovePipeline(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
pipelineID := r.PathValue("pipelineId")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
out := env.PipelineIDs[:0]
for _, p := range env.PipelineIDs {
if p != pipelineID {
out = append(out, p)
}
}
env.PipelineIDs = out
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleEnvReorderPipelines replaces the env's pipeline order. The body
// must be a permutation of the env's current pipeline set — extra or
// missing IDs are rejected so a stale client can't silently drop a
// pipeline.
func (s *Server) handleEnvReorderPipelines(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
var body struct {
PipelineIDs []string `json:"pipelineIds"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
cur := map[string]bool{}
for _, p := range env.PipelineIDs {
cur[p] = true
}
if len(body.PipelineIDs) != len(env.PipelineIDs) {
writeError(w, http.StatusBadRequest, fmt.Errorf("expected a permutation of %d pipeline ids, got %d", len(env.PipelineIDs), len(body.PipelineIDs)))
return
}
seen := map[string]bool{}
for _, p := range body.PipelineIDs {
if !cur[p] {
writeError(w, http.StatusBadRequest, fmt.Errorf("pipeline %q is not in this environment", p))
return
}
if seen[p] {
writeError(w, http.StatusBadRequest, fmt.Errorf("pipeline %q listed twice", p))
return
}
seen[p] = true
}
env.PipelineIDs = body.PipelineIDs
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, env)
}
+1
View File
@@ -14,6 +14,7 @@ type RunCreatedEvent struct {
PipelineID string `json:"pipelineId,omitempty"` PipelineID string `json:"pipelineId,omitempty"`
PipelineName string `json:"pipelineName,omitempty"` PipelineName string `json:"pipelineName,omitempty"`
AgentID string `json:"agentId,omitempty"` AgentID string `json:"agentId,omitempty"`
Environment string `json:"environment,omitempty"`
Source string `json:"source,omitempty"` // "manual" | "github_push" Source string `json:"source,omitempty"` // "manual" | "github_push"
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
} }
+30 -17
View File
@@ -58,10 +58,11 @@ type ServerDeps struct {
// "https://abcd.trycloudflare.com"). Used to render webhook callback // "https://abcd.trycloudflare.com"). Used to render webhook callback
// URLs that GitHub can hit. Empty means webhook install is disabled. // URLs that GitHub can hit. Empty means webhook install is disabled.
PublicURL string PublicURL string
Pipelines storage.PipelineStore Pipelines storage.PipelineStore
Runs storage.RunStore Runs storage.RunStore
Agents storage.AgentStore Agents storage.AgentStore
Credentials storage.CredentialStore Credentials storage.CredentialStore
Environments storage.EnvironmentStore
// Events is the in-memory pub/sub bus the orchestrator publishes // Events is the in-memory pub/sub bus the orchestrator publishes
// per-node lifecycle events to. The SSE handler subscribes per // per-node lifecycle events to. The SSE handler subscribes per
// execution ID. Nil disables /api/executions/{id}/stream. // execution ID. Nil disables /api/executions/{id}/stream.
@@ -88,12 +89,13 @@ type Server struct {
corsOrigins []string corsOrigins []string
publicURL string publicURL string
pipelines storage.PipelineStore pipelines storage.PipelineStore
runs storage.RunStore runs storage.RunStore
agents storage.AgentStore agents storage.AgentStore
credentials storage.CredentialStore credentials storage.CredentialStore
events EventSubscriber environments storage.EnvironmentStore
logs logstore.Store events EventSubscriber
logs logstore.Store
// runsBus broadcasts run_created events to every UI tab subscribed to // runsBus broadcasts run_created events to every UI tab subscribed to
// /api/runs/stream. Used so a webhook-triggered run shows up live in // /api/runs/stream. Used so a webhook-triggered run shows up live in
@@ -111,11 +113,12 @@ func NewServer(deps ServerDeps) *Server {
restateIngres: deps.RestateIngressURL, restateIngres: deps.RestateIngressURL,
corsOrigins: deps.CORSOrigins, corsOrigins: deps.CORSOrigins,
publicURL: strings.TrimRight(deps.PublicURL, "/"), publicURL: strings.TrimRight(deps.PublicURL, "/"),
pipelines: deps.Pipelines, pipelines: deps.Pipelines,
runs: deps.Runs, runs: deps.Runs,
agents: deps.Agents, agents: deps.Agents,
credentials: deps.Credentials, credentials: deps.Credentials,
events: deps.Events, environments: deps.Environments,
events: deps.Events,
logs: deps.Logs, logs: deps.Logs,
runsBus: newRunsBus(), runsBus: newRunsBus(),
} }
@@ -166,8 +169,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/agents/{id}/test-auth", s.handleTestAgentAuth) s.mux.HandleFunc("POST /api/agents/{id}/test-auth", s.handleTestAgentAuth)
s.mux.HandleFunc("POST /api/agents/{id}/webhook", s.handleInstallWebhook) s.mux.HandleFunc("POST /api/agents/{id}/webhook", s.handleInstallWebhook)
s.mux.HandleFunc("DELETE /api/agents/{id}/webhook", s.handleUninstallWebhook) s.mux.HandleFunc("DELETE /api/agents/{id}/webhook", s.handleUninstallWebhook)
s.mux.HandleFunc("POST /api/agents/{id}/pipelines/{pipelineId}", s.handleAttachPipeline) s.mux.HandleFunc("POST /api/agents/{id}/environments/{envName}", s.handleAgentFollowEnv)
s.mux.HandleFunc("DELETE /api/agents/{id}/pipelines/{pipelineId}", s.handleDetachPipeline) s.mux.HandleFunc("DELETE /api/agents/{id}/environments/{envName}", s.handleAgentUnfollowEnv)
s.mux.HandleFunc("POST /api/agents/{id}/trigger", s.handleTriggerAgent) s.mux.HandleFunc("POST /api/agents/{id}/trigger", s.handleTriggerAgent)
// Credentials API — global org-wide pool. Lookup by name is shared // Credentials API — global org-wide pool. Lookup by name is shared
@@ -187,6 +190,16 @@ func (s *Server) routes() {
s.mux.HandleFunc("PUT /api/agents/{id}/credentials/{name}", s.handleUpdateCredential) s.mux.HandleFunc("PUT /api/agents/{id}/credentials/{name}", s.handleUpdateCredential)
s.mux.HandleFunc("DELETE /api/agents/{id}/credentials/{name}", s.handleDeleteCredential) s.mux.HandleFunc("DELETE /api/agents/{id}/credentials/{name}", s.handleDeleteCredential)
// Environments API — global deploy stages owning pipelines + config.
s.mux.HandleFunc("GET /api/environments", s.handleListEnvironments)
s.mux.HandleFunc("POST /api/environments", s.handleCreateEnvironment)
s.mux.HandleFunc("GET /api/environments/{name}", s.handleGetEnvironment)
s.mux.HandleFunc("PUT /api/environments/{name}", s.handleUpdateEnvironment)
s.mux.HandleFunc("DELETE /api/environments/{name}", s.handleDeleteEnvironment)
s.mux.HandleFunc("POST /api/environments/{name}/pipelines/{pipelineId}", s.handleEnvAddPipeline)
s.mux.HandleFunc("DELETE /api/environments/{name}/pipelines/{pipelineId}", s.handleEnvRemovePipeline)
s.mux.HandleFunc("PUT /api/environments/{name}/pipelines", s.handleEnvReorderPipelines)
// Public webhook receiver. GitHub posts here; HMAC signature is the // Public webhook receiver. GitHub posts here; HMAC signature is the
// authentication. Must NOT require CORS / API auth. // authentication. Must NOT require CORS / API auth.
s.mux.HandleFunc("POST /webhooks/github/{id}", s.handleGitHubWebhook) s.mux.HandleFunc("POST /webhooks/github/{id}", s.handleGitHubWebhook)
+72 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"github.com/google/uuid" "github.com/google/uuid"
restate "github.com/restatedev/sdk-go" restate "github.com/restatedev/sdk-go"
@@ -17,6 +18,13 @@ import (
// It pauses the workflow using a Restate Awakeable and blocks until an external // It pauses the workflow using a Restate Awakeable and blocks until an external
// caller resolves it via POST /api/executions/{id}/resume. // 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: // Two outputs:
// - Output 0: approved — items flow with human-supplied data merged in // - Output 0: approved — items flow with human-supplied data merged in
// - Output 1: rejected — items flow with rejection_reason field // - Output 1: rejected — items flow with rejection_reason field
@@ -39,6 +47,27 @@ func (e *ApprovalExecutor) Execute(
inputItems = []models.Item{{}} 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. // Approval requires Restate for durable blocking.
raw := durability.RestateCtxFromContext(ctx) raw := durability.RestateCtxFromContext(ctx)
rctx, ok := raw.(restate.WorkflowContext) rctx, ok := raw.(restate.WorkflowContext)
@@ -69,6 +98,13 @@ func (e *ApprovalExecutor) Execute(
if reason, ok := resolved["reason"].(string); ok && reason != "" { if reason, ok := resolved["reason"].(string); ok && reason != "" {
approvalCtx["reason"] = reason approvalCtx["reason"] = reason
} }
approvalCtx["method"] = method
if method == "quorum" {
approvalCtx["minApprovers"] = minApprovers
}
if timeoutSeconds > 0 {
approvalCtx["timeoutSeconds"] = timeoutSeconds
}
if len(inputItems) == 1 { if len(inputItems) == 1 {
approvalCtx["inputs"] = map[string]any(inputItems[0]) approvalCtx["inputs"] = map[string]any(inputItems[0])
} else if len(inputItems) > 1 { } else if len(inputItems) > 1 {
@@ -111,10 +147,29 @@ func (e *ApprovalExecutor) Execute(
slog.String("awakeable_id", awakeableID), slog.String("awakeable_id", awakeableID),
) )
// Block on the Awakeable. Durable across crashes / restarts. // Block on the Awakeable. Durable across crashes / restarts. If a
approvalData, err := awakeable.Result() // timeout is set, race it against a durable Restate timer — whichever
if err != nil { // fires first wins; on timeout we route to the rejected output.
return nil, fmt.Errorf("approval node %q: %w", node.Name, err) 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. // 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_id")
restate.Clear(rctx, "pending_approval_context") 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.InfoContext(ctx, "workflow_resumed",
slog.String("node", node.Name), 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) 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) trigger := firstItem(inputs)
agentID, _ := trigger["agentId"].(string) agentID, _ := trigger["agentId"].(string)
if agentID == "" { if agentID == "" {
@@ -63,18 +53,33 @@ func (e *DeployExecutor) Execute(ctx context.Context, node models.NodeDef, input
if err != nil { if err != nil {
return nil, fmt.Errorf("deploy: load agent %q: %w", agentID, err) 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) cred, credScope, err := e.lookupCredential(ctx, a, credName)
if err != nil { if err != nil {
return nil, fmt.Errorf("deploy: %w", err) return nil, fmt.Errorf("deploy: %w", err)
} }
logger.Log(fmt.Sprintf("[deploy] using %s credential %q", credScope, credName)) logger.Log(fmt.Sprintf("[deploy] target=%s credential=%s (%s)", target, credName, credScope))
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)
}
image := resolveDeployImage(node.Parameters, inputs) image := resolveDeployImage(node.Parameters, inputs)
if image == "" { if image == "" {
@@ -143,6 +148,7 @@ func (e *DeployExecutor) Execute(ctx context.Context, node models.NodeDef, input
summary := map[string]any{ summary := map[string]any{
"target": "agentcore", "target": "agentcore",
"environment": envName,
"agentId": agentID, "agentId": agentID,
"agentName": a.Name, "agentName": a.Name,
"credentialName": cred.Name, "credentialName": cred.Name,
+72
View File
@@ -72,6 +72,12 @@ func (m *Mongo) Credentials() CredentialStore {
return &mongoCredentials{coll: m.db.Collection("credentials")} return &mongoCredentials{coll: m.db.Collection("credentials")}
} }
// Environments returns the global EnvironmentStore backed by this Mongo
// connection.
func (m *Mongo) Environments() EnvironmentStore {
return &mongoEnvironments{coll: m.db.Collection("environments")}
}
func (m *Mongo) ensureIndexes(ctx context.Context) error { func (m *Mongo) ensureIndexes(ctx context.Context) error {
if _, err := m.db.Collection("pipelines").Indexes().CreateMany(ctx, []mongo.IndexModel{ if _, err := m.db.Collection("pipelines").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "updated_at", Value: -1}}}, {Keys: bson.D{{Key: "updated_at", Value: -1}}},
@@ -95,6 +101,12 @@ func (m *Mongo) ensureIndexes(ctx context.Context) error {
}); err != nil { }); err != nil {
return fmt.Errorf("credentials indexes: %w", err) return fmt.Errorf("credentials indexes: %w", err)
} }
if _, err := m.db.Collection("environments").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "updated_at", Value: -1}}},
}); err != nil {
return fmt.Errorf("environments indexes: %w", err)
}
return nil return nil
} }
@@ -522,3 +534,63 @@ func (s *mongoCredentials) List(ctx context.Context) ([]*Credential, error) {
} }
return out, nil return out, nil
} }
// --- environments (global) -----------------------------------------------
type mongoEnvironments struct{ coll *mongo.Collection }
func (s *mongoEnvironments) Create(ctx context.Context, e *Environment) error {
if _, err := s.coll.InsertOne(ctx, e); err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrAlreadyExists
}
return err
}
return nil
}
func (s *mongoEnvironments) GetByName(ctx context.Context, name string) (*Environment, error) {
var e Environment
if err := s.coll.FindOne(ctx, bson.M{"name": name}).Decode(&e); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
return nil, err
}
return &e, nil
}
func (s *mongoEnvironments) Update(ctx context.Context, e *Environment) error {
res, err := s.coll.ReplaceOne(ctx, bson.M{"name": e.Name}, e)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrNotFound
}
return nil
}
func (s *mongoEnvironments) Delete(ctx context.Context, name string) error {
res, err := s.coll.DeleteOne(ctx, bson.M{"name": name})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
func (s *mongoEnvironments) List(ctx context.Context) ([]*Environment, error) {
cur, err := s.coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []*Environment
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
+54 -1
View File
@@ -132,9 +132,14 @@ type Agent struct {
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty" bson:"webhook_installed_at,omitempty"` WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty" bson:"webhook_installed_at,omitempty"`
AuthStatus AuthStatus `json:"authStatus,omitempty" bson:"auth_status,omitempty"` AuthStatus AuthStatus `json:"authStatus,omitempty" bson:"auth_status,omitempty"`
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty" bson:"auth_checked_at,omitempty"` AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty" bson:"auth_checked_at,omitempty"`
AttachedPipelines []string `json:"attachedPipelines,omitempty" bson:"attached_pipelines,omitempty"` // Environments this agent follows by name. Triggering the agent runs
// the pipelines of these environments (filtered by branch). Replaces
// the older flat AttachedPipelines list — pipelines now live on the
// environment, and agents subscribe to environments.
Environments []string `json:"environments,omitempty" bson:"environments,omitempty"`
// Named credentials — referenced by name from Deploy / future nodes. // Named credentials — referenced by name from Deploy / future nodes.
// These are agent-specific overrides of the global credential pool.
Credentials []Credential `json:"credentials,omitempty" bson:"credentials,omitempty"` Credentials []Credential `json:"credentials,omitempty" bson:"credentials,omitempty"`
CreatedAt time.Time `json:"createdAt" bson:"created_at"` CreatedAt time.Time `json:"createdAt" bson:"created_at"`
@@ -177,3 +182,51 @@ type CredentialStore interface {
Delete(ctx context.Context, name string) error Delete(ctx context.Context, name string) error
List(ctx context.Context) ([]*Credential, error) List(ctx context.Context) ([]*Credential, error)
} }
// Environment is a global, named deploy stage (dev / staging / prod /
// custom — the name is free-form). It is purely a sequencing container:
// it owns an ordered list of pipeline IDs (the promotion sequence) plus
// a description. Per-deploy config (credential, runtime target, approval
// method) lives on the nodes themselves, not here.
//
// Agents subscribe to environments by name (agent.Environments);
// triggering an agent runs the pipelines of the environments it follows
// (each pipeline still gated by its Trigger node's branch filter).
//
// A pipeline may appear in more than one environment.
type Environment struct {
ID string `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"` // unique
Description string `json:"description,omitempty" bson:"description,omitempty"`
// PipelineIDs is ordered — the order is the promotion sequence and is
// reorderable via the API. Dispatch still applies each pipeline's own
// branch filter; the order is the documented progression.
PipelineIDs []string `json:"pipelineIds,omitempty" bson:"pipeline_ids,omitempty"`
CreatedAt time.Time `json:"createdAt" bson:"created_at"`
UpdatedAt time.Time `json:"updatedAt" bson:"updated_at"`
}
// HasPipeline reports whether the pipeline ID is brought into this env.
func (e *Environment) HasPipeline(pipelineID string) bool {
if e == nil {
return false
}
for _, p := range e.PipelineIDs {
if p == pipelineID {
return true
}
}
return false
}
// EnvironmentStore persists global environments. Lookup is by name (the
// user-facing identifier — pipelines/agents reference envs by name).
type EnvironmentStore interface {
Create(ctx context.Context, e *Environment) error
GetByName(ctx context.Context, name string) (*Environment, error)
Update(ctx context.Context, e *Environment) error
Delete(ctx context.Context, name string) error
List(ctx context.Context) ([]*Environment, error)
}
+81 -59
View File
@@ -34,6 +34,7 @@ import {
api, api,
type Agent, type Agent,
type AuthStatus, type AuthStatus,
type Environment,
type FlowSummary, type FlowSummary,
type PublicCredential, type PublicCredential,
type Run, type Run,
@@ -56,27 +57,35 @@ function AgentDetail() {
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
const [config, setConfig] = useState<ServerConfig | null>(null); const [config, setConfig] = useState<ServerConfig | null>(null);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [pipelines, setPipelines] = useState<FlowSummary[]>([]); const [pipelines, setPipelines] = useState<FlowSummary[]>([]);
const [runs, setRuns] = useState<Run[]>([]); const [runs, setRuns] = useState<Run[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // which action is in flight const [busy, setBusy] = useState<string | null>(null); // which action is in flight
const [showPipelinePicker, setShowPipelinePicker] = useState(false); const [showEnvPicker, setShowEnvPicker] = useState(false);
async function load() { async function load() {
if (!id) return; if (!id) return;
try { try {
const [a, cfg, allPipes] = await Promise.all([ const [a, cfg, allEnvs, allPipes] = await Promise.all([
api.getAgent(id), api.getAgent(id),
api.getConfig().catch(() => null), api.getConfig().catch(() => null),
api.listEnvironments().catch(() => []),
api.listFlows().catch(() => []), api.listFlows().catch(() => []),
]); ]);
setAgent(a); setAgent(a);
setConfig(cfg); setConfig(cfg);
setEnvironments(allEnvs);
setPipelines(allPipes); setPipelines(allPipes);
// Pull recent runs across all attached pipelines. // Recent runs across the pipelines of every followed env.
if (a.attachedPipelines?.length) { const followed = (a.environments ?? [])
.map((n) => allEnvs.find((e) => e.name === n))
.filter((e): e is Environment => Boolean(e));
const pipelineIds = new Set<string>();
followed.forEach((e) => (e.pipelineIds ?? []).forEach((pid) => pipelineIds.add(pid)));
if (pipelineIds.size) {
const lists = await Promise.all( const lists = await Promise.all(
a.attachedPipelines.map((pid) => [...pipelineIds].map((pid) =>
api.listRuns({ pipelineId: pid, limit: 5 }).catch(() => []) api.listRuns({ pipelineId: pid, limit: 5 }).catch(() => [])
) )
); );
@@ -134,7 +143,10 @@ function AgentDetail() {
} }
throw new Error( throw new Error(
fails fails
.map((f) => `${f.pipelineId}: ${f.reason}${f.error ? " — " + f.error : ""}`) .map((f) => {
const where = [f.environment, f.pipelineId].filter(Boolean).join("/");
return `${where || "?"}: ${f.reason}${f.error ? " — " + f.error : ""}`;
})
.join("; ") .join("; ")
); );
}); });
@@ -162,18 +174,18 @@ function AgentDetail() {
}); });
} }
async function onAttachPipeline(pipelineId: string) { async function onFollowEnv(envName: string) {
await withBusy("attach", async () => { await withBusy("follow-env", async () => {
await api.attachPipeline(id, pipelineId); await api.agentFollowEnv(id, envName);
setShowPipelinePicker(false); setShowEnvPicker(false);
await load(); await load();
}); });
} }
async function onDetachPipeline(pipelineId: string) { async function onUnfollowEnv(envName: string) {
if (!confirm("Detach this pipeline from the agent?")) return; if (!confirm(`Stop following environment "${envName}"? This agent will no longer dispatch its pipelines.`)) return;
await withBusy("detach", async () => { await withBusy("unfollow-env", async () => {
await api.detachPipeline(id, pipelineId); await api.agentUnfollowEnv(id, envName);
await load(); await load();
}); });
} }
@@ -207,12 +219,14 @@ function AgentDetail() {
} }
const lastRun = runs[0]; const lastRun = runs[0];
const attachedPipelineDetails = (agent.attachedPipelines ?? []) const followedEnvs = (agent.environments ?? [])
.map((pid) => pipelines.find((p) => p.id === pid)) .map((n) => environments.find((e) => e.name === n))
.filter((p): p is FlowSummary => Boolean(p)); .filter((e): e is Environment => Boolean(e));
const attachable = pipelines.filter( const followableEnvs = environments.filter(
(p) => !agent.attachedPipelines?.includes(p.id) (e) => !agent.environments?.includes(e.name)
); );
const pipelineName = (pid: string) =>
pipelines.find((p) => p.id === pid)?.name ?? pid;
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
@@ -263,16 +277,16 @@ function AgentDetail() {
disabled={ disabled={
busy === "trigger" || busy === "trigger" ||
!config?.orchestratorEnabled || !config?.orchestratorEnabled ||
!agent.attachedPipelines?.length !agent.environments?.length
} }
title={ title={
!config?.orchestratorEnabled !config?.orchestratorEnabled
? "Orchestrator not configured (Restate unreachable)" ? "Orchestrator not configured (Restate unreachable)"
: !agent.attachedPipelines?.length : !agent.environments?.length
? "Attach a pipeline first" ? "Follow an environment first"
: busy === "trigger" : busy === "trigger"
? "Dispatching…" ? "Dispatching…"
: "Trigger a run on every attached pipeline" : "Trigger a run across the followed environments' pipelines"
} }
> >
<Play /> <Play />
@@ -307,28 +321,28 @@ function AgentDetail() {
<span className="text-sm text-muted-foreground">No runs yet.</span> <span className="text-sm text-muted-foreground">No runs yet.</span>
)} )}
</Field> </Field>
<Field label="Pipelines"> <Field label="Environments">
{attachedPipelineDetails.length === 0 ? ( {followedEnvs.length === 0 ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
None attached.{" "} None followed.{" "}
<Link href="/flows/new" className="underline hover:text-foreground"> <Link href="/environments" className="underline hover:text-foreground">
Create one Manage environments
</Link> </Link>
. .
</span> </span>
) : ( ) : (
<span className="text-sm"> <span className="text-sm">
{attachedPipelineDetails.length} attached {followedEnvs.map((e) => e.name).join(", ")}
</span> </span>
)} )}
</Field> </Field>
</div> </div>
{agent.attachedPipelines?.length ? ( {agent.environments?.length ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pushes to{" "} Pushes to{" "}
<code className="font-mono text-xs">{agent.name}</code> route through <code className="font-mono text-xs">{agent.name}</code> route through
this agent&rsquo;s pipelines (matched by branch). the pipelines of the followed environments (matched by branch).
</p> </p>
) : null} ) : null}
@@ -430,58 +444,66 @@ function AgentDetail() {
</CardContent> </CardContent>
</Card> </Card>
{/* Pipelines ----------------------------------------------------- */} {/* Environments -------------------------------------------------- */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Pipelines</CardTitle> <CardTitle>Environments followed</CardTitle>
{attachable.length > 0 ? ( {followableEnvs.length > 0 ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setShowPipelinePicker((v) => !v)} onClick={() => setShowEnvPicker((v) => !v)}
> >
<Plus /> <Plus />
Add pipeline Follow env
</Button>
) : environments.length === 0 ? (
<Button size="sm" variant="ghost" asChild>
<Link href="/environments">Create one</Link>
</Button> </Button>
) : ( ) : (
<Button size="sm" variant="ghost" disabled> <Button size="sm" variant="ghost" disabled>
No more to add Following all
</Button> </Button>
)} )}
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{attachedPipelineDetails.length === 0 ? ( {followedEnvs.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No pipelines attached. Click &ldquo;Add pipeline&rdquo; to bind one Not following any environment. Follow one to dispatch its
(or create one in{" "} pipelines for this agent. Manage envs on the{" "}
<Link href="/flows/new" className="underline"> <Link href="/environments" className="underline">
/flows/new Environments page
</Link> </Link>
). .
</p> </p>
) : ( ) : (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{attachedPipelineDetails.map((p) => ( {followedEnvs.map((e) => (
<li <li
key={p.id} key={e.id}
className="flex items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2" className="flex items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2"
> >
<div className="min-w-0"> <div className="min-w-0">
<Link <Link
href={`/flows/view/?id=${encodeURIComponent(p.id)}`} href="/environments"
className="truncate text-sm font-medium hover:underline" className="truncate text-sm font-medium font-mono hover:underline"
> >
{p.name || "Untitled"} {e.name}
</Link> </Link>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{p.nodeCount} nodes · {formatDate(p.updatedAt)} {(e.pipelineIds ?? []).length === 0
? "no pipelines"
: (e.pipelineIds ?? [])
.map(pipelineName)
.join(" → ")}
</div> </div>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Detach pipeline" aria-label="Unfollow environment"
onClick={() => onDetachPipeline(p.id)} onClick={() => onUnfollowEnv(e.name)}
> >
<XCircle className="size-4" /> <XCircle className="size-4" />
</Button> </Button>
@@ -490,23 +512,23 @@ function AgentDetail() {
</ul> </ul>
)} )}
{showPipelinePicker && attachable.length > 0 && ( {showEnvPicker && followableEnvs.length > 0 && (
<div className="rounded-md border bg-background p-2"> <div className="rounded-md border bg-background p-2">
<div className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Attach a pipeline Follow an environment
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{attachable.map((p) => ( {followableEnvs.map((e) => (
<li key={p.id}> <li key={e.id}>
<button <button
type="button" type="button"
onClick={() => onAttachPipeline(p.id)} onClick={() => onFollowEnv(e.name)}
disabled={busy === "attach"} disabled={busy === "follow-env"}
className="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent" className="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
> >
<span className="truncate">{p.name || "Untitled"}</span> <span className="truncate font-mono">{e.name}</span>
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{p.nodeCount} nodes {(e.pipelineIds ?? []).length} pipelines
</span> </span>
</button> </button>
</li> </li>
+1 -1
View File
@@ -48,7 +48,7 @@ export default function CredentialsPage() {
} }
return ( return (
<div className="mx-auto w-full max-w-4xl space-y-6 p-6"> <div className="w-full space-y-6 p-6">
<div> <div>
<h1 className="text-3xl font-semibold tracking-tight">Credentials</h1> <h1 className="text-3xl font-semibold tracking-tight">Credentials</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EnvironmentForm } from "@/components/environments/environment-form";
import { api, type Environment } from "@/lib/api";
export default function EditEnvironmentPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading</div>}>
<EditEnvironment />
</Suspense>
);
}
function EditEnvironment() {
const router = useRouter();
const params = useSearchParams();
const name = params.get("name") ?? "";
const [env, setEnv] = useState<Environment | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!name) return;
api
.getEnvironment(name)
.then(setEnv)
.catch((err) => setError(err instanceof Error ? err.message : "load failed"));
}, [name]);
if (!name) {
return (
<div className="p-6 text-sm text-muted-foreground">
Missing <code>name</code> query param.
</div>
);
}
return (
<div className="mx-auto w-full max-w-3xl space-y-6 p-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/environments">
<ArrowLeft />
Back to environments
</Link>
</Button>
<div>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<Layers className="size-3.5" />
Edit environment
</div>
<h1 className="text-3xl font-semibold tracking-tight font-mono">{name}</h1>
<p className="mt-1 text-sm text-muted-foreground">
Update the description. Pipelines and their order are managed on the
environments list. Name is immutable.
</p>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>Name + description.</CardDescription>
</CardHeader>
<CardContent>
{env === null ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : (
<EnvironmentForm
initial={env}
onCancel={() => router.push("/environments")}
onSubmit={async (body) => {
await api.updateEnvironment(name, body);
router.push("/environments");
}}
onError={setError}
/>
)}
</CardContent>
</Card>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EnvironmentForm } from "@/components/environments/environment-form";
import { api } from "@/lib/api";
export default function NewEnvironmentPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
return (
<div className="mx-auto w-full max-w-3xl space-y-6 p-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/environments">
<ArrowLeft />
Back to environments
</Link>
</Button>
<div>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<Layers className="size-3.5" />
New environment
</div>
<h1 className="text-3xl font-semibold tracking-tight">New environment</h1>
<p className="mt-1 text-sm text-muted-foreground">
A global deploy stage. Add pipelines to it from the environments
list; agents follow this environment and run its pipelines.
</p>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>
Name is how this env is referenced. Description is free-text.
</CardDescription>
</CardHeader>
<CardContent>
<EnvironmentForm
initial={null}
onCancel={() => router.push("/environments")}
onSubmit={async (body) => {
await api.createEnvironment(body);
router.push("/environments");
}}
onError={setError}
/>
</CardContent>
</Card>
</div>
);
}
+246 -100
View File
@@ -1,122 +1,268 @@
"use client"; "use client";
import { Layers, Lock, ScanFace, ShieldCheck } from "lucide-react"; import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowDown, ArrowUp, Layers, Plus } from "lucide-react";
// Static preview of the Environments concept. Not wired to storage yet — import { Button } from "@/components/ui/button";
// this page is the contract we show clients before the runtime work import {
// lands. When the storage / routing actually exists, replace the three Card,
// demo tiles with live env records from the API. CardContent,
CardDescription,
const ENVS: { name: string; description: string; accent: string }[] = [ CardHeader,
{ CardTitle,
name: "DEV", } from "@/components/ui/card";
description: import { api, type Environment, type FlowSummary } from "@/lib/api";
"Auto-deploy on every push, smoke evals only, no approval gates.",
accent: "border-foreground/60",
},
{
name: "STAGING",
description:
"Full eval suite, optional approval, canary or progressive rollout.",
accent: "border-amber-400/60",
},
{
name: "PROD",
description:
"Strict policy gates, human approval, audit log, SLO-backed rollback.",
accent: "border-rose-400/60",
},
];
export default function EnvironmentsPage() { export default function EnvironmentsPage() {
const [envs, setEnvs] = useState<Environment[] | null>(null);
const [pipelines, setPipelines] = useState<FlowSummary[]>([]);
const [error, setError] = useState<string | null>(null);
async function refresh() {
setError(null);
try {
const [e, p] = await Promise.all([api.listEnvironments(), api.listFlows()]);
setEnvs(e);
setPipelines(p);
} catch (err) {
setError(err instanceof Error ? err.message : "load failed");
}
}
useEffect(() => {
refresh();
}, []);
async function handleDelete(name: string) {
if (!confirm(`Delete environment "${name}"? Agents following it will stop dispatching its pipelines.`)) return;
try {
await api.deleteEnvironment(name);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
}
}
const pipelineName = (id: string) =>
pipelines.find((p) => p.id === id)?.name ?? id;
return ( return (
<div className="mx-auto w-full max-w-6xl space-y-6 p-6"> <div className="w-full space-y-6 p-6">
<div> <div className="flex items-start justify-between gap-3">
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"> <div>
<Layers className="size-3.5" /> <div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Environments <Layers className="size-3.5" />
Environments
</div>
<h1 className="text-3xl font-semibold tracking-tight">Environments</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
A deploy stage is a named, ordered list of pipelines the
promotion sequence. Agents <em>follow</em> environments;
triggering an agent runs its followed envs&rsquo; pipelines (each
still gated by its Trigger node&rsquo;s branch).
</p>
</div> </div>
<h1 className="text-3xl font-semibold tracking-tight">Environments</h1> <Button size="sm" asChild>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground"> <Link href="/environments/new">
Each environment owns its runtime target, credentials, secrets, <Plus />
scaling, and approval policy. Pipelines reference envs by name; New environment
promotion moves an artifact from one env&rsquo;s pipeline to the </Link>
next. </Button>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p> </p>
</div> )}
<div className="rounded-lg border bg-card/40 p-5"> {envs === null && <p className="text-sm text-muted-foreground">Loading</p>}
{/* Three env tiles */} {envs?.length === 0 && (
<div className="grid gap-4 md:grid-cols-3"> <p className="text-sm text-muted-foreground">
{ENVS.map((e) => ( No environments yet. Create <code className="font-mono">dev</code>,{" "}
<div <code className="font-mono">staging</code>, and{" "}
key={e.name} <code className="font-mono">prod</code> to get started.
className={`rounded-lg border-2 ${e.accent} bg-background/40 p-5`} </p>
> )}
<div className="mb-2 flex items-start justify-between">
<span className="font-mono text-sm font-semibold tracking-wider"> <div className="space-y-4">
{e.name} {envs?.map((env) => (
</span> <Card key={env.id}>
<span className="text-[11px] text-muted-foreground">tier</span> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="font-mono">{env.name}</CardTitle>
{env.description && <CardDescription>{env.description}</CardDescription>}
</div> </div>
<p className="text-xs text-muted-foreground">{e.description}</p> <div className="flex shrink-0 gap-2">
</div> <Button size="sm" variant="ghost" asChild>
))} <Link href={`/environments/edit/?name=${encodeURIComponent(env.name)}`}>
</div> Edit
</Link>
{/* Concept rows */} </Button>
<div className="mt-6 grid gap-5 border-t pt-5 md:grid-cols-3"> <Button size="sm" variant="ghost" onClick={() => handleDelete(env.name)}>
<ConceptRow Delete
icon={ScanFace} </Button>
title="Runtime target" </div>
body="K8s cluster, Bedrock AgentCore account, or Vertex Agent Engine project. Different per env." </CardHeader>
/> <CardContent>
<ConceptRow <PipelinesInEnv
icon={Lock} env={env}
title="Credentials" allPipelines={pipelines}
body="Cloud creds + registry auth, sealed at rest. Resolved by pipelines at run time." pipelineName={pipelineName}
/> onChanged={refresh}
<ConceptRow onError={setError}
icon={ShieldCheck} />
title="Approval policy" </CardContent>
body="Who can approve, by what method (UI / Slack / auto-policy / quorum), with timeout & escalation." </Card>
/> ))}
</div>
{/* Footer */}
<div className="mt-6 flex items-center justify-end border-t pt-5">
<button
type="button"
disabled
className="rounded-md border bg-foreground/95 px-3 py-1.5 text-xs font-medium text-background opacity-90 disabled:cursor-not-allowed"
title="Coming soon"
>
+ New environment
</button>
</div>
</div> </div>
</div> </div>
); );
} }
function ConceptRow({ // ─── pipelines-in-env sub-component ─────────────────────────────────────────
icon: Icon,
title, function PipelinesInEnv({
body, env,
allPipelines,
pipelineName,
onChanged,
onError,
}: { }: {
icon: React.ComponentType<{ className?: string }>; env: Environment;
title: string; allPipelines: FlowSummary[];
body: string; pipelineName: (id: string) => string;
onChanged: () => void | Promise<void>;
onError: (m: string | null) => void;
}) { }) {
const [picking, setPicking] = useState(false);
const ids = env.pipelineIds ?? [];
const inEnv = new Set(ids);
const available = allPipelines.filter((p) => !inEnv.has(p.id));
async function reorder(next: string[]) {
try {
await api.reorderEnvPipelines(env.name, next);
await onChanged();
} catch (e) {
onError(e instanceof Error ? e.message : "reorder failed");
}
}
function move(i: number, dir: -1 | 1) {
const j = i + dir;
if (j < 0 || j >= ids.length) return;
const next = ids.slice();
[next[i], next[j]] = [next[j], next[i]];
reorder(next);
}
return ( return (
<div className="flex items-start gap-3"> <div className="space-y-2">
<div className="grid size-9 shrink-0 place-items-center rounded-md border bg-muted/30"> <div className="flex items-center justify-between">
<Icon className="size-4 text-muted-foreground" /> <div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
</div> Pipelines in this environment (promotion order)
<div className="min-w-0"> </div>
<div className="text-sm font-medium">{title}</div> <Button size="sm" variant="ghost" onClick={() => setPicking((v) => !v)}>
<p className="mt-0.5 text-xs text-muted-foreground">{body}</p> <Plus className="size-3.5" />
Add pipeline
</Button>
</div> </div>
{ids.length === 0 && (
<p className="text-sm text-muted-foreground">
No pipelines yet. Build one on the{" "}
<Link href="/" className="underline">Pipelines page</Link> and add it
here.
</p>
)}
{ids.length > 0 && (
<ul className="divide-y rounded-md border">
{ids.map((pid, i) => (
<li key={pid} className="flex items-center gap-2 px-3 py-2">
<span className="w-5 shrink-0 text-center text-xs text-muted-foreground">
{i + 1}
</span>
<Link
href={`/flows/edit/?id=${encodeURIComponent(pid)}`}
className="flex-1 truncate font-mono text-xs hover:underline"
>
{pipelineName(pid)}
</Link>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-7"
disabled={i === 0}
onClick={() => move(i, -1)}
aria-label="Move up"
>
<ArrowUp className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7"
disabled={i === ids.length - 1}
onClick={() => move(i, 1)}
aria-label="Move down"
>
<ArrowDown className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={async () => {
try {
await api.envRemovePipeline(env.name, pid);
await onChanged();
} catch (e) {
onError(e instanceof Error ? e.message : "remove failed");
}
}}
>
Remove
</Button>
</div>
</li>
))}
</ul>
)}
{picking && (
<div className="rounded-md border bg-muted/20 p-2">
{available.length === 0 ? (
<p className="px-1 py-1 text-xs text-muted-foreground">
All pipelines are already in this environment.
</p>
) : (
<ul className="divide-y">
{available.map((p) => (
<li key={p.id} className="flex items-center justify-between px-1 py-1.5">
<span className="font-mono text-xs">{p.name}</span>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await api.envAddPipeline(env.name, p.id);
setPicking(false);
await onChanged();
} catch (e) {
onError(e instanceof Error ? e.message : "add failed");
}
}}
>
Add
</Button>
</li>
))}
</ul>
)}
</div>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import { Activity, ArrowLeftRight, Coins, Gauge, Radio } from "lucide-react";
export default function GatewayPage() { export default function GatewayPage() {
return ( return (
<div className="mx-auto w-full max-w-6xl space-y-6 p-6"> <div className="w-full space-y-6 p-6">
<div> <div>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"> <div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<Radio className="size-3.5" /> <Radio className="size-3.5" />
+62 -7
View File
@@ -486,6 +486,9 @@ function ApprovalForm({ node, onChange }: NodeFormProps) {
const reason = getString(node, "reason", "Manual review"); const reason = getString(node, "reason", "Manual review");
const reviewers = getStringArray(node, "reviewers"); const reviewers = getStringArray(node, "reviewers");
const text = reviewers.join("\n"); const text = reviewers.join("\n");
const method = getString(node, "method", "");
const minApprovers = getNumber(node, "minApprovers", 0);
const timeout = getNumber(node, "timeoutSeconds", 0);
function commit(t: string) { function commit(t: string) {
const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean); const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -518,6 +521,58 @@ function ApprovalForm({ node, onChange }: NodeFormProps) {
placeholder="user@example.com" placeholder="user@example.com"
/> />
</div> </div>
<div className="space-y-1.5">
<Label>Method</Label>
<select
value={method}
onChange={(e) => onChange(setParam(node, "method", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="">UI (human) default</option>
<option value="ui">UI (human)</option>
<option value="quorum">Quorum (N approvers)</option>
<option value="auto">Auto (don&rsquo;t pause)</option>
</select>
<p className="text-[11px] text-muted-foreground">
<strong>auto</strong> emits straight to the approved output without
pausing. Quorum N&gt;1 enforcement is surfaced to reviewers but not
yet hard-enforced.
</p>
</div>
{method === "quorum" && (
<div className="space-y-1.5">
<Label htmlFor="a-minappr">Min approvers (override)</Label>
<Input
id="a-minappr"
type="number"
value={minApprovers}
onChange={(e) =>
onChange(setParam(node, "minApprovers", Number(e.target.value)))
}
placeholder="(inherit)"
className="font-mono text-xs"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="a-timeout">Timeout seconds (override, 0=inherit)</Label>
<Input
id="a-timeout"
type="number"
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
When set, the run is auto-rejected after this long (routed to the
rejected output).
</p>
</div>
</div> </div>
); );
} }
@@ -1127,8 +1182,8 @@ function TargetRow({
} }
function DeployForm({ node, onChange }: NodeFormProps) { function DeployForm({ node, onChange }: NodeFormProps) {
const target = getString(node, "target", "agentcore"); const target = getString(node, "target", "");
const credentialName = getString(node, "credentialName", "aws"); const credentialName = getString(node, "credentialName", "");
const runtimeName = getString(node, "runtimeName", ""); const runtimeName = getString(node, "runtimeName", "");
const image = getString(node, "image", ""); const image = getString(node, "image", "");
const timeout = getNumber(node, "timeoutSeconds", 600); const timeout = getNumber(node, "timeoutSeconds", 600);
@@ -1156,6 +1211,7 @@ function DeployForm({ node, onChange }: NodeFormProps) {
onChange={(e) => onChange(setParam(node, "target", e.target.value))} onChange={(e) => onChange(setParam(node, "target", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm" className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
> >
<option value="">AWS Bedrock AgentCore default</option>
<option value="agentcore">AWS Bedrock AgentCore</option> <option value="agentcore">AWS Bedrock AgentCore</option>
<option value="kubernetes" disabled> <option value="kubernetes" disabled>
Kubernetes (coming soon) Kubernetes (coming soon)
@@ -1165,9 +1221,8 @@ function DeployForm({ node, onChange }: NodeFormProps) {
</option> </option>
</select> </select>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
AgentCore deploys the upstream Push image. AWS region + account + AgentCore deploys the upstream Push image; the deploy summary
cross-account role come from the named credential below. The deploy includes the public invoke URL.
summary will include the public invoke URL.
</p> </p>
</div> </div>
@@ -1183,8 +1238,8 @@ function DeployForm({ node, onChange }: NodeFormProps) {
className="font-mono text-xs" className="font-mono text-xs"
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Must match a credential of type <code>aws</code> on the Agent Defaults to <code>aws</code>. Must match a credential of type{" "}
(Credentials section on the agent page). <code>aws</code> in the global pool or an agent override.
</p> </p>
</div> </div>
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Environment, EnvironmentBody } from "@/lib/api";
// Shared environment create/edit form — name + description only. Per-deploy
// config (credential / runtime target / approval method) lives on the nodes,
// not the environment. Pipelines are managed on the environments list page.
//
// Used by /environments/new and /environments/edit as the full-page body.
export type EnvironmentFormProps = {
initial: Environment | null;
onSubmit: (body: EnvironmentBody) => Promise<void>;
onCancel: () => void;
onError: (m: string | null) => void;
};
export function EnvironmentForm({ initial, onSubmit, onCancel, onError }: EnvironmentFormProps) {
const isEdit = !!initial;
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [saving, setSaving] = useState(false);
async function handleSave() {
onError(null);
setSaving(true);
try {
const trimmed = name.trim();
if (!trimmed) throw new Error("name is required");
await onSubmit({ name: trimmed, description: description.trim() || undefined });
} catch (e) {
onError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="env-name">Name</Label>
<Input
id="env-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="dev"
disabled={isEdit}
className="font-mono"
/>
{isEdit && (
<p className="text-[11px] text-muted-foreground">
Name is immutable. Delete + re-create to rename.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="env-desc">Description</Label>
<Input
id="env-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Auto-deploy on push, smoke evals only"
/>
<p className="text-[11px] text-muted-foreground">
Pipelines and their order are managed on the environments list.
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : isEdit ? "Save changes" : "Create environment"}
</Button>
</div>
</div>
);
}
+74 -6
View File
@@ -54,6 +54,24 @@ export type CredentialBody = {
kv?: Record<string, string>; kv?: Record<string, string>;
}; };
// An Environment is a sequencing container: a name + description + an
// ordered list of pipeline IDs (the promotion sequence, reorderable).
// Per-deploy config (credential / runtime target / approval method)
// lives on the nodes, not the env.
export type Environment = {
id: string;
name: string;
description?: string;
pipelineIds?: string[];
createdAt: string;
updatedAt: string;
};
export type EnvironmentBody = {
name: string;
description?: string;
};
export type Agent = { export type Agent = {
id: string; id: string;
name: string; name: string;
@@ -66,7 +84,7 @@ export type Agent = {
webhookInstalledAt?: string; webhookInstalledAt?: string;
authStatus?: AuthStatus; authStatus?: AuthStatus;
authCheckedAt?: string; authCheckedAt?: string;
attachedPipelines?: string[]; environments?: string[];
credentials?: PublicCredential[]; credentials?: PublicCredential[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -196,13 +214,13 @@ export const api = {
uninstallAgentWebhook: (id: string) => uninstallAgentWebhook: (id: string) =>
fetch(`${base}/api/agents/${id}/webhook`, { method: "DELETE" }).then(handle<Agent>), fetch(`${base}/api/agents/${id}/webhook`, { method: "DELETE" }).then(handle<Agent>),
attachPipeline: (id: string, pipelineId: string) => agentFollowEnv: (id: string, envName: string) =>
fetch(`${base}/api/agents/${id}/pipelines/${pipelineId}`, { fetch(`${base}/api/agents/${id}/environments/${encodeURIComponent(envName)}`, {
method: "POST", method: "POST",
}).then(handle<Agent>), }).then(handle<Agent>),
detachPipeline: (id: string, pipelineId: string) => agentUnfollowEnv: (id: string, envName: string) =>
fetch(`${base}/api/agents/${id}/pipelines/${pipelineId}`, { fetch(`${base}/api/agents/${id}/environments/${encodeURIComponent(envName)}`, {
method: "DELETE", method: "DELETE",
}).then(handle<void>), }).then(handle<void>),
@@ -210,10 +228,60 @@ export const api = {
fetch(`${base}/api/agents/${id}/trigger`, { method: "POST" }).then( fetch(`${base}/api/agents/${id}/trigger`, { method: "POST" }).then(
handle<{ handle<{
executionIds: string[]; executionIds: string[];
failures?: { pipelineId: string; reason: string; error?: string }[]; failures?: {
environment?: string;
pipelineId?: string;
reason: string;
error?: string;
}[];
}> }>
), ),
// ── Environments ─────────────────────────────────────────────────────
listEnvironments: () =>
fetch(`${base}/api/environments`).then(handle<Environment[]>),
getEnvironment: (name: string) =>
fetch(`${base}/api/environments/${encodeURIComponent(name)}`).then(
handle<Environment>
),
createEnvironment: (body: EnvironmentBody) =>
fetch(`${base}/api/environments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<Environment>),
updateEnvironment: (name: string, body: EnvironmentBody) =>
fetch(`${base}/api/environments/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<Environment>),
deleteEnvironment: (name: string) =>
fetch(`${base}/api/environments/${encodeURIComponent(name)}`, {
method: "DELETE",
}).then(handle<void>),
envAddPipeline: (envName: string, pipelineId: string) =>
fetch(`${base}/api/environments/${encodeURIComponent(envName)}/pipelines/${pipelineId}`, {
method: "POST",
}).then(handle<Environment>),
envRemovePipeline: (envName: string, pipelineId: string) =>
fetch(`${base}/api/environments/${encodeURIComponent(envName)}/pipelines/${pipelineId}`, {
method: "DELETE",
}).then(handle<void>),
reorderEnvPipelines: (envName: string, pipelineIds: string[]) =>
fetch(`${base}/api/environments/${encodeURIComponent(envName)}/pipelines`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pipelineIds }),
}).then(handle<Environment>),
listCredentials: (agentId: string) => listCredentials: (agentId: string) =>
fetch(`${base}/api/agents/${agentId}/credentials`).then( fetch(`${base}/api/agents/${agentId}/credentials`).then(
handle<PublicCredential[]> handle<PublicCredential[]>
+2 -2
View File
@@ -216,8 +216,8 @@ export const CATALOG: CatalogEntry[] = [
color: "bg-rose-500", color: "bg-rose-500",
outputs: 1, outputs: 1,
defaults: { defaults: {
target: "agentcore", target: "",
credentialName: "aws", credentialName: "",
runtimeName: "", runtimeName: "",
image: "", image: "",
envVars: {}, envVars: {},