relay-flow 0.2.3-alpha → 0.2.5-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 (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. package/package.json +1 -1
@@ -39,9 +39,10 @@ func TestSanitizeErr(t *testing.T) {
39
39
 
40
40
  // fakeCLI is the orcacli.Client seam used by the Orca runner tests.
41
41
  type fakeCLI struct {
42
- repos []orcacli.Repo
43
- worktrees []orcacli.Worktree
44
- terminals map[string]orcacli.Terminal
42
+ repos []orcacli.Repo
43
+ worktrees []orcacli.Worktree
44
+ terminals map[string]orcacli.Terminal
45
+ listedTerminals []orcacli.Terminal
45
46
 
46
47
  createdBaseBranch string
47
48
  createdParent string
@@ -82,7 +83,7 @@ func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Termin
82
83
  }
83
84
  func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
84
85
  func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
85
- return nil, nil
86
+ return f.listedTerminals, nil
86
87
  }
87
88
  func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command string) (string, error) {
88
89
  f.createN++
@@ -157,6 +158,34 @@ func TestSetEnvironmentStatus(t *testing.T) {
157
158
  }
158
159
  }
159
160
 
161
+ func TestDiscoverTerminalByStableTitle(t *testing.T) {
162
+ fx := &fakeCLI{
163
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
164
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
165
+ listedTerminals: []orcacli.Terminal{
166
+ {Handle: "term-other", Title: "PAY-1:other", Connected: true},
167
+ {Handle: "term-node", Title: "PAY-1:implement", Connected: true},
168
+ },
169
+ }
170
+ a, err := New(fx, config.RawValues{})
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ discoverer, ok := a.(runner.TerminalDiscoverer)
175
+ if !ok {
176
+ t.Fatal("Orca runner does not expose recovery terminal discovery")
177
+ }
178
+ got, found, err := discoverer.DiscoverTerminal(context.Background(), runner.RunSpec{
179
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
180
+ }, "PAY-1:implement")
181
+ if err != nil || !found || got.ID != "term-node" || got.Title != "PAY-1:implement" {
182
+ t.Fatalf("DiscoverTerminal = %+v, %v, %v", got, found, err)
183
+ }
184
+ if fx.createN != 0 {
185
+ t.Fatalf("terminal discovery created a terminal: %d", fx.createN)
186
+ }
187
+ }
188
+
160
189
  func TestFindTerminalUsesPersistedID(t *testing.T) {
161
190
  fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
162
191
  "term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
@@ -56,6 +56,14 @@ type RunSpec struct {
56
56
  //
57
57
  // Terminal titles are stable and minimal: only "<ticket>:<node>" — never
58
58
  // nodeVisitID, workflow name, agent name, or other changing metadata.
59
+ // TerminalDiscoverer is an optional recovery capability. It discovers a
60
+ // currently live run-owned terminal by its stable title without creating a
61
+ // terminal or changing runner state. The core Runner contract remains
62
+ // unchanged for adapters that do not support title discovery.
63
+ type TerminalDiscoverer interface {
64
+ DiscoverTerminal(ctx context.Context, spec RunSpec, title string) (Terminal, bool, error)
65
+ }
66
+
59
67
  type Runner interface {
60
68
  DiscoverRepos(ctx context.Context) ([]RepoCandidate, error)
61
69
  ValidateRepo(ctx context.Context, name, path string) error
@@ -328,9 +328,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
328
328
  return nil, err
329
329
  }
330
330
  f := cfg.Filters
331
- if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
332
- f.Assignees = []string{cfg.Assignee}
333
- }
334
331
  return func(ticket task.Ticket) bool {
335
332
  if len(f.ParentStatuses) > 0 && !containsExact(f.ParentStatuses, stringField(ticket.Fields, "status")) {
336
333
  return false
@@ -353,19 +350,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
353
350
  }, nil
354
351
  }
355
352
 
356
- func hasAssigneeFilter(raw config.RawValues) bool {
357
- switch filters := raw["filters"].(type) {
358
- case map[string]any:
359
- _, ok := filters["assignees"]
360
- return ok
361
- case config.RawValues:
362
- _, ok := filters["assignees"]
363
- return ok
364
- default:
365
- return false
366
- }
367
- }
368
-
369
353
  func containsExact(values []string, want string) bool {
370
354
  for _, value := range values {
371
355
  if value == want {
@@ -50,7 +50,7 @@ func TestBeadsConfigRejectsJiraOnlyFields(t *testing.T) {
50
50
  }
51
51
  }
52
52
 
53
- func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
53
+ func TestBeadsCompileFilterDoesNotUseTopLevelAssignee(t *testing.T) {
54
54
  sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
55
55
  "assignee": "Repo.Bot@Example.com",
56
56
  })}
@@ -58,15 +58,14 @@ func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
58
58
  if err != nil {
59
59
  t.Fatalf("CompileFilter failed: %v", err)
60
60
  }
61
- if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
62
- t.Fatal("top-level assignee did not become the default filter")
63
- }
64
- if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
65
- t.Fatal("ticket assigned to another user matched the default assignee filter")
61
+ for _, assignee := range []string{"repo.bot@example.COM", "other@example.com"} {
62
+ if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
63
+ t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
64
+ }
66
65
  }
67
66
  }
68
67
 
69
- func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
68
+ func TestBeadsExplicitAssigneeFilterIsUsed(t *testing.T) {
70
69
  sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
71
70
  "assignee": "repo@example.com",
72
71
  })}
@@ -84,7 +83,7 @@ func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
84
83
  }
85
84
  for _, assignee := range []string{"repo@example.com", "workflow@example.com"} {
86
85
  if match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
87
- t.Fatalf("inherited assignee %q overrode explicit assignee filter", assignee)
86
+ t.Fatalf("explicit assignee filter accepted %q", assignee)
88
87
  }
