relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -0,0 +1,232 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/harness"
14
+ "github.com/rajpopat27/relay-flow/internal/repo"
15
+ "github.com/rajpopat27/relay-flow/internal/runner"
16
+ "github.com/rajpopat27/relay-flow/internal/task"
17
+ )
18
+
19
+ func TestBeadsRepoRegistrationUsesIndependentSystemsAndPollers(t *testing.T) {
20
+ root := t.TempDir()
21
+ logPath := installRepoCompositionFakeBD(t)
22
+ configPath := filepath.Join(root, "config.yaml")
23
+ if err := config.SaveMachine(configPath, &config.Machine{
24
+ TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
25
+ Repos: map[string]config.Repo{},
26
+ }); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+
30
+ paymentsPath := filepath.Join(root, "code", "payments")
31
+ platformPath := filepath.Join(root, "code", "platform")
32
+ paymentsBeads := filepath.Join(root, "beads", "payments", ".beads")
33
+ platformBeads := filepath.Join(root, "beads", "platform", ".beads")
34
+ for _, path := range []string{paymentsPath, platformPath, paymentsBeads, platformBeads} {
35
+ if err := os.MkdirAll(path, 0o755); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ }
39
+
40
+ svc := repo.NewService(repo.ServiceConfig{
41
+ ConfigPath: configPath,
42
+ TaskPlugin: "beads",
43
+ Runner: &compositionRunner{},
44
+ Harness: &compositionHarness{},
45
+ Active: &compositionActive{},
46
+ Workflows: &compositionWorkflowRefs{},
47
+ })
48
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
49
+ Name: "payments", Path: paymentsPath,
50
+ TaskConfig: config.RawValues{"beadsDir": paymentsBeads},
51
+ }); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
55
+ Name: "platform", Path: platformPath,
56
+ TaskConfig: config.RawValues{"beadsDir": platformBeads},
57
+ }); err != nil {
58
+ t.Fatal(err)
59
+ }
60
+
61
+ registered := svc.Registry().List()
62
+ if len(registered) != 2 {
63
+ t.Fatalf("registered repos = %d, want 2", len(registered))
64
+ }
65
+ if registered[0].TaskSystem == registered[1].TaskSystem {
66
+ t.Fatal("independent Beads repos shared one task system")
67
+ }
68
+
69
+ var handledMu sync.Mutex
70
+ handled := map[string]int{}
71
+ group := repo.NewPollerGroup(10, func(_ context.Context, r *repo.Repo, _ []task.Ticket) {
72
+ handledMu.Lock()
73
+ handled[r.Name]++
74
+ handledMu.Unlock()
75
+ })
76
+ group.Interval = 10 * time.Millisecond
77
+ group.ReplaceRepos(registered)
78
+ ctx, cancel := context.WithCancel(context.Background())
79
+ defer cancel()
80
+ go group.Run(ctx)
81
+
82
+ deadline := time.Now().Add(3 * time.Second)
83
+ for time.Now().Before(deadline) {
84
+ handledMu.Lock()
85
+ complete := handled["payments"] > 0 && handled["platform"] > 0
86
+ handledMu.Unlock()
87
+ if complete {
88
+ break
89
+ }
90
+ time.Sleep(5 * time.Millisecond)
91
+ }
92
+ handledMu.Lock()
93
+ if handled["payments"] == 0 || handled["platform"] == 0 {
94
+ t.Fatalf("poller handlers = %v, want both registered repos", handled)
95
+ }
96
+ handledMu.Unlock()
97
+
98
+ assertBDInvocationReachedRepoAndWorkspace(t, logPath, paymentsPath, paymentsBeads)
99
+ assertBDInvocationReachedRepoAndWorkspace(t, logPath, platformPath, platformBeads)
100
+ }
101
+
102
+ func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
103
+ root := t.TempDir()
104
+ installRepoCompositionFakeBD(t)
105
+ configPath := filepath.Join(root, "config.yaml")
106
+ if err := config.SaveMachine(configPath, &config.Machine{
107
+ TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
108
+ Repos: map[string]config.Repo{},
109
+ }); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ firstPath := filepath.Join(root, "code", "payments")
113
+ secondPath := filepath.Join(root, "code", "platform")
114
+ workspace := filepath.Join(root, "beads", "shared", ".beads")
115
+ for _, path := range []string{firstPath, secondPath, workspace} {
116
+ if err := os.MkdirAll(path, 0o755); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ }
120
+
121
+ svc := repo.NewService(repo.ServiceConfig{
122
+ ConfigPath: configPath,
123
+ TaskPlugin: "beads",
124
+ Runner: &compositionRunner{},
125
+ Harness: &compositionHarness{},
126
+ Active: &compositionActive{},
127
+ Workflows: &compositionWorkflowRefs{},
128
+ })
129
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
130
+ Name: "payments", Path: firstPath,
131
+ TaskConfig: config.RawValues{"beadsDir": workspace},
132
+ }); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
136
+ Name: "platform", Path: secondPath,
137
+ TaskConfig: config.RawValues{"beadsDir": filepath.Join(workspace, ".")},
138
+ }); err == nil {
139
+ t.Fatal("second repo sharing canonical beadsDir was accepted")
140
+ }
141
+ if len(svc.Registry().List()) != 1 {
142
+ t.Fatalf("registry after duplicate scope rejection = %d, want 1", len(svc.Registry().List()))
143
+ }
144
+ }
145
+
146
+ func installRepoCompositionFakeBD(t *testing.T) string {
147
+ t.Helper()
148
+ binDir := t.TempDir()
149
+ fake := filepath.Join(binDir, "bd")
150
+ script, err := os.ReadFile(filepath.Join("testdata", "strict-bd-repo.sh"))
151
+ if err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ if err := os.WriteFile(fake, script, 0o755); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ logPath := filepath.Join(t.TempDir(), "bd-invocations.log")
158
+ if err := os.WriteFile(logPath, nil, 0o600); err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
162
+ t.Setenv("BD_LOG", logPath)
163
+ // These must not leak into the child command environment. The adapter
164
+ // explicitly selects BEADS_DIR and removes unrelated Beads selectors.
165
+ t.Setenv("BEADS_DIR", "/ambient/workspace")
166
+ t.Setenv("BEADS_DB", "/ambient/beads.db")
167
+ t.Setenv("BD_DB", "/ambient/legacy.db")
168
+ return logPath
169
+ }
170
+
171
+ func assertBDInvocationReachedRepoAndWorkspace(t *testing.T, logPath, repoPath, beadsDir string) {
172
+ t.Helper()
173
+ raw, err := os.ReadFile(logPath)
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ wantPrefix := repoPath + "|" + beadsDir + "|"
178
+ for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
179
+ if strings.HasPrefix(line, wantPrefix) {
180
+ return
181
+ }
182
+ }
183
+ t.Fatalf("no bd invocation used cmd.Dir=%q and BEADS_DIR=%q; log=%q", repoPath, beadsDir, raw)
184
+ }
185
+
186
+ type compositionRunner struct{}
187
+
188
+ func (*compositionRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
189
+ return nil, nil
190
+ }
191
+ func (*compositionRunner) ValidateRepo(context.Context, string, string) error { return nil }
192
+ func (*compositionRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
193
+ return runner.Environment{}, nil
194
+ }
195
+ func (*compositionRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
196
+ return nil
197
+ }
198
+ func (*compositionRunner) FindTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
199
+ return runner.Terminal{}, false, nil
200
+ }
201
+ func (*compositionRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
202
+ return runner.Terminal{}, nil
203
+ }
204
+ func (*compositionRunner) EnsureTerminal(context.Context, runner.Environment, runner.Terminal, string, runner.Command) (runner.Terminal, error) {
205
+ return runner.Terminal{}, nil
206
+ }
207
+ func (*compositionRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
208
+ func (*compositionRunner) CloseTerminal(context.Context, runner.Terminal) error { return nil }
209
+ func (*compositionRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
210
+ func (*compositionRunner) CleanupRun(context.Context, runner.RunSpec) error { return nil }
211
+
212
+ type compositionHarness struct{}
213
+
214
+ func (*compositionHarness) SetupRepo(context.Context, string) error { return nil }
215
+ func (*compositionHarness) ValidateAgent(context.Context, string, string) error { return nil }
216
+ func (*compositionHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
217
+ return harness.Session{}, false, nil
218
+ }
219
+ func (*compositionHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
220
+ return "", nil
221
+ }
222
+ func (*compositionHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
223
+ return runner.Command{}, nil
224
+ }
225
+
226
+ type compositionActive struct{}
227
+
228
+ func (*compositionActive) HasActiveRepo(context.Context, string) (bool, error) { return false, nil }
229
+
230
+ type compositionWorkflowRefs struct{}
231
+
232
+ func (*compositionWorkflowRefs) ReferencesRepo(string) bool { return false }
@@ -0,0 +1,81 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/config"
9
+ )
10
+
11
+ // Beads-specific runtime configuration behavior. The shared inheritance and
12
+ // precedence invariants live in lifecycle_inheritance_test.go, which is
13
+ // mirrored by the Jira adapter.
14
+
15
+ func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
16
+ client := newStatusClient(map[string]string{"demo-parent.1": "open"})
17
+ sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
18
+ node := config.RawValues{"assignee": "dev@example.com"}
19
+
20
+ cfg := operationConfig(sys.WorkDefaults(), nil, node)
21
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ if len(client.updates) != 1 || client.updates[0].input.Assignee != "dev@example.com" {
25
+ t.Fatalf("updates = %+v, want the node assignee to win", client.updates)
26
+ }
27
+ }
28
+
29
+ // transitionTo is a lifecycle-point setting: a value configured above node
30
+ // scope applies to every lifecycle point that reads it, including end. This
31
+ // test pins that uniform precedence so the behavior is deliberate rather than
32
+ // discovered, and is why the documentation configures transitionTo on nodes.
33
+ func TestInheritedParentStatusAppliesToEveryLifecyclePoint(t *testing.T) {
34
+ client := newStatusClient(map[string]string{"demo-parent": "in_progress"})
35
+ sys := repoScopedSystem(client, config.RawValues{
36
+ "transitionTo": map[string]any{"parentStatus": "in_progress"},
37
+ })
38
+
39
+ cfg := operationConfig(sys.EndDefaults(), nil, nil)
40
+ if err := sys.ApplyTaskConfig(context.Background(), endTarget(), cfg); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if len(client.updates) != 0 {
44
+ t.Fatalf("updates = %+v, want the inherited parentStatus to win over the end default", client.updates)
45
+ }
46
+
47
+ // The end node's own value restores closing behavior.
48
+ node := config.RawValues{"transitionTo": map[string]any{"parentStatus": "closed"}}
49
+ if err := sys.ApplyTaskConfig(context.Background(), endTarget(), operationConfig(sys.EndDefaults(), nil, node)); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if len(client.updates) != 1 || client.updates[0].input.Status != "closed" {
53
+ t.Fatalf("updates = %+v, want end node parentStatus closed", client.updates)
54
+ }
55
+ }
56
+
57
+ // RenderText has a fixed task.System signature and receives no workflow/node
58
+ // config. Beads therefore chooses the explicit limitation: lower-scope
59
+ // template overrides are rejected rather than accepted without runtime effect.
60
+ func TestBeadsLowerScopeTemplateOverridesAreRejected(t *testing.T) {
61
+ sys := repoScopedSystem(nil, nil)
62
+ workflow := config.RawValues{"templates": map[string]any{
63
+ "summaryComment": "workflow {{summaryReport}}",
64
+ }}
65
+ node := config.RawValues{"templates": map[string]any{
66
+ "feedbackComment": "node {{feedbackReport}}",
67
+ }}
68
+
69
+ err := sys.ValidateConfig(context.Background(), workflow, map[string]config.RawValues{"implement": node})
70
+ lower := strings.ToLower(errString(err))
71
+ if err == nil || !strings.Contains(lower, "template") || !strings.Contains(lower, "scope") {
72
+ t.Fatalf("lower-scope template override validation error = %v, want explicit unsupported-scope error", err)
73
+ }
74
+ }
75
+
76
+ func errString(err error) string {
77
+ if err == nil {
78
+ return ""
79
+ }
80
+ return err.Error()
81
+ }
@@ -0,0 +1,233 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ "github.com/rajpopat27/relay-flow/internal/retry"
9
+ "github.com/rajpopat27/relay-flow/internal/task"
10
+ "github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
11
+ )
12
+
13
+ func transitionConfig(parentStatus, taskStatus string) config.RawValues {
14
+ transition := map[string]any{}
15
+ if parentStatus != "" {
16
+ transition["parentStatus"] = parentStatus
17
+ }
18
+ if taskStatus != "" {
19
+ transition["taskStatus"] = taskStatus
20
+ }
21
+ return config.RawValues{"transitionTo": transition}
22
+ }
23
+
24
+ func statusTarget(mailboxID string) task.Target {
25
+ return task.Target{
26
+ Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
27
+ Mailbox: &task.Mailbox{ID: mailboxID, Key: mailboxID, Node: "implement"},
28
+ }
29
+ }
30
+
31
+ // endTarget is the parent-only target used for the start and end lifecycle
32
+ // points.
33
+ func endTarget() task.Target {
34
+ return task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
35
+ }
36
+
37
+ func TestApplyTaskConfigOpensMailboxForFirstWorkEntry(t *testing.T) {
38
+ client := newStatusClient(map[string]string{"demo-parent.1": "open"})
39
+ sys := &system{cli: client}
40
+
41
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
45
+ t.Fatalf("show calls = %v, want one mailbox read", client.shown)
46
+ }
47
+ if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "in_progress" {
48
+ t.Fatalf("updates = %+v, want open → in_progress", client.updates)
49
+ }
50
+ }
51
+
52
+ func TestCompleteMailboxClosesInProgressMailbox(t *testing.T) {
53
+ client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
54
+ sys := &system{cli: client}
55
+
56
+ if err := sys.CompleteMailbox(context.Background(), task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"}); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
60
+ t.Fatalf("show calls = %v, want one mailbox read", client.shown)
61
+ }
62
+ if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "closed" {
63
+ t.Fatalf("updates = %+v, want in_progress → closed", client.updates)
64
+ }
65
+ }
66
+
67
+ func TestApplyTaskConfigTargetStateRetryDoesNotWrite(t *testing.T) {
68
+ client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
69
+ sys := &system{cli: client}
70
+
71
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
75
+ t.Fatalf("show calls = %v, want one target-state read", client.shown)
76
+ }
77
+ if len(client.updates) != 0 {
78
+ t.Fatalf("target-state retry issued an update: %+v", client.updates)
79
+ }
80
+ }
81
+
82
+ func TestApplyTaskConfigReopensClosedMailboxOnWorkflowRevisit(t *testing.T) {
83
+ client := newStatusClient(map[string]string{"demo-parent.1": "closed"})
84
+ sys := &system{cli: client}
85
+
86
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
90
+ t.Fatalf("show calls = %v, want one mailbox read", client.shown)
91
+ }
92
+ if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "in_progress" {
93
+ t.Fatalf("updates = %+v, want closed → in_progress", client.updates)
94
+ }
95
+ }
96
+
97
+ func TestApplyTaskConfigRejectsIncompatibleManualMailboxState(t *testing.T) {
98
+ client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
99
+ sys := &system{cli: client}
100
+
101
+ err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress"))
102
+ if err == nil {
103
+ t.Fatal("incompatible manual mailbox state was accepted")
104
+ }
105
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
106
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
107
+ }
108
+ if len(client.updates) != 0 {
109
+ t.Fatalf("incompatible manual state issued an update: %+v", client.updates)
110
+ }
111
+ }
112
+
113
+ // relay-flow only ever drives a parent through open and in_progress, so those
114
+ // are the only states a close may follow. Any other state is human-owned and
115
+ // blocks the transition, exactly like the mailbox rule.
116
+ func TestApplyTaskConfigClosesParentOnlyFromRelayFlowStates(t *testing.T) {
117
+ for _, tc := range []struct {
118
+ name string
119
+ status string
120
+ wantUpdate bool
121
+ wantConfli bool
122
+ }{
123
+ {name: "open", status: "open", wantUpdate: true},
124
+ {name: "in progress", status: "in_progress", wantUpdate: true},
125
+ {name: "already closed", status: "closed"},
126
+ {name: "human blocked", status: "blocked", wantConfli: true},
127
+ {name: "human deferred", status: "deferred", wantConfli: true},
128
+ {name: "human hooked", status: "hooked", wantConfli: true},
129
+ } {
130
+ t.Run(tc.name, func(t *testing.T) {
131
+ client := newStatusClient(map[string]string{"demo-parent": tc.status})
132
+ sys := &system{cli: client}
133
+ target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
134
+
135
+ err := sys.ApplyTaskConfig(context.Background(), target, transitionConfig("closed", ""))
136
+ if tc.wantConfli {
137
+ if err == nil {
138
+ t.Fatalf("human-owned parent status %q was overwritten", tc.status)
139
+ }
140
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
141
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
142
+ }
143
+ if len(client.updates) != 0 {
144
+ t.Fatalf("human-owned parent status issued an update: %+v", client.updates)
145
+ }
146
+ return
147
+ }
148
+ if err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ if len(client.shown) != 1 || client.shown[0] != "demo-parent" {
152
+ t.Fatalf("show calls = %v, want one parent read", client.shown)
153
+ }
154
+ if tc.wantUpdate {
155
+ if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent" || client.updates[0].input.Status != "closed" {
156
+ t.Fatalf("updates = %+v, want parent → closed", client.updates)
157
+ }
158
+ return
159
+ }
160
+ if len(client.updates) != 0 {
161
+ t.Fatalf("already-target parent issued an update: %+v", client.updates)
162
+ }
163
+ })
164
+ }
165
+ }
166
+
167
+ // Jira parity: the run start moves the parent to in_progress, and the parent
168
+ // stays visible to the claimed-parent poll because that query includes
169
+ // in_progress.
170
+ func TestStartDefaultsMoveParentInProgress(t *testing.T) {
171
+ client := newStatusClient(map[string]string{"demo-parent": "open"})
172
+ sys := &system{cli: client}
173
+ target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
174
+
175
+ if err := sys.ApplyTaskConfig(context.Background(), target, sys.StartDefaults()); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ if len(client.updates) != 1 || client.updates[0].input.Status != "in_progress" {
179
+ t.Fatalf("updates = %+v, want parent open → in_progress at start", client.updates)
180
+ }
181
+ if got := client.issues["demo-parent"].Status; got != "in_progress" {
182
+ t.Fatalf("parent status = %q, want in_progress", got)
183
+ }
184
+ }
185
+
186
+ // Jira parity: entering a node assigns that node's mailbox to the configured
187
+ // assignee in the same update that moves its status.
188
+ func TestApplyTaskConfigAssignsMailboxToConfiguredAssignee(t *testing.T) {
189
+ client := newStatusClient(map[string]string{"demo-parent.1": "open"})
190
+ sys := &system{cli: client}
191
+ cfg := config.Merge(transitionConfig("", "in_progress"), config.RawValues{"assignee": "dev@example.com"})
192
+
193
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ if len(client.shown) != 1 || len(client.updates) != 1 {
197
+ t.Fatalf("calls = show:%v updates:%+v, want one read and one write", client.shown, client.updates)
198
+ }
199
+ update := client.updates[0].input
200
+ if update.Status != "in_progress" || update.Assignee != "dev@example.com" {
201
+ t.Fatalf("update = %+v, want status and assignee in one call", update)
202
+ }
203
+ }
204
+
205
+ func TestApplyTaskConfigAssigneeIsIdempotentAndStandalone(t *testing.T) {
206
+ t.Run("already assigned at target status", func(t *testing.T) {
207
+ client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
208
+ client.issues["demo-parent.1"] = bdcli.Issue{ID: "demo-parent.1", Status: "in_progress", Assignee: "dev@example.com"}
209
+ sys := &system{cli: client}
210
+ cfg := config.Merge(transitionConfig("", "in_progress"), config.RawValues{"assignee": "dev@example.com"})
211
+
212
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ if len(client.updates) != 0 {
216
+ t.Fatalf("retried assignment issued an update: %+v", client.updates)
217
+ }
218
+ })
219
+
220
+ t.Run("assignee without a transition", func(t *testing.T) {
221
+ client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
222
+ sys := &system{cli: client}
223
+
224
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"),
225
+ config.RawValues{"assignee": "dev@example.com"}); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ if len(client.updates) != 1 || client.updates[0].input.Status != "" ||
229
+ client.updates[0].input.Assignee != "dev@example.com" {
230
+ t.Fatalf("updates = %+v, want an assignee-only update", client.updates)
231
+ }
232
+ })
233
+ }