relay-flow 0.3.13-alpha → 0.3.14-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.
Files changed (50) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +9 -0
  3. package/cmd/relay-flow/main.go +16 -7
  4. package/cmd/relay-flow/repo_registration.go +68 -0
  5. package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
  6. package/cmd/relay-flow/scenario_test.go +25 -0
  7. package/cmd/relay-flow/serve.go +33 -2
  8. package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
  9. package/cmd/relay-flow/startup_errors.go +85 -0
  10. package/cmd/relay-flow/startup_errors_test.go +88 -0
  11. package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
  12. package/cmd/relay-flow/startup_ordering_test.go +75 -0
  13. package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
  14. package/cmd/relay-flow/startup_readiness_test.go +116 -0
  15. package/cmd/relay-flow/startup_submission_test.go +123 -0
  16. package/internal/harness/opencode/opencode.go +14 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +14 -0
  20. package/internal/harness/startup.go +25 -0
  21. package/internal/harness/startup_test.go +85 -0
  22. package/internal/runner/herdr/herdr.go +9 -0
  23. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  24. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  25. package/internal/runner/herdr/herdrcli/response.go +6 -0
  26. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  27. package/internal/runner/herdr/startup_test.go +58 -0
  28. package/internal/runner/orca/orca.go +9 -0
  29. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  30. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  31. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  32. package/internal/runner/orca/startup_test.go +57 -0
  33. package/internal/runner/startup.go +27 -0
  34. package/internal/runner/startup_test.go +59 -0
  35. package/internal/task/beads/bdcli/bdcli.go +12 -0
  36. package/internal/task/beads/bdcli/startup.go +20 -0
  37. package/internal/task/beads/bdcli/startup_test.go +27 -0
  38. package/internal/task/beads/beads.go +1 -0
  39. package/internal/task/beads/startup.go +75 -0
  40. package/internal/task/beads/startup_test.go +155 -0
  41. package/internal/task/factory.go +6 -2
  42. package/internal/task/jira/jira.go +4 -3
  43. package/internal/task/jira/rest/client.go +40 -6
  44. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  45. package/internal/task/jira/rest/startup_test.go +133 -0
  46. package/internal/task/jira/startup.go +22 -0
  47. package/internal/task/jira/startup_test.go +78 -0
  48. package/internal/task/startup.go +25 -0
  49. package/internal/task/startup_test.go +51 -0
  50. package/package.json +1 -1
@@ -113,14 +113,27 @@ func New(site, email, token string) (*HTTPClient, error) {
113
113
  }
114
114
 
