mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: add agents management and enhance inspector with typed forms
- Introduced a new "Agents" section in the app sidebar for better navigation. - Enhanced the Inspector component to support typed forms for various node types, improving user experience when configuring nodes. - Created a new NodeForm component to handle specific forms for different node types, including Trigger, Build, Test, Eval, Policy, Approval, Deploy, Promote, and Rollback. - Updated the API layer to manage agents, including listing, creating, deleting, and testing agent authentication. - Modified the node catalog to include new node types and their respective configurations. - Adjusted the Next.js configuration and Nginx settings to reflect changes in API endpoint ports.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# Langship
|
||||
|
||||
Langship is a framework-agnostic deployment, governance, and operations layer for agent applications. It supports LangChain/LangGraph and other agent frameworks (LlamaIndex, CrewAI, AutoGen, Pydantic AI, raw SDK agents, etc.) — not tied to any single framework.
|
||||
|
||||
## Three pillars
|
||||
|
||||
- **Deployment** — packaging, versioning, rollouts, env/secret management for agent apps
|
||||
- **Governance** — policies, budgets, approvals, audit logs, tenant isolation, safety filters
|
||||
- **Operations** — monitoring, replay/debug, incident response, cost & performance management
|
||||
|
||||
## Deployment targets
|
||||
|
||||
Langship is multi-runtime. Supported deployment targets include:
|
||||
|
||||
- **Kubernetes** (self-hosted / any cloud)
|
||||
- **AWS Bedrock AgentCore Runtime**
|
||||
- **GCP Vertex AI Agent Engine**
|
||||
|
||||
The deployment layer abstracts over these runtimes so the same agent app definition can ship to any of them. Governance and operations policies must apply uniformly across runtimes.
|
||||
|
||||
## Positioning
|
||||
|
||||
Closer to a "platform for agents" (Kubernetes/Datadog-style) than a single-framework tool like LangSmith or LangGraph Platform.
|
||||
|
||||
## Top-level scope — Project
|
||||
|
||||
Langship's top-level scoping primitive is a **Project**. A Project owns: users + roles, environments, workflows, secrets, deployment configs, audit log. Self-hosted Langship typically serves several teams, so Projects isolate one team/agent from another from day one (rather than retrofitting tenancy later). API paths are scoped: `/projects/:id/...`. Note: "Project" in this doc always refers to the Langship Project; cloud-provider projects (e.g., GCP project) are always qualified with the provider name.
|
||||
|
||||
## CI/CD — configurable workflow builder
|
||||
|
||||
CI/CD is **not** a fixed pipeline. It is a **configurable, drag-and-drop workflow builder** where pipelines are graphs of nodes (triggers, build, test, eval, policy, approval, deploy, promote, rollback). The visual canvas is the UI; YAML in git is the source of truth (GitOps).
|
||||
|
||||
- **Two modes** — replace CI entirely (Langship runs build/test/eval/deploy) or CD + release-gates only (external CI hands off an artifact, Langship picks up at eval/governance/deploy).
|
||||
- **Role-based views** — agent devs, platform/DevOps, governance owners see different node palettes on the same underlying graph.
|
||||
- **Environments are first-class** — dev, staging, prod each have their own pipeline. Per-env config (secrets, runtime, scaling) is separate from the graph. Different runtimes per env are expected (e.g., dev on K8s, prod on Vertex Agent Engine).
|
||||
- **Promotion is gated and branching-strategy-driven** — evals must pass → approval gate (human, automated policy, or quorum) → `Promote` node executes per project's branching strategy (trunk-based, env-branches, release branches, or custom). Promotion and rollback are auditable events.
|
||||
- **Governance is a node, not a wrapper** — policies/approvals/budget gates are first-class, visible, reorderable steps in the graph.
|
||||
|
||||
|
||||
## Deployment model — self-hosted
|
||||
|
||||
Langship is **self-hosted by the customer**. The customer runs the whole stack (API server, Restate, Postgres, workers, secrets manager) in their own infrastructure. Distribution is via Helm chart / installer / Docker Compose for local dev.
|
||||
|
||||
**Why self-hosted:**
|
||||
- Customer's cloud credentials, agent code, eval data, and audit logs never leave their network — strong fit for the governance-focused positioning
|
||||
- Clear regulatory story for finance/healthcare/gov buyers who can't adopt hosted control planes
|
||||
- Simpler security model: no cross-tenant credential storage, no proxy of LLM traffic
|
||||
- Customer's compliance team monitors logs in systems they already operate
|
||||
|
||||
**Trade-offs accepted:**
|
||||
- Higher friction to adopt vs. hosted SaaS — installer/upgrade UX matters more
|
||||
- Support is harder — no production access by default; need good telemetry-with-consent + clear runbooks
|
||||
- Distribution: ship a Helm chart as primary path; Docker Compose for local dev / small teams
|
||||
|
||||
**Hosted offering may come later** as a managed deployment of the same stack, but the product is designed self-hosted-first. CLI/UI/API contracts assume the server is something the customer operates.
|
||||
|
||||
## Server architecture — three layers
|
||||
|
||||
The "server" is actually three layers, all running in the customer's infrastructure:
|
||||
|
||||
1. **API / control plane** — REST or gRPC. CLI and UI call this. Handles auth, RBAC, workflow CRUD, run triggers, approvals, audit queries. Stateless app servers.
|
||||
2. **Orchestration layer** — Restate (primary) cluster + worker pool. Workers execute node logic (build, eval, deploy, etc.). Long-lived and durable.
|
||||
3. **Data layer** — MongoDB (runs, approvals, audit, users, projects, workflows index); S3-compatible object store (artifacts, large eval outputs, trace blobs); secrets manager (Vault or cloud-native KMS) for cloud credentials. Postgres runs alongside, but **only** as Restate's required persistence backend — never accessed by Langship app code.
|
||||
|
||||
Plus a **GitOps sync** component (initially inside the API, possibly its own service later) that watches git refs and triggers workflows on push/tag/merge events.
|
||||
|
||||
### Minimal v0 server
|
||||
|
||||
For the CLI-first vertical slice:
|
||||
- One API server process, Postgres-backed
|
||||
- One Restate (Restate Cloud is fine for prototyping; final product self-hosts Restate too)
|
||||
- One worker process executing a few node types
|
||||
- CLI talks to the API
|
||||
- No UI yet
|
||||
|
||||
That's the smallest thing that closes the loop: `langship deploy` → API receives → Restate workflow runs → status updates → CLI shows result.
|
||||
|
||||
## User flow — two roles, two experiences
|
||||
|
||||
Langship has two distinct user journeys. The product must serve both well.
|
||||
|
||||
### Platform engineer — one-time project setup
|
||||
|
||||
Heavy, infrequent. Done once per project, occasionally revisited. Mixes CLI + UI.
|
||||
|
||||
1. **Environments** — define dev / staging / prod (and any custom envs like `eu-prod`, `preview`)
|
||||
2. **Branching strategy** — trunk-based / env-branches / release-branches / custom
|
||||
3. **CI/CD pipeline with stages** — the workflow graph (build → eval → approval → deploy → promote, etc.) per env
|
||||
4. **Deployment configs** — per-env cloud credentials and runtime targets (e.g., K8s cluster X for dev, Bedrock AgentCore account Y for staging, Vertex Agent Engine project Z for prod)
|
||||
|
||||
Output: a configured project that agent developers consume.
|
||||
|
||||
### Agent developer — repeatable deploy loop
|
||||
|
||||
Light, frequent. Daily driver. CLI-first.
|
||||
|
||||
5. **Drop in agent repo** — link the repo to a Langship project (GitOps: Langship watches refs per env, not a one-time blob upload)
|
||||
6. **Select** — pick project / pipeline / env target
|
||||
7. **Deploy** — trigger the run; pipeline executes; agent ships
|
||||
|
||||
### Implications for product surface
|
||||
|
||||
- **CLI is the daily-use surface** for agent devs (steps 5–7) and the bootstrap surface for platform engineers (steps 1–4 initially).
|
||||
- **UI is the visualization + governance surface** — workflow canvas, run history, approval inbox, audit log, release-flow view across envs. Comes after CLI proves the model.
|
||||
- **Build CLI first.** A working CLI gives end-to-end usefulness sooner; UI built on top of unproven flows risks designing for the wrong thing.
|
||||
- **Agent repo is git-based, not uploaded.** Langship references the repo by URL + ref; pipelines trigger on push/tag/merge events per env's branching strategy. Preserves traceability, reproducibility, and the GitOps story.
|
||||
|
||||
## Design principle
|
||||
|
||||
Core APIs and data models must stay framework-agnostic. The key abstraction is a common interface across frameworks (runs, traces via OpenTelemetry/OpenLLMetry) so governance and operations policies apply uniformly regardless of the underlying agent framework. Avoid LangChain-only assumptions in core abstractions.
|
||||
|
||||
Engine Restate) must stay wrapped behind Langship's own DSL — users never see the engine directly. Swapping later is possible but disruptive; pick deliberately.
|
||||
+30
-2
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/lyzrai/flow/pkg/engine"
|
||||
"github.com/lyzrai/flow/pkg/executors"
|
||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||
"github.com/lyzrai/flow/pkg/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -51,8 +52,11 @@ usage:
|
||||
flow version print version
|
||||
|
||||
env vars (for `+"`flow serve`"+`):
|
||||
FLOW_ADDR HTTP listen address (default :8080)
|
||||
FLOW_ADDR HTTP listen address (default :8090)
|
||||
FLOW_CORS_ORIGINS comma-separated allow-list (default *)
|
||||
FLOW_PUBLIC_URL externally-reachable base URL (used for webhook callbacks)
|
||||
MONGO_URI Mongo connection string (required)
|
||||
MONGO_DB Mongo database name (default flow)
|
||||
RESTATE_INGRESS_URL Restate ingress URL (default http://localhost:8081)
|
||||
RESTATE_ADMIN_URL Restate admin URL (default http://localhost:9070)
|
||||
RESTATE_SERVICE_ADDR Restate service-endpoint listen addr (default :9080)
|
||||
@@ -94,11 +98,31 @@ func serve() int {
|
||||
executors.RegisterAll()
|
||||
lookup := executors.BuildLookup()
|
||||
|
||||
addr := envOr("FLOW_ADDR", ":8080")
|
||||
addr := envOr("FLOW_ADDR", ":8090")
|
||||
ingressURL := envOr("RESTATE_INGRESS_URL", "http://localhost:8081")
|
||||
adminURL := envOr("RESTATE_ADMIN_URL", "http://localhost:9070")
|
||||
serviceAddr := envOr("RESTATE_SERVICE_ADDR", ":9080")
|
||||
deployURI := envOr("RESTATE_DEPLOYMENT_URI", "http://localhost"+serviceAddr)
|
||||
mongoURI := envOr("MONGO_URI", "")
|
||||
mongoDB := envOr("MONGO_DB", "flow")
|
||||
|
||||
// Mongo is a hard dependency — pipelines/runs/agents all live there.
|
||||
mongoCtx, mongoCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer mongoCancel()
|
||||
mongo, err := storage.NewMongo(mongoCtx, mongoURI, mongoDB)
|
||||
if err != nil {
|
||||
slog.Error("mongo not reachable, exiting",
|
||||
slog.String("hint", "set MONGO_URI (e.g. mongodb://localhost:27017)"),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return 1
|
||||
}
|
||||
defer func() {
|
||||
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = mongo.Close(closeCtx)
|
||||
}()
|
||||
slog.Info("mongo connected", slog.String("db", mongoDB))
|
||||
|
||||
slog.Info("checking restate",
|
||||
slog.String("ingress", ingressURL),
|
||||
@@ -140,6 +164,10 @@ func serve() int {
|
||||
Orchestrator: orch,
|
||||
RestateIngressURL: orch.IngressURL(),
|
||||
CORSOrigins: parseCSV(envOr("FLOW_CORS_ORIGINS", "*")),
|
||||
PublicURL: envOr("FLOW_PUBLIC_URL", ""),
|
||||
Pipelines: mongo.Pipelines(),
|
||||
Runs: mongo.Runs(),
|
||||
Agents: mongo.Agents(),
|
||||
}),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,9 +15,10 @@ services:
|
||||
flow:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080" # JSON API
|
||||
- "8090:8090" # JSON API
|
||||
- "9080:9080" # restate callback endpoint
|
||||
environment:
|
||||
- FLOW_ADDR=:8090
|
||||
- MONGO_URI=mongodb://mongo:27017
|
||||
- MONGO_DB=flow
|
||||
- FLOW_CORS_ORIGINS=http://localhost:3000,http://web:3000
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lyzrai/flow/pkg/engine"
|
||||
"github.com/lyzrai/flow/pkg/github"
|
||||
"github.com/lyzrai/flow/pkg/models"
|
||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||
"github.com/lyzrai/flow/pkg/storage"
|
||||
)
|
||||
|
||||
// Agent is the wire shape of an agent record. PAT and webhook secret are
|
||||
// scrubbed; HasPAT and webhook fields surface only the safe parts.
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RepoURL string `json:"repoUrl"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
HasPAT bool `json:"hasPat"`
|
||||
WebhookID int64 `json:"webhookId,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl,omitempty"`
|
||||
WebhookInstalled bool `json:"webhookInstalled"`
|
||||
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty"`
|
||||
AuthStatus storage.AuthStatus `json:"authStatus,omitempty"`
|
||||
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty"`
|
||||
AttachedPipelines []string `json:"attachedPipelines,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (s *Server) publicAgent(a *storage.Agent) Agent {
|
||||
return Agent{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
RepoURL: a.RepoURL,
|
||||
Ref: a.Ref,
|
||||
HasPAT: a.PAT != "",
|
||||
WebhookID: a.WebhookID,
|
||||
WebhookURL: s.webhookURLFor(a.ID),
|
||||
WebhookInstalled: a.WebhookID != 0,
|
||||
WebhookInstalledAt: a.WebhookInstalledAt,
|
||||
AuthStatus: a.AuthStatus,
|
||||
AuthCheckedAt: a.AuthCheckedAt,
|
||||
AttachedPipelines: a.AttachedPipelines,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) webhookURLFor(agentID string) string {
|
||||
if s.publicURL == "" {
|
||||
return ""
|
||||
}
|
||||
return s.publicURL + "/webhooks/github/" + agentID
|
||||
}
|
||||
|
||||
// --- CRUD -----------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := s.agents.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
out := make([]Agent, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
out = append(out, s.publicAgent(a))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
RepoURL string `json:"repoUrl"`
|
||||
PAT string `json:"pat"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
repo := strings.TrimSpace(body.RepoURL)
|
||||
if repo == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("repoUrl is required"))
|
||||
return
|
||||
}
|
||||
if _, err := url.Parse(repo); err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid repoUrl: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
id := newID()
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
name = deriveAgentName(repo)
|
||||
}
|
||||
ref := strings.TrimSpace(body.Ref)
|
||||
if ref == "" {
|
||||
ref = "main"
|
||||
}
|
||||
|
||||
a := &storage.Agent{
|
||||
ID: id,
|
||||
Name: name,
|
||||
RepoURL: repo,
|
||||
Ref: ref,
|
||||
PAT: strings.TrimSpace(body.PAT),
|
||||
AuthStatus: storage.AuthUntested,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.agents.Create(r.Context(), a); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, s.publicAgent(a))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
// Best-effort uninstall webhook before deleting, so we don't leak
|
||||
// dangling hooks pointing at a dead agent ID.
|
||||
if a, err := s.agents.Get(r.Context(), id); err == nil && a.WebhookID != 0 {
|
||||
if repo, perr := github.ParseRepo(a.RepoURL); perr == nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
if err := s.agents.Delete(r.Context(), id); err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- auth probe ----------------------------------------------------------
|
||||
|
||||
func (s *Server) handleTestAgentAuth(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
repo, err := github.ParseRepo(a.RepoURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
authErr := github.NewClient(a.PAT).TestAuth(ctx, repo)
|
||||
|
||||
now := time.Now().UTC()
|
||||
a.AuthCheckedAt = &now
|
||||
if authErr == nil {
|
||||
a.AuthStatus = storage.AuthOK
|
||||
} else {
|
||||
a.AuthStatus = storage.AuthFailed
|
||||
}
|
||||
a.UpdatedAt = now
|
||||
if uerr := s.agents.Update(r.Context(), a); uerr != nil {
|
||||
writeError(w, http.StatusInternalServerError, uerr)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"authStatus": a.AuthStatus,
|
||||
"authCheckedAt": a.AuthCheckedAt,
|
||||
}
|
||||
if authErr != nil {
|
||||
resp["error"] = authErr.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// --- webhook install / uninstall -----------------------------------------
|
||||
|
||||
func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if s.publicURL == "" {
|
||||
writeError(w, http.StatusServiceUnavailable,
|
||||
errors.New("FLOW_PUBLIC_URL is not configured"))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
if a.PAT == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("agent has no PAT — re-create with one"))
|
||||
return
|
||||
}
|
||||
repo, err := github.ParseRepo(a.RepoURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Fresh secret per install so rotating is just "uninstall + install".
|
||||
secret, err := github.GenerateSecret()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
callback := s.webhookURLFor(id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
hookID, err := github.NewClient(a.PAT).InstallWebhook(ctx, repo, callback, secret)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
a.WebhookID = hookID
|
||||
a.WebhookSecret = secret
|
||||
a.WebhookInstalledAt = &now
|
||||
a.UpdatedAt = now
|
||||
if err := s.agents.Update(r.Context(), a); err != nil {
|
||||
// Try to roll back the hook so we don't leak it.
|
||||
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, hookID)
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
||||
}
|
||||
|
||||
func (s *Server) handleUninstallWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
if a.WebhookID == 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("no webhook installed"))
|
||||
return
|
||||
}
|
||||
repo, err := github.ParseRepo(a.RepoURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID); err != nil {
|
||||
writeError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.WebhookID = 0
|
||||
a.WebhookSecret = ""
|
||||
a.WebhookInstalledAt = nil
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
if err := s.agents.Update(r.Context(), a); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
||||
}
|
||||
|
||||
// --- pipeline attachments ------------------------------------------------
|
||||
|
||||
func (s *Server) handleAttachPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
pipelineID := r.PathValue("pipelineId")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
if _, err := s.pipelines.Get(r.Context(), pipelineID); err != nil {
|
||||
writeStorageErr(w, err, "pipeline not found")
|
||||
return
|
||||
}
|
||||
for _, existing := range a.AttachedPipelines {
|
||||
if existing == pipelineID {
|
||||
writeJSON(w, http.StatusOK, s.publicAgent(a)) // already attached
|
||||
return
|
||||
}
|
||||
}
|
||||
a.AttachedPipelines = append(a.AttachedPipelines, pipelineID)
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
if err := s.agents.Update(r.Context(), a); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
||||
}
|
||||
|
||||
func (s *Server) handleDetachPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
pipelineID := r.PathValue("pipelineId")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
out := a.AttachedPipelines[:0]
|
||||
for _, p := range a.AttachedPipelines {
|
||||
if p != pipelineID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
a.AttachedPipelines = out
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
if err := s.agents.Update(r.Context(), a); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- manual trigger ------------------------------------------------------
|
||||
|
||||
// handleTriggerAgent dispatches a run on each attached pipeline. Trigger
|
||||
// data describes who/what triggered the run (manual / webhook / etc.).
|
||||
func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.orch == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
if len(a.AttachedPipelines) == 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("agent has no attached pipelines"))
|
||||
return
|
||||
}
|
||||
triggerData := []map[string]any{{
|
||||
"source": "manual",
|
||||
"agentId": a.ID,
|
||||
"agentName": a.Name,
|
||||
"repoUrl": a.RepoURL,
|
||||
"ref": a.Ref,
|
||||
}}
|
||||
execIDs, err := s.dispatchAgent(r.Context(), a, triggerData)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"executionIds": execIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// dispatchAgent runs each attached pipeline asynchronously. Returns the
|
||||
// execution IDs collected. Failures on individual pipelines are logged
|
||||
// but don't abort the rest.
|
||||
func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger any) ([]string, error) {
|
||||
triggerJSON, _ := json.Marshal(trigger)
|
||||
var triggerItems []models.Item
|
||||
if items, ok := trigger.([]map[string]any); ok {
|
||||
for _, m := range items {
|
||||
triggerItems = append(triggerItems, models.Item(m))
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(a.AttachedPipelines))
|
||||
for _, pid := range a.AttachedPipelines {
|
||||
p, err := s.pipelines.Get(ctx, pid)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "agent_dispatch_pipeline_missing",
|
||||
slog.String("agent_id", a.ID),
|
||||
slog.String("pipeline_id", pid),
|
||||
)
|
||||
continue
|
||||
}
|
||||
wf, err := engine.ParseWorkflow(p.Definition)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "agent_dispatch_parse_failed",
|
||||
slog.String("pipeline_id", pid),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
slog.WarnContext(ctx, "agent_dispatch_failed",
|
||||
slog.String("pipeline_id", pid),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- webhook receiver ----------------------------------------------------
|
||||
|
||||
func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "agent not found")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := github.VerifySignature(r.Header.Get("X-Hub-Signature-256"), a.WebhookSecret, body); err != nil {
|
||||
slog.WarnContext(r.Context(), "github_webhook_signature_invalid",
|
||||
slog.String("agent_id", id),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
writeError(w, http.StatusUnauthorized, errors.New("signature invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
event := r.Header.Get("X-GitHub-Event")
|
||||
switch event {
|
||||
case "ping":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
|
||||
return
|
||||
case "push":
|
||||
// fall through
|
||||
default:
|
||||
// Acknowledge unknown events; nothing to dispatch.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
push, err := github.ParsePushEvent(body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Per the agent's configured ref: only dispatch if the branch matches.
|
||||
if a.Ref != "" && a.Ref != "*" && github.BranchFromRef(push.Ref) != a.Ref {
|
||||
slog.InfoContext(r.Context(), "github_webhook_ref_mismatch",
|
||||
slog.String("agent_id", id),
|
||||
slog.String("event_ref", push.Ref),
|
||||
slog.String("agent_ref", a.Ref),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "ref filtered"})
|
||||
return
|
||||
}
|
||||
|
||||
if s.orch == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
trigger := []map[string]any{{
|
||||
"source": "github_push",
|
||||
"agentId": a.ID,
|
||||
"agentName": a.Name,
|
||||
"repoUrl": a.RepoURL,
|
||||
"ref": push.Ref,
|
||||
"branch": github.BranchFromRef(push.Ref),
|
||||
"commit": push.After,
|
||||
"pusher": push.Pusher.Name,
|
||||
}}
|
||||
execIDs, err := s.dispatchAgent(r.Context(), a, trigger)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"executionIds": execIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
// deriveAgentName extracts an "org/repo" name from a git URL. Falls back to
|
||||
// the URL itself if parsing fails.
|
||||
func deriveAgentName(raw string) string {
|
||||
if r, err := github.ParseRepo(raw); err == nil {
|
||||
return r.String()
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
+174
-57
@@ -16,13 +16,14 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lyzrai/flow/pkg/engine"
|
||||
"github.com/lyzrai/flow/pkg/models"
|
||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||
"github.com/lyzrai/flow/pkg/storage"
|
||||
)
|
||||
|
||||
// FlowSummary is the list-shape returned to the dashboard.
|
||||
@@ -38,11 +39,19 @@ type FlowSummary struct {
|
||||
// ServerDeps groups the construction-time dependencies of the HTTP server.
|
||||
// Orchestrator drives durable execution; RestateIngressURL is used to
|
||||
// resolve Awakeables from the resume handler. CORSOrigins lists allowed
|
||||
// browser origins (use "*" to allow any — fine for dev).
|
||||
// browser origins (use "*" to allow any — fine for dev). Pipelines / Runs /
|
||||
// Agents are required — the API has no in-memory fallback.
|
||||
type ServerDeps struct {
|
||||
Orchestrator orchestrator.Orchestrator
|
||||
RestateIngressURL string
|
||||
CORSOrigins []string
|
||||
// PublicURL is the externally-reachable base URL for this API (e.g.
|
||||
// "https://abcd.trycloudflare.com"). Used to render webhook callback
|
||||
// URLs that GitHub can hit. Empty means webhook install is disabled.
|
||||
PublicURL string
|
||||
Pipelines storage.PipelineStore
|
||||
Runs storage.RunStore
|
||||
Agents storage.AgentStore
|
||||
}
|
||||
|
||||
// Server is a thin JSON API server. It does not serve a frontend.
|
||||
@@ -51,30 +60,26 @@ type Server struct {
|
||||
orch orchestrator.Orchestrator
|
||||
restateIngres string
|
||||
corsOrigins []string
|
||||
publicURL string
|
||||
|
||||
mu sync.Mutex
|
||||
flows map[string]storedFlow // in-memory placeholder; storage layer lands next
|
||||
}
|
||||
|
||||
// Definition is held as raw n8n-format JSON so we don't lose connection
|
||||
// shape on round-trip. We parse on read for validation + node count.
|
||||
type storedFlow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Definition json.RawMessage `json:"definition"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
pipelines storage.PipelineStore
|
||||
runs storage.RunStore
|
||||
agents storage.AgentStore
|
||||
}
|
||||
|
||||
// NewServer constructs an API-only Server. deps.Orchestrator may be nil —
|
||||
// execute routes will then return 503.
|
||||
// execute routes will then return 503. Stores must be non-nil; CRUD routes
|
||||
// will panic without them — the API has no in-memory fallback.
|
||||
func NewServer(deps ServerDeps) *Server {
|
||||
s := &Server{
|
||||
mux: http.NewServeMux(),
|
||||
orch: deps.Orchestrator,
|
||||
restateIngres: deps.RestateIngressURL,
|
||||
corsOrigins: deps.CORSOrigins,
|
||||
flows: make(map[string]storedFlow),
|
||||
publicURL: strings.TrimRight(deps.PublicURL, "/"),
|
||||
pipelines: deps.Pipelines,
|
||||
runs: deps.Runs,
|
||||
agents: deps.Agents,
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
@@ -93,15 +98,33 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("GET /api/workflows", s.handleListFlows)
|
||||
s.mux.HandleFunc("POST /api/workflows", s.handleCreateFlow)
|
||||
s.mux.HandleFunc("GET /api/workflows/{id}", s.handleGetFlow)
|
||||
s.mux.HandleFunc("PUT /api/workflows/{id}", s.handleUpdateFlow)
|
||||
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.handleDeleteFlow)
|
||||
s.mux.HandleFunc("POST /api/workflows/execute", s.handleExecuteWorkflow)
|
||||
s.mux.HandleFunc("GET /api/executions", s.handleListExecutions)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
|
||||
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
|
||||
|
||||
// Agents — Langship-style agent registry (git URL + PAT)
|
||||
s.mux.HandleFunc("GET /api/agents", s.handleListAgents)
|
||||
s.mux.HandleFunc("POST /api/agents", s.handleCreateAgent)
|
||||
s.mux.HandleFunc("GET /api/agents/{id}", s.handleGetAgent)
|
||||
s.mux.HandleFunc("DELETE /api/agents/{id}", s.handleDeleteAgent)
|
||||
s.mux.HandleFunc("POST /api/agents/{id}/test-auth", s.handleTestAgentAuth)
|
||||
s.mux.HandleFunc("POST /api/agents/{id}/webhook", s.handleInstallWebhook)
|
||||
s.mux.HandleFunc("DELETE /api/agents/{id}/webhook", s.handleUninstallWebhook)
|
||||
s.mux.HandleFunc("POST /api/agents/{id}/pipelines/{pipelineId}", s.handleAttachPipeline)
|
||||
s.mux.HandleFunc("DELETE /api/agents/{id}/pipelines/{pipelineId}", s.handleDetachPipeline)
|
||||
s.mux.HandleFunc("POST /api/agents/{id}/trigger", s.handleTriggerAgent)
|
||||
|
||||
// Public webhook receiver. GitHub posts here; HMAC signature is the
|
||||
// authentication. Must NOT require CORS / API auth.
|
||||
s.mux.HandleFunc("POST /webhooks/github/{id}", s.handleGitHubWebhook)
|
||||
|
||||
// Anything not under /api/ is not our concern — the UI server handles it.
|
||||
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, fmt.Errorf("no handler for %s", r.URL.Path))
|
||||
@@ -139,17 +162,30 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]FlowSummary, 0, len(s.flows))
|
||||
for _, f := range s.flows {
|
||||
// handleConfig exposes a few server-side config values to the UI so it can
|
||||
// render webhook URLs / decide whether to disable buttons.
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"publicUrl": s.publicURL,
|
||||
"webhooksAvailable": s.publicURL != "",
|
||||
"orchestratorEnabled": s.orch != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListFlows(w http.ResponseWriter, r *http.Request) {
|
||||
pipes, err := s.pipelines.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
out := make([]FlowSummary, 0, len(pipes))
|
||||
for _, p := range pipes {
|
||||
out = append(out, FlowSummary{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
UpdatedAt: f.UpdatedAt,
|
||||
NodeCount: f.NodeCount,
|
||||
Status: "draft",
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
NodeCount: p.NodeCount,
|
||||
Status: firstNonEmpty(p.Status, "draft"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
@@ -169,30 +205,31 @@ func (s *Server) handleCreateFlow(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := newID()
|
||||
f := storedFlow{
|
||||
p := &storage.Pipeline{
|
||||
ID: id,
|
||||
Name: firstNonEmpty(body.Name, parsedName, "Untitled flow"),
|
||||
Name: firstNonEmpty(body.Name, parsedName, "Untitled pipeline"),
|
||||
Definition: body.Definition,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
NodeCount: count,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.pipelines.Create(r.Context(), p); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.flows[id] = f
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
f, ok := s.flows[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, errors.New("flow not found"))
|
||||
p, err := s.pipelines.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "pipeline not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, f)
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateFlow(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -205,15 +242,15 @@ func (s *Server) handleUpdateFlow(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.flows[id]
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, errors.New("flow not found"))
|
||||
|
||||
p, err := s.pipelines.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStorageErr(w, err, "pipeline not found")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
f.Name = *body.Name
|
||||
p.Name = *body.Name
|
||||
}
|
||||
if len(body.Definition) > 0 {
|
||||
count, _, err := analyzeDefinition(body.Definition)
|
||||
@@ -221,11 +258,14 @@ func (s *Server) handleUpdateFlow(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
f.Definition = body.Definition
|
||||
f.NodeCount = count
|
||||
p.Definition = body.Definition
|
||||
p.NodeCount = count
|
||||
}
|
||||
p.UpdatedAt = time.Now().UTC()
|
||||
if err := s.pipelines.Update(r.Context(), p); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
f.UpdatedAt = time.Now().UTC()
|
||||
s.flows[id] = f
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -253,9 +293,10 @@ func analyzeDefinition(raw json.RawMessage) (int, string, error) {
|
||||
|
||||
func (s *Server) handleDeleteFlow(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
delete(s.flows, id)
|
||||
s.mu.Unlock()
|
||||
if err := s.pipelines.Delete(r.Context(), id); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -289,12 +330,16 @@ func (s *Server) handleExecuteWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pull the originating pipeline ID/name out of the body again so we can
|
||||
// stamp the run record (parseExecuteRequest doesn't expose it).
|
||||
pipelineID, pipelineName := pipelineRefFromBody(rawBody, wf.Name)
|
||||
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
execID, err := s.orch.RunAsync(ctx, &orchestrator.RunRequest{
|
||||
RequestMeta: orchestrator.RequestMeta{APIKey: apiKey},
|
||||
RequestMeta: orchestrator.RequestMeta{APIKey: apiKey, WorkflowID: pipelineID},
|
||||
Workflow: wf,
|
||||
TriggerData: input,
|
||||
})
|
||||
@@ -303,12 +348,45 @@ func (s *Server) handleExecuteWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort run record. A failure here shouldn't block the response —
|
||||
// the orchestrator already accepted the workflow.
|
||||
if s.runs != nil {
|
||||
triggerJSON, _ := json.Marshal(input)
|
||||
if err := s.runs.Insert(r.Context(), &storage.Run{
|
||||
ID: execID,
|
||||
PipelineID: pipelineID,
|
||||
PipelineName: pipelineName,
|
||||
Status: "running",
|
||||
StartedAt: time.Now().UTC(),
|
||||
TriggerData: triggerJSON,
|
||||
}); err != nil {
|
||||
slog.WarnContext(r.Context(), "run_insert_failed",
|
||||
slog.String("execution_id", execID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"execution_id": execID,
|
||||
"status": "running",
|
||||
})
|
||||
}
|
||||
|
||||
// pipelineRefFromBody peeks at the execute request body to recover the
|
||||
// pipeline ID + name for the run record. Best-effort; missing fields are OK.
|
||||
func pipelineRefFromBody(body []byte, fallbackName string) (id string, name string) {
|
||||
var probe struct {
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
Workflow struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"workflow"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &probe)
|
||||
name = firstNonEmpty(probe.Workflow.Name, fallbackName)
|
||||
return probe.WorkflowID, name
|
||||
}
|
||||
|
||||
// handleResumeExecution resolves a Restate Awakeable so a paused workflow
|
||||
// continues. Body: { "awakeable_id": "...", "data": <any> }
|
||||
//
|
||||
@@ -386,6 +464,35 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleListExecutions returns recent runs from storage. Optional
|
||||
// ?pipeline_id= filters by source pipeline; ?limit= caps the page size.
|
||||
func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.runs == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, errors.New("run store not configured"))
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
var (
|
||||
runs []*storage.Run
|
||||
err error
|
||||
)
|
||||
if pid := r.URL.Query().Get("pipeline_id"); pid != "" {
|
||||
runs, err = s.runs.ListByPipeline(r.Context(), pid, limit)
|
||||
} else {
|
||||
runs, err = s.runs.List(r.Context(), limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// parseExecuteRequest accepts the structured ExecuteRequest shape:
|
||||
// { "workflow": <n8n-JSON object>, "input": [{...}, ...] }
|
||||
// { "workflow_id": "<id>", "input": [...] }
|
||||
@@ -406,13 +513,13 @@ func (s *Server) parseExecuteRequest(body []byte) (json.RawMessage, []models.Ite
|
||||
}
|
||||
|
||||
if probe.WorkflowID != "" {
|
||||
s.mu.Lock()
|
||||
f, ok := s.flows[probe.WorkflowID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
p, err := s.pipelines.Get(ctx, probe.WorkflowID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("workflow %q not found", probe.WorkflowID)
|
||||
}
|
||||
return f.Definition, input, nil
|
||||
return p.Definition, input, nil
|
||||
}
|
||||
if len(probe.Workflow) > 0 && string(probe.Workflow) != "null" {
|
||||
return probe.Workflow, input, nil
|
||||
@@ -434,6 +541,16 @@ func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// writeStorageErr maps storage.ErrNotFound to 404 and other errors to 500.
|
||||
// notFoundMsg is the user-facing message on 404.
|
||||
func writeStorageErr(w http.ResponseWriter, err error, notFoundMsg string) {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, errors.New(notFoundMsg))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
|
||||
+30
-13
@@ -13,10 +13,27 @@ import (
|
||||
|
||||
"github.com/lyzrai/flow/pkg/models"
|
||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||
"github.com/lyzrai/flow/pkg/storage"
|
||||
)
|
||||
|
||||
// newTestServer returns a Server with in-memory stores wired up. Tests pass
|
||||
// extra fields (Orchestrator, RestateIngressURL) that override the defaults.
|
||||
func newTestServer(deps ServerDeps) *Server {
|
||||
mem := storage.NewMemory()
|
||||
if deps.Pipelines == nil {
|
||||
deps.Pipelines = mem.Pipelines()
|
||||
}
|
||||
if deps.Runs == nil {
|
||||
deps.Runs = mem.Runs()
|
||||
}
|
||||
if deps.Agents == nil {
|
||||
deps.Agents = mem.Agents()
|
||||
}
|
||||
return NewServer(deps)
|
||||
}
|
||||
|
||||
func TestHealth_returns200(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{})
|
||||
srv := newTestServer(ServerDeps{})
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, r)
|
||||
@@ -34,7 +51,7 @@ func TestHealth_returns200(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkflowCRUD_roundTrip(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{})
|
||||
srv := newTestServer(ServerDeps{})
|
||||
|
||||
// Create
|
||||
wf := map[string]any{
|
||||
@@ -132,7 +149,7 @@ func TestWorkflowCRUD_roundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecute_requiresOrchestrator(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{})
|
||||
srv := newTestServer(ServerDeps{})
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"workflow": map[string]any{
|
||||
"name": "x",
|
||||
@@ -176,7 +193,7 @@ func (s *stubOrch) GetExecution(_ context.Context, _ string) (*orchestrator.Exec
|
||||
|
||||
func TestExecute_inlineWorkflow_callsOrchestrator(t *testing.T) {
|
||||
orch := &stubOrch{execID: "exec-123"}
|
||||
srv := NewServer(ServerDeps{Orchestrator: orch})
|
||||
srv := newTestServer(ServerDeps{Orchestrator: orch})
|
||||
|
||||
body := []byte(`{"workflow":{"name":"x","nodes":[{"id":"1","name":"T","type":"n8n-nodes-base.manualTrigger","parameters":{},"position":[0,0]}],"connections":{}},"input":[{"k":"v"}]}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body))
|
||||
@@ -204,7 +221,7 @@ func TestExecute_inlineWorkflow_callsOrchestrator(t *testing.T) {
|
||||
|
||||
func TestExecute_byWorkflowID(t *testing.T) {
|
||||
orch := &stubOrch{execID: "exec-7"}
|
||||
srv := NewServer(ServerDeps{Orchestrator: orch})
|
||||
srv := newTestServer(ServerDeps{Orchestrator: orch})
|
||||
|
||||
// Pre-create a workflow.
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
@@ -234,7 +251,7 @@ func TestExecute_byWorkflowID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecute_missing_workflow_400(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{}})
|
||||
srv := newTestServer(ServerDeps{Orchestrator: &stubOrch{}})
|
||||
body := []byte(`{}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -246,7 +263,7 @@ func TestExecute_missing_workflow_400(t *testing.T) {
|
||||
|
||||
func TestGetExecution_returnsOrchStatus(t *testing.T) {
|
||||
want := &orchestrator.ExecutionStatus{ExecutionID: "abc", Status: "success"}
|
||||
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusOut: want}})
|
||||
srv := newTestServer(ServerDeps{Orchestrator: &stubOrch{statusOut: want}})
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -262,7 +279,7 @@ func TestGetExecution_returnsOrchStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetExecution_notFound(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusErr: errors.New("nope")}})
|
||||
srv := newTestServer(ServerDeps{Orchestrator: &stubOrch{statusErr: errors.New("nope")}})
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, r)
|
||||
@@ -285,7 +302,7 @@ func TestResume_proxiesToRestate(t *testing.T) {
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
srv := NewServer(ServerDeps{RestateIngressURL: stub.URL})
|
||||
srv := newTestServer(ServerDeps{RestateIngressURL: stub.URL})
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"awakeable_id": "sign_xyz",
|
||||
"data": map[string]any{"approved": true},
|
||||
@@ -309,7 +326,7 @@ func TestResume_proxiesToRestate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResume_requiresAwakeableID(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{RestateIngressURL: "http://localhost"})
|
||||
srv := newTestServer(ServerDeps{RestateIngressURL: "http://localhost"})
|
||||
body, _ := json.Marshal(map[string]any{"awakeable_id": "", "data": map[string]any{}})
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -320,7 +337,7 @@ func TestResume_requiresAwakeableID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResume_unwiredIngress503(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{}) // no RestateIngressURL
|
||||
srv := newTestServer(ServerDeps{}) // no RestateIngressURL
|
||||
body, _ := json.Marshal(map[string]any{"awakeable_id": "sign_x", "data": map[string]any{}})
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -332,7 +349,7 @@ func TestResume_unwiredIngress503(t *testing.T) {
|
||||
|
||||
func TestNonAPI_returns404(t *testing.T) {
|
||||
// API server no longer hosts the SPA — the UI is a separate process.
|
||||
srv := NewServer(ServerDeps{})
|
||||
srv := newTestServer(ServerDeps{})
|
||||
for _, path := range []string{"/", "/flows/abc", "/runs/123"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -344,7 +361,7 @@ func TestNonAPI_returns404(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnknownAPIRoute_404(t *testing.T) {
|
||||
srv := NewServer(ServerDeps{})
|
||||
srv := newTestServer(ServerDeps{})
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/does-not-exist", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, r)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package github provides minimal helpers for the agent webhook flow:
|
||||
// repo URL parsing, webhook install/uninstall, and PAT auth probing.
|
||||
//
|
||||
// Intentionally tiny — we don't need the full go-github surface. If/when
|
||||
// we do, swap this for github.com/google/go-github.
|
||||
package github
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.github.com"
|
||||
|
||||
// Repo identifies a GitHub repository.
|
||||
type Repo struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
// String returns "owner/name".
|
||||
func (r Repo) String() string { return r.Owner + "/" + r.Name }
|
||||
|
||||
// ParseRepo extracts owner/name from common Git URL forms:
|
||||
// - https://github.com/owner/name(.git)?
|
||||
// - http://github.com/owner/name(.git)?
|
||||
// - git@github.com:owner/name(.git)?
|
||||
//
|
||||
// Returns an error if the host isn't github.com or the path isn't owner/name.
|
||||
func ParseRepo(raw string) (Repo, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
s = strings.TrimSuffix(s, "/")
|
||||
s = strings.TrimSuffix(s, ".git")
|
||||
|
||||
// SSH form
|
||||
if strings.HasPrefix(s, "git@") {
|
||||
i := strings.Index(s, ":")
|
||||
if i < 0 {
|
||||
return Repo{}, fmt.Errorf("invalid ssh url %q", raw)
|
||||
}
|
||||
host := strings.TrimPrefix(s[:i], "git@")
|
||||
if host != "github.com" {
|
||||
return Repo{}, fmt.Errorf("unsupported host %q (only github.com)", host)
|
||||
}
|
||||
return splitOwnerRepo(s[i+1:])
|
||||
}
|
||||
|
||||
// HTTPS form
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return Repo{}, fmt.Errorf("parse url: %w", err)
|
||||
}
|
||||
if u.Host != "github.com" {
|
||||
return Repo{}, fmt.Errorf("unsupported host %q (only github.com)", u.Host)
|
||||
}
|
||||
return splitOwnerRepo(strings.Trim(u.Path, "/"))
|
||||
}
|
||||
|
||||
func splitOwnerRepo(p string) (Repo, error) {
|
||||
parts := strings.Split(p, "/")
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return Repo{}, fmt.Errorf("expected owner/name, got %q", p)
|
||||
}
|
||||
return Repo{Owner: parts[0], Name: parts[1]}, nil
|
||||
}
|
||||
|
||||
// GenerateSecret returns a 32-byte hex string for use as a webhook secret.
|
||||
func GenerateSecret() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Client is a thin GitHub REST client using a personal access token.
|
||||
type Client struct {
|
||||
pat string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new GitHub client. The PAT is used as a Bearer token.
|
||||
func NewClient(pat string) *Client {
|
||||
return &Client{
|
||||
pat: pat,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuth probes /repos/{owner}/{name}. Returns nil on 200, an error
|
||||
// describing the failure mode on anything else (including 401/404).
|
||||
func (c *Client) TestAuth(ctx context.Context, repo Repo) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
apiBase+"/repos/"+repo.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("github request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("github returned %d: %s", resp.StatusCode, truncate(string(body), 240))
|
||||
}
|
||||
|
||||
// HookConfig is the payload section GitHub stores for our webhook.
|
||||
type HookConfig struct {
|
||||
URL string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
Secret string `json:"secret"`
|
||||
InsecureSSL string `json:"insecure_ssl,omitempty"`
|
||||
}
|
||||
|
||||
// Hook is the response shape we care about from GitHub's hook endpoints.
|
||||
type Hook struct {
|
||||
ID int64 `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
Events []string `json:"events"`
|
||||
Config struct {
|
||||
URL string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// InstallWebhook creates a repo webhook firing on push events. Returns the
|
||||
// hook ID (used for later uninstall).
|
||||
func (c *Client) InstallWebhook(ctx context.Context, repo Repo, callbackURL, secret string) (int64, error) {
|
||||
body := map[string]any{
|
||||
"name": "web",
|
||||
"active": true,
|
||||
"events": []string{"push"},
|
||||
"config": HookConfig{
|
||||
URL: callbackURL,
|
||||
ContentType: "json",
|
||||
Secret: secret,
|
||||
},
|
||||
}
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
apiBase+"/repos/"+repo.String()+"/hooks", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.applyAuth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("github request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return 0, fmt.Errorf("install webhook: github returned %d: %s",
|
||||
resp.StatusCode, truncate(string(respBody), 240))
|
||||
}
|
||||
var hook Hook
|
||||
if err := json.Unmarshal(respBody, &hook); err != nil {
|
||||
return 0, fmt.Errorf("decode webhook response: %w", err)
|
||||
}
|
||||
return hook.ID, nil
|
||||
}
|
||||
|
||||
// UninstallWebhook removes a previously-created webhook by ID. A 404 is
|
||||
// treated as success (idempotent removal).
|
||||
func (c *Client) UninstallWebhook(ctx context.Context, repo Repo, hookID int64) error {
|
||||
if hookID == 0 {
|
||||
return errors.New("hookID is required")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("%s/repos/%s/hooks/%d", apiBase, repo.String(), hookID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("github request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("uninstall webhook: github returned %d: %s",
|
||||
resp.StatusCode, truncate(string(body), 240))
|
||||
}
|
||||
|
||||
func (c *Client) applyAuth(req *http.Request) {
|
||||
if c.pat != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.pat)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "langship-flow")
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifySignature compares the X-Hub-Signature-256 header against an HMAC
|
||||
// of the request body using the shared secret. Returns nil if valid.
|
||||
func VerifySignature(signatureHeader, secret string, body []byte) error {
|
||||
if signatureHeader == "" {
|
||||
return errors.New("missing X-Hub-Signature-256")
|
||||
}
|
||||
if secret == "" {
|
||||
return errors.New("webhook secret not configured")
|
||||
}
|
||||
const prefix = "sha256="
|
||||
if !strings.HasPrefix(signatureHeader, prefix) {
|
||||
return errors.New("signature must start with sha256=")
|
||||
}
|
||||
gotSig, err := hex.DecodeString(signatureHeader[len(prefix):])
|
||||
if err != nil {
|
||||
return errors.New("malformed signature")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
wantSig := mac.Sum(nil)
|
||||
if !hmac.Equal(gotSig, wantSig) {
|
||||
return errors.New("signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushEvent captures the small slice of GitHub's push payload we care about.
|
||||
type PushEvent struct {
|
||||
Ref string `json:"ref"` // refs/heads/main
|
||||
After string `json:"after"` // commit SHA
|
||||
Repo struct {
|
||||
FullName string `json:"full_name"` // owner/repo
|
||||
HTMLURL string `json:"html_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"pusher"`
|
||||
}
|
||||
|
||||
// ParsePushEvent unmarshals a GitHub push event body.
|
||||
func ParsePushEvent(body []byte) (*PushEvent, error) {
|
||||
var p PushEvent
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// BranchFromRef returns "main" from "refs/heads/main", or the input if it
|
||||
// doesn't match the heads/* form.
|
||||
func BranchFromRef(ref string) string {
|
||||
const p = "refs/heads/"
|
||||
if strings.HasPrefix(ref, p) {
|
||||
return ref[len(p):]
|
||||
}
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is an in-memory implementation of all stores. Useful for tests and
|
||||
// for short-lived processes where Mongo isn't available. Not safe across
|
||||
// process restarts. Pass via storage.NewMemory().
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
pipelines map[string]Pipeline
|
||||
runs map[string]Run
|
||||
agents map[string]Agent
|
||||
}
|
||||
|
||||
// NewMemory returns a fresh Memory with empty collections.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
pipelines: map[string]Pipeline{},
|
||||
runs: map[string]Run{},
|
||||
agents: map[string]Agent{},
|
||||
}
|
||||
}
|
||||
|
||||
// --- pipelines ------------------------------------------------------------
|
||||
|
||||
type memoryPipelines struct{ m *Memory }
|
||||
|
||||
func (s *memoryPipelines) Create(_ context.Context, p *Pipeline) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
s.m.pipelines[p.ID] = *p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryPipelines) Get(_ context.Context, id string) (*Pipeline, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
p, ok := s.m.pipelines[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *memoryPipelines) Update(_ context.Context, p *Pipeline) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
if _, ok := s.m.pipelines[p.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.m.pipelines[p.ID] = *p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryPipelines) Delete(_ context.Context, id string) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
if _, ok := s.m.pipelines[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.m.pipelines, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryPipelines) List(_ context.Context) ([]*Pipeline, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
out := make([]*Pipeline, 0, len(s.m.pipelines))
|
||||
for _, p := range s.m.pipelines {
|
||||
p := p
|
||||
out = append(out, &p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Pipelines returns the in-memory PipelineStore.
|
||||
func (m *Memory) Pipelines() PipelineStore { return &memoryPipelines{m: m} }
|
||||
|
||||
// --- runs ----------------------------------------------------------------
|
||||
|
||||
type memoryRuns struct{ m *Memory }
|
||||
|
||||
func (s *memoryRuns) Insert(_ context.Context, r *Run) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
s.m.runs[r.ID] = *r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryRuns) UpdateStatus(_ context.Context, id, status string) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
r, ok := s.m.runs[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
r.Status = status
|
||||
s.m.runs[id] = r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryRuns) Complete(_ context.Context, id, status string, outputs, nodeOutputs json.RawMessage, errMsg string) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
r, ok := s.m.runs[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
r.Status = status
|
||||
r.FinishedAt = &now
|
||||
r.Outputs = outputs
|
||||
r.NodeOutputs = nodeOutputs
|
||||
if errMsg != "" {
|
||||
r.Errors = []string{errMsg}
|
||||
}
|
||||
s.m.runs[id] = r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryRuns) Get(_ context.Context, id string) (*Run, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
r, ok := s.m.runs[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *memoryRuns) ListByPipeline(_ context.Context, pipelineID string, limit int) ([]*Run, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
var out []*Run
|
||||
for _, r := range s.m.runs {
|
||||
if r.PipelineID == pipelineID {
|
||||
r := r
|
||||
out = append(out, &r)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memoryRuns) List(_ context.Context, limit int) ([]*Run, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
out := make([]*Run, 0, len(s.m.runs))
|
||||
for _, r := range s.m.runs {
|
||||
r := r
|
||||
out = append(out, &r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Runs returns the in-memory RunStore.
|
||||
func (m *Memory) Runs() RunStore { return &memoryRuns{m: m} }
|
||||
|
||||
// --- agents --------------------------------------------------------------
|
||||
|
||||
type memoryAgents struct{ m *Memory }
|
||||
|
||||
func (s *memoryAgents) Create(_ context.Context, a *Agent) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
s.m.agents[a.ID] = *a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAgents) Update(_ context.Context, a *Agent) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
if _, ok := s.m.agents[a.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.m.agents[a.ID] = *a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAgents) Get(_ context.Context, id string) (*Agent, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
a, ok := s.m.agents[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *memoryAgents) Delete(_ context.Context, id string) error {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
if _, ok := s.m.agents[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.m.agents, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAgents) List(_ context.Context) ([]*Agent, error) {
|
||||
s.m.mu.Lock()
|
||||
defer s.m.mu.Unlock()
|
||||
out := make([]*Agent, 0, len(s.m.agents))
|
||||
for _, a := range s.m.agents {
|
||||
a := a
|
||||
out = append(out, &a)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Agents returns the in-memory AgentStore.
|
||||
func (m *Memory) Agents() AgentStore { return &memoryAgents{m: m} }
|
||||
@@ -62,6 +62,9 @@ func (m *Mongo) Pipelines() PipelineStore { return &mongoPipelines{coll: m.db.Co
|
||||
// Runs returns the RunStore backed by this Mongo connection.
|
||||
func (m *Mongo) Runs() RunStore { return &mongoRuns{coll: m.db.Collection("runs")} }
|
||||
|
||||
// Agents returns the AgentStore backed by this Mongo connection.
|
||||
func (m *Mongo) Agents() AgentStore { return &mongoAgents{coll: m.db.Collection("agents")} }
|
||||
|
||||
func (m *Mongo) ensureIndexes(ctx context.Context) error {
|
||||
if _, err := m.db.Collection("pipelines").Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{Keys: bson.D{{Key: "updated_at", Value: -1}}},
|
||||
@@ -74,6 +77,11 @@ func (m *Mongo) ensureIndexes(ctx context.Context) error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("runs indexes: %w", err)
|
||||
}
|
||||
if _, err := m.db.Collection("agents").Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{Keys: bson.D{{Key: "updated_at", Value: -1}}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("agents indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -384,3 +392,58 @@ func bsonToJSON(r bson.Raw) (json.RawMessage, error) {
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// --- agents ---------------------------------------------------------------
|
||||
|
||||
type mongoAgents struct{ coll *mongo.Collection }
|
||||
|
||||
func (s *mongoAgents) Create(ctx context.Context, a *Agent) error {
|
||||
_, err := s.coll.InsertOne(ctx, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *mongoAgents) Update(ctx context.Context, a *Agent) error {
|
||||
res, err := s.coll.ReplaceOne(ctx, bson.M{"_id": a.ID}, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mongoAgents) Get(ctx context.Context, id string) (*Agent, error) {
|
||||
var a Agent
|
||||
if err := s.coll.FindOne(ctx, bson.M{"_id": id}).Decode(&a); err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *mongoAgents) Delete(ctx context.Context, id string) error {
|
||||
res, err := s.coll.DeleteOne(ctx, bson.M{"_id": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mongoAgents) List(ctx context.Context) ([]*Agent, error) {
|
||||
cur, err := s.coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var out []*Agent
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -59,3 +59,41 @@ type RunStore interface {
|
||||
ListByPipeline(ctx context.Context, pipelineID string, limit int) ([]*Run, error)
|
||||
List(ctx context.Context, limit int) ([]*Run, error)
|
||||
}
|
||||
|
||||
// AuthStatus reflects the result of the most recent PAT/repo auth probe.
|
||||
type AuthStatus string
|
||||
|
||||
const (
|
||||
AuthUntested AuthStatus = "untested"
|
||||
AuthOK AuthStatus = "ok"
|
||||
AuthFailed AuthStatus = "failed"
|
||||
)
|
||||
|
||||
// Agent is an agent repo registered with Langship. The PAT and webhook
|
||||
// secret are stored server-side; the API layer scrubs them before the
|
||||
// record leaves the boundary (see pkg/api/agents.go).
|
||||
type Agent struct {
|
||||
ID string `json:"id" bson:"_id"`
|
||||
Name string `json:"name" bson:"name"`
|
||||
RepoURL string `json:"repoUrl" bson:"repo_url"`
|
||||
Ref string `json:"ref,omitempty" bson:"ref,omitempty"`
|
||||
PAT string `json:"-" bson:"pat,omitempty"`
|
||||
WebhookID int64 `json:"webhookId,omitempty" bson:"webhook_id,omitempty"`
|
||||
WebhookSecret string `json:"-" bson:"webhook_secret,omitempty"`
|
||||
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty" bson:"webhook_installed_at,omitempty"`
|
||||
AuthStatus AuthStatus `json:"authStatus,omitempty" bson:"auth_status,omitempty"`
|
||||
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty" bson:"auth_checked_at,omitempty"`
|
||||
AttachedPipelines []string `json:"attachedPipelines,omitempty" bson:"attached_pipelines,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt" bson:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" bson:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentStore persists agent registrations. Update mutates the entire
|
||||
// record; callers do read-modify-write under their own consistency model.
|
||||
type AgentStore interface {
|
||||
Create(ctx context.Context, a *Agent) error
|
||||
Get(ctx context.Context, id string) (*Agent, error)
|
||||
Update(ctx context.Context, a *Agent) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context) ([]*Agent, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Eye, EyeOff, KeyRound, Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const router = useRouter();
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [pat, setPat] = useState("");
|
||||
const [showPat, setShowPat] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = repoUrl.trim();
|
||||
if (!url) throw new Error("Repository URL is required");
|
||||
await api.createAgent({ repoUrl: url, pat: pat.trim() || undefined });
|
||||
router.push("/agents");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 p-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/agents">
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Add agent</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Link a git repository that contains your agent code. The PAT is
|
||||
stored encrypted server-side and used only for git operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repository</CardTitle>
|
||||
<CardDescription>
|
||||
HTTPS or SSH URL — public repos can leave the PAT blank.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repoUrl">Git URL</Label>
|
||||
<Input
|
||||
id="repoUrl"
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/org/agent-repo.git"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Name is auto-derived from the repo path (e.g.{" "}
|
||||
<code className="font-mono">org/repo</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pat" className="flex items-center gap-1">
|
||||
<KeyRound className="size-3.5" />
|
||||
Personal access token (optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="pat"
|
||||
type={showPat ? "text" : "password"}
|
||||
value={pat}
|
||||
onChange={(e) => setPat(e.target.value)}
|
||||
placeholder="ghp_… or glpat_…"
|
||||
spellCheck={false}
|
||||
autoComplete="new-password"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPat((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPat ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showPat ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Required for private repos. Scope:{" "}
|
||||
<code className="font-mono">repo</code> read access is enough.
|
||||
Never returned by the API after save.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/agents">Cancel</Link>
|
||||
</Button>
|
||||
<Button onClick={onSave} disabled={saving || !repoUrl.trim()}>
|
||||
<Save />
|
||||
{saving ? "Saving…" : "Add agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bot,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api, type Agent } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const list = await api.listAgents();
|
||||
list.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
||||
setAgents(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "failed to load");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function onDelete(id: string) {
|
||||
if (!confirm("Remove this agent?")) return;
|
||||
try {
|
||||
await api.deleteAgent(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Agents</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Agent repositories Langship watches and deploys. Add a git URL — we
|
||||
track the ref and trigger pipelines on push.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/agents/new">
|
||||
<Plus />
|
||||
Add agent
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agents === null ? (
|
||||
<SkeletonGrid />
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{agents.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/agents/view/?id=${encodeURIComponent(a.id)}`}
|
||||
className="group block"
|
||||
>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate">{a.name}</CardTitle>
|
||||
<CardDescription className="mt-0.5 truncate text-[11px]">
|
||||
{a.ref || "main"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{a.hasPat && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<KeyRound className="size-3" />
|
||||
PAT
|
||||
</Badge>
|
||||
)}
|
||||
{a.webhookInstalled && (
|
||||
<Badge variant="success">webhook</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
className="flex items-center gap-1 truncate text-xs text-muted-foreground"
|
||||
title={a.repoUrl}
|
||||
>
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
<span className="truncate">{a.repoUrl}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>added {formatDate(a.createdAt)}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(a.id);
|
||||
}}
|
||||
aria-label="Remove agent"
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-1/3 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">No agents yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a git repository containing your agent code.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/agents/new">Add your first agent</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Github,
|
||||
KeyRound,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
Webhook,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
api,
|
||||
type Agent,
|
||||
type AuthStatus,
|
||||
type FlowSummary,
|
||||
type Run,
|
||||
type ServerConfig,
|
||||
} from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading…</div>}>
|
||||
<AgentDetail />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDetail() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const id = params.get("id") ?? "";
|
||||
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [pipelines, setPipelines] = useState<FlowSummary[]>([]);
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null); // which action is in flight
|
||||
const [showPipelinePicker, setShowPipelinePicker] = useState(false);
|
||||
|
||||
async function load() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const [a, cfg, allPipes] = await Promise.all([
|
||||
api.getAgent(id),
|
||||
api.getConfig().catch(() => null),
|
||||
api.listFlows().catch(() => []),
|
||||
]);
|
||||
setAgent(a);
|
||||
setConfig(cfg);
|
||||
setPipelines(allPipes);
|
||||
// Pull recent runs across all attached pipelines.
|
||||
if (a.attachedPipelines?.length) {
|
||||
const lists = await Promise.all(
|
||||
a.attachedPipelines.map((pid) =>
|
||||
api.listRuns({ pipelineId: pid, limit: 5 }).catch(() => [])
|
||||
)
|
||||
);
|
||||
const merged = lists.flat();
|
||||
merged.sort((x, y) => (y.startedAt || "").localeCompare(x.startedAt || ""));
|
||||
setRuns(merged.slice(0, 10));
|
||||
} else {
|
||||
setRuns([]);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "load failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function withBusy<T>(label: string, fn: () => Promise<T>) {
|
||||
setBusy(label);
|
||||
setError(null);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : `${label} failed`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onTrigger() {
|
||||
await withBusy("trigger", async () => {
|
||||
const res = await api.triggerAgent(id);
|
||||
if (res.executionIds?.length) {
|
||||
router.push(
|
||||
`/executions/view/?id=${encodeURIComponent(res.executionIds[0])}`
|
||||
);
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onTestAuth() {
|
||||
await withBusy("test-auth", async () => {
|
||||
await api.testAgentAuth(id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function onInstallWebhook() {
|
||||
await withBusy("webhook-install", async () => {
|
||||
await api.installAgentWebhook(id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function onUninstallWebhook() {
|
||||
if (!confirm("Uninstall the GitHub webhook for this agent?")) return;
|
||||
await withBusy("webhook-uninstall", async () => {
|
||||
await api.uninstallAgentWebhook(id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function onAttachPipeline(pipelineId: string) {
|
||||
await withBusy("attach", async () => {
|
||||
await api.attachPipeline(id, pipelineId);
|
||||
setShowPipelinePicker(false);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function onDetachPipeline(pipelineId: string) {
|
||||
if (!confirm("Detach this pipeline from the agent?")) return;
|
||||
await withBusy("detach", async () => {
|
||||
await api.detachPipeline(id, pipelineId);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function onDeleteAgent() {
|
||||
if (!confirm("Delete this agent? Webhook will be removed too.")) return;
|
||||
await withBusy("delete", async () => {
|
||||
await api.deleteAgent(id);
|
||||
router.push("/agents");
|
||||
});
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
Missing <code>id</code> query param.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="p-6 space-y-3">
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading agent…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastRun = runs[0];
|
||||
const attachedPipelineDetails = (agent.attachedPipelines ?? [])
|
||||
.map((pid) => pipelines.find((p) => p.id === pid))
|
||||
.filter((p): p is FlowSummary => Boolean(p));
|
||||
const attachable = pipelines.filter(
|
||||
(p) => !agent.attachedPipelines?.includes(p.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/agents">
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{agent.name}</h1>
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">{agent.id}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteAgent}
|
||||
disabled={busy === "delete"}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Overview ---------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={agent.repoUrl} target="_blank" rel="noreferrer">
|
||||
<Github />
|
||||
Repository
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onTrigger}
|
||||
disabled={
|
||||
busy === "trigger" ||
|
||||
!config?.orchestratorEnabled ||
|
||||
!agent.attachedPipelines?.length
|
||||
}
|
||||
>
|
||||
<Play />
|
||||
{busy === "trigger" ? "Triggering…" : "Trigger run"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<Field label="Repository">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm">{agent.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<WebhookBadge agent={agent} />
|
||||
<AuthBadge status={agent.authStatus ?? "untested"} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Last run">
|
||||
{lastRun ? (
|
||||
<Link
|
||||
href={`/executions/view/?id=${encodeURIComponent(lastRun.id)}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
<RunStatus status={lastRun.status} />
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{formatDate(lastRun.startedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No runs yet.</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Pipelines">
|
||||
{attachedPipelineDetails.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
None attached.{" "}
|
||||
<Link href="/flows/new" className="underline hover:text-foreground">
|
||||
Create one
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
{attachedPipelineDetails.length} attached
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{agent.attachedPipelines?.length ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pushes to{" "}
|
||||
<code className="font-mono text-xs">{agent.name}</code> route through
|
||||
this agent’s pipelines (matched by branch).
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{agent.webhookUrl && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Webhook className="size-3.5" />
|
||||
<code className="break-all font-mono">{agent.webhookUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Repo / webhook -------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="font-mono text-sm">{agent.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Token{" "}
|
||||
{agent.hasPat ? (
|
||||
<span className="font-mono">********</span>
|
||||
) : (
|
||||
<span>not set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Auth:</span>
|
||||
<AuthBadge status={agent.authStatus ?? "untested"} />
|
||||
{agent.authCheckedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {formatDate(agent.authCheckedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Webhook:</span>
|
||||
<WebhookBadge agent={agent} />
|
||||
{agent.webhookInstalledAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {formatDate(agent.webhookInstalledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent.webhookUrl && (
|
||||
<p className="break-all rounded-md border bg-muted/30 p-2 font-mono text-[11px]">
|
||||
{agent.webhookUrl}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!config?.webhooksAvailable && !agent.webhookInstalled && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
FLOW_PUBLIC_URL is not configured on the server. Set it (e.g. to
|
||||
a <code className="font-mono">cloudflared</code> tunnel) to install
|
||||
webhooks.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTestAuth}
|
||||
disabled={!agent.hasPat || busy === "test-auth"}
|
||||
>
|
||||
{busy === "test-auth" ? "Testing…" : "Test auth"}
|
||||
</Button>
|
||||
{agent.webhookInstalled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUninstallWebhook}
|
||||
disabled={busy === "webhook-uninstall"}
|
||||
>
|
||||
{busy === "webhook-uninstall" ? "Removing…" : "Uninstall webhook"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onInstallWebhook}
|
||||
disabled={
|
||||
!agent.hasPat ||
|
||||
!config?.webhooksAvailable ||
|
||||
busy === "webhook-install"
|
||||
}
|
||||
>
|
||||
<Webhook />
|
||||
{busy === "webhook-install" ? "Installing…" : "Install webhook"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pipelines ----------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Pipelines</CardTitle>
|
||||
{attachable.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowPipelinePicker((v) => !v)}
|
||||
>
|
||||
<Plus />
|
||||
Add pipeline
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" disabled>
|
||||
No more to add
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{attachedPipelineDetails.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No pipelines attached. Click “Add pipeline” to bind one
|
||||
(or create one in{" "}
|
||||
<Link href="/flows/new" className="underline">
|
||||
/flows/new
|
||||
</Link>
|
||||
).
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{attachedPipelineDetails.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/flows/view/?id=${encodeURIComponent(p.id)}`}
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
>
|
||||
{p.name || "Untitled"}
|
||||
</Link>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{p.nodeCount} nodes · {formatDate(p.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Detach pipeline"
|
||||
onClick={() => onDetachPipeline(p.id)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showPipelinePicker && attachable.length > 0 && (
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Attach a pipeline
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attachable.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAttachPipeline(p.id)}
|
||||
disabled={busy === "attach"}
|
||||
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="text-[11px] text-muted-foreground">
|
||||
{p.nodeCount} nodes
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent runs ------------------------------------------------------ */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Recent runs</CardTitle>
|
||||
{runs.length > 0 && (
|
||||
<Link
|
||||
href="/executions/view"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No runs yet — trigger one from the Overview block above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{runs.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between py-2">
|
||||
<Link
|
||||
href={`/executions/view/?id=${encodeURIComponent(r.id)}`}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RunStatus status={r.status} />
|
||||
<span className="truncate font-mono text-xs">{r.id}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{r.pipelineName || r.pipelineId} ·{" "}
|
||||
{formatDate(r.startedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthBadge({ status }: { status: AuthStatus }) {
|
||||
if (status === "ok") {
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
auth ok
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="size-3" />
|
||||
auth failed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">auth untested</Badge>;
|
||||
}
|
||||
|
||||
function WebhookBadge({ agent }: { agent: Agent }) {
|
||||
if (agent.webhookInstalled) {
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<Webhook className="size-3" />
|
||||
webhook installed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Webhook className="size-3" />
|
||||
no webhook
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function RunStatus({ status }: { status: string }) {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "success" || s === "completed") {
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (s === "failed" || s === "error") {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="size-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
|
||||
// Avoid unused-import lint when the symbol is referenced only by type.
|
||||
void KeyRound;
|
||||
void ExternalLink;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -57,6 +58,12 @@ const primary: NavItem[] = [
|
||||
icon: Activity,
|
||||
match: (p) => p.startsWith("/executions"),
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
href: "/agents",
|
||||
icon: Bot,
|
||||
match: (p) => p.startsWith("/agents"),
|
||||
},
|
||||
];
|
||||
|
||||
const docs = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { lookup } from "@/lib/node-catalog";
|
||||
import type { PipelineNode } from "@/lib/pipeline-graph";
|
||||
import { NodeForm, hasTypedForm } from "./node-form";
|
||||
|
||||
interface InspectorProps {
|
||||
node: PipelineNode | null;
|
||||
@@ -21,14 +22,22 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
const [name, setName] = useState("");
|
||||
const [paramsText, setParamsText] = useState("{}");
|
||||
const [paramsErr, setParamsErr] = useState<string | null>(null);
|
||||
const [showJSON, setShowJSON] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setName(node.name);
|
||||
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
|
||||
setParamsErr(null);
|
||||
setShowJSON(false);
|
||||
}, [node?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Keep the JSON textarea in sync when typed-form edits change parameters.
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
|
||||
}, [node?.parameters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="w-80 shrink-0 border-l bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
@@ -38,6 +47,7 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
}
|
||||
|
||||
const entry = lookup(node.type);
|
||||
const typed = hasTypedForm(node.type);
|
||||
|
||||
function commitName(next: string) {
|
||||
if (!node) return;
|
||||
@@ -89,21 +99,61 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-params">Parameters (JSON)</Label>
|
||||
<Textarea
|
||||
id="node-params"
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
{typed ? (
|
||||
<div className="space-y-3 rounded-md border bg-background p-3">
|
||||
<NodeForm node={node} onChange={onChange} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-params">Parameters (JSON)</Label>
|
||||
<Textarea
|
||||
id="node-params"
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{typed && (
|
||||
<div className="rounded-md border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-xs font-medium hover:bg-accent"
|
||||
onClick={() => setShowJSON((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
{showJSON ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Raw JSON
|
||||
</span>
|
||||
</button>
|
||||
{showJSON && (
|
||||
<div className="border-t p-2">
|
||||
<Textarea
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="mt-1 text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry && (
|
||||
<div className="rounded-md border bg-background p-2 text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { PipelineNode } from "@/lib/pipeline-graph";
|
||||
|
||||
interface NodeFormProps {
|
||||
node: PipelineNode;
|
||||
onChange: (next: PipelineNode) => void;
|
||||
}
|
||||
|
||||
// Typed forms per node type. Anything we don't know about renders a generic
|
||||
// JSON view (handled by the inspector — this component returns null in that
|
||||
// case so the parent shows the JSON fallback).
|
||||
//
|
||||
// Each form mutates node.parameters and calls onChange with the updated node.
|
||||
// We deliberately keep these dumb (no internal state); the inspector owns
|
||||
// debouncing and persistence.
|
||||
export function NodeForm({ node, onChange }: NodeFormProps) {
|
||||
switch (node.type) {
|
||||
case "flow-nodes-base.trigger":
|
||||
return <TriggerForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.build":
|
||||
return <BuildForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.test":
|
||||
return <TestForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.eval":
|
||||
return <EvalForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.policy":
|
||||
return <PolicyForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.waitForApproval":
|
||||
return <ApprovalForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.deploy":
|
||||
return <DeployForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.promote":
|
||||
return <PromoteForm node={node} onChange={onChange} />;
|
||||
case "flow-nodes-base.rollback":
|
||||
return <RollbackForm node={node} onChange={onChange} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if we render a typed form for this type (so the JSON
|
||||
* fallback can be hidden). */
|
||||
export function hasTypedForm(type: string): boolean {
|
||||
return [
|
||||
"flow-nodes-base.trigger",
|
||||
"flow-nodes-base.build",
|
||||
"flow-nodes-base.test",
|
||||
"flow-nodes-base.eval",
|
||||
"flow-nodes-base.policy",
|
||||
"flow-nodes-base.waitForApproval",
|
||||
"flow-nodes-base.deploy",
|
||||
"flow-nodes-base.promote",
|
||||
"flow-nodes-base.rollback",
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
function setParam<T>(node: PipelineNode, key: string, value: T): PipelineNode {
|
||||
return {
|
||||
...node,
|
||||
parameters: { ...(node.parameters ?? {}), [key]: value },
|
||||
};
|
||||
}
|
||||
|
||||
function getString(node: PipelineNode, key: string, fallback = ""): string {
|
||||
const v = node.parameters?.[key];
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function getNumber(node: PipelineNode, key: string, fallback = 0): number {
|
||||
const v = node.parameters?.[key];
|
||||
return typeof v === "number" ? v : fallback;
|
||||
}
|
||||
|
||||
function getStringArray(node: PipelineNode, key: string): string[] {
|
||||
const v = node.parameters?.[key];
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
// --- forms ----------------------------------------------------------------
|
||||
|
||||
function TriggerForm({ node, onChange }: NodeFormProps) {
|
||||
const mode = getString(node, "mode", "manual");
|
||||
const cron = getString(node, "cron", "0 * * * *");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Mode</Label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="webhook">Git webhook (push)</option>
|
||||
<option value="schedule">Scheduled (cron)</option>
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How runs are dispatched. Webhook + manual are wired today; schedule
|
||||
lands once the cron worker exists.
|
||||
</p>
|
||||
</div>
|
||||
{mode === "schedule" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cron">Cron expression</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={cron}
|
||||
onChange={(e) => onChange(setParam(node, "cron", e.target.value))}
|
||||
placeholder="0 * * * *"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildForm({ node, onChange }: NodeFormProps) {
|
||||
const command = getString(
|
||||
node,
|
||||
"command",
|
||||
"docker build -t $AGENT_NAME:$COMMIT_SHA ."
|
||||
);
|
||||
const workdir = getString(node, "workdir", ".");
|
||||
const timeout = getNumber(node, "timeoutSeconds", 600);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="command">Command</Label>
|
||||
<Textarea
|
||||
id="command"
|
||||
rows={3}
|
||||
value={command}
|
||||
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runs in a clone of the agent repo. Available env:{" "}
|
||||
<code>$AGENT_NAME</code>, <code>$REPO_URL</code>,{" "}
|
||||
<code>$COMMIT_SHA</code>, <code>$REF</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="workdir">Workdir</Label>
|
||||
<Input
|
||||
id="workdir"
|
||||
value={workdir}
|
||||
onChange={(e) => onChange(setParam(node, "workdir", e.target.value))}
|
||||
placeholder="."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="timeout">Timeout (s)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={timeout}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestForm({ node, onChange }: NodeFormProps) {
|
||||
const command = getString(node, "command", "pytest -q");
|
||||
const workdir = getString(node, "workdir", ".");
|
||||
const timeout = getNumber(node, "timeoutSeconds", 600);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="t-cmd">Command</Label>
|
||||
<Textarea
|
||||
id="t-cmd"
|
||||
rows={3}
|
||||
value={command}
|
||||
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="t-wd">Workdir</Label>
|
||||
<Input
|
||||
id="t-wd"
|
||||
value={workdir}
|
||||
onChange={(e) => onChange(setParam(node, "workdir", e.target.value))}
|
||||
placeholder="."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="t-to">Timeout (s)</Label>
|
||||
<Input
|
||||
id="t-to"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={timeout}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Stub today: logs the command and returns success. Wire to a real
|
||||
executor when the test runner exists.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvalForm({ node, onChange }: NodeFormProps) {
|
||||
const suite = getString(node, "suite", "default");
|
||||
const metric = getString(node, "metric", "accuracy");
|
||||
const threshold = getNumber(node, "threshold", 0.8);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="e-suite">Suite</Label>
|
||||
<Input
|
||||
id="e-suite"
|
||||
value={suite}
|
||||
onChange={(e) => onChange(setParam(node, "suite", e.target.value))}
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="e-metric">Metric</Label>
|
||||
<Input
|
||||
id="e-metric"
|
||||
value={metric}
|
||||
onChange={(e) => onChange(setParam(node, "metric", e.target.value))}
|
||||
placeholder="accuracy"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="e-threshold">Threshold</Label>
|
||||
<Input
|
||||
id="e-threshold"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
max={1}
|
||||
value={threshold}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "threshold", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyForm({ node, onChange }: NodeFormProps) {
|
||||
const rules = getStringArray(node, "rules");
|
||||
const mode = getString(node, "mode", "enforce");
|
||||
const text = rules.join("\n");
|
||||
|
||||
function commit(t: string) {
|
||||
const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
onChange(setParam(node, "rules", parsed));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Enforcement</Label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="enforce">Enforce — fail on violation</option>
|
||||
<option value="warn">Warn — log only</option>
|
||||
<option value="audit">Audit — record, never block</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="p-rules">Rules (one per line)</Label>
|
||||
<Textarea
|
||||
id="p-rules"
|
||||
rows={6}
|
||||
defaultValue={text}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
placeholder={"max_monthly_spend_usd:1000\nno_pii_in_outputs"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalForm({ node, onChange }: NodeFormProps) {
|
||||
const reason = getString(node, "reason", "Manual review");
|
||||
const reviewers = getStringArray(node, "reviewers");
|
||||
const text = reviewers.join("\n");
|
||||
|
||||
function commit(t: string) {
|
||||
const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
onChange(setParam(node, "reviewers", parsed));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="a-reason">Reason</Label>
|
||||
<Input
|
||||
id="a-reason"
|
||||
value={reason}
|
||||
onChange={(e) => onChange(setParam(node, "reason", e.target.value))}
|
||||
placeholder="Manual review before deploy"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Shown in the Resume panel on the executions page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="a-reviewers">Reviewers (one per line)</Label>
|
||||
<Textarea
|
||||
id="a-reviewers"
|
||||
rows={4}
|
||||
defaultValue={text}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeployForm({ node, onChange }: NodeFormProps) {
|
||||
const runtime = getString(node, "runtime", "kubernetes");
|
||||
const env = getString(node, "env", "dev");
|
||||
const target = getString(node, "target", "");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Runtime</Label>
|
||||
<select
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "runtime", e.target.value))
|
||||
}
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="kubernetes">Kubernetes</option>
|
||||
<option value="bedrock">AWS Bedrock AgentCore</option>
|
||||
<option value="vertex">GCP Vertex Agent Engine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-env">Environment</Label>
|
||||
<Input
|
||||
id="d-env"
|
||||
value={env}
|
||||
onChange={(e) => onChange(setParam(node, "env", e.target.value))}
|
||||
placeholder="dev"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-target">Target</Label>
|
||||
<Input
|
||||
id="d-target"
|
||||
value={target}
|
||||
onChange={(e) => onChange(setParam(node, "target", e.target.value))}
|
||||
placeholder="cluster name / project / agent ID"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runtime-specific. E.g. for K8s: cluster + namespace; for Vertex: GCP
|
||||
project + agent ID.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteForm({ node, onChange }: NodeFormProps) {
|
||||
const fromEnv = getString(node, "fromEnv", "staging");
|
||||
const toEnv = getString(node, "toEnv", "prod");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pr-from">From env</Label>
|
||||
<Input
|
||||
id="pr-from"
|
||||
value={fromEnv}
|
||||
onChange={(e) => onChange(setParam(node, "fromEnv", e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pr-to">To env</Label>
|
||||
<Input
|
||||
id="pr-to"
|
||||
value={toEnv}
|
||||
onChange={(e) => onChange(setParam(node, "toEnv", e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Promote follows the project’s branching strategy. Stub today;
|
||||
will execute the real promotion (tag/branch/merge) when wired.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollbackForm({ node, onChange }: NodeFormProps) {
|
||||
const revision = getString(node, "revision", "previous");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rb-rev">Revision</Label>
|
||||
<Input
|
||||
id="rb-rev"
|
||||
value={revision}
|
||||
onChange={(e) => onChange(setParam(node, "revision", e.target.value))}
|
||||
placeholder='"previous" or a specific build ID'
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,44 @@ export type ExecutionStatus = {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export type AuthStatus = "untested" | "ok" | "failed";
|
||||
|
||||
export type Agent = {
|
||||
id: string;
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
ref?: string;
|
||||
hasPat: boolean;
|
||||
webhookId?: number;
|
||||
webhookUrl?: string;
|
||||
webhookInstalled: boolean;
|
||||
webhookInstalledAt?: string;
|
||||
authStatus?: AuthStatus;
|
||||
authCheckedAt?: string;
|
||||
attachedPipelines?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Run = {
|
||||
id: string;
|
||||
pipelineId: string;
|
||||
pipelineName?: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
triggerData?: unknown;
|
||||
outputs?: unknown;
|
||||
nodeOutputs?: unknown;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export type ServerConfig = {
|
||||
publicUrl: string;
|
||||
webhooksAvailable: boolean;
|
||||
orchestratorEnabled: boolean;
|
||||
};
|
||||
|
||||
const base = ""; // same-origin
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
@@ -80,4 +118,58 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(handle<{ message: string }>),
|
||||
|
||||
// --- config ---
|
||||
getConfig: () => fetch(`${base}/api/config`).then(handle<ServerConfig>),
|
||||
|
||||
// --- agents ---
|
||||
listAgents: () => fetch(`${base}/api/agents`).then(handle<Agent[]>),
|
||||
|
||||
createAgent: (body: { repoUrl: string; pat?: string; ref?: string; name?: string }) =>
|
||||
fetch(`${base}/api/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(handle<Agent>),
|
||||
|
||||
getAgent: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}`).then(handle<Agent>),
|
||||
|
||||
deleteAgent: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}`, { method: "DELETE" }).then(handle<void>),
|
||||
|
||||
testAgentAuth: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}/test-auth`, { method: "POST" }).then(
|
||||
handle<{ authStatus: AuthStatus; authCheckedAt: string; error?: string }>
|
||||
),
|
||||
|
||||
installAgentWebhook: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}/webhook`, { method: "POST" }).then(handle<Agent>),
|
||||
|
||||
uninstallAgentWebhook: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}/webhook`, { method: "DELETE" }).then(handle<Agent>),
|
||||
|
||||
attachPipeline: (id: string, pipelineId: string) =>
|
||||
fetch(`${base}/api/agents/${id}/pipelines/${pipelineId}`, {
|
||||
method: "POST",
|
||||
}).then(handle<Agent>),
|
||||
|
||||
detachPipeline: (id: string, pipelineId: string) =>
|
||||
fetch(`${base}/api/agents/${id}/pipelines/${pipelineId}`, {
|
||||
method: "DELETE",
|
||||
}).then(handle<void>),
|
||||
|
||||
triggerAgent: (id: string) =>
|
||||
fetch(`${base}/api/agents/${id}/trigger`, { method: "POST" }).then(
|
||||
handle<{ executionIds: string[] }>
|
||||
),
|
||||
|
||||
// --- runs ---
|
||||
listRuns: (params?: { pipelineId?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.pipelineId) qs.set("pipeline_id", params.pipelineId);
|
||||
if (params?.limit) qs.set("limit", String(params.limit));
|
||||
const q = qs.toString();
|
||||
return fetch(`${base}/api/executions${q ? `?${q}` : ""}`).then(handle<Run[]>);
|
||||
},
|
||||
};
|
||||
|
||||
+143
-26
@@ -1,10 +1,27 @@
|
||||
// Catalog of node types the canvas can drop. Mirrors the executors registered
|
||||
// in pkg/executors/registry.go RegisterAll(). Keep this in sync with the Go
|
||||
// registry — anything listed here without a matching executor will fail at run
|
||||
// time with "executor not implemented for node type".
|
||||
// Catalog of node types the canvas can drop. These are the Langship CI/CD
|
||||
// pipeline primitives — Trigger → Build → Test → Eval → Policy → Approval
|
||||
// → Deploy → Promote → Rollback. The canonical type strings keep the
|
||||
// `flow-nodes-base.` prefix so they line up with the executor registry in
|
||||
// pkg/executors.
|
||||
//
|
||||
// Keep this in sync with pkg/executors/registry.go::RegisterAll(). Anything
|
||||
// listed here without a matching executor will fail at run time with
|
||||
// "executor not implemented for node type".
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { Play, Settings2, Pause, CircleSlash } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
Hammer,
|
||||
TestTube2,
|
||||
Gauge,
|
||||
ShieldCheck,
|
||||
Pause,
|
||||
Rocket,
|
||||
ArrowUpFromLine,
|
||||
Undo2,
|
||||
CircleSlash,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
export type CatalogEntry = {
|
||||
/** Runtime type string: flow-nodes-base.X */
|
||||
@@ -24,10 +41,9 @@ export type CatalogEntry = {
|
||||
/** Default Settings (retry etc.) */
|
||||
settings?: Record<string, unknown>;
|
||||
/** Group in palette */
|
||||
group: "trigger" | "transform" | "human";
|
||||
group: "trigger" | "build" | "verify" | "gate" | "deploy" | "passthrough";
|
||||
};
|
||||
|
||||
// Only the executors registered in pkg/executors/registry.go::RegisterAll().
|
||||
export const CATALOG: CatalogEntry[] = [
|
||||
{
|
||||
type: "flow-nodes-base.trigger",
|
||||
@@ -36,45 +52,146 @@ export const CATALOG: CatalogEntry[] = [
|
||||
icon: Play,
|
||||
color: "bg-emerald-500",
|
||||
outputs: 1,
|
||||
defaults: {},
|
||||
defaults: { mode: "manual" },
|
||||
group: "trigger",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.build",
|
||||
label: "Build",
|
||||
description: "Clone the agent repo and run a build command (e.g. docker build).",
|
||||
icon: Hammer,
|
||||
color: "bg-amber-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
command: "docker build -t $AGENT_NAME:$COMMIT_SHA .",
|
||||
workdir: ".",
|
||||
timeoutSeconds: 600,
|
||||
},
|
||||
settings: { retryOnFail: true, maxTries: 2, waitBetweenTries: 5000 },
|
||||
group: "build",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.test",
|
||||
label: "Test",
|
||||
description: "Run unit / integration tests against the build artifact.",
|
||||
icon: TestTube2,
|
||||
color: "bg-sky-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
command: "pytest -q",
|
||||
workdir: ".",
|
||||
timeoutSeconds: 600,
|
||||
},
|
||||
group: "verify",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.eval",
|
||||
label: "Eval",
|
||||
description: "Run agent evals (LLM benchmarks, scoring suites).",
|
||||
icon: Gauge,
|
||||
color: "bg-violet-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
suite: "default",
|
||||
threshold: 0.8,
|
||||
metric: "accuracy",
|
||||
},
|
||||
group: "verify",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.policy",
|
||||
label: "Policy",
|
||||
description: "Apply governance rules (budget, safety, compliance gates).",
|
||||
icon: ShieldCheck,
|
||||
color: "bg-indigo-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
rules: ["max_monthly_spend_usd:1000", "no_pii_in_outputs"],
|
||||
mode: "enforce",
|
||||
},
|
||||
group: "gate",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.waitForApproval",
|
||||
label: "Approval",
|
||||
description: "Pause until a human (or quorum) approves continuation.",
|
||||
icon: Pause,
|
||||
color: "bg-fuchsia-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
reason: "Manual review",
|
||||
reviewers: [],
|
||||
},
|
||||
group: "gate",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.deploy",
|
||||
label: "Deploy",
|
||||
description: "Ship the artifact to a runtime (K8s / Bedrock / Vertex).",
|
||||
icon: Rocket,
|
||||
color: "bg-rose-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
runtime: "kubernetes",
|
||||
env: "dev",
|
||||
target: "",
|
||||
},
|
||||
group: "deploy",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.promote",
|
||||
label: "Promote",
|
||||
description: "Promote a deployed artifact to the next environment.",
|
||||
icon: ArrowUpFromLine,
|
||||
color: "bg-orange-500",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
fromEnv: "staging",
|
||||
toEnv: "prod",
|
||||
},
|
||||
group: "deploy",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.rollback",
|
||||
label: "Rollback",
|
||||
description: "Revert to a previous deployed revision.",
|
||||
icon: Undo2,
|
||||
color: "bg-red-600",
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
revision: "previous",
|
||||
},
|
||||
group: "deploy",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.set",
|
||||
label: "Set",
|
||||
description: "Define or transform fields on each item.",
|
||||
icon: Settings2,
|
||||
color: "bg-sky-500",
|
||||
color: "bg-slate-500",
|
||||
outputs: 1,
|
||||
defaults: { values: { string: [] } },
|
||||
group: "transform",
|
||||
group: "passthrough",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.noOp",
|
||||
label: "No-op",
|
||||
description: "Pass items through unchanged.",
|
||||
description: "Pass items through unchanged. Useful as a placeholder.",
|
||||
icon: CircleSlash,
|
||||
color: "bg-slate-500",
|
||||
color: "bg-slate-400",
|
||||
outputs: 1,
|
||||
defaults: {},
|
||||
group: "transform",
|
||||
},
|
||||
{
|
||||
type: "flow-nodes-base.waitForApproval",
|
||||
label: "Wait for approval",
|
||||
description: "Pause until a human resolves an awakeable.",
|
||||
icon: Pause,
|
||||
color: "bg-fuchsia-500",
|
||||
outputs: 1,
|
||||
defaults: { reason: "Manual review" },
|
||||
group: "human",
|
||||
group: "passthrough",
|
||||
},
|
||||
];
|
||||
|
||||
export const GROUP_LABELS: Record<CatalogEntry["group"], string> = {
|
||||
trigger: "Trigger",
|
||||
transform: "Transform",
|
||||
human: "Human-in-the-loop",
|
||||
build: "Build",
|
||||
verify: "Verify",
|
||||
gate: "Gate",
|
||||
deploy: "Deploy",
|
||||
passthrough: "Pass-through",
|
||||
};
|
||||
|
||||
/** Look up by runtime type. Returns undefined for unsupported types so the
|
||||
@@ -83,7 +200,7 @@ export function lookup(type: string): CatalogEntry | undefined {
|
||||
return CATALOG.find((c) => c.type === type);
|
||||
}
|
||||
|
||||
/** Suggest a unique name like "Set", "Set 2", "Set 3". */
|
||||
/** Suggest a unique name like "Build", "Build 2", "Build 3". */
|
||||
export function uniqueName(base: string, existing: Set<string>): string {
|
||||
if (!existing.has(base)) return base;
|
||||
let i = 2;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
// Where the Go backend is listening during `npm run dev`.
|
||||
const apiTarget = process.env.FLOW_API_URL ?? "http://localhost:8080";
|
||||
const apiTarget = process.env.FLOW_API_URL ?? "http://localhost:8090";
|
||||
|
||||
const nextConfig = {
|
||||
// Static export only at build time — the Go binary embeds ./out.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ server {
|
||||
# Proxy API calls to the Go service.
|
||||
# `flow` is the service name on the docker-compose network.
|
||||
location /api/ {
|
||||
proxy_pass http://flow:8080;
|
||||
proxy_pass http://flow:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Reference in New Issue
Block a user