relay-flow 0.2.1-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 (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  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 +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. package/package.json +1 -1
@@ -0,0 +1,451 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
13
+ "github.com/rajpopat27/relay-flow/internal/repo"
14
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
15
+ "github.com/rajpopat27/relay-flow/internal/server"
16
+ "github.com/rajpopat27/relay-flow/internal/workflow"
17
+ )
18
+
19
+ // TestBeadsCompositionUsesRealRepoServiceAndDurableRun exercises the real
20
+ // composition path with the production Beads factory and bd subprocess seam.
21
+ // The fake bd intentionally blocks mailbox discovery until the test observes
22
+ // the durable run, making claim -> run creation -> mailbox creation ordering
23
+ // explicit rather than inferred from timing.
24
+ func TestBeadsCompositionUsesRealRepoServiceAndDurableRun(t *testing.T) {
25
+ base := t.TempDir()
26
+ relayHome := filepath.Join(base, "relay-flow")
27
+ codePath := filepath.Join(base, "code", "payments")
28
+ beadsDir := filepath.Join(base, "beads", "payments", ".beads")
29
+ for _, dir := range []string{relayHome, codePath, beadsDir} {
30
+ if err := os.MkdirAll(dir, 0o755); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ }
34
+ t.Setenv("RELAY_FLOW_HOME", relayHome)
35
+
36
+ logPath, releasePath, stateDir := installCompositionFakeBD(t, codePath, beadsDir)
37
+
38
+ p, err := home()
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if err := config.SaveMachine(p.Config, &config.Machine{
43
+ PollIntervalSeconds: 1,
44
+ CompletedRunRetentionDays: 1,
45
+ TaskPlugin: "beads",
46
+ RunnerPlugin: scenarioRunnerPlugin,
47
+ HarnessPlugin: scenarioHarnessPlugin,
48
+ Repos: map[string]config.Repo{},
49
+ }); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+
53
+ log := newScenarioLog()
54
+ rnr := newScenarioRunner(log)
55
+ hrn := newScenarioHarness(log)
56
+ // The scenario runner and harness are the existing replaceable seams used
57
+ // by the composition tests; only the task system is the real Beads adapter.
58
+ setScenarioFactoryAdapters(nil, rnr, hrn)
59
+ repoService := repo.NewService(repo.ServiceConfig{
60
+ ConfigPath: p.Config,
61
+ TaskPlugin: "beads",
62
+ Runner: rnr,
63
+ Harness: hrn,
64
+ Active: compositionNoActiveRuns{},
65
+ Workflows: compositionNoWorkflowRefs{},
66
+ })
67
+ if _, err := repoService.Register(context.Background(), repo.RegisterInput{
68
+ Name: "payments",
69
+ Path: codePath,
70
+ TaskConfig: config.RawValues{"beadsDir": beadsDir},
71
+ }); err != nil {
72
+ t.Fatalf("register Beads repo: %v", err)
73
+ }
74
+ // Registration probes the real adapter once. Start the composition
75
+ // assertions with a clean command trace and clean fake state.
76
+ if err := os.WriteFile(logPath, nil, 0o600); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if err := os.RemoveAll(stateDir); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+
86
+ if err := os.MkdirAll(p.Workflows, 0o700); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ store := &workflow.Store{Dir: p.Workflows}
90
+ if err := store.Put("beadsComposition", []byte(beadsCompositionWorkflowYAML)); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+
97
+ ctx, cancel := context.WithCancel(context.Background())
98
+ done := make(chan error, 1)
99
+ go func() { done <- serveRoot(ctx, p, false) }()
100
+
101
+ released := false
102
+ cleanup := func() {
103
+ if !released {
104
+ _ = os.WriteFile(releasePath, []byte("release\n"), 0o600)
105
+ released = true
106
+ }
107
+ cancel()
108
+ select {
109
+ case <-done:
110
+ case <-time.After(15 * time.Second):
111
+ }
112
+ }
113
+ t.Cleanup(cleanup)
114
+
115
+ client := server.NewClient(p.Socket)
116
+ waitForServer(t, client)
117
+
118
+ var active runsvc.Run
119
+ waitScenario(t, 15*time.Second, func() bool {
120
+ var getErr error
121
+ active, getErr = client.GetRunByTicket(context.Background(), "demo-parent")
122
+ return getErr == nil
123
+ })
124
+ if active.ID == "" {
125
+ t.Fatal("durable run was not created after the Beads claim")
126
+ }
127
+ if err := appendCompositionLog(logPath, "test|run-observed\n"); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ lines := readCompositionLog(t, logPath)
131
+ readyIndex := compositionLogIndex(lines, "list --ready --no-parent --limit 0 --json")
132
+ claimIndex := compositionLogIndex(lines, "update demo-parent --add-label wf:beadsComposition --json")
133
+ runObservedIndex := compositionLogIndex(lines, "test|run-observed")
134
+ if readyIndex < 0 || claimIndex < 0 || runObservedIndex < 0 {
135
+ t.Fatalf("composition trace missing poll/claim/run events: %v", lines)
136
+ }
137
+ if !(readyIndex < claimIndex && claimIndex < runObservedIndex) {
138
+ t.Fatalf("poll -> claim -> run ordering = ready:%d claim:%d run:%d; trace=%v", readyIndex, claimIndex, runObservedIndex, lines)
139
+ }
140
+
141
+ if err := os.WriteFile(releasePath, []byte("release\n"), 0o600); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ released = true
145
+ waitScenario(t, 15*time.Second, func() bool {
146
+ current, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
147
+ return getErr == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "implement"
148
+ })
149
+ lines = readCompositionLog(t, logPath)
150
+ createImplementIndex := compositionLogIndex(lines, "create demo-parent:implement")
151
+ if createImplementIndex <= runObservedIndex {
152
+ t.Fatalf("mailbox creation did not follow run creation: run:%d create:%d; trace=%v", runObservedIndex, createImplementIndex, lines)
153
+ }
154
+
155
+ current, err := client.GetRunByTicket(context.Background(), "demo-parent")
156
+ if err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if ack, err := client.SubmitReport(context.Background(), runsvc.ReportRequest{
160
+ RunID: current.ID,
161
+ Node: current.CurrentNode,
162
+ ReportID: string(current.CurrentNodeVisitID) + ":composition-implement",
163
+ Report: scenarioReport(workflow.OutcomeSuccess, "verify"),
164
+ }); err != nil || !ack.Accepted {
165
+ t.Fatalf("submit implement report: ack=%+v err=%v", ack, err)
166
+ }
167
+ waitScenario(t, 15*time.Second, func() bool {
168
+ current, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
169
+ return getErr == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "verify"
170
+ })
171
+
172
+ current, err = client.GetRunByTicket(context.Background(), "demo-parent")
173
+ if err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ if ack, err := client.SubmitReport(context.Background(), runsvc.ReportRequest{
177
+ RunID: current.ID,
178
+ Node: current.CurrentNode,
179
+ ReportID: string(current.CurrentNodeVisitID) + ":composition-verify",
180
+ Report: scenarioReport(workflow.OutcomeSuccess, "end"),
181
+ }); err != nil || !ack.Accepted {
182
+ t.Fatalf("submit verify report: ack=%+v err=%v", ack, err)
183
+ }
184
+ waitScenario(t, 15*time.Second, func() bool {
185
+ finished, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
186
+ return getErr == nil && finished.State == runsvc.StateCompleted
187
+ })
188
+
189
+ lines = readCompositionLog(t, logPath)
190
+ if compositionLogIndex(lines, "create demo-parent:verify") < 0 {
191
+ t.Fatalf("verify mailbox was not created: %v", lines)
192
+ }
193
+ comments := readCompositionState(t, stateDir, "comments.log")
194
+ for _, want := range []string{
195
+ "comment demo-parent.1",
196
+ "comment demo-parent.2",
197
+ "Summary for implement",
198
+ "Feedback from implement to verify mailbox demo-parent.2",
199
+ "Summary for verify",
200
+ } {
201
+ if !strings.Contains(comments, want) {
202
+ t.Fatalf("comments missing %q: %q", want, comments)
203
+ }
204
+ }
205
+ if strings.Contains(comments, "comment demo-parent ") {
206
+ t.Fatalf("summary/feedback was written to the parent: %q", comments)
207
+ }
208
+ if log.count("runner-repo-validated") == 0 || log.count("harness-agent-validated:implementer") == 0 {
209
+ t.Fatalf("existing runner/harness seams were not used: %v", log.all())
210
+ }
211
+
212
+ if err := client.Stop(context.Background()); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ select {
216
+ case serveErr := <-done:
217
+ if serveErr != nil {
218
+ t.Fatalf("serveRoot: %v", serveErr)
219
+ }
220
+ case <-time.After(15 * time.Second):
221
+ t.Fatal("serveRoot did not stop")
222
+ }
223
+ released = true
224
+ }
225
+
226
+ func installCompositionFakeBD(t *testing.T, repoPath, beadsDir string) (logPath, releasePath, stateDir string) {
227
+ t.Helper()
228
+ binDir := t.TempDir()
229
+ logPath = filepath.Join(t.TempDir(), "bd.log")
230
+ releasePath = filepath.Join(t.TempDir(), "release")
231
+ stateDir = filepath.Join(t.TempDir(), "state")
232
+ if err := os.WriteFile(logPath, nil, 0o600); err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
236
+ t.Fatal(err)
237
+ }
238
+ path := filepath.Join(binDir, "bd")
239
+ if err := os.WriteFile(path, []byte(compositionFakeBDScript), 0o755); err != nil {
240
+ t.Fatal(err)
241
+ }
242
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
243
+ t.Setenv("BD_LOG", logPath)
244
+ t.Setenv("BD_STATE", stateDir)
245
+ t.Setenv("BD_GATE_RELEASE", releasePath)
246
+ t.Setenv("BD_EXPECT_CWD", repoPath)
247
+ t.Setenv("BD_EXPECT_BEADS_DIR", beadsDir)
248
+ // These ambient selectors must be removed by bdcli.commandEnvironment.
249
+ t.Setenv("BEADS_DIR", "/ambient/workspace")
250
+ t.Setenv("BEADS_DB", "/ambient/beads.db")
251
+ t.Setenv("BD_DB", "/ambient/legacy.db")
252
+ return logPath, releasePath, stateDir
253
+ }
254
+
255
+ func appendCompositionLog(path, text string) error {
256
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
257
+ if err != nil {
258
+ return err
259
+ }
260
+ defer f.Close()
261
+ _, err = f.WriteString(text)
262
+ return err
263
+ }
264
+
265
+ func readCompositionLog(t *testing.T, path string) []string {
266
+ t.Helper()
267
+ raw, err := os.ReadFile(path)
268
+ if err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ return strings.Split(strings.TrimSpace(string(raw)), "\n")
272
+ }
273
+
274
+ func compositionLogIndex(lines []string, want string) int {
275
+ for i, line := range lines {
276
+ if strings.Contains(line, want) {
277
+ return i
278
+ }
279
+ }
280
+ return -1
281
+ }
282
+
283
+ func readCompositionState(t *testing.T, dir, name string) string {
284
+ t.Helper()
285
+ raw, err := os.ReadFile(filepath.Join(dir, name))
286
+ if err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ return string(raw)
290
+ }
291
+
292
+ type compositionNoActiveRuns struct{}
293
+
294
+ func (compositionNoActiveRuns) HasActiveRepo(context.Context, string) (bool, error) {
295
+ return false, nil
296
+ }
297
+
298
+ type compositionNoWorkflowRefs struct{}
299
+
300
+ func (compositionNoWorkflowRefs) ReferencesRepo(string) bool { return false }
301
+
302
+ const beadsCompositionWorkflowYAML = `name: beadsComposition
303
+ repos: [payments]
304
+ cleanupRunnerOnEnd: true
305
+ nodes:
306
+ start:
307
+ onSuccess: [{target: implement}]
308
+ implement:
309
+ type: agent
310
+ agent: implementer
311
+ description: Implement the requested change.
312
+ onSuccess: [{target: verify}]
313
+ onFailure: [{target: implement, when: implementation cannot proceed}]
314
+ verify:
315
+ type: agent
316
+ agent: verifier
317
+ description: Verify the implementation.
318
+ onSuccess: [{target: end}]
319
+ onFailure: [{target: implement, when: verification fails}]
320
+ end: {}
321
+ `
322
+
323
+ const compositionFakeBDScript = `#!/bin/sh
324
+ set -eu
325
+
326
+ fail() {
327
+ printf 'unsupported or malformed composition bd invocation: %s\n' "$*" >&2
328
+ exit 64
329
+ }
330
+
331
+ [ -n "${BD_LOG-}" ] || fail "BD_LOG is missing"
332
+ [ -n "${BD_STATE-}" ] || fail "BD_STATE is missing"
333
+ [ -n "${BD_GATE_RELEASE-}" ] || fail "BD_GATE_RELEASE is missing"
334
+ [ "$PWD" = "${BD_EXPECT_CWD-}" ] || fail "wrong cwd: $PWD"
335
+ [ "${BEADS_DIR-}" = "${BD_EXPECT_BEADS_DIR-}" ] || fail "wrong BEADS_DIR: ${BEADS_DIR-}"
336
+ [ "${BEADS_DB+x}" != x ] || fail "ambient BEADS_DB leaked"
337
+ [ "${BD_DB+x}" != x ] || fail "ambient BD_DB leaked"
338
+ printf 'bd|%s\n' "$*" >> "$BD_LOG"
339
+
340
+ status_file() {
341
+ case "$1" in
342
+ demo-parent) printf '%s/parent.status' "$BD_STATE" ;;
343
+ demo-parent.1) printf '%s/implement.status' "$BD_STATE" ;;
344
+ demo-parent.2) printf '%s/verify.status' "$BD_STATE" ;;
345
+ *) fail "unknown issue $1" ;;
346
+ esac
347
+ }
348
+
349
+ issue_status() {
350
+ file=$(status_file "$1")
351
+ if [ -f "$file" ]; then
352
+ tr -d '\n' < "$file"
353
+ else
354
+ printf 'open'
355
+ fi
356
+ }
357
+
358
+ issue_json() {
359
+ issue=$1
360
+ status=$(issue_status "$issue")
361
+ case "$issue" in
362
+ demo-parent)
363
+ if [ -f "$BD_STATE/claimed" ]; then
364
+ labels='["wf:beadsComposition"]'
365
+ else
366
+ labels='[]'
367
+ fi
368
+ printf '[{"id":"demo-parent","title":"Composition parent","status":"%s","issue_type":"epic","priority":1,"labels":%s}]\n' "$status" "$labels"
369
+ ;;
370
+ demo-parent.1)
371
+ printf '[{"id":"demo-parent.1","title":"demo-parent:implement","status":"%s","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}]\n' "$status"
372
+ ;;
373
+ demo-parent.2)
374
+ printf '[{"id":"demo-parent.2","title":"demo-parent:verify","status":"%s","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}]\n' "$status"
375
+ ;;
376
+ *) fail "unknown issue $issue" ;;
377
+ esac
378
+ }
379
+
380
+ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --limit ] && [ "$4" = 1 ] && [ "$5" = --no-parent ] && [ "$6" = --json ]; then
381
+ printf '[]\n'
382
+ exit 0
383
+ fi
384
+ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --no-parent ] && [ "$4" = --limit ] && [ "$5" = 0 ] && [ "$6" = --json ]; then
385
+ if [ -f "$BD_STATE/claimed" ]; then
386
+ printf '[]\n'
387
+ else
388
+ printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":[]}]\n'
389
+ fi
390
+ exit 0
391
+ fi
392
+ if [ "$#" -eq 9 ] && [ "$1" = list ] && [ "$2" = --no-parent ] && [ "$3" = --status ] && [ "$4" = open,in_progress,blocked,deferred ] && [ "$5" = --label-pattern ] && [ "$6" = 'wf:*' ] && [ "$7" = --limit ] && [ "$8" = 0 ] && [ "$9" = --json ]; then
393
+ printf '[]\n'
394
+ exit 0
395
+ fi
396
+ if [ "$#" -eq 7 ] && [ "$1" = list ] && [ "$2" = --parent ] && [ "$3" = demo-parent ] && [ "$4" = --all ] && [ "$5" = --limit ] && [ "$6" = 0 ] && [ "$7" = --json ]; then
397
+ while [ ! -f "$BD_GATE_RELEASE" ]; do
398
+ sleep 0.01
399
+ done
400
+ out='['
401
+ sep=
402
+ if [ -f "$BD_STATE/implement" ]; then
403
+ out="${out}${sep}{\"id\":\"demo-parent.1\",\"title\":\"demo-parent:implement\",\"status\":\"$(issue_status demo-parent.1)\",\"issue_type\":\"task\",\"priority\":2,\"labels\":[\"wf:beadsComposition\"],\"parent\":\"demo-parent\"}"
404
+ sep=,
405
+ fi
406
+ if [ -f "$BD_STATE/verify" ]; then
407
+ out="${out}${sep}{\"id\":\"demo-parent.2\",\"title\":\"demo-parent:verify\",\"status\":\"$(issue_status demo-parent.2)\",\"issue_type\":\"task\",\"priority\":2,\"labels\":[\"wf:beadsComposition\"],\"parent\":\"demo-parent\"}"
408
+ fi
409
+ printf '%s]\n' "$out"
410
+ exit 0
411
+ fi
412
+ if [ "$#" -eq 3 ] && [ "$1" = show ] && [ "$3" = --json ]; then
413
+ issue_json "$2"
414
+ exit 0
415
+ fi
416
+ if [ "$#" -eq 11 ] && [ "$1" = create ] && [ "$3" = --type ] && [ "$4" = task ] && [ "$5" = --parent ] && [ "$7" = --no-inherit-labels ] && [ "$8" = --labels ] && [ "$9" = wf:beadsComposition ] && [ "${10}" = --stdin ] && [ "${11}" = --json ]; then
417
+ case "$2" in
418
+ demo-parent:implement) id=demo-parent.1; node=implement ;;
419
+ demo-parent:verify) id=demo-parent.2; node=verify ;;
420
+ *) fail "unknown child title $2" ;;
421
+ esac
422
+ cat > "$BD_STATE/$node.description"
423
+ : > "$BD_STATE/$node"
424
+ printf '%s\n' open > "$BD_STATE/$node.status"
425
+ printf '{"id":"%s","title":"%s","status":"open","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}\n' "$id" "$2"
426
+ exit 0
427
+ fi
428
+ if [ "$#" -eq 5 ] && [ "$1" = update ] && [ "$2" = demo-parent ] && [ "$3" = --add-label ] && [ "$4" = wf:beadsComposition ] && [ "$5" = --json ]; then
429
+ : > "$BD_STATE/claimed"
430
+ printf '{}\n'
431
+ exit 0
432
+ fi
433
+ if [ "$#" -eq 5 ] && [ "$1" = update ] && [ "$3" = --status ] && [ "$5" = --json ]; then
434
+ file=$(status_file "$2")
435
+ printf '%s\n' "$4" > "$file"
436
+ printf '{}\n'
437
+ exit 0
438
+ fi
439
+ if [ "$#" -eq 3 ] && [ "$1" = comments ] && [ "$3" = --json ]; then
440
+ printf '[]\n'
441
+ exit 0
442
+ fi
443
+ if [ "$#" -eq 4 ] && [ "$1" = comment ] && [ "$3" = --stdin ] && [ "$4" = --json ]; then
444
+ printf 'comment %s\n' "$2" >> "$BD_STATE/comments.log"
445
+ cat >> "$BD_STATE/comments.log"
446
+ printf '\n---\n' >> "$BD_STATE/comments.log"
447
+ printf '{}\n'
448
+ exit 0
449
+ fi
450
+ fail "$@"
451
+ `
@@ -346,6 +346,43 @@ func TestInitPrintsSelectedValuesAndCompletion(t *testing.T) {
346
346
  }
