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
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "errors"
7
7
  "path/filepath"
8
+ "reflect"
8
9
  "testing"
9
10
  "time"
10
11
 
@@ -189,7 +190,7 @@ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing
189
190
 
190
191
  func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
191
192
  ctx := context.Background()
192
- fr := &runtimeTestRunner{createErr: runner.ErrSessionUnavailable}
193
+ fr := &runtimeTestRunner{}
193
194
  fh := &runtimeTestHarness{}
194
195
  db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
195
196
  defer db.Close()
@@ -219,11 +220,20 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
219
220
  if err != nil {
220
221
  t.Fatal(err)
221
222
  }
222
- if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "" {
223
+ if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
223
224
  t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
224
225
  }
225
- if fr.findCalls != 0 || fh.buildCalls != 2 || fr.createCalls != 2 {
226
- t.Fatalf("fallback used discovery or wrong launch count: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
226
+ if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
227
+ t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
228
+ }
229
+ if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
230
+ t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
231
+ }
232
+ if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
233
+ t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
234
+ }
235
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
236
+ t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
227
237
  }
228
238
  for _, prompt := range fh.prompts {
229
239
  if prompt != "work" {
@@ -248,15 +258,19 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
248
258
  t.Fatal(err)
249
259
  }
250
260
  fh := &runtimeTestHarness{}
251
- a := &Activities{Runner: &runtimeTestRunner{}, Harness: fh, Runs: p}
261
+ fr := &runtimeTestRunner{}
262
+ a := &Activities{Runner: fr, Harness: fh, Runs: p}
252
263
  nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
253
- spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
264
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
254
265
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
255
266
  t.Fatal(err)
256
267
  }
257
- if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
268
+ if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
258
269
  t.Fatalf("initial prompt = %q", fh.prompts)
259
270
  }
271
+ if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
272
+ t.Fatalf("HITL workspace statuses = %v, want in-review", fr.statuses)
273
+ }
260
274
  }
261
275
 
262
276
  func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
@@ -288,16 +302,22 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
288
302
  if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
289
303
  t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
290
304
  }
291
- if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nRead the latest review feedback." {
305
+ if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
292
306
  t.Fatalf("live revisit prompt = %q", fr.sentTexts)
293
307
  }
294
308
  if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
295
309
  t.Fatalf("revisit replacement prompt = %q", fh.prompts)
296
310
  }
297
311
  rt, _ := p.getNodeRuntime(ctx, id, "implement")
298
- if rt.TerminalID == "live-old" || rt.SessionID != "" {
312
+ if rt.TerminalID == "live-old" || rt.SessionID != "session-old" {
299
313
  t.Fatalf("send failure did not replace IDs: %+v", rt)
300
314
  }
315
+ if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
316
+ t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
317
+ }
318
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
319
+ t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
320
+ }
301
321
  }
302
322
 
303
323
  func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
@@ -325,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
325
345
  if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
326
346
  t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
327
347
  }
348
+ fh := a.Harness.(*runtimeTestHarness)
349
+ if len(fh.rendered) != 0 || fh.buildCalls != 0 {
350
+ t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
351
+ }
328
352
  }
329
353
 
330
354
  func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
@@ -412,14 +436,16 @@ func TestEngineRuntimePolicyDefaultsKeepBoth(t *testing.T) {
412
436
  }
413
437
 
414
438
  type runtimeTestRunner struct {
415
- createErr error
416
439
  findCalls int
440
+ findIDs []string
417
441
  createCalls int
418
442
  live bool
443
+ liveID string
419
444
  sendErr error
420
445
  sentTexts []string
421
446
  closeCalls int
422
447
  closedIDs []string
448
+ statuses []string
423
449
  }
424
450
 
