relay-flow 0.2.9-alpha → 0.2.10-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "sort"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/run"
8
+ "github.com/rajpopat27/relay-flow/internal/workflow"
9
+ )
10
+
11
+ // WorkflowSummary is a read-only consumer DTO for compact workflow listing.
12
+ // The embedded definition keeps the existing JSON fields available while the
13
+ // additive fields provide the one-request execution summary used by the CLI.
14
+ type WorkflowSummary struct {
15
+ *workflow.Workflow
16
+ Repositories []string `json:"repositories,omitempty"`
17
+ NodeCount int `json:"nodeCount"`
18
+ ActiveRuns int `json:"activeRuns"`
19
+ LatestRun *run.Run `json:"latestRun,omitempty"`
20
+ }
21
+
22
+ // WorkflowDetail is a read-only query DTO. It deliberately contains domain
23
+ // values only; no executor, database, runner, or task-system implementation
24
+ // type crosses the Unix-socket boundary.
25
+ type WorkflowDetail struct {
26
+ *workflow.Workflow
27
+ Valid bool `json:"valid"`
28
+ ActiveRuns int `json:"activeRuns"`
29
+ RecentRuns []run.Run `json:"recentRuns"`
30
+ }
31
+
32
+ // WorkflowSummaryQueries is an optional server capability. Keeping it
33
+ // separate from Deps preserves the small existing service contract for test
34
+ // fakes and older composition roots.
35
+ type WorkflowSummaryQueries interface {
36
+ ListWorkflowSummaries(context.Context) ([]WorkflowSummary, error)
37
+ }
38
+
39
+ type WorkflowDetailQueries interface {
40
+ GetWorkflowDetail(context.Context, string) (WorkflowDetail, error)
41
+ }
42
+
43
+ type RunDetailQueries interface {
44
+ GetRunDetail(context.Context, string) (run.RunDetail, error)
45
+ }
46
+
47
+ // BuildWorkflowSummaries computes all summaries from one workflow list and one
48
+ // run list. The helper is used by the composition root and is intentionally
49
+ // independent of a durable executor implementation.
50
+ func BuildWorkflowSummaries(workflows []*workflow.Workflow, runs []run.Run) []WorkflowSummary {
51
+ latest := map[string]run.Run{}
52
+ active := map[string]int{}
53
+ for _, candidate := range runs {
54
+ if current, ok := latest[candidate.Workflow]; !ok || newerRun(candidate, current) {
55
+ latest[candidate.Workflow] = candidate
56
+ }
57
+ if candidate.State != run.StateCompleted && candidate.State != run.StateCanceled {
58
+ active[candidate.Workflow]++
59
+ }
60
+ }
61
+ out := make([]WorkflowSummary, 0, len(workflows))
62
+ for _, wf := range workflows {
63
+ if wf == nil {
64
+ continue
65
+ }
66
+ repos := append([]string(nil), wf.Repos...)
67
+ sort.Strings(repos)
68
+ var latestRun *run.Run
69
+ if candidate, ok := latest[wf.Name]; ok {
70
+ copy := candidate
71
+ latestRun = &copy
72
+ }
73
+ out = append(out, WorkflowSummary{
74
+ Workflow: wf, Repositories: repos, NodeCount: len(wf.Nodes),
75
+ ActiveRuns: active[wf.Name], LatestRun: latestRun,
76
+ })
77
+ }
78
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
79
+ return out
80
+ }
81
+
82
+ func newerRun(candidate, current run.Run) bool {
83
+ if candidate.StartedAt.After(current.StartedAt) {
84
+ return true
85
+ }
86
+ return candidate.StartedAt.Equal(current.StartedAt) && candidate.UpdatedAt.After(current.UpdatedAt)
87
+ }
88
+
89
+ // BuildWorkflowDetail builds the static-definition plus recent execution
90
+ // summary response without changing workflow validation or execution.
91
+ func BuildWorkflowDetail(wf *workflow.Workflow, runs []run.Run) WorkflowDetail {
92
+ recent := append([]run.Run(nil), runs...)
93
+ sort.SliceStable(recent, func(i, j int) bool {
94
+ if recent[i].UpdatedAt.Equal(recent[j].UpdatedAt) {
95
+ return recent[i].StartedAt.After(recent[j].StartedAt)
96
+ }
97
+ return recent[i].UpdatedAt.After(recent[j].UpdatedAt)
98
+ })
99
+ if len(recent) > 5 {
100
+ recent = recent[:5]
101
+ }
102
+ active := 0
103
+ for _, candidate := range runs {
104
+ if candidate.State != run.StateCompleted && candidate.State != run.StateCanceled {
105
+ active++
106
+ }
107
+ }
108
+ return WorkflowDetail{Workflow: wf, Valid: wf != nil, ActiveRuns: active, RecentRuns: recent}
109
+ }
@@ -0,0 +1,60 @@
1
+ package server
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ "github.com/rajpopat27/relay-flow/internal/task"
10
+ "github.com/rajpopat27/relay-flow/internal/workflow"
11
+ )
12
+
13
+ func TestWorkflowSummaryKeepsDefinitionFieldsOnJSONRoundTrip(t *testing.T) {
14
+ definition := &workflow.Workflow{Name: "basicFlow", Repos: []string{"payments"}, Nodes: map[string]workflow.Node{"start": {}, "end": {}}}
15
+ summary := WorkflowSummary{Workflow: definition, Repositories: []string{"payments"}, NodeCount: 2, ActiveRuns: 1}
16
+ raw, err := json.Marshal(summary)
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ var decoded WorkflowSummary
21
+ if err := json.Unmarshal(raw, &decoded); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ if decoded.Workflow == nil || decoded.Name != definition.Name || decoded.NodeCount != 2 || decoded.ActiveRuns != 1 {
25
+ t.Fatalf("decoded summary = %#v", decoded)
26
+ }
27
+ arrayRaw, err := json.Marshal([]WorkflowSummary{summary})
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ var legacy []*workflow.Workflow
32
+ if err := json.Unmarshal(arrayRaw, &legacy); err != nil || len(legacy) != 1 || legacy[0].Name != definition.Name {
33
+ t.Fatalf("legacy workflow decode = %#v, err=%v", legacy, err)
34
+ }
35
+ baseRaw, err := json.Marshal([]*workflow.Workflow{definition})
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ var fallback []WorkflowSummary
40
+ if err := json.Unmarshal(baseRaw, &fallback); err != nil || len(fallback) != 1 || fallback[0].Workflow == nil || fallback[0].Name != definition.Name {
41
+ t.Fatalf("fallback summary decode = %#v, err=%v", fallback, err)
42
+ }
43
+ }
44
+
45
+ func TestBuildWorkflowSummariesUsesLatestRunAndActiveCount(t *testing.T) {
46
+ start := time.Now().UTC().Add(-time.Hour)
47
+ workflows := []*workflow.Workflow{
48
+ {Name: "basicFlow", Repos: []string{"payments"}, Nodes: map[string]workflow.Node{
49
+ "start": {}, "end": {},
50
+ }},
51
+ }
52
+ runs := []run.Run{
53
+ {Workflow: "basicFlow", State: run.StateCompleted, StartedAt: start, UpdatedAt: start.Add(10 * time.Minute), Ticket: task.TicketRef{Key: "PAY-1"}},
54
+ {Workflow: "basicFlow", State: run.StateWaiting, StartedAt: start.Add(time.Minute), UpdatedAt: start.Add(20 * time.Minute), Ticket: task.TicketRef{Key: "PAY-2"}},
55
+ }
56
+ got := BuildWorkflowSummaries(workflows, runs)
57
+ if len(got) != 1 || got[0].ActiveRuns != 1 || got[0].LatestRun == nil || got[0].LatestRun.Ticket.Key != "PAY-2" {
58
+ t.Fatalf("summaries = %#v", got)
59
+ }
60
+ }
@@ -131,6 +131,8 @@ func mapErr(w http.ResponseWriter, err error) {
131
131
  switch {
132
132
  case errors.Is(err, run.ErrRestartConflict):
133
133
  writeErr(w, http.StatusConflict, "conflict", err.Error())
134
+ case errors.Is(err, run.ErrNotFound):
135
+ writeErr(w, http.StatusNotFound, "notFound", err.Error())
134
136
  case errors.Is(err, ErrNotFound):
135
137
  writeErr(w, http.StatusNotFound, "notFound", err.Error())
136
138
  case errors.Is(err, ErrConflict):
@@ -191,6 +193,18 @@ func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
191
193
  func (s *server) handleWorkflows(w http.ResponseWriter, r *http.Request) {
192
194
  switch r.Method {
193
195
  case http.MethodGet:
196
+ // The summary DTO embeds the original definition, so returning it is
197
+ // additive for clients that still decode []workflow.Workflow. Older
198
+ // composition roots without this optional capability use the base list.
199
+ if queries, ok := s.deps.(WorkflowSummaryQueries); ok {
200
+ summaries, err := queries.ListWorkflowSummaries(r.Context())
201
+ if err != nil {
202
+ mapErr(w, err)
203
+ return
204
+ }
205
+ writeOK(w, http.StatusOK, summaries)
206
+ return
207
+ }
194
208
  wfs, err := s.deps.ListWorkflows(r.Context())
195
209
  if err != nil {
196
210
  mapErr(w, err)
@@ -225,6 +239,15 @@ func (s *server) handleWorkflowByName(w http.ResponseWriter, r *http.Request) {
225
239
  }
226
240
  switch r.Method {
227
241
  case http.MethodGet:
242
+ if queries, ok := s.deps.(WorkflowDetailQueries); ok {
243
+ detail, err := queries.GetWorkflowDetail(r.Context(), name)
244
+ if err != nil {
245
+ mapErr(w, err)
246
+ return
247
+ }
248
+ writeOK(w, http.StatusOK, detail)
249
+ return
250
+ }
228
251
  wf, err := s.deps.GetWorkflow(r.Context(), name)
229
252
  if err != nil {
230
253
  mapErr(w, err)
@@ -544,6 +567,10 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
544
567
  filter.Repo = q.Get("repo")
545
568
  filter.Workflow = q.Get("workflow")
546
569
  filter.Ticket = q.Get("ticket")
570
+ if active := q.Get("active"); active != "" {
571
+ value := active == "1" || strings.EqualFold(active, "true")
572
+ filter.Active = &value
573
+ }
547
574
  runs, err := s.deps.ListRuns(r.Context(), filter)
548
575
  if err != nil {
549
576
  mapErr(w, err)
@@ -562,6 +589,15 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
562
589
  if !methodOnly(w, r, http.MethodGet) {
563
590
  return
564
591
  }
592
+ if queries, ok := s.deps.(RunDetailQueries); ok {
593
+ detail, err := queries.GetRunDetail(r.Context(), parts[0])
594
+ if err != nil {
595
+ mapErr(w, err)
596
+ return
597
+ }
598
+ writeOK(w, http.StatusOK, detail)
599
+ return
600
+ }
565
601
  rn, err := s.deps.GetRunByTicket(r.Context(), parts[0])
566
602
  if err != nil {
567
603
  mapErr(w, err)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.9-alpha",
3
+ "version": "0.2.10-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",