relay-flow 0.3.7-alpha → 0.3.9-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 +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +17 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +10 -1
- package/internal/execution/goworkflows/interpreter.go +17 -1
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +482 -0
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
|
@@ -16,6 +16,21 @@ import (
|
|
|
16
16
|
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
func TestBeadsLocalFactoryDoesNotProbeMissingWorkspace(t *testing.T) {
|
|
20
|
+
missing := filepath.Join(t.TempDir(), "not-yet-available")
|
|
21
|
+
sys, err := task.NewLocal(context.Background(), "beads", task.RepoSpec{
|
|
22
|
+
Name: "payments",
|
|
23
|
+
Path: t.TempDir(),
|
|
24
|
+
RepoConfig: config.RawValues{"beadsDir": missing},
|
|
25
|
+
})
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatalf("NewLocal returned error for a missing remote workspace: %v", err)
|
|
28
|
+
}
|
|
29
|
+
if sys == nil {
|
|
30
|
+
t.Fatal("NewLocal returned nil task system")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
func TestBeadsFactoryIsRegisteredWithBeadsDirRequirement(t *testing.T) {
|
|
20
35
|
if !hasString(task.Names(), "beads") {
|
|
21
36
|
t.Fatalf("task plugins = %v, want beads", task.Names())
|
package/internal/task/factory.go
CHANGED
|
@@ -44,7 +44,13 @@ type Factory struct {
|
|
|
44
44
|
Auth func(context.Context, []string, io.Reader) error
|
|
45
45
|
DefaultConfig func() config.RawValues
|
|
46
46
|
ValidateTextConfig func(config.RawValues) error
|
|
47
|
-
New
|
|
47
|
+
// New constructs a fully validated repo-bound system. It is used by repo
|
|
48
|
+
// registration, where connectivity must be confirmed immediately.
|
|
49
|
+
New func(context.Context, RepoSpec) (System, error)
|
|
50
|
+
// NewLocal constructs only the local adapter state needed for startup. It
|
|
51
|
+
// must not probe remote services; submission-time validation owns those
|
|
52
|
+
// checks. Factories without a local constructor retain the old behavior.
|
|
53
|
+
NewLocal func(context.Context, RepoSpec) (System, error)
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
var (
|
|
@@ -72,16 +78,49 @@ func lookup(name string) (Factory, error) {
|
|
|
72
78
|
return f, nil
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
// New constructs the repo-bound task System for the named plugin
|
|
81
|
+
// New constructs the repo-bound task System for the named plugin and runs
|
|
82
|
+
// the adapter's immediate connectivity checks.
|
|
76
83
|
func New(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
77
84
|
f, err := lookup(name)
|
|
78
85
|
if err != nil {
|
|
79
86
|
return nil, err
|
|
80
87
|
}
|
|
81
88
|
spec.RootConfig = config.Merge(defaultConfig(f), spec.RootConfig)
|
|
89
|
+
if f.New == nil {
|
|
90
|
+
return nil, fmt.Errorf("task plugin %q has no constructor", name)
|
|
91
|
+
}
|
|
82
92
|
return f.New(ctx, spec)
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
// ValidateLocal reports whether a task plugin provides the local constructor
|
|
96
|
+
// required by normal startup. This is a machine-wide plugin configuration
|
|
97
|
+
// check, not a repo-specific health result.
|
|
98
|
+
func ValidateLocal(name string) error {
|
|
99
|
+
f, err := lookup(name)
|
|
100
|
+
if err != nil {
|
|
101
|
+
return err
|
|
102
|
+
}
|
|
103
|
+
if f.NewLocal == nil {
|
|
104
|
+
return fmt.Errorf("task plugin %q has no local constructor", name)
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// NewLocal constructs the repo-bound task state without remote probes. Every
|
|
110
|
+
// startup-capable task plugin must explicitly provide this seam; falling back
|
|
111
|
+
// to New would silently reintroduce startup connectivity checks.
|
|
112
|
+
func NewLocal(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
113
|
+
f, err := lookup(name)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return nil, err
|
|
116
|
+
}
|
|
117
|
+
spec.RootConfig = config.Merge(defaultConfig(f), spec.RootConfig)
|
|
118
|
+
if f.NewLocal == nil {
|
|
119
|
+
return nil, fmt.Errorf("task plugin %q has no local constructor", name)
|
|
120
|
+
}
|
|
121
|
+
return f.NewLocal(ctx, spec)
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
// Defaults returns a fresh copy of the selected task plugin's root config
|
|
86
125
|
// defaults for relay-flow init.
|
|
87
126
|
func Defaults(name string) (config.RawValues, error) {
|
|
@@ -267,31 +267,49 @@ func init() {
|
|
|
267
267
|
}
|
|
268
268
|
return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
|
|
269
269
|
},
|
|
270
|
-
Auth:
|
|
271
|
-
New:
|
|
272
|
-
|
|
273
|
-
var cfg Config
|
|
274
|
-
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
275
|
-
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
276
|
-
}
|
|
277
|
-
creds, err := loadCredentialsDefault()
|
|
278
|
-
if err != nil {
|
|
279
|
-
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
280
|
-
}
|
|
281
|
-
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
282
|
-
if err != nil {
|
|
283
|
-
return nil, err
|
|
284
|
-
}
|
|
285
|
-
sys, err := newSystem(ctx, client, spec)
|
|
286
|
-
if err != nil {
|
|
287
|
-
return nil, err
|
|
288
|
-
}
|
|
289
|
-
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
290
|
-
return sys, nil
|
|
291
|
-
},
|
|
270
|
+
Auth: auth,
|
|
271
|
+
New: newSystemFromCredentials,
|
|
272
|
+
NewLocal: newSystemLocal,
|
|
292
273
|
})
|
|
293
274
|
}
|
|
294
275
|
|
|
276
|
+
func newSystemFromCredentials(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
277
|
+
creds, err := loadCredentialsDefault()
|
|
278
|
+
if err != nil {
|
|
279
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
280
|
+
}
|
|
281
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
282
|
+
if err != nil {
|
|
283
|
+
return nil, err
|
|
284
|
+
}
|
|
285
|
+
sys, err := newSystem(ctx, client, spec)
|
|
286
|
+
if err != nil {
|
|
287
|
+
return nil, err
|
|
288
|
+
}
|
|
289
|
+
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
290
|
+
return sys, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// newSystemLocal builds a Jira client and typed adapter state without making
|
|
294
|
+
// assignee or status requests. Those remote checks run only while submitting
|
|
295
|
+
// a candidate workflow.
|
|
296
|
+
func newSystemLocal(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
297
|
+
creds, err := loadCredentialsDefault()
|
|
298
|
+
if err != nil {
|
|
299
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
300
|
+
}
|
|
301
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
302
|
+
if err != nil {
|
|
303
|
+
return nil, err
|
|
304
|
+
}
|
|
305
|
+
sys, err := newSystemWithValidation(ctx, client, spec, false)
|
|
306
|
+
if err != nil {
|
|
307
|
+
return nil, err
|
|
308
|
+
}
|
|
309
|
+
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
310
|
+
return sys, nil
|
|
311
|
+
}
|
|
312
|
+
|
|
295
313
|
func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
|
|
296
314
|
key := strings.TrimRight(site, "/") + "\x00" + email
|
|
297
315
|
clientsMu.Lock()
|
|
@@ -321,6 +339,10 @@ type system struct {
|
|
|
321
339
|
}
|
|
322
340
|
|
|
323
341
|
func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
|
|
342
|
+
return newSystemWithValidation(ctx, cli, spec, true)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func newSystemWithValidation(ctx context.Context, cli jirarest.Client, spec task.RepoSpec, remoteValidation bool) (*system, error) {
|
|
324
346
|
if spec.Name == "" {
|
|
325
347
|
return nil, fmt.Errorf("jira: repo name is required")
|
|
326
348
|
}
|
|
@@ -338,7 +360,7 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
|
|
|
338
360
|
if err := validateTemplates(cfg.Templates); err != nil {
|
|
339
361
|
return nil, fmt.Errorf("jira repo %q taskConfig.templates: %w", spec.Name, err)
|
|
340
362
|
}
|
|
341
|
-
if cfg.Assignee != "" {
|
|
363
|
+
if remoteValidation && cfg.Assignee != "" {
|
|
342
364
|
if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
|
|
343
365
|
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
344
366
|
}
|
|
@@ -349,11 +371,13 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
|
|
|
349
371
|
base: merged,
|
|
350
372
|
effective: cfg,
|
|
351
373
|
}
|
|
352
|
-
if
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
374
|
+
if remoteValidation {
|
|
375
|
+
if err := s.validateTransition(ctx, "repo config", cfg.Project, cfg.Transition); err != nil {
|
|
376
|
+
return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
|
|
377
|
+
}
|
|
378
|
+
if err := validateStatusDefaults(ctx, cli, "repo config", cfg.Project, cfg.StatusDefaults); err != nil {
|
|
379
|
+
return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
|
|
380
|
+
}
|
|
357
381
|
}
|
|
358
382
|
// project/component are required repo keys enforced at registration. Do not
|
|
359
383
|
// probe conventional lifecycle names here: repository registration stores
|
|
@@ -40,6 +40,11 @@ type Service struct {
|
|
|
40
40
|
// task system before storage. The composition root supplies this callback;
|
|
41
41
|
// keeping it here avoids widening RepoLookup beyond its documented query.
|
|
42
42
|
ValidateTaskConfig func(context.Context, *Workflow) error
|
|
43
|
+
// ValidateSubmission performs the workflow-scoped runner, task-system,
|
|
44
|
+
// harness, and other environment checks that must happen before the
|
|
45
|
+
// definition is persisted. Startup intentionally does not repeat these
|
|
46
|
+
// expensive checks for an accepted workflow.
|
|
47
|
+
ValidateSubmission func(context.Context, *Workflow) error
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
func NewService(store *Store, active ActiveRuns, repos RepoLookup) *Service {
|
|
@@ -89,12 +94,41 @@ func (s *Service) Submit(ctx context.Context, yamlBytes []byte) (*Workflow, erro
|
|
|
89
94
|
if active {
|
|
90
95
|
return nil, fmt.Errorf("workflow %q has active runs; replacement is rejected", wf.Name)
|
|
91
96
|
}
|
|
97
|
+
if s.ValidateSubmission != nil {
|
|
98
|
+
if err := s.ValidateSubmission(ctx, wf); err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
previousFiles, err := s.store.snapshot(wf.Name)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return nil, err
|
|
105
|
+
}
|
|
106
|
+
previous, hadPrevious := s.reg.Get(wf.Name)
|
|
107
|
+
wf.MarkHealthy()
|
|
92
108
|
if err := s.store.Put(wf.Name, yamlBytes); err != nil {
|
|
93
109
|
return nil, err
|
|
94
110
|
}
|
|
95
111
|
s.reg.Replace(wf)
|
|
96
112
|
if s.Rebind != nil {
|
|
97
113
|
if err := s.Rebind(); err != nil {
|
|
114
|
+
// A binding failure is part of submission failure: restore both the
|
|
115
|
+
// durable pair and the registry entry before returning.
|
|
116
|
+
restoreErr := s.store.restorePair(wf.Name, previousFiles)
|
|
117
|
+
if hadPrevious {
|
|
118
|
+
s.reg.Replace(previous)
|
|
119
|
+
} else {
|
|
120
|
+
s.reg.Remove(wf.Name)
|
|
121
|
+
}
|
|
122
|
+
if rebindErr := s.Rebind(); rebindErr != nil {
|
|
123
|
+
if restoreErr == nil {
|
|
124
|
+
restoreErr = rebindErr
|
|
125
|
+
} else {
|
|
126
|
+
restoreErr = fmt.Errorf("restore bindings: %v; restore files: %w", rebindErr, restoreErr)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if restoreErr != nil {
|
|
130
|
+
return nil, fmt.Errorf("rebind workflows for %q: %w (rollback failed: %v)", wf.Name, err, restoreErr)
|
|
131
|
+
}
|
|
98
132
|
return nil, fmt.Errorf("rebind workflows for %q: %w", wf.Name, err)
|
|
99
133
|
}
|
|
100
134
|
}
|
|
@@ -115,12 +149,31 @@ func (s *Service) Remove(ctx context.Context, name string) error {
|
|
|
115
149
|
if active {
|
|
116
150
|
return fmt.Errorf("workflow %q has active runs; removal is rejected", name)
|
|
117
151
|
}
|
|
152
|
+
previousFiles, err := s.store.snapshot(name)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return err
|
|
155
|
+
}
|
|
156
|
+
previous, hadPrevious := s.reg.Get(name)
|
|
118
157
|
if err := s.store.Remove(name); err != nil {
|
|
119
158
|
return err
|
|
120
159
|
}
|
|
121
160
|
s.reg.Remove(name)
|
|
122
161
|
if s.Rebind != nil {
|
|
123
162
|
if err := s.Rebind(); err != nil {
|
|
163
|
+
restoreErr := s.store.restorePair(name, previousFiles)
|
|
164
|
+
if hadPrevious {
|
|
165
|
+
s.reg.Replace(previous)
|
|
166
|
+
}
|
|
167
|
+
if rebindErr := s.Rebind(); rebindErr != nil {
|
|
168
|
+
if restoreErr == nil {
|
|
169
|
+
restoreErr = rebindErr
|
|
170
|
+
} else {
|
|
171
|
+
restoreErr = fmt.Errorf("restore bindings: %v; restore files: %w", rebindErr, restoreErr)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if restoreErr != nil {
|
|
175
|
+
return fmt.Errorf("rebind workflows after removing %q: %w (rollback failed: %v)", name, err, restoreErr)
|
|
176
|
+
}
|
|
124
177
|
return fmt.Errorf("rebind workflows after removing %q: %w", name, err)
|
|
125
178
|
}
|
|
126
179
|
}
|