425
451
  func (*runtimeTestRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
@@ -429,8 +455,17 @@ func (*runtimeTestRunner) ValidateRepo(context.Context, string, string) error {
429
455
  func (*runtimeTestRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
430
456
  return runner.Environment{ID: "env"}, nil
431
457
  }
432
- func (r *runtimeTestRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
433
- return terminal, r.live, nil
458
+ func (r *runtimeTestRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
459
+ r.statuses = append(r.statuses, status)
460
+ return nil
461
+ }
462
+ func (r *runtimeTestRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
463
+ r.findCalls++
464
+ r.findIDs = append(r.findIDs, terminal.ID)
465
+ if !r.live || (r.liveID != "" && terminal.ID != r.liveID) {
466
+ return runner.Terminal{}, false, nil
467
+ }
468
+ return terminal, true, nil
434
469
  }
435
470
  func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, text string) error {
436
471
  r.sentTexts = append(r.sentTexts, text)
@@ -438,16 +473,9 @@ func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, t
438
473
  }
439
474
  func (r *runtimeTestRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
440
475
  r.createCalls++
441
- if r.createErr != nil {
442
- err := r.createErr
443
- r.createErr = nil
444
- return runner.Terminal{}, err
445
- }
446
- return runner.Terminal{ID: "fresh-term"}, nil
447
- }
448
- func (r *runtimeTestRunner) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
449
- r.findCalls++
450
- return runner.Terminal{}, false, nil
476
+ r.live = true
477
+ r.liveID = "fresh-term"
478
+ return runner.Terminal{ID: r.liveID}, nil
451
479
  }
452
480
  func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
453
481
  r.closeCalls++
@@ -455,7 +483,12 @@ func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Ter
455
483
  r.live = false
456
484
  return nil
457
485
  }
458
- func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
486
+ func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
487
+ if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
488
+ return runner.Terminal{}, err
489
+ } else if ok {
490
+ return terminal, nil
491
+ }
459
492
  return r.CreateTerminal(ctx, env, title, command)
460
493
  }
461
494
  func (*runtimeTestRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
@@ -464,8 +497,23 @@ func (*runtimeTestRunner) CleanupRun(context.Context, runner.RunSpec) error
464
497
  type runtimeTestHarness struct {
465
498
  buildCalls int
466
499
  prompts []string
500
+ resumeIDs []string
501
+ rendered []harness.PromptKind
502
+ }
503
+
504
+ func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
505
+ h.rendered = append(h.rendered, kind)
506
+ prompt := "work"
507
+ if kind == harness.PromptFeedback {
508
+ prompt = "feedback"
509
+ }
510
+ if nudge != "" {
511
+ prompt += "\n\n" + nudge
512
+ }
513
+ return prompt, nil
467
514
  }
468
515
 
516
+ func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
469
517
  func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
470
518
  func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
471
519
  return harness.Session{}, false, nil
@@ -473,6 +521,7 @@ func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness
473
521
  func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
474
522
  h.buildCalls++
475
523
  h.prompts = append(h.prompts, spec.Prompt)
524
+ h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
476
525
  return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
477
526
  }
478
527
 
@@ -490,7 +490,7 @@ func TestTerminalReconcile(t *testing.T) {
490
490
  terminalsBefore := fr.liveTerminals()
491
491
  relaunchBefore := log.count("ensureTerminal:PAY-101:coding")
492
492
  buildBefore := log.count("buildCommand:")
493
- inspectBefore := log.count("inspectTerminal:")
493
+ inspectBefore := log.count("findTerminalID:")
494
494
 
495
495
  // Healthy terminal: EnsureRun checks the persisted direct handle and,
496
496
  // finding it live, sends no reconcile and relaunches
@@ -502,7 +502,7 @@ func TestTerminalReconcile(t *testing.T) {
502
502
  t.Fatal(err)
503
503
  }
504
504
  time.Sleep(300 * time.Millisecond)
505
- if log.count("inspectTerminal:") == inspectBefore {
505
+ if log.count("findTerminalID:") == inspectBefore {
506
506
  t.Fatal("repeated EnsureRun never checked the persisted terminal handle")
507
507
  }
508
508
  if got := log.count("buildCommand:") - buildBefore; got != 0 {
@@ -759,8 +759,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
759
759
  return err
760
760
  }
761
761
  rm := &run.RunManager{Executor: engine, Runs: engine}
762
- specsFor := func(w *workflow.Workflow, key string) []task.MailboxSpec {
763
- return goworkflows.MailboxSpecs(w, key)
762
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
763
+ return goworkflows.RenderMailboxSpecs(system, work, w)
764
764
  }
765
765
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
766
766
  }
@@ -792,7 +792,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
792
792
  RepoName: "payments", RepoPath: "/srv/payments", TicketKey: "PAY-101",
793
793
  }
