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.
- package/README.md +121 -10
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +96 -13
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +100 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +35 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +112 -0
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +264 -0
- package/internal/harness/pi/pi_test.go +338 -0
- package/internal/harness/pi/prompt_test.go +150 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +144 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +592 -0
- package/internal/runner/herdr/herdr_test.go +708 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- package/internal/task/beads/beads.go +0 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/jira.go +27 -17
- 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
|
|
43
|
-
worktrees
|
|
44
|
-
terminals
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
repoName
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 ---
|