89
88
  }
90
89
  }
@@ -232,6 +232,33 @@ func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
232
232
  }
233
233
  }
234
234
 
235
+ func TestCompileFilterCurrentUserMatchesAuthenticatedJiraEmail(t *testing.T) {
236
+ sys := newSystemWithFake(t, &fakeJira{}).(*system)
237
+ sys.currentUser = "me@example.com"
238
+ match, err := sys.CompileFilter(config.RawValues{
239
+ "filters": map[string]any{"assignees": []any{"currentUser()"}},
240
+ })
241
+ if err != nil {
242
+ t.Fatalf("CompileFilter failed: %v", err)
243
+ }
244
+ if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "ME@EXAMPLE.COM"}}) {
245
+ t.Fatal("currentUser() did not resolve to the authenticated Jira email")
246
+ }
247
+ if match(task.Ticket{Key: "PAY-2", Fields: map[string]any{"assignee": "other@example.com"}}) {
248
+ t.Fatal("currentUser() matched another Jira assignee")
249
+ }
250
+ }
251
+
252
+ func TestCompileFilterCurrentUserRequiresCredentials(t *testing.T) {
253
+ sys := newSystemWithFake(t, &fakeJira{})
254
+ _, err := sys.CompileFilter(config.RawValues{
255
+ "filters": map[string]any{"assignees": []any{"currentUser()"}},
256
+ })
257
+ if err == nil || !strings.Contains(err.Error(), "authenticated Jira credentials") {
258
+ t.Fatalf("CompileFilter error = %v, want authenticated-credentials error", err)
259
+ }
260
+ }
261
+
235
262
  func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
236
263
  // Assignee is a supported structured filter dimension
237
264
  // (repo-workflow-routing: "parent statuses, issue types, labels, and
@@ -253,7 +280,7 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
253
280
  }
254
281
  }
255
282
 
256
- func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
283
+ func TestCompileFilterDoesNotUseEffectiveAssigneeAsFilter(t *testing.T) {
257
284
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
258
285
  Name: "payments",
259
286
  RootConfig: config.RawValues{"assignee": "root@example.com"},
@@ -266,11 +293,10 @@ func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
266
293
  if err != nil {
267
294
  t.Fatal(err)
268
295
  }
269
- if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
270
- t.Fatal("effective repo assignee did not match normalized email case-insensitively")
271
- }
272
- if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
273
- t.Fatal("ticket assigned to another user matched effective assignee")
296
+ for _, assignee := range []string{"root@example.com", "repo.bot@example.COM", "other@example.com"} {
297
+ if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
298
+ t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
299
+ }
274
300
  }
275
301
  }
276
302
 
@@ -53,6 +53,7 @@ const (
53
53
  defaultStartParentStatus = "In Progress"
54
54
  defaultWorkTaskStatus = "In Progress"
55
55
  defaultEndParentStatus = "Done"
56
+ currentUserAssigneeFilter = "currentUser()"
56
57
  defaultMailboxDescription = `Parent ticket: {{ticket}}
57
58
  Workflow: {{workflow}}
58
59
  Node: {{node}}
@@ -147,7 +148,12 @@ func init() {
147
148
  if err != nil {
148
149
  return nil, err
149
150
  }
150
- return newSystem(ctx, client, spec)
151
+ sys, err := newSystem(ctx, client, spec)
152
+ if err != nil {
153
+ return nil, err
154
+ }
155
+ sys.currentUser = strings.TrimSpace(creds.Email)
156
+ return sys, nil
151
157
  },
152
158
  })
153
159
  }
@@ -173,10 +179,11 @@ func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
173
179
  // system is the repo-bound Jira task.System. It is safe for concurrent use;
174
180
  // the REST client owns connection reuse, caches, and request limiting.
175
181
  type system struct {
176
- cli jirarest.Client
177
- repoName string
178
- base config.RawValues
179
- effective Config // root+repo merged
182
+ cli jirarest.Client
183
+ repoName string
184
+ currentUser string
185
+ base config.RawValues
186
+ effective Config // root+repo merged
180
187
  }
181
188
 
182
189
  func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
@@ -323,9 +330,11 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
323
330
  return nil, err
324
331
  }
325
332
  f := cfg.Filters
326
- if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
327
- f.Assignees = []string{cfg.Assignee}
333
+ resolvedAssignees, err := s.resolveAssigneeFilters(f.Assignees)
334
+ if err != nil {
335
+ return nil, err
328
336
  }
337
+ f.Assignees = resolvedAssignees
329
338
  return func(t task.Ticket) bool {
330
339
  if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
331
340
  return false
@@ -348,17 +357,18 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
348
357
  }, nil
349
358
  }
350
359
 
351
- func hasAssigneeFilter(raw config.RawValues) bool {
352
- switch filters := raw["filters"].(type) {
353
- case map[string]any:
354
- _, ok := filters["assignees"]
355
- return ok
356
- case config.RawValues:
357
- _, ok := filters["assignees"]
358
- return ok
359
- default:
360
- return false
360
+ func (s *system) resolveAssigneeFilters(values []string) ([]string, error) {
361
+ resolved := make([]string, 0, len(values))
362
+ for _, value := range values {
363
+ if value == currentUserAssigneeFilter {
364
+ if s.currentUser == "" {
365
+ return nil, fmt.Errorf("jira: filters.assignees value %q requires authenticated Jira credentials", currentUserAssigneeFilter)
366
+ }
367
+ value = s.currentUser
368
+ }
369
+ resolved = append(resolved, value)
361
370
  }
371
+ return resolved, nil
362
372
  }
363
373
 
364
374
  // --- Claim ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.3-alpha",
3
+ "version": "0.2.5-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",