794
794
  env, _ := fr.EnsureEnvironment(context.Background(), survSpec)
795
- _, _ = fr.EnsureTerminal(context.Background(), env, "PAY-101:coding", runner.Command{})
795
+ _, _ = fr.EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
796
796
 
797
797
  deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh}
798
798
 
@@ -821,7 +821,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
821
821
  if err != nil || preRuntime.TerminalID == "" {
822
822
  t.Fatalf("pre-loss runtime = %+v, %v", preRuntime, err)
823
823
  }
824
- inspectBeforeRecover := log.count("inspectTerminal:")
824
+ inspectBeforeRecover := log.count("findTerminalID:")
825
825
  pc, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
826
826
  _ = preEngine.Shutdown(pc)
827
827
  pcancel()
@@ -871,7 +871,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
871
871
  t.Fatalf("%s recovery reused pre-loss terminal ID %q", key, rt.TerminalID)
872
872
  }
873
873
  }
874
- if log.count("inspectTerminal:") != inspectBeforeRecover {
874
+ if log.count("findTerminalID:") != inspectBeforeRecover {
875
875
  t.Fatalf("recover used pre-loss direct terminal IDs: %v", log.all())
876
876
  }
877
877
  if log.count("closeTerminals:") == 0 {
@@ -0,0 +1,31 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ )
10
+
11
+ func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
12
+ b, err := os.ReadFile("../../../testdata/report-contract.json")
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ var fixtures map[string]struct {
17
+ Envelope run.ReportRequest `json:"envelope"`
18
+ SummaryReport string `json:"summaryReport"`
19
+ FeedbackReport string `json:"feedbackReport"`
20
+ }
21
+ if err := json.Unmarshal(b, &fixtures); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ fixture := fixtures["work"]
25
+ if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
26
+ t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
27
+ }
28
+ if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
29
+ t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
30
+ }
31
+ }
@@ -6,7 +6,7 @@ import (
6
6
  )
7
7
 
8
8
  // 9.6: the info-level retry record must never embed argv payloads
9
- // (acli --body, orca --command carrying the agent prompt + RELAY_FLOW_*
9
+ // (Jira/Orca request bodies or commands carrying prompts and RELAY_FLOW_*
10
10
  // env, JQL). sanitizeRetryMessage strips each "[args...]: " span while
11
11
  // preserving the surrounding wrap context and trailing stderr fragment.
