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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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
|
+
}
|