relay-flow 0.3.9-alpha → 0.3.10-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 (46) hide show
  1. package/README.md +27 -19
  2. package/cmd/relay-flow/commands_test.go +440 -21
  3. package/cmd/relay-flow/main.go +203 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/internal/execution/goworkflows/fakes_test.go +20 -0
  10. package/internal/execution/goworkflows/interpreter.go +1 -0
  11. package/internal/execution/goworkflows/ownership_test.go +259 -0
  12. package/internal/execution/goworkflows/recovery_test.go +81 -1
  13. package/internal/execution/temporal/interpreter.go +2 -1
  14. package/internal/execution/temporal/recovery.go +68 -36
  15. package/internal/harness/harness.go +1 -0
  16. package/internal/harness/opencode/opencode.go +5 -0
  17. package/internal/harness/opencode/opencode_test.go +17 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +6 -0
  20. package/internal/harness/pi/task_env_test.go +16 -0
  21. package/internal/recover/recover.go +30 -1
  22. package/internal/repo/binding_test.go +63 -0
  23. package/internal/repo/repo.go +47 -6
  24. package/internal/router/router.go +39 -5
  25. package/internal/router/router_test.go +67 -1
  26. package/internal/run/manager.go +90 -3
  27. package/internal/run/run_manager_test.go +74 -0
  28. package/internal/server/api_test.go +13 -0
  29. package/internal/server/client.go +7 -0
  30. package/internal/server/fixture_test.go +5 -0
  31. package/internal/server/server.go +32 -3
  32. package/internal/task/beads/beads.go +201 -3
  33. package/internal/task/beads/beads_test.go +172 -0
  34. package/internal/task/jira/effects_test.go +86 -0
  35. package/internal/task/jira/filters_test.go +103 -0
  36. package/internal/task/jira/helpers_test.go +11 -2
  37. package/internal/task/jira/jira.go +228 -4
  38. package/internal/task/jira/normalize.go +30 -15
  39. package/internal/task/jira/rest/client.go +17 -2
  40. package/internal/task/jira/transition_defaults_test.go +3 -1
  41. package/internal/task/task.go +66 -0
  42. package/internal/workflow/report.go +3 -0
  43. package/internal/workflow/report_test.go +16 -0
  44. package/internal/workflow/workflow.go +24 -0
  45. package/internal/workflow/workflow_test.go +58 -1
  46. package/package.json +1 -1
