relay-flow 0.3.8-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 (52) 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/activities.go +11 -0
  10. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  11. package/internal/execution/goworkflows/engine.go +278 -9
  12. package/internal/execution/goworkflows/fakes_test.go +30 -1
  13. package/internal/execution/goworkflows/interpreter.go +18 -1
  14. package/internal/execution/goworkflows/ownership_test.go +259 -0
  15. package/internal/execution/goworkflows/projection.go +8 -0
  16. package/internal/execution/goworkflows/recovery_test.go +563 -1
  17. package/internal/execution/projection/detail_test.go +38 -0
  18. package/internal/execution/projection/projection.go +80 -15
  19. package/internal/execution/temporal/interpreter.go +2 -1
  20. package/internal/execution/temporal/recovery.go +68 -36
  21. package/internal/harness/harness.go +1 -0
  22. package/internal/harness/opencode/opencode.go +5 -0
  23. package/internal/harness/opencode/opencode_test.go +17 -1
  24. package/internal/harness/opencode/repo_setup.go +1 -1
  25. package/internal/harness/pi/pi.go +6 -0
  26. package/internal/harness/pi/task_env_test.go +16 -0
  27. package/internal/recover/recover.go +30 -1
  28. package/internal/repo/binding_test.go +63 -0
  29. package/internal/repo/repo.go +47 -6
  30. package/internal/router/router.go +39 -5
  31. package/internal/router/router_test.go +67 -1
  32. package/internal/run/manager.go +90 -3
  33. package/internal/run/run_manager_test.go +74 -0
  34. package/internal/server/api_test.go +13 -0
  35. package/internal/server/client.go +7 -0
  36. package/internal/server/fixture_test.go +5 -0
  37. package/internal/server/server.go +32 -3
  38. package/internal/task/beads/beads.go +201 -3
  39. package/internal/task/beads/beads_test.go +172 -0
  40. package/internal/task/jira/effects_test.go +86 -0
  41. package/internal/task/jira/filters_test.go +103 -0
  42. package/internal/task/jira/helpers_test.go +11 -2
  43. package/internal/task/jira/jira.go +228 -4
  44. package/internal/task/jira/normalize.go +30 -15
  45. package/internal/task/jira/rest/client.go +17 -2
  46. package/internal/task/jira/transition_defaults_test.go +3 -1
  47. package/internal/task/task.go +66 -0
  48. package/internal/workflow/report.go +3 -0
  49. package/internal/workflow/report_test.go +16 -0
  50. package/internal/workflow/workflow.go +24 -0
  51. package/internal/workflow/workflow_test.go +58 -1
  52. package/package.json +1 -1
@@ -257,24 +257,69 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
257
257
  }
258
258
 
259
259
  func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