347
347
  }
348
348
 
349
+ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
350
+ home := t.TempDir()
351
+ if code := cli(t, home, "", initArgs()...); code != 0 {
352
+ t.Fatalf("init exit = %d", code)
353
+ }
354
+ cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
355
+ if err != nil {
356
+ t.Fatal(err)
357
+ }
358
+ for _, key := range []string{"initial", "feedback", "hitl"} {
359
+ value, ok := cfg.HarnessConfig[key].(string)
360
+ if !ok || value == "" {
361
+ t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
362
+ }
363
+ }
364
+ }
365
+
366
+ func TestInitWritesTaskTextDefaults(t *testing.T) {
367
+ home := t.TempDir()
368
+ if code := cli(t, home, "", initArgs()...); code != 0 {
369
+ t.Fatalf("init exit = %d", code)
370
+ }
371
+ cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
372
+ if err != nil {
373
+ t.Fatal(err)
374
+ }
375
+ templates, ok := rawValuesMap(cfg.TaskConfig["templates"])
376
+ if !ok {
377
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
378
+ }
379
+ for _, key := range []string{"mailboxDescription", "summaryComment", "feedbackComment"} {
380
+ if value, ok := templates[key].(string); !ok || value == "" {
381
+ t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
382
+ }
383
+ }
384
+ }
385
+
349
386
  func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