@@ -0,0 +1,259 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/identity"
13
+ recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
14
+ "github.com/rajpopat27/relay-flow/internal/router"
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/task"
17
+ "github.com/rajpopat27/relay-flow/internal/workflow"
18
+ )
19
+
20
+ // ownershipCompositionState models the provider-owned labels and current
21
+ // assignee shared by two independent relay-flow databases. The adapter fake
22
+ // persists a durable wf-owner marker when the first server claims the ticket.
23
+ type ownershipCompositionState struct {
24
+ mu sync.Mutex
25
+ assignee string
26
+ labels []string
27
+ }
28
+
29
+ type ownershipCompositionTaskSystem struct {
30
+ *fakeTaskSystem
31
+ owner string
32
+ state *ownershipCompositionState
33
+ }
34
+
35
+ func newOwnershipCompositionTaskSystem(owner string, state *ownershipCompositionState, log *eventLog) *ownershipCompositionTaskSystem {
36
+ return &ownershipCompositionTaskSystem{
37
+ fakeTaskSystem: newFakeTaskSystem(log), owner: owner, state: state,
38
+ }
39
+ }
40
+
41
+ func (s *ownershipCompositionTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
42
+ s.state.mu.Lock()
43
+ defer s.state.mu.Unlock()
44
+ claims := make([]string, 0, 1)
45
+ for _, label := range s.state.labels {
46
+ if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
47
+ claims = append(claims, label)
48
+ }
49
+ }
50
+ return []task.Ticket{{
51
+ ID: "1", Key: "PAY-101", Title: "ownership composition",
52
+ WorkflowClaims: claims,
53
+ Fields: map[string]any{
54
+ "assignee": s.state.assignee,
55
+ "labels": append([]string(nil), s.state.labels...),
56
+ },
57
+ }}, nil
58
+ }
59
+
60
+ func (s *ownershipCompositionTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
61
+ return func(ticket task.Ticket) bool {
62
+ assignee, _ := ticket.Fields["assignee"].(string)
63
+ return assignee == s.owner
64
+ }, nil
65
+ }
66
+
67
+ func (s *ownershipCompositionTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
68
+ return func(ticket task.Ticket) bool {
69
+ if len(ticket.WorkflowClaims) != 1 {
70
+ return false
71
+ }
72
+ assignee, _ := ticket.Fields["assignee"].(string)
73
+ if assignee != s.owner {
74
+ return false
75
+ }
76
+ want := "wf-owner:" + ticket.WorkflowClaims[0][len("wf:"):] + ":" + s.owner
77
+ labels, _ := ticket.Fields["labels"].([]string)
78
+ for _, label := range labels {
79
+ if label == want {
80
+ return true
81
+ }
82
+ }
83
+ return false
84
+ }, nil
85
+ }
86
+
87
+ func (s *ownershipCompositionTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
88
+ s.state.mu.Lock()
89
+ defer s.state.mu.Unlock()
90
+ if s.hasOwnerMarkerLocked("ownershipFlow") {
91
+ return nil
92
+ }
93
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
94
+ }
95
+
96
+ func (s *ownershipCompositionTaskSystem) BackfillClaimOwner(context.Context, task.TicketRef, string, config.RawValues) error {
97
+ s.state.mu.Lock()
98
+ defer s.state.mu.Unlock()
99
+ if !containsLabel(s.state.labels, "wf:ownershipFlow") {
100
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
101
+ }
102
+ marker := "wf-owner:ownershipFlow:" + s.owner
103
+ if !containsLabel(s.state.labels, marker) {
104
+ s.state.labels = append(s.state.labels, marker)
105
+ }
106
+ return nil
107
+ }
108
+
109
+ func (s *ownershipCompositionTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflowName string, _ config.RawValues) error {
110
+ s.state.mu.Lock()
111
+ defer s.state.mu.Unlock()
112
+ if s.state.assignee != s.owner {
113
+ return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
114
+ }
115
+ ownerMarker := "wf-owner:" + workflowName + ":" + s.owner
116
+ claim := "wf:" + workflowName
117
+ for _, label := range s.state.labels {
118
+ if label == ownerMarker && containsLabel(s.state.labels, claim) {
119
+ return nil
120
+ }
121
+ if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
122
+ return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
123
+ }
124
+ }
125
+ s.state.labels = append(s.state.labels, ownerMarker, claim)
126
+ return nil
127
+ }
128
+
129
+ func (s *ownershipCompositionTaskSystem) hasOwnerMarkerLocked(workflowName string) bool {
130
+ return containsLabel(s.state.labels, "wf-owner:"+workflowName+":"+s.owner)
131
+ }
132
+
133
+ func containsLabel(labels []string, want string) bool {
134
+ for _, label := range labels {
135
+ if label == want {
136
+ return true
137
+ }
138
+ }
139
+ return false
140
+ }
141
+
142
+ func TestExplicitLegacyBackfillPreservesClaimAndEnablesRoutingAndRecovery(t *testing.T) {
143
+ state := &ownershipCompositionState{assignee: "alice", labels: []string{"wf:ownershipFlow"}}
144
+ log := newEventLog()
145
+ sys := newOwnershipCompositionTaskSystem("alice", state, log)
146
+ reg := repoRegistryWith("payments", sys)
147
+ wf := linearWorkflow(false)
148
+ wf.Name = "ownershipFlow"
149
+ wf.TaskConfig = config.RawValues{"filters": map[string]any{"assignees": []any{"currentUser()"}}}
150
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ workflowReg := &workflow.Registry{}
154
+ workflowReg.Replace(&wf)
155
+ fr := newFakeRunner(log)
156
+ engine := newEngine(t, goworkflows.Dependencies{Repos: reg, Runner: fr, Harness: newFakeHarness(log)})
157
+ manager := &run.RunManager{Executor: engine, Runs: engine, Repos: reg, Workflows: workflowReg}
158
+ if err := manager.BackfillClaimOwner(context.Background(), "payments", "PAY-101", "ownershipFlow"); err != nil {
159
+ t.Fatalf("explicit backfill failed: %v", err)
160
+ }
161
+ state.mu.Lock()
162
+ labels := append([]string(nil), state.labels...)
163
+ state.mu.Unlock()
164
+ if !containsLabel(labels, "wf:ownershipFlow") || !containsLabel(labels, "wf-owner:ownershipFlow:alice") || len(labels) != 2 {
165
+ t.Fatalf("backfill labels = %v, want preserved claim plus one provenance marker", labels)
166
+ }
167
+ rp, _ := reg.Get("payments")
168
+ ticket, _ := sys.Poll(context.Background())
169
+ if _, err := router.ResolveWorkflow(rp, ticket[0]); err != nil {
170
+ t.Fatalf("backfilled claim did not route normally: %v", err)
171
+ }
172
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
173
+ return goworkflows.RenderMailboxSpecs(system, work, w)
174
+ }
175
+ if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, manager, specsFor); err != nil {
176
+ t.Fatalf("backfilled claim did not recover: %v", err)
177
+ }
178
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
179
+ t.Fatalf("recovered runs = %v, %v; want one run", runs, err)
180
+ }
181
+ }
182
+
183
+ func TestIndependentRunDatabasesCannotTakeOverReassignedClaim(t *testing.T) {
184
+ state := &ownershipCompositionState{assignee: "alice"}
185
+ logA, logB := newEventLog(), newEventLog()
186
+ sysA := newOwnershipCompositionTaskSystem("alice", state, logA)
187
+ sysB := newOwnershipCompositionTaskSystem("bob", state, logB)
188
+ regA := repoRegistryWith("payments", sysA)
189
+ regB := repoRegistryWith("payments", sysB)
190
+ wf := linearWorkflow(false)
191
+ wf.Name = "ownershipFlow"
192
+ wf.TaskConfig = config.RawValues{"filters": map[string]any{
193
+ "assignees": []any{"currentUser()"},
194
+ }}
195
+ if err := regA.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ if err := regB.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+
202
+ engineA := newEngine(t, goworkflows.Dependencies{
203
+ Repos: regA, Runner: newFakeRunner(logA), Harness: newFakeHarness(logA),
204
+ })
205
+ engineB := newEngine(t, goworkflows.Dependencies{
206
+ Repos: regB, Runner: newFakeRunner(logB), Harness: newFakeHarness(logB),
207
+ })
208
+ managerA := &run.RunManager{Executor: engineA, Runs: engineA}
209
+ managerB := &run.RunManager{Executor: engineB, Runs: engineB}
210
+
211
+ ticketA, err := sysA.Poll(context.Background())
212
+ if err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ rpA, _ := regA.Get("payments")
216
+ resolvedA, err := router.ResolveWorkflow(rpA, ticketA[0])
217
+ if err != nil {
218
+ t.Fatalf("owner A route failed: %v", err)
219
+ }
220
+ if err := managerA.EnsureRun(context.Background(), rpA, resolvedA, ticketA[0]); err != nil {
221
+ t.Fatalf("owner A EnsureRun failed: %v", err)
222
+ }
223
+ waitFor(t, 10*time.Second, func() bool {
224
+ current, err := engineA.GetRun(context.Background(), identity.NewRunID("payments", "ownershipFlow", "PAY-101"))
225
+ return err == nil && current.State != run.StateCompleted && current.State != run.StateCanceled
226
+ })
227
+
228
+ state.mu.Lock()
229
+ state.assignee = "bob"
230
+ state.mu.Unlock()
231
+ ticketB, err := sysB.Poll(context.Background())
232
+ if err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ rpB, _ := regB.Get("payments")
236
+ if _, err := router.ResolveWorkflow(rpB, ticketB[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
237
+ t.Fatalf("owner B route error = %v, want claim-owner-mismatch", err)
238
+ }
239
+ if err := managerB.EnsureRun(context.Background(), rpB, &wf, ticketB[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
240
+ t.Fatalf("owner B EnsureRun error = %v, want ownership mismatch", err)
241
+ }
242
+ if runs, err := engineB.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
243
+ t.Fatalf("owner B database runs = %v, %v; want none", runs, err)
244
+ }
245
+
246
+ // Reassignment is a current-owner mismatch for both servers. The
247
+ // original durable run remains in database A, but neither server calls
248
+ // EnsureRun to reconcile or create execution state after the mismatch.
249
+ ticketAAfterReassignment, _ := sysA.Poll(context.Background())
250
+ if _, err := router.ResolveWorkflow(rpA, ticketAAfterReassignment[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
251
+ t.Fatalf("owner A route after reassignment = %v, want claim-owner-mismatch", err)
252
+ }
253
+ if err := managerA.EnsureRun(context.Background(), rpA, &wf, ticketAAfterReassignment[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
254
+ t.Fatalf("owner A EnsureRun after reassignment = %v, want ownership mismatch", err)
255
+ }
256
+ if runs, err := engineA.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
257
+ t.Fatalf("owner A database runs after reassignment = %v, %v; want one existing run", runs, err)
258
+ }
259
+ }
@@ -10,6 +10,7 @@ import (
10
10
  "time"
11
11
 
12
12
  "github.com/cschleiden/go-workflows/backend/history"
13
+ "github.com/rajpopat27/relay-flow/internal/config"
13
14
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
14
15
  "github.com/rajpopat27/relay-flow/internal/identity"
15
16
  recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
@@ -1358,7 +1359,7 @@ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
1358
1359
  // task.System/runner.Runner plus the real engine and the real RunManager.
1359
1360
  // Rewritten in 6.3 from a test-local copy so the production wiring is
1360
1361
  // actually covered.
1361
- func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTaskSystem, fr *fakeRunner, wf workflow.Workflow) error {
1362
+ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys task.System, fr *fakeRunner, wf workflow.Workflow) error {
1362
1363
  reg := repo.NewRegistry()
1363
1364
  reg.Replace(&repo.Repo{
1364
1365
  Name: "payments",
@@ -1376,6 +1377,85 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
1376
1377
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
1377
1378
  }
1378
1379
 
1380
+ type mismatchedRecoveryTaskSystem struct{ *fakeTaskSystem }
1381
+
1382
+ type legacyRecoveryTaskSystem struct{ *fakeTaskSystem }
1383
+
1384
+ func (s *legacyRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1385
+ return func(task.Ticket) bool { return false }, nil
1386
+ }
1387
+
1388
+ func (s *mismatchedRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1389
+ // Let the router resolve the claim; the current-provider read below is the
1390
+ // mismatch gate being exercised by this recovery regression.
1391
+ return func(task.Ticket) bool { return true }, nil
1392
+ }
1393
+
1394
+ func (s *mismatchedRecoveryTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
1395
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"}
1396
+ }
1397
+
1398
+ func TestServeRecoverSkipsOwnerMismatchBeforeMutations(t *testing.T) {
1399
+ log := newEventLog()
1400
+ base := newFakeTaskSystem(log)
1401
+ sys := &mismatchedRecoveryTaskSystem{fakeTaskSystem: base}
1402
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1403
+ fr := newFakeRunner(log)
1404
+ engine := newEngine(t, goworkflows.Dependencies{
1405
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1406
+ })
1407
+ wf := linearWorkflow(false)
1408
+ reg := repo.NewRegistry()
1409
+ reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
1410
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
1411
+ t.Fatal(err)
1412
+ }
1413
+ rm := &run.RunManager{Executor: engine, Runs: engine}
1414
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
1415
+ return goworkflows.RenderMailboxSpecs(system, work, w)
1416
+ }
1417
+ if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, rm, specsFor); err != nil {
1418
+ t.Fatalf("recovery failed on owner mismatch: %v", err)
1419
+ }
1420
+ if len(fr.closedRun) != 0 {
1421
+ t.Fatalf("CloseTerminals calls = %v, want none", fr.closedRun)
1422
+ }
1423
+ if base.hasCommentCount() != 0 {
1424
+ t.Fatalf("HasComment calls = %d, want none before owner rejection", base.hasCommentCount())
1425
+ }
1426
+ if len(base.resets) != 0 {
1427
+ t.Fatalf("ResetForRecovery calls = %v, want none", base.resets)
1428
+ }
1429
+ for _, event := range log.all() {
1430
+ if strings.HasPrefix(event, "ensureMailboxes:") || strings.HasPrefix(event, "createMailbox:") {
1431
+ t.Fatalf("mailbox mutation after owner mismatch: %v", log.all())
1432
+ }
1433
+ }
1434
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1435
+ t.Fatalf("durable runs after owner mismatch = %v, %v; want none", runs, err)
1436
+ }
1437
+ }
1438
+
1439
+ func TestServeRecoverSkipsLegacyClaimWithoutProvenance(t *testing.T) {
1440
+ log := newEventLog()
1441
+ base := newFakeTaskSystem(log)
1442
+ sys := &legacyRecoveryTaskSystem{fakeTaskSystem: base}
1443
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1444
+ fr := newFakeRunner(log)
1445
+ engine := newEngine(t, goworkflows.Dependencies{
1446
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1447
+ })
1448
+ if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
1449
+ t.Fatalf("legacy-claim recovery failed: %v", err)
1450
+ }
1451
+ if len(fr.closedRun) != 0 || base.hasCommentCount() != 0 || len(base.resets) != 0 {
1452
+ t.Fatalf("legacy claim caused recovery side effects: closed=%v comments=%d resets=%v", fr.closedRun, base.hasCommentCount(), base.resets)
1453
+ }
1454
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1455
+ t.Fatalf("legacy claim durable runs = %v, %v; want none", runs, err)
1456
+ }
1457
+ }
1458
+
1379
1459
  func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