260
+ _, err := p.updateStateCAS(ctx, id, nil, state, lastErr, finished)
261
+ return err
262
+ }
263
+
264
+ // beginCancellation atomically claims cancellation of a nonterminal run. A
265
+ // run already in canceling keeps its original reason; completed/canceled runs
266
+ // are never moved backwards. The returned row is read after the conditional
267
+ // update so concurrent cancel/complete callers observe the winner.
268
+ func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
269
+ _, err := p.DB.ExecContext(ctx, `
270
+ UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?
271
+ WHERE id = ? AND state NOT IN ('completed', 'canceled', 'canceling')`,
272
+ string(run.StateCanceling), reason, time.Now().UTC(), string(id))
273
+ if err != nil {
274
+ return run.Run{}, err
275
+ }
276
+ return p.get(ctx, id)
277
+ }
278
+
279
+ func (p *RunProjection) updateStateCAS(ctx context.Context, id run.ID, expected *run.State, state run.State, lastErr string, finished *time.Time) (bool, error) {
260
280
  terminal := state == run.StateCompleted || state == run.StateCanceled
261
281
  now := time.Now().UTC()
262
- _, err := p.DB.ExecContext(ctx, `
282
+ query := `
263
283
  UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
264
284
  retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
265
285
  retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
266
286
  next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
267
- WHERE id = ?`,
268
- string(state), lastErr, now, finished, terminal, terminal, terminal, string(id))
287
+ WHERE id = ?`
288
+ args := []any{
289
+ string(state), lastErr, now, finished, terminal, terminal, terminal,
290
+ string(id),
291
+ }
292
+ if expected == nil {
293
+ // Ordinary workflow projection updates are fenced after cancellation or
294
+ // any terminal state has won. Explicit compare-and-set callers below
295
+ // are allowed to reconcile a known canceling row to its inspected
296
+ // terminal engine result.
297
+ query += ` AND NOT (
298
+ (state = 'canceling' AND ? NOT IN ('canceling', 'canceled'))
299
+ OR (state IN ('completed', 'canceled') AND state <> ?)
300
+ )`
301
+ args = append(args, string(state), string(state))
302
+ } else {
303
+ query += ` AND state = ? AND NOT (state IN ('completed', 'canceled') AND state <> ?)`
304
+ args = append(args, string(*expected), string(state))
305
+ }
306
+ result, err := p.DB.ExecContext(ctx, query, args...)
269
307
  if err != nil {
270
- return err
308
+ return false, err
309
+ }
310
+ updated, err := result.RowsAffected()
311
+ if err != nil {
312
+ return false, err
313
+ }
314
+ if updated != 1 {
315
+ return false, nil
271
316
  }
272
317
  // Keep the derived timeline useful during retries and cancellation without
273
318
  // making it an execution authority. Identify one active row first; a
274
319
  // repeated terminal update with no active row is a no-op for the timeline.
275
320
  stepStatus := stepStatusForRunState(state)
276
321
  if stepStatus == "" {
277
- return nil
322
+ return true, nil
278
323
  }
279
324
  var sequence int64
280
325
  var startedAt sql.NullTime
@@ -283,14 +328,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
283
328
  WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
284
329
  ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
285
330
  if errors.Is(lookupErr, sql.ErrNoRows) {
286
- return nil
331
+ return true, nil
287
332
  }
288
333
  if lookupErr != nil {
289
334
  // relay_run_steps is display/cache data. The authoritative relay_runs
290
335
  // state update above has already succeeded and must not be retried just
291
336
  // because this optional projection is unavailable.
292
337
  slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
293
- return nil
338
+ return true, nil
294
339
  }
295
340
  var stepFinished any
296
341
  if terminal {
@@ -300,7 +345,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
300
345
  stepFinished = now
301
346
  }
302
347
  }
303
- result, err := p.DB.ExecContext(ctx, `
348
+ result, err = p.DB.ExecContext(ctx, `
304
349
  UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
305
350
  finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
306
351
  WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
@@ -308,11 +353,11 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
308
353
  string(id), sequence)
309
354
  if err != nil {
310
355
  slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
311
- return nil
356
+ return true, nil
312
357
  }
313
- updated, err := result.RowsAffected()
358
+ updated, err = result.RowsAffected()
314
359
  if err != nil || updated != 1 {
315
- return nil
360
+ return true, nil
316
361
  }
317
362
  if terminal && startedAt.Valid {
318
363
  endAt := now
@@ -327,7 +372,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
327
372
  slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
328
373
  }
329
374
  }
330
- return nil
375
+ return true, nil
331
376
  }
332
377
 
333
378
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
@@ -352,12 +397,20 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
352
397
  }
353
398
  defer tx.Rollback()
354
399
  now := time.Now().UTC()
355
- if _, err := tx.ExecContext(ctx, `
400
+ result, err := tx.ExecContext(ctx, `
356
401
  UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
357
- WHERE id = ?`,
358
- string(state), node, string(visit), now, string(id)); err != nil {
402
+ WHERE id = ? AND state NOT IN ('canceling', 'completed', 'canceled')`,
403
+ string(state), node, string(visit), now, string(id))
404
+ if err != nil {
359
405
  return err
360
406
  }
407
+ updated, err := result.RowsAffected()
408
+ if err != nil {
409
+ return err
410
+ }
411
+ if updated != 1 {
412
+ return tx.Commit()
413
+ }
361
414
  // A revisit changes only the latest visit ID. Reusable terminal/session
362
415
  // identities remain attached to this run/node row.
