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