1380
1460
  log := newEventLog()
1381
1461
  sys := newFakeTaskSystem(log)
@@ -402,7 +402,8 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
402
402
  spec := harness.LaunchSpec{
403
403
  RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
404
404
  Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
405
- Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
405
+ AutoReject: node.AutoReject,
406
+ Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
406
407
  PromptData: harness.PromptData{
407
408
  TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
408
409
  Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
@@ -6,10 +6,12 @@ import (
6
6
  "fmt"
7
7
  "log/slog"
8
8
  "sort"
9
- "strings"
10
9
  "time"
11
10
 
12
11
  "github.com/rajpopat27/relay-flow/internal/identity"
12
+ "github.com/rajpopat27/relay-flow/internal/repo"
13
+ "github.com/rajpopat27/relay-flow/internal/router"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
13
15
  enumspb "go.temporal.io/api/enums/v1"
14
16
  "go.temporal.io/api/serviceerror"
15
17
  workflowpb "go.temporal.io/api/workflow/v1"
@@ -145,52 +147,82 @@ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string
145
147
  slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
146
148
  continue
147
149
  }
150
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
151
+ if !ok {
152
+ slog.Warn("Temporal recovery skipping repository without ownership capabilities", "repo", rp.Name,
153
+ "outcome", "ownership-capability-missing")
154
+ continue
155
+ }
148
156
  tickets, err := rp.TaskSystem.Poll(ctx)
149
157
  if err != nil {
150
158
  return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
151
159
  }
152
160
  for _, ticket := range tickets {
153
- for _, claim := range ticket.WorkflowClaims {
154
- if !strings.HasPrefix(claim, "wf:") {
155
- continue
156
- }
157
- workflowName := strings.TrimPrefix(claim, "wf:")
158
- var matched bool
159
- for _, binding := range rp.Bindings() {
160
- if binding.Workflow != nil && binding.Workflow.Name == workflowName {
161
- matched = true
162
- break
163
- }
164
- }
165
- if !matched {
166
- continue
167
- }
168
- expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
169
- if visible[string(expectedID)] {
170
- continue
161
+ // Projection reconciliation only considers claimed parents. Resolve
162
+ // through the same router as normal polling so ambiguous, unknown,
163
+ // and owner-mismatched claims cannot restore an execution.
164
+ if len(ticket.WorkflowClaims) != 1 {
165
+ continue
166
+ }
167
+ wf, err := router.ResolveWorkflow(rp, ticket)
168
+ if err != nil {
169
+ var ownerMismatch *router.ClaimOwnerMismatchError
170
+ if errors.As(err, &ownerMismatch) {
171
+ slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
172
+ "workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch")
173
+ } else {
174
+ slog.Warn("Temporal recovery skipped claimed ticket", "repo", rp.Name, "ticket", ticket.Key, "outcome", "invalid-claim", "error", err)
171
175
  }
172
- info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
173
- if err != nil {
174
- var notFound *serviceerror.NotFound
175
- if errors.As(err, &notFound) {
176
- // This is the documented claim-before-run gap. Record it
177
- // for operators, but leave creation to normal polling after
178
- // recovery; this path must never start a replacement.
179
- slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
180
- continue
181
- }
182
- return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
176
+ continue
177
+ }
178
+ workflowName := wf.Name
179
+ var matched *repo.WorkflowBinding
180
+ for _, binding := range rp.Bindings() {
181
+ if binding.Workflow != nil && binding.Workflow.Name == workflowName {
182
+ candidate := binding
183
+ matched = &candidate
184
+ break
183
185
  }
184
- if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
185
- info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
186
+ }
187
+ if matched == nil {
188
+ continue
189
+ }
190
+ // Existing Temporal executions are restored only after the same
191
+ // ownership gate used by normal claimed routing. Lifecycle
192
+ // filters remain intentionally bypassed.
193
+ if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), workflowName, matched.Workflow.TaskConfig); err != nil {
194
+ if errors.Is(err, task.ErrOwnershipMismatch) {
195
+ slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
196
+ "workflow", workflowName, "outcome", "claim-owner-mismatch", "error", err)
186
197
  continue
187
198
  }
188
- if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
199
+ return fmt.Errorf("poll repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
200
+ }
201
+ expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
202
+ if visible[string(expectedID)] {
203
+ continue
204
+ }
205
+ info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
206
+ if err != nil {
207
+ var notFound *serviceerror.NotFound
208
+ if errors.As(err, &notFound) {
209
+ // This is the documented claim-before-run gap. Record it
210
+ // for operators, but leave creation to normal polling after
211
+ // recovery; this path must never start a replacement.
212
+ slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
189
213
  continue
190
214
  }
191
- if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
192
- return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
193
- }
215
+ return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
216
+ }
217
+ if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
218
+ info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
219
+ continue
220
+ }
221
+ if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
222
+ continue
223
+ }
224
+ if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
225
+ return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
194
226
  }