115
115
  func (c *HTTPClient) ValidateCredentials(ctx context.Context) error {
116
+ return c.validateCredentials(ctx, 4)
117
+ }
118
+
119
+ // ValidateCredentialsStartup performs a single authenticated read, without
120
+ // inheriting runtime retries or Retry-After delays. The shorter caller budget
121
+ // wins over the five-second ceiling and the normal HTTP client's timeout.
122
+ func (c *HTTPClient) ValidateCredentialsStartup(ctx context.Context) error {
123
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
124
+ defer cancel()
125
+ return c.validateCredentials(ctx, 0)
126
+ }
127
+
128
+ func (c *HTTPClient) validateCredentials(ctx context.Context, maxRetries int) error {
116
129
  var me struct {
117
130
  AccountID string `json:"accountId"`
118
131
  }
119
- if err := c.request(ctx, http.MethodGet, "/rest/api/3/myself", nil, nil, &me, true); err != nil {
132
+ if err := c.requestWithRetries(ctx, http.MethodGet, "/rest/api/3/myself", nil, nil, &me, true, maxRetries); err != nil {
120
133
  return fmt.Errorf("validate Jira credentials: %w", err)
121
134
  }
122
135
  if me.AccountID == "" {
123
- return errors.New("validate Jira credentials: Jira returned no account ID")
136
+ return errors.New("validate Jira credentials: malformed response: Jira returned no account ID")
124
137
  }
125
138
  return nil
126
139
  }
@@ -525,6 +538,10 @@ func (c *HTTPClient) issueState(key string) issueState {
525
538
  }
526
539
 
527
540
  func (c *HTTPClient) request(ctx context.Context, method, path string, query url.Values, body any, out any, safe bool) error {
541
+ return c.requestWithRetries(ctx, method, path, query, body, out, safe, 4)
542
+ }
543
+
544
+ func (c *HTTPClient) requestWithRetries(ctx context.Context, method, path string, query url.Values, body any, out any, safe bool, maxRetries int) error {
528
545
  var payload []byte
529
546
  var err error
530
547
  if body != nil {
@@ -554,27 +571,41 @@ func (c *HTTPClient) request(ctx context.Context, method, path string, query url
554
571
  req.Header.Set("Content-Type", "application/json")
555
572
  }
556
573
  slog.Debug("jira call", "method", method, "path", path)
557
- resp, callErr := c.http.Do(req)
574
+ client := c.http
575
+ if maxRetries == 0 {
576
+ // Startup is one authenticated read, not a redirect chain. Leave
577
+ // normal runtime client/redirect behavior unchanged.
578
+ singleAttempt := *c.http
579
+ singleAttempt.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
580
+ client = &singleAttempt
581
+ }
582
+ resp, callErr := client.Do(req)
558
583
  <-requestSlots
559
584
  if callErr != nil {
560
- return fmt.Errorf("Jira %s %s: %s", method, path, redact(callErr.Error(), c.token, c.email))
585
+ if ctx.Err() != nil {
586
+ return fmt.Errorf("Jira %s %s: timeout/cancellation: %w", method, path, ctx.Err())
587
+ }
588
+ return fmt.Errorf("Jira %s %s: connection failure: %s", method, path, redact(callErr.Error(), c.token, c.email))
561
589
  }
562
590
  raw, readErr := readBounded(resp.Body)
563
591
  resp.Body.Close()
564
592
  if readErr != nil {
593
+ if ctx.Err() != nil {
594
+ return fmt.Errorf("Jira %s %s: timeout/cancellation: %w", method, path, ctx.Err())
595
+ }
565
596
  return fmt.Errorf("Jira %s %s: %w", method, path, readErr)
566
597
  }
567
598
  if resp.StatusCode >= 200 && resp.StatusCode < 300 {
568
599
  if out != nil && len(raw) > 0 {
569
600
  if err := json.Unmarshal(raw, out); err != nil {
570
- return fmt.Errorf("Jira %s %s: parse response: %w", method, path, err)
601
+ return fmt.Errorf("Jira %s %s: malformed response: %w", method, path, err)
571
602
  }
572
603
  }
573
604
  slog.Info("jira outcome", "method", method, "path", path, "result", "ok")
574
605
  return nil
575
606
  }
576
607
  retryable := resp.StatusCode == http.StatusTooManyRequests || (safe && resp.StatusCode >= 500)
577
- if retryable && attempt < 4 {
608
+ if retryable && attempt < maxRetries {
578
609
  delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
579
610
  select {
580
611
  case <-ctx.Done():
@@ -592,6 +623,9 @@ func (c *HTTPClient) request(ctx context.Context, method, path string, query url
592
623
  message = resp.Status
593
624
  }
594
625
  slog.Info("jira outcome", "method", method, "path", path, "result", "error", "status", resp.StatusCode)
626
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
627
+ return fmt.Errorf("Jira %s %s: authentication/permission failure: HTTP %d: %s", method, path, resp.StatusCode, message)
628
+ }
595
629
  return fmt.Errorf("Jira %s %s: HTTP %d: %s", method, path, resp.StatusCode, message)
596
630
  }
597
631
  }
@@ -0,0 +1,23 @@
1
+ package rest
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestStartupCredentialsDoesNotFollowRedirects(t *testing.T) {
11
+ s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
12
+ if r.URL.Path != "/rest/api/3/myself" {
13
+ t.Errorf("startup followed redirect to %s", r.URL.Path)
14
+ writeJSON(w, map[string]any{"accountId": "account"})
15
+ return
16
+ }
17
+ http.Redirect(w, r, "/unrelated-endpoint", http.StatusFound)
18
+ })
19
+ err := s.client(t).ValidateCredentialsStartup(context.Background())
20
+ if err == nil || !strings.Contains(err.Error(), "HTTP 302") || s.count(http.MethodGet, "/rest/api/3/myself") != 1 || s.count(http.MethodGet, "/unrelated-endpoint") != 0 {
21
+ t.Fatalf("startup redirect = %v", err)
22
+ }
23
+ }
@@ -0,0 +1,133 @@
1
+ package rest
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "sync/atomic"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestStartupCredentialsUsesOneAuthenticatedMyselfRead(t *testing.T) {
15
+ s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
16
+ if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/myself" || r.URL.RawQuery != "" {
17
+ t.Errorf("unexpected startup request: %s %s", r.Method, r.URL)
18
+ }
19
+ writeJSON(w, map[string]any{"accountId": "account"})
20
+ })
21
+ if err := s.client(t).ValidateCredentialsStartup(context.Background()); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ if s.count(http.MethodGet, "/rest/api/3/myself") != 1 {
25
+ t.Fatal("startup did not make exactly one credential request")
26
+ }
27
+ }
28
+
29
+ func TestStartupCredentialsNeverRetriesRateLimitsOrServerErrors(t *testing.T) {
30
+ for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable} {
31
+ t.Run(http.StatusText(status), func(t *testing.T) {
32
+ s := newJiraServer(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) {
33
+ w.Header().Set("Retry-After", "60")
34
+ http.Error(w, "unavailable", status)
35
+ })
36
+ start := time.Now()
37
+ if err := s.client(t).ValidateCredentialsStartup(context.Background()); err == nil {
38
+ t.Fatal("startup accepted failed credentials check")
39
+ }
40
+ if s.count(http.MethodGet, "/rest/api/3/myself") != 1 || time.Since(start) > time.Second {
41
+ t.Fatal("startup inherited runtime retry count or delay")
42
+ }
43
+ })
44
+ }
45
+ }
46
+
47
+ func TestStartupCredentialFailureCategoriesAndRedaction(t *testing.T) {
48
+ for _, tc := range []struct {
49
+ name, body, want string
50
+ status int
51
+ }{
52
+ {"unauthorized", "secret bot@example.com", "authentication", http.StatusUnauthorized},
53
+ {"forbidden", "secret bot@example.com", "authentication", http.StatusForbidden},
54
+ {"invalid-json", "not-json", "malformed", http.StatusOK},
55
+ {"missing-account", `{}`, "malformed", http.StatusOK},
56
+ {"empty-response", "", "malformed", http.StatusOK},
57
+ } {
58
+ t.Run(tc.name, func(t *testing.T) {
59
+ s := newJiraServer(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) {
60
+ w.WriteHeader(tc.status)
61
+ _, _ = w.Write([]byte(tc.body))
62
+ })
63
+ err := s.client(t).ValidateCredentialsStartup(context.Background())
64
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
65
+ t.Fatalf("%s = %v, want category %s", tc.name, err, tc.want)
66
+ }
67
+ if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
68
+ t.Fatalf("credentials leaked: %v", err)
69
+ }
70
+ })
71
+ }
72
+ t.Run("connection", func(t *testing.T) {
73
+ s := httptest.NewServer(http.NotFoundHandler())
74
+ s.Close()
75
+ c, err := New(s.URL, "bot@example.com", "secret")
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if err := c.ValidateCredentialsStartup(context.Background()); err == nil || !strings.Contains(err.Error(), "connection") {
80
+ t.Fatalf("connection failure = %v", err)
81
+ }
82
+ })
83
+ }
84
+
85
+ func TestStartupCredentialsHonorsEarlierDeadlineAndCancellation(t *testing.T) {
86
+ var calls atomic.Int32
87
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88
+ calls.Add(1)
89
+ <-r.Context().Done()
90
+ }))
91
+ defer s.Close()
92
+ c, err := New(s.URL, "bot@example.com", "secret")
93
+ if err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
97
+ defer cancel()
98
+ start := time.Now()
99
+ if err := c.ValidateCredentialsStartup(ctx); !errors.Is(err, context.DeadlineExceeded) {
100
+ t.Fatalf("deadline = %v", err)
101
+ }
102
+ if time.Since(start) > time.Second || calls.Load() != 1 {
103
+ t.Fatalf("timeout exceeded caller budget or retried: calls=%d", calls.Load())
104
+ }
105
+ ctx, cancelNow := context.WithCancel(context.Background())
106
+ cancelNow()
107
+ if err := c.ValidateCredentialsStartup(ctx); !errors.Is(err, context.Canceled) || calls.Load() != 1 {
108
+ t.Fatalf("cancellation = %v, calls=%d", err, calls.Load())
109
+ }
110
+ }
111
+
112
+ type deadlineTransport struct {
113
+ deadline time.Time
114
+ }
115
+
116
+ func (d *deadlineTransport) RoundTrip(r *http.Request) (*http.Response, error) {
117
+ d.deadline, _ = r.Context().Deadline()
118
+ return nil, errors.New("transport unavailable")
119
+ }
120
+
121
+ func TestStartupCredentialsCapsRequestDeadlineAtFiveSeconds(t *testing.T) {
122
+ c, err := New("https://jira.example.invalid", "bot@example.com", "secret")
123
+ if err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ transport := &deadlineTransport{}
127
+ c.http.Transport = transport
128
+ start := time.Now()
129
+ _ = c.ValidateCredentialsStartup(context.Background())
130
+ if transport.deadline.IsZero() || transport.deadline.After(start.Add(5*time.Second+time.Millisecond)) {
131
+ t.Fatalf("request deadline = %v", transport.deadline)
132
+ }
133
+ }
@@ -0,0 +1,22 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ )
9
+
10
+ // probeStartup uses system-wide credentials once, even without registered
11
+ // repositories. It does not need project, component, assignee or status data.
12
+ func probeStartup(ctx context.Context, _ config.RawValues, _ map[string]config.Repo) error {
13
+ creds, err := loadCredentialsDefault()
14
+ if err != nil {
15
+ return fmt.Errorf("jira startup credentials: %w", err)
16
+ }
17
+ client, err := sharedClient(creds.Site, creds.Email, creds.Token)
18
+ if err != nil {
19
+ return fmt.Errorf("jira startup credentials: %w", err)
20
+ }
21
+ return client.ValidateCredentialsStartup(ctx)
22
+ }
@@ -0,0 +1,78 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync/atomic"
11
+ "testing"
12
+
13
+ "github.com/rajpopat27/relay-flow/internal/config"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
15
+ )
16
+
17
+ func TestSelectedJiraStartupValidatesSystemCredentialsOnceEvenWithoutRepos(t *testing.T) {
18
+ for _, withRepos := range []bool{false, true} {
19
+ t.Run(map[bool]string{false: "no-repos", true: "many-repos"}[withRepos], func(t *testing.T) {
20
+ root := t.TempDir()
21
+ t.Setenv("RELAY_FLOW_HOME", root)
22
+ var calls atomic.Int32
23
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24
+ calls.Add(1)
25
+ user, token, ok := r.BasicAuth()
26
+ if !ok || user != "bot@example.com" || token != "secret" || r.Method != "GET" || r.URL.Path != "/rest/api/3/myself" {
27
+ t.Errorf("unexpected credential request: %s %s", r.Method, r.URL.Path)
28
+ http.Error(w, "invalid request", http.StatusBadRequest)
29
+ return
30
+ }
31
+ _, _ = w.Write([]byte(`{"accountId":"account"}`))
32
+ }))
33
+ defer s.Close()
34
+ path := filepath.Join(root, "credentials.yaml")
35
+ if err := saveCredentials(path, credentials{Site: s.URL, Email: "bot@example.com", Token: "secret"}); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ before, err := os.ReadFile(path)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ var repos map[string]config.Repo
43
+ if withRepos {
44
+ // Probe must not construct/validate dummy repo mappings or queries.
45
+ repos = map[string]config.Repo{"one": {Path: "/absent/one"}, "two": {Path: "/absent/two"}}
46
+ }
47
+ if err := task.ProbeStartup(context.Background(), "jira", config.RawValues{"assignee": "unverified-assignee"}, repos); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ if calls.Load() != 1 {
51
+ t.Fatalf("system credential requests = %d", calls.Load())
52
+ }
53
+ after, err := os.ReadFile(path)
54
+ if err != nil || string(after) != string(before) {
55
+ t.Fatalf("startup changed credentials: %v", err)
56
+ }
57
+ })
58
+ }
59
+ }
60
+
61
+ func TestSelectedJiraStartupRejectsMissingAndInvalidCredentials(t *testing.T) {
62
+ root := t.TempDir()
63
+ t.Setenv("RELAY_FLOW_HOME", root)
64
+ if err := task.ProbeStartup(context.Background(), "jira", nil, nil); err == nil || !strings.Contains(err.Error(), "credentials") {
65
+ t.Fatalf("missing credentials = %v", err)
66
+ }
67
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
68
+ http.Error(w, "secret bot@example.com", http.StatusUnauthorized)
69
+ }))
70
+ defer s.Close()
71
+ if err := saveCredentials(filepath.Join(root, "credentials.yaml"), credentials{Site: s.URL, Email: "bot@example.com", Token: "secret"}); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ err := task.ProbeStartup(context.Background(), "jira", nil, nil)
75
+ if err == nil || !strings.Contains(err.Error(), "authentication") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
76
+ t.Fatalf("invalid credentials not safely rejected: %v", err)
77
+ }
78
+ }
@@ -0,0 +1,25 @@
1
+ package task
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ )
9
+
10
+ // ProbeStartup dispatches only the selected plugin's machine prerequisite
11
+ // checks, even without registered repos. The caller supplies the aggregate
12
+ // deadline; the adapter owns connection deduplication and per-probe limits.
13
+ func ProbeStartup(ctx context.Context, name string, root config.RawValues, repos map[string]config.Repo) error {
14
+ f, err := lookup(name)
15
+ if err != nil {
16
+ return err
17
+ }
18
+ if f.ProbeStartup == nil {
19
+ return fmt.Errorf("task plugin %q has no startup probe", name)
20
+ }
21
+ if err := ctx.Err(); err != nil {
22
+ return err
23
+ }
24
+ return f.ProbeStartup(ctx, config.Merge(defaultConfig(f), root), repos)
25
+ }
@@ -0,0 +1,51 @@
1
+ package task_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ "github.com/rajpopat27/relay-flow/internal/task"
11
+ )
12
+
13
+ func TestStartupDispatchCallsOnlySelectedPluginWithoutConstructingSystems(t *testing.T) {
14
+ selected := t.Name() + t.TempDir()
15
+ other := selected + "-other"
16
+ calls := 0
17
+ failure := errors.New("connection unavailable")
18
+ task.Register(other, task.Factory{ProbeStartup: func(context.Context, config.RawValues, map[string]config.Repo) error {
19
+ t.Fatal("called unselected plugin")
20
+ return nil
21
+ }})
22
+ task.Register(selected, task.Factory{
23
+ DefaultConfig: func() config.RawValues { return config.RawValues{"default": "value"} },
24
+ New: func(context.Context, task.RepoSpec) (task.System, error) {
25
+ t.Fatal("startup constructed a repo system")
26
+ return nil, nil
27
+ },
28
+ ProbeStartup: func(ctx context.Context, root config.RawValues, repos map[string]config.Repo) error {
29
+ calls++
30
+ if root["default"] != "value" || root["explicit"] != "override" || repos["app"].Path != "/work/app" {
31
+ t.Fatalf("startup inputs = %v, %v", root, repos)
32
+ }
33
+ return failure
34
+ },
35
+ })
36
+ err := task.ProbeStartup(context.Background(), selected, config.RawValues{"explicit": "override"}, map[string]config.Repo{"app": {Path: "/work/app"}})
37
+ if !errors.Is(err, failure) || calls != 1 {
38
+ t.Fatalf("dispatch = %v, calls=%d", err, calls)
39
+ }
40
+ }
41
+
42
+ func TestStartupDispatchRequiresProbeSupportEvenWithoutRepos(t *testing.T) {
43
+ name := t.Name() + t.TempDir()
44
+ task.Register(name, task.Factory{})
45
+ if err := task.ProbeStartup(context.Background(), name, nil, nil); err == nil || !strings.Contains(err.Error(), "startup probe") {
46
+ t.Fatalf("missing startup callback = %v", err)
47
+ }
48
+ if err := task.ProbeStartup(context.Background(), "unknown-startup-plugin", nil, nil); err == nil || !strings.Contains(err.Error(), "registered:") {
49
+ t.Fatalf("unknown plugin = %v", err)
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.13-alpha",
3
+ "version": "0.3.14-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",