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:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 64a857d1b7
commit 0a1874e736
21 changed files with 3175 additions and 117 deletions
+521
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+220
View File
@@ -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] + "…"
}
+68
View File
@@ -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
}
+227
View File
@@ -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} }
+63
View File
@@ -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
}
+38
View File
@@ -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)
}