350
387
  home := t.TempDir()
351
388
  if code := cli(t, home, "", initArgs()...); code != 0 {
@@ -413,6 +450,12 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
413
450
  t.Fatal(err)
414
451
  }
415
452
  cfg.Repos = map[string]config.Repo{"existing": {Path: "/srv/existing"}}
453
+ cfg.HarnessConfig["initial"] = "custom {{ticket}}"
454
+ taskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
455
+ if !ok {
456
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
457
+ }
458
+ taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
416
459
  if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
417
460
  t.Fatal(err)
418
461
  }
@@ -453,6 +496,26 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
453
496
  if _, ok := cfg.Repos["existing"]; !ok {
454
497
  t.Fatal("forced init removed existing repo config")
455
498
  }
499
+ if got := cfg.HarnessConfig["initial"]; got != "custom {{ticket}}" {
500
+ t.Fatalf("forced init changed prompt override: %#v", got)
501
+ }
502
+ gotTaskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
503
+ if !ok {
504
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
505
+ }
506
+ if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
507
+ t.Fatalf("forced init changed task text override: %#v", got)
508
+ }
509
+ for _, key := range []string{"summaryComment", "feedbackComment"} {
510
+ if got, ok := gotTaskTemplates[key].(string); !ok || got == "" {
511
+ t.Fatalf("forced init omitted task default %s: %#v", key, gotTaskTemplates[key])
512
+ }
513
+ }
514
+ for _, key := range []string{"feedback", "hitl"} {
515
+ if got, ok := cfg.HarnessConfig[key].(string); !ok || got == "" {
516
+ t.Fatalf("forced init omitted harness default %s: %#v", key, cfg.HarnessConfig[key])
517
+ }
518
+ }
456
519
  db, err := sql.Open("sqlite", dbPath)