12
12
  func TestSanitizeRetryMessage(t *testing.T) {
@@ -19,25 +19,25 @@ func TestSanitizeRetryMessage(t *testing.T) {
19
19
  mustContain []string
20
20
  }{
21
21
  {
22
- name: "acli comment with body payload",
23
- in: `acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`,
24
- mustNot: []string{"SECRET-BODY", "--body", "--key PAY-1"},
22
+ name: "acli comment with body payload",
23
+ in: `acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`,
24
+ mustNot: []string{"SECRET-BODY", "--body", "--key PAY-1"},
25
25
  mustContain: []string{"acli", "exit status 1", "permission denied"},
26
26
  },
27
27
  {
28
- name: "orca create with command payload",
29
- in: `ensure run X: orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'RELAY_FLOW_RUN_ID=r1 opencode --agent coder PROMPT-TEXT']: exit status 1: closed`,
30
- mustNot: []string{"PROMPT-TEXT", "RELAY_FLOW_RUN_ID=r1", "--command", "--title PAY-1:coding"},
28
+ name: "orca create with command payload",
29
+ in: `ensure run X: orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'RELAY_FLOW_RUN_ID=r1 opencode --agent coder PROMPT-TEXT']: exit status 1: closed`,
30
+ mustNot: []string{"PROMPT-TEXT", "RELAY_FLOW_RUN_ID=r1", "--command", "--title PAY-1:coding"},
31
31
  mustContain: []string{"ensure run X", "orca terminal create", "exit status 1", "closed"},
32
32
  },
33
33
  {
34
- name: "no brackets passes through",
35
- in: "plain failure",
34
+ name: "no brackets passes through",
35
+ in: "plain failure",
36
36
  mustContain: []string{"plain failure"},
37
37
  },
38
38
  {
39
- name: "unterminated bracket passes through",
40
- in: "weird [unterminated",
39
+ name: "unterminated bracket passes through",
40
+ in: "weird [unterminated",
41
41
  mustContain: []string{"weird"},
42
42
  },
43
43
  }
@@ -23,6 +23,8 @@ type fakeHarness struct {
23
23
  session map[string]harness.Session // title -> session
24
24
  }
25
25
 
26
+ var _ harness.Harness = (*fakeHarness)(nil)
27
+
26
28
  func newFakeHarness() *fakeHarness {
27
29
  return &fakeHarness{
28
30
  agents: map[string]bool{"build": true},
@@ -30,6 +32,8 @@ func newFakeHarness() *fakeHarness {
30
32
  }
31
33
  }
32
34
 
35
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
36
+
33
37
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
34
38
  if !f.agents[agent] {
35
39
  return errUnknownAgent
@@ -42,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
42
46
  return s, ok, nil
43
47
  }
44
48
 
49
+ func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
50
+ prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
51
+ if nudge != "" {
52
+ prompt += "\n\n" + nudge
53
+ }
54
+ return prompt, nil
55
+ }
56
+
45
57
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
46
58
  // The fake mirrors the required env contract; the real opencode harness
47
59
  // builds the executable/args. NEXT_STEPS_JSON carries the legal targets
@@ -141,6 +153,9 @@ func TestBuildCommandEnvContract(t *testing.T) {
141
153
  if cmd.Env["RELAY_FLOW_TICKET"] != "PAY-101" || cmd.Env["RELAY_FLOW_NODE"] != "coding" {
142
154
  t.Fatalf("ticket/node env wrong: %v", cmd.Env)
143
155
  }
156
+ if _, ok := cmd.Env["RELAY_FLOW_NODE_VISIT_ID"]; ok {
157
+ t.Fatalf("internal node visit ID leaked into harness env: %v", cmd.Env)
158
+ }
144
159
 
145
160
  // NEXT_STEPS_JSON must decode to the legal targets and their when
146
161
  // explanations, not just exist.
@@ -9,8 +9,12 @@ import (
9
9
  "github.com/rajpopat27/relay-flow/internal/config"
10
10
  )
11
11
 
12
- // Factory constructs a Harness from root harness config.
13
- type Factory func(config.RawValues) (Harness, error)
12
+ // Factory constructs a Harness from root harness config and supplies the
13
+ // selected harness's init defaults without coupling core to an adapter.
14
+ type Factory struct {
15
+ DefaultConfig func() config.RawValues
16
+ New func(config.RawValues) (Harness, error)
17
+ }
14
18
 
15
19
  var (
16
20
  registryMu sync.RWMutex
@@ -35,7 +39,25 @@ func New(name string, cfg config.RawValues) (Harness, error) {
35
39
  if !ok {
36
40
  return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
37
41
  }
38
- return f(cfg)
42
+ return f.New(config.Merge(defaultConfig(f), cfg))
43
+ }
44
+
45
+ // Defaults returns a fresh copy of the named harness's root config defaults.
46
+ func Defaults(name string) (config.RawValues, error) {
47
+ registryMu.RLock()
48
+ f, ok := registry[name]
49
+ registryMu.RUnlock()
50
+ if !ok {
51
+ return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
52
+ }
53
+ return config.Merge(defaultConfig(f)), nil
54
+ }
55
+
56
+ func defaultConfig(f Factory) config.RawValues {
57
+ if f.DefaultConfig == nil {
58
+ return nil
59
+ }
60
+ return f.DefaultConfig()
39
61
  }
40
62
 
41
63
  // ValidateName returns an error listing registered names when name is not
@@ -1,7 +1,7 @@
1
- // Package harness defines the harness contract: agent validation, session
2
- // discovery, resume syntax, and launch command construction. The runner
3
- // executes the returned command; the harness never manipulates runner
4
- // state.
1
+ // Package harness defines the harness contract: repository setup, prompt
2
+ // rendering, agent validation, session discovery, resume syntax, and launch
3
+ // command construction. The runner executes the returned command; the harness
4
+ // never manipulates runner state.
5
5
  package harness
6
6
 
7
7
  import (
@@ -17,6 +17,29 @@ type Session struct {
17
17
  Title string
18
18
  }
19
19
 
20
+ type PromptKind string
21
+
22
+ const (
23
+ PromptInitial PromptKind = "initial"
24
+ PromptFeedback PromptKind = "feedback"
25
+ )
26
+
27
+ // PromptData is the task-system-neutral data core supplies to the selected
28
+ // harness. Harness templates, including HITL instructions, are rendered only
29
+ // by the harness.
30
+ type PromptData struct {
31
+ TaskSystem string
32
+ Ticket string
33
+ Workflow string
34
+ Repo string
35
+ Node string
36
+ NodeType workflow.NodeType
37
+ Agent string
38
+ NodeDescription string
39
+ NextSteps string
40
+ Mailbox string
41
+ }
42
+
20
43
  type LaunchSpec struct {
21
44
  RunID identity.RunID
22
45
  NodeVisitID identity.NodeVisitID
@@ -30,12 +53,15 @@ type LaunchSpec struct {
30
53
  Title string
31
54
  Prompt string
32
55
  NudgePrompt string
56
+ PromptData PromptData
33
57
  NextSteps []workflow.Route
34
58
  ResumeID string
35
59
  }
36
60
 
37
61
  type Harness interface {
62
+ SetupRepo(ctx context.Context, repoPath string) error
38
63
  ValidateAgent(ctx context.Context, repoPath, agent string) error
39
64
  FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
65
+ RenderPrompt(kind PromptKind, data PromptData, nudgeTemplate string) (string, error)
40
66
  BuildCommand(spec LaunchSpec) (runner.Command, error)
41
67
  }
@@ -15,36 +15,94 @@ import (
15
15
  "os"
16
16
  "os/exec"
17
17
  "path/filepath"
18
+ "regexp"
18
19
  "strings"
19
20
 
20
21
  "github.com/rajpopat27/relay-flow/internal/config"
21
22
  "github.com/rajpopat27/relay-flow/internal/harness"
22
23
  "github.com/rajpopat27/relay-flow/internal/runner"
24
+ "github.com/rajpopat27/relay-flow/internal/workflow"
23
25
  )
24
26
 
25
- // Config is the adapter-owned root harnessConfig. OpenCode takes no
26
- // configuration; any key is rejected.
27
- type Config struct{}
27
+ const (
28
+ defaultInitialPrompt = `Task system: {{taskSystem}}
29
+ Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
30
+
31
+ Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
32
+ defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
33
+ defaultHITLPrompt = `Before submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer.`
34
+ )
35
+
36
+ var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
37
+
38
+ var knownPromptVars = map[string]bool{
39
+ "taskSystem": true, "ticket": true, "workflow": true, "repo": true,
40
+ "node": true, "nodeType": true, "agent": true, "nodeDescription": true,
41
+ "nextSteps": true, "mailbox": true,
42
+ }
43
+
44
+ // Config is the adapter-owned root harnessConfig.
45
+ type Config struct {
46
+ Initial string `yaml:"initial"`
47
+ Feedback string `yaml:"feedback"`
48
+ HITL string `yaml:"hitl"`
49
+ }
50
+
51
+ // DefaultConfig is written by relay-flow init and also fills omitted values
52
+ // when older or hand-written config is loaded.
53
+ func DefaultConfig() config.RawValues {
54
+ return config.RawValues{
55
+ "initial": defaultInitialPrompt,
56
+ "feedback": defaultFeedbackPrompt,
57
+ "hitl": defaultHITLPrompt,
58
+ }
59
+ }
28
60
 
29
61
  func init() {
30
- harness.Register("opencode", func(raw config.RawValues) (harness.Harness, error) {
31
- var cfg Config
32
- if err := config.DecodeStrict(raw, &cfg); err != nil {
33
- return nil, fmt.Errorf("opencode harnessConfig: %w", err)
34
- }
35
- return New(), nil
62
+ harness.Register("opencode", harness.Factory{
63
+ DefaultConfig: DefaultConfig,
64
+ New: func(raw config.RawValues) (harness.Harness, error) {
65
+ var cfg Config
66
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
67
+ return nil, fmt.Errorf("opencode harnessConfig: %w", err)
68
+ }
69
+ for name, tmpl := range map[string]string{
70
+ "initial": cfg.Initial,
71
+ "feedback": cfg.Feedback,
72
+ "hitl": cfg.HITL,
73
+ } {
74
+ if err := validateTemplate(tmpl); err != nil {
75
+ return nil, fmt.Errorf("opencode harnessConfig templates.%s: %w", name, err)
76
+ }
77
+ }
78
+ return New(cfg), nil
79
+ },
36
80
  })
37
81
  }
38
82
 
39
83
  // Harness implements harness.Harness for OpenCode. It is safe for
40
84
  // concurrent use; the seams below are only swapped by tests before use.
41
85
  type Harness struct {
86
+ templates Config
42
87
  // listAgents is the test seam; nil → real `opencode agent list`.
43
88
  listAgents func(ctx context.Context) ([]string, error)
44
89
  }
45
90
 
46
91
  // New returns the production Harness.
47
- func New() *Harness { return &Harness{} }
92
+ func New(cfg ...Config) *Harness {
93
+ if len(cfg) > 0 {
94
+ return &Harness{templates: cfg[0]}
95
+ }
96
+ return &Harness{templates: Config{
97
+ Initial: defaultInitialPrompt, Feedback: defaultFeedbackPrompt, HITL: defaultHITLPrompt,
98
+ }}
99
+ }
100
+
101
+ // SetupRepo ensures the relay-flow runtime plugin is configured for OpenCode
102
+ // in the registered repository.
103
+ func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
104
+ return setupRepo(repoPath)
105
+ }
48
106
 
49
107
  // ValidateAgent reports whether name is a known OpenCode agent for the
50
108
  // repo, per `opencode agent list` (agent names are the unindented first
@@ -75,6 +133,26 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
75
133
  return harness.Session{}, false, nil
76
134
  }
77
135
 
136
+ // RenderPrompt renders the selected session prompt, appends HITL approval
137
+ // instructions for HITL nodes, then renders and appends the node's nudge
138
+ // template.
139
+ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
140
+ var tmpl string
141
+ switch kind {
142
+ case harness.PromptInitial:
143
+ tmpl = h.templates.Initial
144
+ case harness.PromptFeedback:
145
+ tmpl = h.templates.Feedback
146
+ default:
147
+ return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
148
+ }
149
+ prompt := renderTemplate(tmpl, data)
150
+ if data.NodeType == workflow.NodeHITL {
151
+ prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
152
+ }
153
+ return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
154
+ }
155
+
78
156
  // BuildCommand returns the structured opencode invocation with the
79
157
  // required RELAY_FLOW_* env. spec.ResumeID non-empty resumes the prior
80
158
  // session (`opencode --session <id>`); empty is a fresh launch. The
@@ -164,3 +242,40 @@ func listAgents(ctx context.Context) ([]string, error) {
164
242
  }
165
243
  return names, nil
166
244
  }
245
+
246
+ func validateTemplate(tmpl string) error {
247
+ for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
248
+ if !knownPromptVars[match[1]] {
249
+ return fmt.Errorf("unknown template variable {{%s}}", match[1])
250
+ }
251
+ }
252
+ return nil
253
+ }
254
+
255
+ func renderTemplate(tmpl string, data harness.PromptData) string {
256
+ values := map[string]string{
257
+ "taskSystem": data.TaskSystem,
258
+ "ticket": data.Ticket,
259
+ "workflow": data.Workflow,
260
+ "repo": data.Repo,
261
+ "node": data.Node,
262
+ "nodeType": string(data.NodeType),
263
+ "agent": data.Agent,
264
+ "nodeDescription": data.NodeDescription,
265
+ "nextSteps": data.NextSteps,
266
+ "mailbox": data.Mailbox,
267
+ }
268
+ return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
269
+ return values[promptVarPattern.FindStringSubmatch(match)[1]]
270
+ })
271
+ }
272
+
273
+ func appendPrompt(prompt, extra string) string {
274
+ if extra == "" {
275
+ return prompt
276
+ }
277
+ if prompt == "" {
278
+ return extra
279
+ }
280
+ return prompt + "\n\n" + extra
281
+ }