relay-flow 0.3.7-alpha → 0.3.8-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 +6 -0
- 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
|
@@ -2,6 +2,9 @@ package workflow_test
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
5
8
|
"errors"
|
|
6
9
|
"os"
|
|
7
10
|
"path/filepath"
|
|
@@ -59,6 +62,159 @@ func newService(t *testing.T, active *fakeActiveRuns, repos map[string]bool) (*w
|
|
|
59
62
|
return svc, store
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
func TestStorePutPersistsAcceptedHashAndDetectsEdits(t *testing.T) {
|
|
66
|
+
s := newStore(t)
|
|
67
|
+
raw := []byte(storeValid)
|
|
68
|
+
if err := s.Put("basicFlow", raw); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
sum := sha256.Sum256(raw)
|
|
72
|
+
hash, err := os.ReadFile(filepath.Join(s.Dir, "basicFlow.sha256"))
|
|
73
|
+
if err != nil {
|
|
74
|
+
t.Fatal(err)
|
|
75
|
+
}
|
|
76
|
+
if got, want := strings.TrimSpace(string(hash)), hex.EncodeToString(sum[:]); got != want {
|
|
77
|
+
t.Fatalf("accepted hash = %q, want %q", got, want)
|
|
78
|
+
}
|
|
79
|
+
records, err := s.LoadAllRecords()
|
|
80
|
+
if err != nil || len(records) != 1 {
|
|
81
|
+
t.Fatalf("LoadAllRecords = %#v, %v", records, err)
|
|
82
|
+
}
|
|
83
|
+
if records[0].Status != workflow.HealthHealthy || records[0].Workflow.Status != workflow.HealthHealthy {
|
|
84
|
+
t.Fatalf("stored workflow status = %#v", records[0])
|
|
85
|
+
}
|
|
86
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.yaml"), []byte(strings.Replace(storeValid, "description: work", "description: edited", 1)), 0o644); err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
records, err = s.LoadAllRecords()
|
|
90
|
+
if err != nil || len(records) != 1 {
|
|
91
|
+
t.Fatalf("LoadAllRecords after edit = %#v, %v", records, err)
|
|
92
|
+
}
|
|
93
|
+
if records[0].Status != workflow.HealthOutdated || records[0].Workflow.Status != workflow.HealthOutdated || records[0].Workflow.RepairCommand == "" || !strings.Contains(records[0].StatusReason, "hash mismatch") || !strings.Contains(records[0].Workflow.RepairCommand, "relay-flow workflow submit --file") {
|
|
94
|
+
t.Fatalf("edited workflow status = %#v", records[0])
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestStoreRecoversMixedPairFromStorageTransaction(t *testing.T) {
|
|
99
|
+
s := newStore(t)
|
|
100
|
+
oldYAML := []byte(storeValid)
|
|
101
|
+
newYAML := []byte(strings.Replace(storeValid, "description: work", "description: recovered", 1))
|
|
102
|
+
if err := s.Put("basicFlow", oldYAML); err != nil {
|
|
103
|
+
t.Fatal(err)
|
|
104
|
+
}
|
|
105
|
+
oldHash := mustHashBytes(oldYAML)
|
|
106
|
+
newHash := mustHashBytes(newYAML)
|
|
107
|
+
journal := func() []byte {
|
|
108
|
+
raw, err := json.Marshal(map[string]any{
|
|
109
|
+
"name": "basicFlow", "oldYaml": oldYAML, "oldYamlExists": true,
|
|
110
|
+
"oldHash": oldHash, "oldHashExists": true,
|
|
111
|
+
"newYaml": newYAML, "newYamlExists": true,
|
|
112
|
+
"newHash": newHash, "newHashExists": true,
|
|
113
|
+
})
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
return raw
|
|
118
|
+
}
|
|
119
|
+
// Simulate a crash after the YAML rename but before the hash rename.
|
|
120
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.txn"), journal(), 0o600); err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.yaml"), newYAML, 0o644); err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
if records, err := s.LoadAllRecords(); err != nil || len(records) != 1 || records[0].Status != workflow.HealthHealthy {
|
|
127
|
+
t.Fatalf("recovered old pair = %#v, %v", records, err)
|
|
128
|
+
}
|
|
129
|
+
if got, err := os.ReadFile(filepath.Join(s.Dir, "basicFlow.yaml")); err != nil || string(got) != string(oldYAML) {
|
|
130
|
+
t.Fatalf("old YAML after recovery = %q, %v", got, err)
|
|
131
|
+
}
|
|
132
|
+
if got, err := os.ReadFile(filepath.Join(s.Dir, "basicFlow.sha256")); err != nil || string(got) != string(oldHash) {
|
|
133
|
+
t.Fatalf("old hash after recovery = %q, %v", got, err)
|
|
134
|
+
}
|
|
135
|
+
if _, err := os.Stat(filepath.Join(s.Dir, "basicFlow.txn")); !os.IsNotExist(err) {
|
|
136
|
+
t.Fatalf("transaction journal remains after rollback: %v", err)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A complete new pair wins when the process crashed only before journal
|
|
140
|
+
// cleanup.
|
|
141
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.txn"), journal(), 0o600); err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.yaml"), newYAML, 0o644); err != nil {
|
|
145
|
+
t.Fatal(err)
|
|
146
|
+
}
|
|
147
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.sha256"), newHash, 0o644); err != nil {
|
|
148
|
+
t.Fatal(err)
|
|
149
|
+
}
|
|
150
|
+
if records, err := s.LoadAllRecords(); err != nil || len(records) != 1 || records[0].Status != workflow.HealthHealthy || records[0].Workflow.Nodes["coding"].Description != "recovered" {
|
|
151
|
+
t.Fatalf("recovered new pair = %#v, %v", records, err)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A rollback journal has opposite semantics: Old is the failed candidate
|
|
155
|
+
// and New is the requested prior pair. Even a mixed restoration must move
|
|
156
|
+
// toward New rather than accepting or restoring Old.
|
|
157
|
+
rollbackJournal, err := json.Marshal(map[string]any{
|
|
158
|
+
"name": "basicFlow", "kind": "rollback",
|
|
159
|
+
"oldYaml": newYAML, "oldYamlExists": true,
|
|
160
|
+
"oldHash": newHash, "oldHashExists": true,
|
|
161
|
+
"newYaml": oldYAML, "newYamlExists": true,
|
|
162
|
+
"newHash": oldHash, "newHashExists": true,
|
|
163
|
+
})
|
|
164
|
+
if err != nil {
|
|
165
|
+
t.Fatal(err)
|
|
166
|
+
}
|
|
167
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.txn"), rollbackJournal, 0o600); err != nil {
|
|
168
|
+
t.Fatal(err)
|
|
169
|
+
}
|
|
170
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "basicFlow.yaml"), oldYAML, 0o644); err != nil {
|
|
171
|
+
t.Fatal(err)
|
|
172
|
+
}
|
|
173
|
+
// Leave the candidate hash in place to simulate an interruption during
|
|
174
|
+
// rollback, then let startup recovery finish the desired prior pair.
|
|
175
|
+
if records, err := s.LoadAllRecords(); err != nil || len(records) != 1 || records[0].Status != workflow.HealthHealthy || records[0].Workflow.Nodes["coding"].Description != "work" {
|
|
176
|
+
t.Fatalf("recovered rollback pair = %#v, %v", records, err)
|
|
177
|
+
}
|
|
178
|
+
if got, err := os.ReadFile(filepath.Join(s.Dir, "basicFlow.sha256")); err != nil || string(got) != string(oldHash) {
|
|
179
|
+
t.Fatalf("rollback hash after recovery = %q, %v", got, err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func mustHashBytes(raw []byte) []byte {
|
|
184
|
+
sum := sha256.Sum256(raw)
|
|
185
|
+
return []byte(hex.EncodeToString(sum[:]) + "\n")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestStoreLoadAllRecordsIsolatesMalformedAndLegacyFiles(t *testing.T) {
|
|
189
|
+
s := newStore(t)
|
|
190
|
+
if err := s.Put("healthyFlow", []byte(strings.Replace(storeValid, "basicFlow", "healthyFlow", 1))); err != nil {
|
|
191
|
+
t.Fatal(err)
|
|
192
|
+
}
|
|
193
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "broken.yaml"), []byte("name: [not valid"), 0o644); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
if err := os.WriteFile(filepath.Join(s.Dir, "legacy.yaml"), []byte(strings.Replace(storeValid, "basicFlow", "legacy", 1)), 0o644); err != nil {
|
|
197
|
+
t.Fatal(err)
|
|
198
|
+
}
|
|
199
|
+
records, err := s.LoadAllRecords()
|
|
200
|
+
if err != nil || len(records) != 3 {
|
|
201
|
+
t.Fatalf("LoadAllRecords = %#v, %v", records, err)
|
|
202
|
+
}
|
|
203
|
+
statuses := map[string]workflow.HealthStatus{}
|
|
204
|
+
for _, record := range records {
|
|
205
|
+
statuses[record.Name] = record.Status
|
|
206
|
+
}
|
|
207
|
+
if statuses["healthyFlow"] != workflow.HealthHealthy {
|
|
208
|
+
t.Fatalf("healthy status = %q", statuses["healthyFlow"])
|
|
209
|
+
}
|
|
210
|
+
if statuses["broken"] != workflow.HealthBlocked {
|
|
211
|
+
t.Fatalf("broken status = %q", statuses["broken"])
|
|
212
|
+
}
|
|
213
|
+
if statuses["legacy"] != workflow.HealthUnverified {
|
|
214
|
+
t.Fatalf("legacy status = %q", statuses["legacy"])
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
62
218
|
func TestStorePutGetLoadAll(t *testing.T) {
|
|
63
219
|
s := newStore(t)
|
|
64
220
|
if err := s.Put("basicFlow", []byte(storeValid)); err != nil {
|
|
@@ -229,6 +385,85 @@ func TestServiceFailedWritePreservesExisting(t *testing.T) {
|
|
|
229
385
|
}
|
|
230
386
|
}
|
|
231
387
|
|
|
388
|
+
func TestServiceRebindFailureRollsBackDefinitionAndRegistry(t *testing.T) {
|
|
389
|
+
active := &fakeActiveRuns{active: map[string]bool{}}
|
|
390
|
+
svc, store := newService(t, active, map[string]bool{"payments": true})
|
|
391
|
+
calls := 0
|
|
392
|
+
svc.Rebind = func() error {
|
|
393
|
+
calls++
|
|
394
|
+
if calls == 2 {
|
|
395
|
+
return errors.New("matcher unavailable")
|
|
396
|
+
}
|
|
397
|
+
return nil
|
|
398
|
+
}
|
|
399
|
+
if _, err := svc.Submit(context.Background(), []byte(storeValid)); err != nil {
|
|
400
|
+
t.Fatal(err)
|
|
401
|
+
}
|
|
402
|
+
beforeHash, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.sha256"))
|
|
403
|
+
if err != nil {
|
|
404
|
+
t.Fatal(err)
|
|
405
|
+
}
|
|
406
|
+
replacement := []byte(strings.Replace(storeValid, "description: work", "description: changed", 1))
|
|
407
|
+
if _, err := svc.Submit(context.Background(), replacement); err == nil {
|
|
408
|
+
t.Fatal("submission succeeded despite binding failure")
|
|
409
|
+
}
|
|
410
|
+
raw, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.yaml"))
|
|
411
|
+
if err != nil {
|
|
412
|
+
t.Fatal(err)
|
|
413
|
+
}
|
|
414
|
+
if string(raw) != storeValid {
|
|
415
|
+
t.Fatalf("stored YAML changed after failed rebind: %q", raw)
|
|
416
|
+
}
|
|
417
|
+
afterHash, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.sha256"))
|
|
418
|
+
if err != nil {
|
|
419
|
+
t.Fatal(err)
|
|
420
|
+
}
|
|
421
|
+
if string(afterHash) != string(beforeHash) {
|
|
422
|
+
t.Fatalf("accepted hash changed after failed rebind: %q -> %q", beforeHash, afterHash)
|
|
423
|
+
}
|
|
424
|
+
wf, err := svc.Get("basicFlow")
|
|
425
|
+
if err != nil {
|
|
426
|
+
t.Fatal(err)
|
|
427
|
+
}
|
|
428
|
+
if wf.Nodes["coding"].Description != "work" {
|
|
429
|
+
t.Fatalf("registry definition changed after failed rebind: %q", wf.Nodes["coding"].Description)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func TestServiceRemoveRebindFailureRollsBackDefinitionAndRegistry(t *testing.T) {
|
|
434
|
+
active := &fakeActiveRuns{active: map[string]bool{}}
|
|
435
|
+
svc, store := newService(t, active, map[string]bool{"payments": true})
|
|
436
|
+
calls := 0
|
|
437
|
+
svc.Rebind = func() error {
|
|
438
|
+
calls++
|
|
439
|
+
if calls == 2 {
|
|
440
|
+
return errors.New("binding unavailable")
|
|
441
|
+
}
|
|
442
|
+
return nil
|
|
443
|
+
}
|
|
444
|
+
if _, err := svc.Submit(context.Background(), []byte(storeValid)); err != nil {
|
|
445
|
+
t.Fatal(err)
|
|
446
|
+
}
|
|
447
|
+
beforeHash, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.sha256"))
|
|
448
|
+
if err != nil {
|
|
449
|
+
t.Fatal(err)
|
|
450
|
+
}
|
|
451
|
+
if err := svc.Remove(context.Background(), "basicFlow"); err == nil {
|
|
452
|
+
t.Fatal("remove succeeded despite binding failure")
|
|
453
|
+
}
|
|
454
|
+
raw, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.yaml"))
|
|
455
|
+
if err != nil || string(raw) != storeValid {
|
|
456
|
+
t.Fatalf("stored YAML after failed remove = %q, %v", raw, err)
|
|
457
|
+
}
|
|
458
|
+
afterHash, err := os.ReadFile(filepath.Join(store.Dir, "basicFlow.sha256"))
|
|
459
|
+
if err != nil || string(afterHash) != string(beforeHash) {
|
|
460
|
+
t.Fatalf("accepted hash after failed remove = %q, %v", afterHash, err)
|
|
461
|
+
}
|
|
462
|
+
if _, err := svc.Get("basicFlow"); err != nil {
|
|
463
|
+
t.Fatalf("registry definition lost after failed remove: %v", err)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
232
467
|
func TestServiceRemoveProtectsActiveRuns(t *testing.T) {
|
|
233
468
|
active := &fakeActiveRuns{active: map[string]bool{}}
|
|
234
469
|
svc, _ := newService(t, active, map[string]bool{"payments": true})
|
|
@@ -27,6 +27,18 @@ const (
|
|
|
27
27
|
OutcomeFailure Outcome = "failure"
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
+
// HealthStatus describes whether a stored workflow is trusted for new
|
|
31
|
+
// routing. The status is runtime metadata and is never part of the submitted
|
|
32
|
+
// YAML definition.
|
|
33
|
+
type HealthStatus string
|
|
34
|
+
|
|
35
|
+
const (
|
|
36
|
+
HealthHealthy HealthStatus = "healthy"
|
|
37
|
+
HealthBlocked HealthStatus = "blocked"
|
|
38
|
+
HealthUnverified HealthStatus = "unverified"
|
|
39
|
+
HealthOutdated HealthStatus = "outdated"
|
|
40
|
+
)
|
|
41
|
+
|
|
30
42
|
// Reserved lifecycle node names. The word "terminal" is runner-only.
|
|
31
43
|
const (
|
|
32
44
|
StartNode = "start"
|
|
@@ -39,6 +51,13 @@ type Workflow struct {
|
|
|
39
51
|
CleanupRunnerOnEnd bool `yaml:"cleanupRunnerOnEnd" json:"cleanupRunnerOnEnd"`
|
|
40
52
|
TaskConfig config.RawValues `yaml:"taskConfig,omitempty" json:"taskConfig,omitempty"`
|
|
41
53
|
Nodes map[string]Node `yaml:"nodes" json:"nodes"`
|
|
54
|
+
|
|
55
|
+
// Status, StatusReason, and RepairCommand are derived startup metadata.
|
|
56
|
+
// They are deliberately excluded from YAML so a workflow file remains the
|
|
57
|
+
// exact submitted definition.
|
|
58
|
+
Status HealthStatus `yaml:"-" json:"status,omitempty"`
|
|
59
|
+
StatusReason string `yaml:"-" json:"statusReason,omitempty"`
|
|
60
|
+
RepairCommand string `yaml:"-" json:"repairCommand,omitempty"`
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
type Node struct {
|
|
@@ -306,6 +325,58 @@ func (w *Workflow) Routes(node string, outcome Outcome) ([]Route, error) {
|
|
|
306
325
|
}
|
|
307
326
|
}
|
|
308
327
|
|
|
328
|
+
// IsRoutable reports whether this workflow may receive new tickets. Parsed
|
|
329
|
+
// values created by callers before startup metadata was added have an empty
|
|
330
|
+
// status and remain routable for compatibility; stored workflows are always
|
|
331
|
+
// assigned an explicit status by the loader.
|
|
332
|
+
func (w *Workflow) IsRoutable() bool {
|
|
333
|
+
return w != nil && (w.Status == "" || w.Status == HealthHealthy)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// MarkHealthy marks a workflow as trusted and routable.
|
|
337
|
+
func (w *Workflow) MarkHealthy() {
|
|
338
|
+
if w == nil {
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
w.Status = HealthHealthy
|
|
342
|
+
w.StatusReason = ""
|
|
343
|
+
w.RepairCommand = ""
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// MarkBlocked isolates a workflow from new routing while retaining it for
|
|
347
|
+
// inspection and explicit resubmission.
|
|
348
|
+
func (w *Workflow) MarkBlocked(reason, repairCommand string) {
|
|
349
|
+
if w == nil {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
w.Status = HealthBlocked
|
|
353
|
+
w.StatusReason = reason
|
|
354
|
+
w.RepairCommand = repairCommand
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// MarkUnverified isolates a workflow with no accepted integrity metadata. It
|
|
358
|
+
// must be explicitly resubmitted before it can receive new tickets.
|
|
359
|
+
func (w *Workflow) MarkUnverified(reason, repairCommand string) {
|
|
360
|
+
if w == nil {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
w.Status = HealthUnverified
|
|
364
|
+
w.StatusReason = reason
|
|
365
|
+
w.RepairCommand = repairCommand
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// MarkOutdated isolates a workflow whose bytes no longer match the accepted
|
|
369
|
+
// content hash. The explicit resubmission command is diagnostic guidance; it
|
|
370
|
+
// does not make direct file edits routable.
|
|
371
|
+
func (w *Workflow) MarkOutdated(reason, repairCommand string) {
|
|
372
|
+
if w == nil {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
w.Status = HealthOutdated
|
|
376
|
+
w.StatusReason = reason
|
|
377
|
+
w.RepairCommand = repairCommand
|
|
378
|
+
}
|
|
379
|
+
|
|
309
380
|
// RenderNudge renders the node's optional custom instructions with the
|
|
310
381
|
// supported variables.
|
|
311
382
|
func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, error) {
|