relay-flow 0.0.1 → 0.2.0-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 (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,59 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ // 9.6: the info-level retry record must never embed argv payloads
9
+ // (acli --body, orca --command carrying the agent prompt + RELAY_FLOW_*
10
+ // env, JQL). sanitizeRetryMessage strips each "[args...]: " span while
11
+ // preserving the surrounding wrap context and trailing stderr fragment.
12
+ func TestSanitizeRetryMessage(t *testing.T) {
13
+ cases := []struct {
14
+ name string
15
+ in string
16
+ // mustNot: argv payloads that must be stripped
17
+ mustNot []string
18
+ // mustContain: failure context that must survive
19
+ mustContain []string
20
+ }{
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"},
25
+ mustContain: []string{"acli", "exit status 1", "permission denied"},
26
+ },
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"},
31
+ mustContain: []string{"ensure run X", "orca terminal create", "exit status 1", "closed"},
32
+ },
33
+ {
34
+ name: "no brackets passes through",
35
+ in: "plain failure",
36
+ mustContain: []string{"plain failure"},
37
+ },
38
+ {
39
+ name: "unterminated bracket passes through",
40
+ in: "weird [unterminated",
41
+ mustContain: []string{"weird"},
42
+ },
43
+ }
44
+ for _, c := range cases {
45
+ t.Run(c.name, func(t *testing.T) {
46
+ got := sanitizeRetryMessage(c.in)
47
+ for _, s := range c.mustNot {
48
+ if strings.Contains(got, s) {
49
+ t.Fatalf("sanitizeRetryMessage leaked %q in %q", s, got)
50
+ }
51
+ }
52
+ for _, s := range c.mustContain {
53
+ if !strings.Contains(got, s) {
54
+ t.Fatalf("sanitizeRetryMessage dropped %q from %q", s, got)
55
+ }
56
+ }
57
+ })
58
+ }
59
+ }
@@ -0,0 +1,98 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
10
+ "github.com/rajpopat27/relay-flow/internal/run"
11
+ )
12
+
13
+ func TestRunProjectionShowsActiveRetryAndClearsAfterRecovery(t *testing.T) {
14
+ log := newEventLog()
15
+ sys := newFakeTaskSystem(log)
16
+ sys.completeFail = 1
17
+ engine := newEngine(t, goworkflows.Dependencies{
18
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
19
+ })
20
+ rid, err := startRun(engine, linearWorkflow(false))
21
+ if err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ waitFor(t, 10*time.Second, func() bool {
25
+ r, _ := engine.GetRun(context.Background(), rid)
26
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
27
+ })
28
+ r, _ := engine.GetRun(context.Background(), rid)
29
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+
33
+ waitFor(t, 10*time.Second, func() bool {
34
+ r, _ = engine.GetRun(context.Background(), rid)
35
+ return r.Retry != nil
36
+ })
37
+ if r.State != run.StateWaiting {
38
+ t.Fatalf("lifecycle state changed to %q during transient retry; want waiting", r.State)
39
+ }
40
+ if r.Retry.Attempt != 1 {
41
+ t.Fatalf("retry attempt = %d, want 1", r.Retry.Attempt)
42
+ }
43
+ if !strings.Contains(r.Retry.LastError, "jira unavailable") {
44
+ t.Fatalf("retry error = %q, want sanitized provider reason", r.Retry.LastError)
45
+ }
46
+ if !r.Retry.NextRetryAt.After(r.UpdatedAt) {
47
+ t.Fatalf("next retry %s is not after projection update %s", r.Retry.NextRetryAt, r.UpdatedAt)
48
+ }
49
+
50
+ listed, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments"})
51
+ if err != nil || len(listed) != 1 || listed[0].Retry == nil {
52
+ t.Fatalf("ListRuns retry projection = %+v, err=%v", listed, err)
53
+ }
54
+ byTicket, err := engine.FindRunByTicket(context.Background(), "PAY-101")
55
+ if err != nil || byTicket.Retry == nil {
56
+ t.Fatalf("FindRunByTicket retry projection = %+v, err=%v", byTicket, err)
57
+ }
58
+
59
+ waitFor(t, 15*time.Second, func() bool {
60
+ r, _ = engine.GetRun(context.Background(), rid)
61
+ return r.State == run.StateCompleted
62
+ })
63
+ if r.Retry != nil {
64
+ t.Fatalf("retry details remained after recovery: %+v", r.Retry)
65
+ }
66
+ }
67
+
68
+ func TestRunProjectionClearsRetryAfterCancellation(t *testing.T) {
69
+ log := newEventLog()
70
+ sys := newFakeTaskSystem(log)
71
+ sys.completeFail = 100
72
+ engine := newEngine(t, goworkflows.Dependencies{
73
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
74
+ })
75
+ rid, _ := startRun(engine, linearWorkflow(false))
76
+ waitFor(t, 10*time.Second, func() bool {
77
+ r, _ := engine.GetRun(context.Background(), rid)
78
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
79
+ })
80
+ r, _ := engine.GetRun(context.Background(), rid)
81
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ waitFor(t, 10*time.Second, func() bool {
85
+ r, _ = engine.GetRun(context.Background(), rid)
86
+ return r.Retry != nil
87
+ })
88
+ if err := engine.CancelRun(context.Background(), rid, "operator canceled"); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ waitFor(t, 10*time.Second, func() bool {
92
+ r, _ = engine.GetRun(context.Background(), rid)
93
+ return r.State == run.StateCanceled
94
+ })
95
+ if r.Retry != nil {
96
+ t.Fatalf("retry details remained after terminal cancellation: %+v", r.Retry)
97
+ }
98
+ }
@@ -0,0 +1,169 @@
1
+ package harness_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/harness"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/runner"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ )
14
+
15
+ // 3.8: harness contract tests against a fake harness, per
16
+ // specs/integration-contracts "Harness owns agent launch semantics" and
17
+ // specs/structured-node-reporting "Runtime plugin metadata is explicit".
18
+
19
+ var errUnknownAgent = errors.New("unknown agent")
20
+
21
+ type fakeHarness struct {
22
+ agents map[string]bool
23
+ session map[string]harness.Session // title -> session
24
+ }
25
+
26
+ func newFakeHarness() *fakeHarness {
27
+ return &fakeHarness{
28
+ agents: map[string]bool{"build": true},
29
+ session: map[string]harness.Session{},
30
+ }
31
+ }
32
+
33
+ func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
34
+ if !f.agents[agent] {
35
+ return errUnknownAgent
36
+ }
37
+ return nil
38
+ }
39
+
40
+ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.Session, bool, error) {
41
+ s, ok := f.session[title]
42
+ return s, ok, nil
43
+ }
44
+
45
+ func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
46
+ // The fake mirrors the required env contract; the real opencode harness
47
+ // builds the executable/args. NEXT_STEPS_JSON carries the legal targets
48
+ // and their when explanations.
49
+ nextSteps, err := json.Marshal(spec.NextSteps)
50
+ if err != nil {
51
+ return runner.Command{}, err
52
+ }
53
+ return runner.Command{
54
+ Executable: "opencode",
55
+ Args: []string{"--agent", spec.Agent},
56
+ Env: map[string]string{
57
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
58
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
59
+ "RELAY_FLOW_REPO": spec.RepoName,
60
+ "RELAY_FLOW_TICKET": spec.Ticket,
61
+ "RELAY_FLOW_NODE": spec.Node,
62
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
63
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
64
+ "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
65
+ },
66
+ }, nil
67
+ }
68
+
69
+ func launchSpec() harness.LaunchSpec {
70
+ return harness.LaunchSpec{
71
+ RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
72
+ NodeVisitID: identity.NewNodeVisitID(),
73
+ RepoName: "payments",
74
+ RepoPath: "/srv/payments",
75
+ Workflow: "basicFlow",
76
+ Ticket: "PAY-101",
77
+ Node: "coding",
78
+ NodeType: workflow.NodeAgent,
79
+ Agent: "build",
80
+ Title: "PAY-101:coding",
81
+ NextSteps: []workflow.Route{
82
+ {Target: "end", When: "work complete"},
83
+ {Target: "coding", When: "needs rework"},
84
+ },
85
+ }
86
+ }
87
+
88
+ func TestValidateAgent(t *testing.T) {
89
+ f := newFakeHarness()
90
+ if err := f.ValidateAgent(context.Background(), "/srv/payments", "build"); err != nil {
91
+ t.Fatalf("known agent rejected: %v", err)
92
+ }
93
+ if err := f.ValidateAgent(context.Background(), "/srv/payments", "unknown"); err == nil {
94
+ t.Fatal("unknown agent accepted")
95
+ }
96
+ }
97
+
98
+ func TestFindSessionByStableTitle(t *testing.T) {
99
+ f := newFakeHarness()
100
+ f.session["PAY-101:coding"] = harness.Session{ID: "s1", Title: "PAY-101:coding"}
101
+
102
+ s, ok, err := f.FindSession(context.Background(), "/srv/payments", "PAY-101:coding")
103
+ if err != nil || !ok || s.ID != "s1" {
104
+ t.Fatalf("FindSession = %v,%v,%v", s, ok, err)
105
+ }
106
+ _, ok, err = f.FindSession(context.Background(), "/srv/payments", "PAY-101:review")
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ if ok {
111
+ t.Fatal("FindSession found a session for an unknown title")
112
+ }
113
+ }
114
+
115
+ func TestBuildCommandEnvContract(t *testing.T) {
116
+ f := newFakeHarness()
117
+ spec := launchSpec()
118
+ cmd, err := f.BuildCommand(spec)
119
+ if err != nil {
120
+ t.Fatalf("BuildCommand failed: %v", err)
121
+ }
122
+
123
+ required := []string{
124
+ "RELAY_FLOW_RUN_ID",
125
+ "RELAY_FLOW_WORKFLOW",
126
+ "RELAY_FLOW_REPO",
127
+ "RELAY_FLOW_TICKET",
128
+ "RELAY_FLOW_NODE",
129
+ "RELAY_FLOW_NODE_TYPE",
130
+ "RELAY_FLOW_NUDGE_PROMPT",
131
+ "RELAY_FLOW_NEXT_STEPS_JSON",
132
+ }
133
+ for _, key := range required {
134
+ if _, ok := cmd.Env[key]; !ok {
135
+ t.Fatalf("command env missing %s (env=%v)", key, cmd.Env)
136
+ }
137
+ }
138
+ if cmd.Env["RELAY_FLOW_RUN_ID"] != string(spec.RunID) {
139
+ t.Fatalf("RELAY_FLOW_RUN_ID = %q", cmd.Env["RELAY_FLOW_RUN_ID"])
140
+ }
141
+ if cmd.Env["RELAY_FLOW_TICKET"] != "PAY-101" || cmd.Env["RELAY_FLOW_NODE"] != "coding" {
142
+ t.Fatalf("ticket/node env wrong: %v", cmd.Env)
143
+ }
144
+
145
+ // NEXT_STEPS_JSON must decode to the legal targets and their when
146
+ // explanations, not just exist.
147
+ var steps []map[string]string
148
+ if err := json.Unmarshal([]byte(cmd.Env["RELAY_FLOW_NEXT_STEPS_JSON"]), &steps); err != nil {
149
+ t.Fatalf("NEXT_STEPS_JSON not valid JSON: %v", err)
150
+ }
151
+ if len(steps) != 2 {
152
+ t.Fatalf("NEXT_STEPS_JSON = %v, want 2 legal targets", steps)
153
+ }
154
+ found := map[string]string{}
155
+ for _, s := range steps {
156
+ found[s["target"]] = s["when"]
157
+ }
158
+ if found["end"] != "work complete" || found["coding"] != "needs rework" {
159
+ t.Fatalf("NEXT_STEPS_JSON targets/when = %v", found)
160
+ }
161
+ }
162
+
163
+ func TestHarnessDoesNotManipulateRunnerState(t *testing.T) {
164
+ // The harness returns a structured runner.Command; it never receives or
165
+ // mutates runner terminals/environments. Compile-time shape check:
166
+ // Harness methods take repoPath/title/spec values, not runner state.
167
+ var h harness.Harness = newFakeHarness()
168
+ _ = h
169
+ }
@@ -0,0 +1,63 @@
1
+ package harness
2
+
3
+ import (
4
+ "fmt"
5
+ "sort"
6
+ "strings"
7
+ "sync"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ )
11
+
12
+ // Factory constructs a Harness from root harness config.
13
+ type Factory func(config.RawValues) (Harness, error)
14
+
15
+ var (
16
+ registryMu sync.RWMutex
17
+ registry = map[string]Factory{}
18
+ )
19
+
20
+ // Register adds a harness factory by name. Duplicate registration panics.
21
+ func Register(name string, factory Factory) {
22
+ registryMu.Lock()
23
+ defer registryMu.Unlock()
24
+ if _, exists := registry[name]; exists {
25
+ panic(fmt.Sprintf("harness: duplicate registration of %q", name))
26
+ }
27
+ registry[name] = factory
28
+ }
29
+
30
+ // New constructs the named harness.
31
+ func New(name string, cfg config.RawValues) (Harness, error) {
32
+ registryMu.RLock()
33
+ f, ok := registry[name]
34
+ registryMu.RUnlock()
35
+ if !ok {
36
+ return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
37
+ }
38
+ return f(cfg)
39
+ }
40
+
41
+ // ValidateName returns an error listing registered names when name is not
42
+ // a registered plugin. Used by `relay-flow init` to reject unknown plugin
43
+ // selections without constructing a Harness.
44
+ func ValidateName(name string) error {
45
+ registryMu.RLock()
46
+ defer registryMu.RUnlock()
47
+ if _, ok := registry[name]; !ok {
48
+ return fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
49
+ }
50
+ return nil
51
+ }
52
+
53
+ // Names returns the registered plugin names sorted.
54
+ func Names() []string {
55
+ registryMu.RLock()
56
+ defer registryMu.RUnlock()
57
+ out := make([]string, 0, len(registry))
58
+ for name := range registry {
59
+ out = append(out, name)
60
+ }
61
+ sort.Strings(out)
62
+ return out
63
+ }
@@ -0,0 +1,41 @@
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.
5
+ package harness
6
+
7
+ import (
8
+ "context"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/runner"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ )
14
+
15
+ type Session struct {
16
+ ID string
17
+ Title string
18
+ }
19
+
20
+ type LaunchSpec struct {
21
+ RunID identity.RunID
22
+ NodeVisitID identity.NodeVisitID
23
+ RepoName string
24
+ RepoPath string
25
+ Workflow string
26
+ Ticket string
27
+ Node string
28
+ NodeType workflow.NodeType
29
+ Agent string
30
+ Title string
31
+ Prompt string
32
+ NudgePrompt string
33
+ NextSteps []workflow.Route
34
+ ResumeID string
35
+ }
36
+
37
+ type Harness interface {
38
+ ValidateAgent(ctx context.Context, repoPath, agent string) error
39
+ FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
40
+ BuildCommand(spec LaunchSpec) (runner.Command, error)
41
+ }
@@ -0,0 +1,166 @@
1
+ // Package opencode is the built-in launch-time Harness adapter for the
2
+ // OpenCode agent runtime. It validates agents, finds prior sessions by
3
+ // their stable <ticket>:<node> title, and builds the structured command
4
+ // the runner executes. The OpenCode runtime plugin (TypeScript, under
5
+ // plugin/) owns message parsing, title pinning, nudge-on-invalid for
6
+ // agent nodes, silence for HITL, and exact-report JSON retry with the
7
+ // shared backoff constants mirrored in TypeScript.
8
+ package opencode
9
+
10
+ import (
11
+ "context"
12
+ "encoding/json"
13
+ "fmt"
14
+ "log/slog"
15
+ "os"
16
+ "os/exec"
17
+ "path/filepath"
18
+ "strings"
19
+
20
+ "github.com/rajpopat27/relay-flow/internal/config"
21
+ "github.com/rajpopat27/relay-flow/internal/harness"
22
+ "github.com/rajpopat27/relay-flow/internal/runner"
23
+ )
24
+
25
+ // Config is the adapter-owned root harnessConfig. OpenCode takes no
26
+ // configuration; any key is rejected.
27
+ type Config struct{}
28
+
29
+ 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
36
+ })
37
+ }
38
+
39
+ // Harness implements harness.Harness for OpenCode. It is safe for
40
+ // concurrent use; the seams below are only swapped by tests before use.
41
+ type Harness struct {
42
+ // listAgents is the test seam; nil → real `opencode agent list`.
43
+ listAgents func(ctx context.Context) ([]string, error)
44
+ }
45
+
46
+ // New returns the production Harness.
47
+ func New() *Harness { return &Harness{} }
48
+
49
+ // ValidateAgent reports whether name is a known OpenCode agent for the
50
+ // repo, per `opencode agent list` (agent names are the unindented first
51
+ // tokens).
52
+ func (h *Harness) ValidateAgent(ctx context.Context, _ string, agent string) error {
53
+ if agent == "" {
54
+ return fmt.Errorf("opencode: agent name is empty")
55
+ }
56
+ list := h.listAgents
57
+ if list == nil {
58
+ list = listAgents
59
+ }
60
+ names, err := list(ctx)
61
+ if err != nil {
62
+ return err
63
+ }
64
+ for _, n := range names {
65
+ if n == agent {
66
+ return nil
67
+ }
68
+ }
69
+ return fmt.Errorf("opencode: unknown agent %q", agent)
70
+ }
71
+
72
+ // FindSession is intentionally discovery-free. Normal execution resumes only
73
+ // the session ID persisted from an OpenCode event.
74
+ func (h *Harness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
75
+ return harness.Session{}, false, nil
76
+ }
77
+
78
+ // BuildCommand returns the structured opencode invocation with the
79
+ // required RELAY_FLOW_* env. spec.ResumeID non-empty resumes the prior
80
+ // session (`opencode --session <id>`); empty is a fresh launch. The
81
+ // prompt is delivered as the first message in both cases.
82
+ //
83
+ // 9.5 external-call logging: this is the harness launch boundary — the
84
+ // runner executes the returned command. One debug line carries agent +
85
+ // session id + resume/fresh; one info line carries only the outcome.
86
+ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
87
+ mode := "fresh"
88
+ if spec.ResumeID != "" {
89
+ mode = "resume"
90
+ }
91
+ slog.Debug("harness call",
92
+ "op", "launch", "agent", spec.Agent, "session", spec.ResumeID, "mode", mode,
93
+ "ticket", spec.Ticket, "runID", string(spec.RunID),
94
+ "node", spec.Node, "nodeVisitID", string(spec.NodeVisitID))
95
+ if spec.Agent == "" {
96
+ err := fmt.Errorf("opencode: LaunchSpec.Agent is empty")
97
+ slog.Info("harness outcome", "op", "launch", "agent", spec.Agent, "mode", mode, "result", "error", "error", err)
98
+ return runner.Command{}, err
99
+ }
100
+ nextSteps, err := json.Marshal(spec.NextSteps)
101
+ if err != nil {
102
+ slog.Info("harness outcome", "op", "launch", "agent", spec.Agent, "mode", mode, "result", "error", "error", err)
103
+ return runner.Command{}, fmt.Errorf("opencode: marshal next steps: %w", err)
104
+ }
105
+ root, err := relayFlowHome()
106
+ if err != nil {
107
+ return runner.Command{}, err
108
+ }
109
+ env := map[string]string{
110
+ "RELAY_FLOW_HOME": root,
111
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
112
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
113
+ "RELAY_FLOW_REPO": spec.RepoName,
114
+ "RELAY_FLOW_TICKET": spec.Ticket,
115
+ "RELAY_FLOW_NODE": spec.Node,
116
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
117
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
118
+ "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
119
+ }
120
+ args := []string{}
121
+ if spec.ResumeID != "" {
122
+ args = append(args, "--session", spec.ResumeID)
123
+ }
124
+ args = append(args, "--agent", spec.Agent, "--prompt", spec.Prompt)
125
+ slog.Info("harness outcome",
126
+ "op", "launch", "agent", spec.Agent, "session", spec.ResumeID, "mode", mode,
127
+ "ticket", spec.Ticket, "runID", string(spec.RunID),
128
+ "node", spec.Node, "nodeVisitID", string(spec.NodeVisitID),
129
+ "result", "ok")
130
+ return runner.Command{
131
+ Executable: "opencode",
132
+ Args: args,
133
+ Env: env,
134
+ }, nil
135
+ }
136
+
137
+ func relayFlowHome() (string, error) {
138
+ if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
139
+ return root, nil
140
+ }
141
+ home, err := os.UserHomeDir()
142
+ if err != nil {
143
+ return "", fmt.Errorf("opencode: resolve relay-flow home: %w", err)
144
+ }
145
+ return filepath.Join(home, ".relay-flow"), nil
146
+ }
147
+
148
+ // listAgents runs `opencode agent list` and returns the agent names
149
+ // (the unindented lines' first tokens).
150
+ func listAgents(ctx context.Context) ([]string, error) {
151
+ out, err := exec.CommandContext(ctx, "opencode", "agent", "list").Output()
152
+ if err != nil {
153
+ return nil, fmt.Errorf("opencode agent list: %w", err)
154
+ }
155
+ var names []string
156
+ for _, line := range strings.Split(string(out), "\n") {
157
+ if line == "" || line[0] == ' ' || line[0] == '[' || line[0] == '{' {
158
+ continue
159
+ }
160
+ fields := strings.Fields(line)
161
+ if len(fields) > 0 {
162
+ names = append(names, fields[0])
163
+ }
164
+ }
165
+ return names, nil
166
+ }
@@ -0,0 +1,50 @@
1
+ package opencode_test
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/harness"
8
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
9
+ )
10
+
11
+ func TestBuildCommandArgv(t *testing.T) {
12
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
13
+ tests := []struct {
14
+ name string
15
+ resumeID string
16
+ want []string
17
+ }{
18
+ {
19
+ name: "fresh",
20
+ want: []string{"--agent", "build", "--prompt", "implement the ticket"},
21
+ },
22
+ {
23
+ name: "resumed",
24
+ resumeID: "session-123",
25
+ want: []string{"--session", "session-123", "--agent", "build", "--prompt", "implement the ticket"},
26
+ },
27
+ }
28
+
29
+ for _, tt := range tests {
30
+ t.Run(tt.name, func(t *testing.T) {
31
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
32
+ Agent: "build",
33
+ Prompt: "implement the ticket",
34
+ ResumeID: tt.resumeID,
35
+ })
36
+ if err != nil {
37
+ t.Fatalf("BuildCommand: %v", err)
38
+ }
39
+ if cmd.Executable != "opencode" {
40
+ t.Fatalf("Executable = %q, want opencode", cmd.Executable)
41
+ }
42
+ if !reflect.DeepEqual(cmd.Args, tt.want) {
43
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, tt.want)
44
+ }
45
+ if cmd.Env["RELAY_FLOW_HOME"] != "/var/lib/relay-flow-test" {
46
+ t.Fatalf("RELAY_FLOW_HOME = %q", cmd.Env["RELAY_FLOW_HOME"])
47
+ }
48
+ })
49
+ }
50
+ }