195
227
  }
196
228
  }
@@ -49,6 +49,7 @@ type LaunchSpec struct {
49
49
  Ticket string
50
50
  Node string
51
51
  NodeType workflow.NodeType
52
+ AutoReject bool
52
53
  Agent string
53
54
  Title string
54
55
  Prompt string
@@ -17,6 +17,7 @@ import (
17
17
  "os/exec"
18
18
  "path/filepath"
19
19
  "regexp"
20
+ "strconv"
20
21
  "strings"
21
22
 
22
23
  "github.com/rajpopat27/relay-flow/internal/config"
@@ -206,6 +207,10 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
206
207
  } {
207
208
  env[key] = value
208
209
  }
210
+ if spec.NodeType == workflow.NodeHITL {
211
+ // HITL policy is launch metadata, not part of the report wire object.
212
+ env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
213
+ }
209
214
  args := []string{}
210
215
  if spec.ResumeID != "" {
211
216
  args = append(args, "--session", spec.ResumeID)
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.9-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.10-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -57,6 +57,22 @@ func TestBuildCommandArgv(t *testing.T) {
57
57
  }
58
58
  }
59
59
 
60
+ func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
61
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
62
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
63
+ NodeType: workflow.NodeHITL,
64
+ AutoReject: true,
65
+ Agent: "reviewer",
66
+ Prompt: "review the ticket",
67
+ })
68
+ if err != nil {
69
+ t.Fatalf("BuildCommand: %v", err)
70
+ }
71
+ if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
72
+ t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
73
+ }
74
+ }
75
+
60
76
  func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
