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.
- package/README.md +19 -5
- package/cmd/relay-flow/main.go +224 -23
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/serve.go +32 -1
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/server/client.go +39 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +36 -0
- package/package.json +1 -1
|
@@ -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 = ©
|
|
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)
|