363
416
  if _, err := tx.ExecContext(ctx, `
@@ -907,6 +960,18 @@ func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.St
907
960
  return p.updateState(ctx, id, state, lastErr, finished)
908
961
  }
909
962
 
963
+ // BeginCancellation atomically moves a nonterminal run to canceling and
964
+ // returns the persisted row. Existing canceling rows retain their reason.
965
+ func (p *RunProjection) BeginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
966
+ return p.beginCancellation(ctx, id, reason)
967
+ }
968
+
969
+ // UpdateStateIf applies a lifecycle transition only while the row has the
970
+ // expected state. It returns false when another transition won the race.
971
+ func (p *RunProjection) UpdateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
972
+ return p.updateStateCAS(ctx, id, &expected, state, lastErr, finished)
973
+ }
974
+
910
975
  // UpdateRetry updates or clears active retry metadata.
911
976
  func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
912
977
  return p.updateRetry(ctx, id, status)
@@ -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.8-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.8-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
@@ -50,6 +50,12 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
50
50
  slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
51
51
  continue
52
52
  }
53
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
54
+ if !ok {
55
+ slog.Warn("recover: skip repository without ownership capabilities", "repo", rp.Name,
56
+ "outcome", "ownership-capability-missing")
57
+ continue
58
+ }
53
59
  tickets, err := rp.TaskSystem.Poll(ctx)
54
60
  if err != nil {
55
61
  return fmt.Errorf("repo %q poll: %w", rp.Name, err)
@@ -65,9 +71,32 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
65
71
  continue
66
72
  }
67
73
  if err != nil {
68
- slog.Warn("recover: skip ticket with invalid claim", "ticket", ticket.Key, "error", err)
74
+ var ownerMismatch *router.ClaimOwnerMismatchError
75
+ var invalid *router.InvalidClaimError
76
+ if errors.As(err, &ownerMismatch) {
77
+ slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
78
+ "workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch", "error", err)
79
+ } else if errors.As(err, &invalid) {
80
+ slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
81
+ "workflow", invalid.Workflow, "outcome", "invalid-claim", "error", err)
82
+ } else {
83
+ slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
84
+ "outcome", "error", "error", err)
85
+ }
69
86
  continue
70
87
  }
88
+ // Router ownership uses the poll snapshot. Re-read the current
89
+ // provider-owned ticket before any recovery mutation so a
90
+ // poll-to-recovery reassignment cannot close terminals or reset
91
+ // mailboxes for the wrong owner.
92
+ if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), wf.Name, wf.TaskConfig); err != nil {
93
+ if errors.Is(err, task.ErrOwnershipMismatch) {
94
+ slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
95
+ "workflow", wf.Name, "outcome", "claim-owner-mismatch", "error", err)
96
+ continue
97
+ }
98
+ return fmt.Errorf("repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
99
+ }
71
100
  // Skip canceled parents: the cancellation marker is the
72
101
  // task-system recovery record.
73
102
  runID := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
@@ -1,6 +1,7 @@
1
1
  package repo
2
2
 
3
3
  import (
4
+ "context"
4
5
  "errors"
5
6
  "strings"
6
7
  "testing"
@@ -15,6 +16,21 @@ type bindingTaskSystem struct {
15
16
  compileErr map[string]error
16
17
  }
17
18
 
19
+ type ownershipBindingTaskSystem struct{ bindingTaskSystem }
20
+
21
+ type legacyBindingTaskSystem struct{ task.System }
22
+
23
+ func (legacyBindingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
24
+ return func(task.Ticket) bool { return true }, nil
25
+ }
26
+
27
+ func (s ownershipBindingTaskSystem) CompileOwnershipFilter(values config.RawValues) (func(task.Ticket) bool, error) {
28
+ owner, _ := values["owner"].(string)
29
+ return func(ticket task.Ticket) bool {
30
+ return owner == "" || ticket.Fields["assignee"] == owner
31
+ }, nil
32
+ }
33
+
18
34
  func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
19
35
  if name, ok := values["name"].(string); ok {
20
36
  if err := s.compileErr[name]; err != nil {
@@ -25,6 +41,53 @@ func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Tic
25
41
  return func(task.Ticket) bool { return true }, nil
26
42
  }
27
43
 
44
+ func (s bindingTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
45
+ return func(task.Ticket) bool { return true }, nil
46
+ }
47
+
48
+ func (s bindingTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
49
+ return nil
50
+ }
51
+
52
+ func (s bindingTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, _ config.RawValues) error {
53
+ return s.Claim(ctx, ticket, workflow)
54
+ }
55
+
56
+ func TestBindWorkflowsRejectsMissingOwnershipCapabilities(t *testing.T) {
57
+ registered := &Repo{Name: "payments", TaskSystem: legacyBindingTaskSystem{}}
58
+ registry := NewRegistry()
59
+ registry.Replace(registered)
60
+ wf := &workflow.Workflow{Name: "legacyFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
61
+ if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err == nil || !strings.Contains(err.Error(), "ownership capabilities") {
62
+ t.Fatalf("BindWorkflows error = %v, want missing-capability failure", err)
63
+ }
64
+ if bindings := registered.Bindings(); len(bindings) != 0 {
65
+ t.Fatalf("bindings = %#v, want none after capability rejection", bindings)
66
+ }
67
+ }
68
+
69
+ func TestBindWorkflowsPublishesSeparateOwnershipMatcher(t *testing.T) {
70
+ system := ownershipBindingTaskSystem{bindingTaskSystem: bindingTaskSystem{}}
71
+ registered := &Repo{Name: "payments", TaskSystem: system}
72
+ registry := NewRegistry()
73
+ registry.Replace(registered)
74
+ wf := &workflow.Workflow{
75
+ Name: "ownerFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy,
76
+ TaskConfig: config.RawValues{"owner": "alice"},
77
+ }
78
+ if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ bindings := registered.Bindings()
82
+ if len(bindings) != 1 || bindings[0].Ownership == nil {
83
+ t.Fatalf("bindings = %#v, want a published ownership matcher", bindings)
84
+ }
85
+ if !bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "alice"}}) ||
86
+ bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "bob"}}) {
87
+ t.Fatal("published ownership matcher did not enforce the adapter predicate")
88
+ }
89
+ }
90
+
28
91
  func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
29
92
  system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
30
93
  registered := &Repo{Name: "payments", TaskSystem: system}
@@ -16,8 +16,11 @@ import (
16
16
  // repo. Repo.Workflows is a derived in-memory index rebuilt at startup and
17
17
  // after workflow submission/removal; Workflow.Repos is the source of truth.
18
18
  type WorkflowBinding struct {
19
- Workflow *workflow.Workflow
20
- Match func(task.Ticket) bool
19
+ Workflow *workflow.Workflow
20
+ Match func(task.Ticket) bool
21
+ // Ownership is compiled from the adapter-owned durable claim-owner
22
+ // predicate. It is mandatory for published automatic-routing bindings.
23
+ Ownership func(task.Ticket) bool
21
24
  }
22
25
 
23
26
  type Info struct {
@@ -124,8 +127,9 @@ func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []Bindi
124
127
 
125
128
  func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
126
129
  type binding struct {
127
- wf *workflow.Workflow
128
- match func(task.Ticket) bool
130
+ wf *workflow.Workflow
131
+ match func(task.Ticket) bool
132
+ ownership func(task.Ticket) bool
129
133
  }
130
134
  byRepo := map[string][]binding{}
131
135
  issues := []BindingIssue{}
@@ -171,7 +175,44 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
171
175
  failed = true
172
176
  break
173
177
  }
174
- byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
178
+ // Ownership is a separate matcher so a claimed ticket can bypass
179
+ // lifecycle filters while still remaining isolated to the adapter's
180
+ // effective assignee/owner. All automatic-routing adapters must
181
+ // expose the complete capability set; there is no unrestricted Claim
182
+ // fallback for a missing capability.
183
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
184
+ if !ok {
185
+ err = fmt.Errorf("workflow %q repo %q: task system lacks required ownership capabilities", wf.Name, repoName)
186
+ if !isolate {
187
+ return nil, err
188
+ }
189
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
190
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
191
+ failed = true
192
+ break
193
+ }
194
+ ownership, err := capabilities.CompileOwnershipFilter(wf.TaskConfig)
195
+ if err != nil {
196
+ err = fmt.Errorf("workflow %q repo %q: compile ownership filter: %w", wf.Name, repoName, err)
197
+ if !isolate {
198
+ return nil, err
199
+ }
200
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
201
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
202
+ failed = true
203
+ break
204
+ }
205
+ if ownership == nil {
206
+ err = fmt.Errorf("workflow %q repo %q: ownership compiler returned a nil matcher", wf.Name, repoName)
207
+ if !isolate {
208
+ return nil, err
209
+ }
210
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
211
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
212
+ failed = true
213
+ break
214
+ }
215
+ byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match, ownership: ownership})
175
216
  }
176
217
  if failed {
177
218
  // A workflow is all-or-nothing across its referenced repositories;
@@ -197,7 +238,7 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
197
238
  binds := byRepo[name]
198
239
  next := make([]WorkflowBinding, 0, len(binds))
199
240
  for _, b := range binds {
200
- next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
241
+ next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match, Ownership: b.ownership})
201
242
  }
202
243
  sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
203
244
  rp.bindingsMu.Lock()