61
77
  raw := config.RawValues{
62
78
  "initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.9-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.10-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -13,11 +13,13 @@ import (
13
13
  "os/exec"
14
14
  "path/filepath"
15
15
  "regexp"
16
+ "strconv"
16
17
  "strings"
17
18
 
18
19
  "github.com/rajpopat27/relay-flow/internal/config"
19
20
  "github.com/rajpopat27/relay-flow/internal/harness"
20
21
  "github.com/rajpopat27/relay-flow/internal/runner"
22
+ "github.com/rajpopat27/relay-flow/internal/workflow"
21
23
  )
22
24
 
23
25
  const (
@@ -214,6 +216,10 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
214
216
  } {
215
217
  env[key] = value
216
218
  }
219
+ if spec.NodeType == workflow.NodeHITL {
220
+ // HITL policy is launch metadata, not part of the report wire object.
221
+ env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
222
+ }
217
223
  args := []string{"--name", spec.Title}
218
224
  if spec.Agent != "default" {
219
225
  args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
@@ -2,8 +2,24 @@ package pi
2
2
 
3
3
  import (
4
4
  "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/workflow"
5
7
  )
6
8
 
9
+ func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
10
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
11
+ spec := launchSpec(t)
12
+ spec.NodeType = workflow.NodeHITL
13
+ spec.AutoReject = true
14
+ cmd, err := (&Harness{}).BuildCommand(spec)
15
+ if err != nil {
16
+ t.Fatalf("BuildCommand: %v", err)
17
+ }
18
+ if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
19
+ t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
20
+ }
21
+ }
22
+
7
23
  // TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
8
24
  // workspace environment reaches the runner command so agent task commands
9
25
  // address the same workspace as relay-flow, and that relay-flow's own