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.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.7-alpha",
3
+ "version": "0.3.8-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",