457
520
  if err != nil {
458
521
  t.Fatal(err)
@@ -467,6 +530,17 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
467
530
  }
468
531
  }
469
532
 
533
+ func rawValuesMap(value any) (map[string]any, bool) {
534
+ switch values := value.(type) {
535
+ case map[string]any:
536
+ return values, true
537
+ case config.RawValues:
538
+ return map[string]any(values), true
539
+ default:
540
+ return nil, false
541
+ }
542
+ }
543
+
470
544
  // 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
471
545
  // prompt/stdin and write the same machine config as the stdin path.
472
546
  func TestInitFlagsNonInteractive(t *testing.T) {
@@ -38,6 +38,7 @@ import (
38
38
  // name validation at init-time).
39
39
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
40
40
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
41
+ _ "github.com/rajpopat27/relay-flow/internal/task/beads"
41
42
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
42
43
  )
43
44
 
@@ -269,6 +270,30 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
269
270
  cfg.TaskPlugin = names[0]
270
271
  cfg.RunnerPlugin = names[1]
271
272
  cfg.HarnessPlugin = names[2]
273
+ taskDefaults, err := task.Defaults(names[0])
274
+ if err != nil {
275
+ fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
276
+ return exitFail
277
+ }
278
+ if !*force || !configExists {
279
+ cfg.TaskConfig = taskDefaults
280
+ } else {
281
+ cfg.TaskConfig = config.Merge(taskDefaults, cfg.TaskConfig)
282
+ }
283
+ if err := task.ValidateTextConfig(names[0], cfg.TaskConfig); err != nil {
284
+ fmt.Fprintln(os.Stderr, "init: task plugin config: "+err.Error())
285
+ return exitFail
286
+ }
287
+ harnessDefaults, err := harness.Defaults(names[2])
288
+ if err != nil {
289
+ fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
290
+ return exitFail
291
+ }
292
+ if !*force || !configExists {
293
+ cfg.HarnessConfig = harnessDefaults
294
+ } else {
295
+ cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
296
+ }
272
297
  if err := config.SaveMachine(p.Config, cfg); err != nil {
273
298
  fmt.Fprintln(os.Stderr, err)
274
299
  return exitFail