relay-flow 0.2.7-alpha → 0.2.9-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 +76 -37
- package/cmd/relay-flow/commands_test.go +199 -19
- package/cmd/relay-flow/main.go +183 -144
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +454 -0
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/config-reference.yaml +7 -0
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +47 -3
- package/internal/repo/service_test.go +36 -1
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +65 -0
- package/internal/server/client.go +40 -2
- package/internal/server/fixture_test.go +27 -16
- package/internal/server/server.go +80 -4
- package/internal/task/beads/beads.go +7 -2
- package/internal/task/beads/beads_test.go +13 -0
- package/internal/task/factory.go +9 -7
- package/internal/task/jira/auth_test.go +9 -1
- package/internal/task/jira/filters_test.go +2 -2
- package/internal/task/jira/helpers_test.go +13 -0
- package/internal/task/jira/jira.go +233 -44
- package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
- package/internal/task/jira/rest/client.go +73 -31
- package/internal/task/jira/rest/client_test.go +25 -0
- package/internal/task/jira/status_defaults_test.go +134 -0
- package/internal/task/jira/templates_test.go +2 -2
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/task/jira/transition_defaults_test.go +8 -12
- package/internal/task/jira/validation_test.go +3 -1
- package/internal/task/registration.go +59 -0
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/charmbracelet/huh"
|
|
14
|
+
huhspinner "github.com/charmbracelet/huh/spinner"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/paths"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Small command-level seams keep the interactive orchestration testable
|
|
23
|
+
// without changing task, runner, or server package boundaries.
|
|
24
|
+
var (
|
|
25
|
+
interactiveInitTTY = isTTY
|
|
26
|
+
interactiveInitPluginPick = pickPluginsInteractive
|
|
27
|
+
interactiveInitRepoPrompt = promptFirstRunRepositorySetup
|
|
28
|
+
interactiveInitRepoSetup = runFirstRunRepositorySetup
|
|
29
|
+
firstRunRegistration = cmdRepoRegister
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// stageFirstRunAuthentication gives the selected task plugin its normal auth
|
|
33
|
+
// entry point without exposing a partially initialized relay-flow home. Jira's
|
|
34
|
+
// auth flow also writes the first mailbox-assignee default into config.yaml;
|
|
35
|
+
// staging that file keeps the plugin's existing behavior while allowing the
|
|
36
|
+
// final config, credentials, and database to be committed only after auth.
|
|
37
|
+
func stageFirstRunAuthentication(p paths.Paths, cfg *config.Machine, plugin string, stdin io.Reader) (*config.Machine, string, error) {
|
|
38
|
+
if err := os.MkdirAll(filepath.Dir(p.Root), 0o700); err != nil {
|
|
39
|
+
return nil, "", fmt.Errorf("create initialization parent directory: %w", err)
|
|
40
|
+
}
|
|
41
|
+
stage, err := os.MkdirTemp(filepath.Dir(p.Root), ".relay-flow-init-")
|
|
42
|
+
if err != nil {
|
|
43
|
+
return nil, "", fmt.Errorf("create initialization staging directory: %w", err)
|
|
44
|
+
}
|
|
45
|
+
cleanup := func() {
|
|
46
|
+
_ = os.RemoveAll(stage)
|
|
47
|
+
}
|
|
48
|
+
if err := config.SaveMachine(filepath.Join(stage, "config.yaml"), cfg); err != nil {
|
|
49
|
+
cleanup()
|
|
50
|
+
return nil, "", fmt.Errorf("stage machine config: %w", err)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
previousHome, hadHome := os.LookupEnv("RELAY_FLOW_HOME")
|
|
54
|
+
if err := os.Setenv("RELAY_FLOW_HOME", stage); err != nil {
|
|
55
|
+
cleanup()
|
|
56
|
+
return nil, "", fmt.Errorf("stage relay-flow home: %w", err)
|
|
57
|
+
}
|
|
58
|
+
authErr := task.Auth(context.Background(), plugin, nil, stdin)
|
|
59
|
+
if hadHome {
|
|
60
|
+
_ = os.Setenv("RELAY_FLOW_HOME", previousHome)
|
|
61
|
+
} else {
|
|
62
|
+
_ = os.Unsetenv("RELAY_FLOW_HOME")
|
|
63
|
+
}
|
|
64
|
+
if authErr != nil {
|
|
65
|
+
cleanup()
|
|
66
|
+
return nil, "", authErr
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
staged, err := config.LoadMachine(filepath.Join(stage, "config.yaml"))
|
|
70
|
+
if err != nil {
|
|
71
|
+
cleanup()
|
|
72
|
+
return nil, "", fmt.Errorf("load authenticated machine config: %w", err)
|
|
73
|
+
}
|
|
74
|
+
return staged, stage, nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const firstRunPendingName = ".init-pending"
|
|
78
|
+
|
|
79
|
+
type firstRunPending struct {
|
|
80
|
+
Credentials bool `json:"credentials"`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func firstRunPendingPath(p paths.Paths) string {
|
|
84
|
+
return filepath.Join(p.Root, firstRunPendingName)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// commitFirstRun publishes the staged config and task credentials only after
|
|
88
|
+
// the durable database has been initialized. The pending marker makes the
|
|
89
|
+
// multi-file publication recoverable if the process is interrupted; each
|
|
90
|
+
// regular file is written with renameio through config.WriteAtomic.
|
|
91
|
+
func commitFirstRun(p paths.Paths, stage string, identity projection.ExecutorIdentity) (err error) {
|
|
92
|
+
stagedConfig := filepath.Join(stage, "config.yaml")
|
|
93
|
+
stagedCredentials := filepath.Join(stage, "credentials.yaml")
|
|
94
|
+
configData, err := os.ReadFile(stagedConfig)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return fmt.Errorf("read staged machine config: %w", err)
|
|
97
|
+
}
|
|
98
|
+
credentialsData, credentials, err := stagedCredentialsData(stagedCredentials)
|
|
99
|
+
if err != nil {
|
|
100
|
+
return err
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for _, path := range []string{p.Config, p.Database, p.Credentials} {
|
|
104
|
+
if _, statErr := os.Stat(path); statErr == nil {
|
|
105
|
+
return fmt.Errorf("initialization artifact %s already exists; refusing to overwrite it", path)
|
|
106
|
+
} else if !os.IsNotExist(statErr) {
|
|
107
|
+
return fmt.Errorf("stat initialization artifact %s: %w", path, statErr)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
111
|
+
return fmt.Errorf("create relay-flow home: %w", err)
|
|
112
|
+
}
|
|
113
|
+
if err := os.Chmod(p.Root, 0o700); err != nil {
|
|
114
|
+
return fmt.Errorf("chmod relay-flow home: %w", err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pending := firstRunPending{Credentials: credentials}
|
|
118
|
+
pendingData, err := json.Marshal(pending)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return fmt.Errorf("marshal initialization marker: %w", err)
|
|
121
|
+
}
|
|
122
|
+
if err := config.WriteAtomic(firstRunPendingPath(p), pendingData, 0o600); err != nil {
|
|
123
|
+
return fmt.Errorf("write initialization marker: %w", err)
|
|
124
|
+
}
|
|
125
|
+
published := false
|
|
126
|
+
defer func() {
|
|
127
|
+
if published {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
_ = removeFirstRunArtifacts(p)
|
|
131
|
+
}()
|
|
132
|
+
|
|
133
|
+
if err := projection.InitDatabaseWithIdentity(p.Database, identity); err != nil {
|
|
134
|
+
return fmt.Errorf("initialize durable state: %w", err)
|
|
135
|
+
}
|
|
136
|
+
if err := config.WriteAtomic(p.Config, configData, 0o600); err != nil {
|
|
137
|
+
return fmt.Errorf("publish machine config: %w", err)
|
|
138
|
+
}
|
|
139
|
+
if credentials {
|
|
140
|
+
if err := config.WriteAtomic(p.Credentials, credentialsData, 0o600); err != nil {
|
|
141
|
+
return fmt.Errorf("publish task credentials: %w", err)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Once all durable artifacts exist, leave them in place even if marker
|
|
146
|
+
// cleanup is interrupted. The next init removes the marker idempotently.
|
|
147
|
+
published = true
|
|
148
|
+
if err := os.Remove(firstRunPendingPath(p)); err != nil && !os.IsNotExist(err) {
|
|
149
|
+
return fmt.Errorf("finish initialization publication: %w", err)
|
|
150
|
+
}
|
|
151
|
+
if err := syncDirectory(p.Root); err != nil {
|
|
152
|
+
return fmt.Errorf("sync relay-flow home: %w", err)
|
|
153
|
+
}
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func stagedCredentialsData(path string) ([]byte, bool, error) {
|
|
158
|
+
data, err := os.ReadFile(path)
|
|
159
|
+
if err == nil {
|
|
160
|
+
return data, true, nil
|
|
161
|
+
}
|
|
162
|
+
if os.IsNotExist(err) {
|
|
163
|
+
return nil, false, nil
|
|
164
|
+
}
|
|
165
|
+
return nil, false, fmt.Errorf("read staged task credentials: %w", err)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func removeFirstRunArtifacts(p paths.Paths) error {
|
|
169
|
+
// Keep the marker until every owned artifact is gone. If cleanup fails or
|
|
170
|
+
// the process is interrupted, the next init can retry from the marker.
|
|
171
|
+
for _, path := range []string{
|
|
172
|
+
p.Config, p.Credentials, p.Database,
|
|
173
|
+
p.Database + "-wal", p.Database + "-shm",
|
|
174
|
+
} {
|
|
175
|
+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
176
|
+
return fmt.Errorf("remove initialization artifact %s: %w", path, err)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if _, err := os.Stat(p.Root); os.IsNotExist(err) {
|
|
180
|
+
return nil
|
|
181
|
+
} else if err != nil {
|
|
182
|
+
return fmt.Errorf("stat relay-flow home: %w", err)
|
|
183
|
+
}
|
|
184
|
+
// Persist the artifact removals while the marker still protects recovery.
|
|
185
|
+
if err := syncDirectory(p.Root); err != nil {
|
|
186
|
+
return fmt.Errorf("sync cleaned initialization artifacts: %w", err)
|
|
187
|
+
}
|
|
188
|
+
if err := os.Remove(firstRunPendingPath(p)); err != nil && !os.IsNotExist(err) {
|
|
189
|
+
return fmt.Errorf("remove initialization marker: %w", err)
|
|
190
|
+
}
|
|
191
|
+
if err := syncDirectory(p.Root); err != nil {
|
|
192
|
+
return fmt.Errorf("sync initialization marker removal: %w", err)
|
|
193
|
+
}
|
|
194
|
+
return nil
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func syncDirectory(path string) error {
|
|
198
|
+
dir, err := os.Open(path)
|
|
199
|
+
if err != nil {
|
|
200
|
+
return err
|
|
201
|
+
}
|
|
202
|
+
defer dir.Close()
|
|
203
|
+
return dir.Sync()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// recoverFirstRunPublication completes marker cleanup for a complete
|
|
207
|
+
// publication or removes only the artifacts owned by an incomplete first-run.
|
|
208
|
+
// It runs before init's normal existing-state refusal so an interrupted setup
|
|
209
|
+
// can be retried instead of wedging the home.
|
|
210
|
+
func recoverFirstRunPublication(p paths.Paths) error {
|
|
211
|
+
marker := firstRunPendingPath(p)
|
|
212
|
+
raw, err := os.ReadFile(marker)
|
|
213
|
+
if err != nil {
|
|
214
|
+
if os.IsNotExist(err) {
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
return fmt.Errorf("read initialization marker: %w", err)
|
|
218
|
+
}
|
|
219
|
+
var pending firstRunPending
|
|
220
|
+
if err := json.Unmarshal(raw, &pending); err != nil {
|
|
221
|
+
if cleanupErr := removeFirstRunArtifacts(p); cleanupErr != nil {
|
|
222
|
+
return fmt.Errorf("recover invalid initialization marker: %v; cleanup: %w", err, cleanupErr)
|
|
223
|
+
}
|
|
224
|
+
return nil
|
|
225
|
+
}
|
|
226
|
+
complete := true
|
|
227
|
+
for _, path := range []string{p.Config, p.Database} {
|
|
228
|
+
if _, statErr := os.Stat(path); statErr != nil {
|
|
229
|
+
if !os.IsNotExist(statErr) {
|
|
230
|
+
return fmt.Errorf("inspect initialization artifact %s: %w", path, statErr)
|
|
231
|
+
}
|
|
232
|
+
complete = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if pending.Credentials {
|
|
236
|
+
if _, statErr := os.Stat(p.Credentials); statErr != nil {
|
|
237
|
+
if !os.IsNotExist(statErr) {
|
|
238
|
+
return fmt.Errorf("inspect initialization credentials: %w", statErr)
|
|
239
|
+
}
|
|
240
|
+
complete = false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if complete {
|
|
244
|
+
if err := os.Remove(marker); err != nil && !os.IsNotExist(err) {
|
|
245
|
+
return fmt.Errorf("remove completed initialization marker: %w", err)
|
|
246
|
+
}
|
|
247
|
+
return syncDirectory(p.Root)
|
|
248
|
+
}
|
|
249
|
+
return removeFirstRunArtifacts(p)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func cleanupFirstRunStaging(p paths.Paths) error {
|
|
253
|
+
entries, err := os.ReadDir(filepath.Dir(p.Root))
|
|
254
|
+
if err != nil {
|
|
255
|
+
if os.IsNotExist(err) {
|
|
256
|
+
return nil
|
|
257
|
+
}
|
|
258
|
+
return fmt.Errorf("read initialization staging parent: %w", err)
|
|
259
|
+
}
|
|
260
|
+
for _, entry := range entries {
|
|
261
|
+
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), ".relay-flow-init-") {
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
if err := os.RemoveAll(filepath.Join(filepath.Dir(p.Root), entry.Name())); err != nil {
|
|
265
|
+
return fmt.Errorf("remove stale initialization staging %s: %w", entry.Name(), err)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func promptFirstRunRepositorySetup() (bool, error) {
|
|
272
|
+
register := false
|
|
273
|
+
form := huh.NewForm(huh.NewGroup(
|
|
274
|
+
huh.NewConfirm().
|
|
275
|
+
Title("Register repositories now?").
|
|
276
|
+
Description("You can register repositories later with relay-flow repo register.").
|
|
277
|
+
Affirmative("Yes").
|
|
278
|
+
Negative("No").
|
|
279
|
+
Value(®ister),
|
|
280
|
+
))
|
|
281
|
+
if err := form.Run(); err != nil {
|
|
282
|
+
return false, fmt.Errorf("repository setup prompt: %w", err)
|
|
283
|
+
}
|
|
284
|
+
return register, nil
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// runFirstRunRepositorySetup starts or reuses the server, showing a small
|
|
288
|
+
// terminal spinner while the Unix socket becomes ready, then delegates to the
|
|
289
|
+
// existing interactive repository-registration flow.
|
|
290
|
+
func runFirstRunRepositorySetup(p paths.Paths, stdin io.Reader) error {
|
|
291
|
+
if err := runOnboardingSpinner("Starting relay-flow server", func() error {
|
|
292
|
+
return ensureFirstRunServer(p)
|
|
293
|
+
}); err != nil {
|
|
294
|
+
return err
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if code := firstRunRegistration(server.NewClient(p.Socket), "", "", nil, stdin); code != exitOK {
|
|
298
|
+
return fmt.Errorf("repository registration failed")
|
|
299
|
+
}
|
|
300
|
+
return nil
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func ensureFirstRunServer(p paths.Paths) error {
|
|
304
|
+
client := server.NewClient(p.Socket)
|
|
305
|
+
if serverResponding(client, 200*time.Millisecond) {
|
|
306
|
+
return nil
|
|
307
|
+
}
|
|
308
|
+
if err := startBackgroundServe(p, false, false); err != nil {
|
|
309
|
+
// A server may have become ready between the initial probe and the
|
|
310
|
+
// child launch. Reuse it when that race is harmless.
|
|
311
|
+
if serverResponding(client, 200*time.Millisecond) {
|
|
312
|
+
return nil
|
|
313
|
+
}
|
|
314
|
+
return err
|
|
315
|
+
}
|
|
316
|
+
return nil
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// runOnboardingSpinner keeps the server-readiness wait visible without
|
|
320
|
+
// changing the existing Huh forms or the non-interactive command paths.
|
|
321
|
+
func runOnboardingSpinner(title string, action func() error) error {
|
|
322
|
+
return huhspinner.New().
|
|
323
|
+
Type(huhspinner.Line).
|
|
324
|
+
Title(title + "...").
|
|
325
|
+
Output(os.Stderr).
|
|
326
|
+
ActionWithErr(func(context.Context) error { return action() }).
|
|
327
|
+
Run()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// standaloneRepoServerHint adds the actionable startup hint required for the
|
|
331
|
+
// independent repo-register command while preserving server/API messages for
|
|
332
|
+
// failures that occur after a successful connection.
|
|
333
|
+
func standaloneRepoServerHint(err error) error {
|
|
334
|
+
if err == nil {
|
|
335
|
+
return nil
|
|
336
|
+
}
|
|
337
|
+
message := err.Error()
|
|
338
|
+
for _, marker := range []string{"dial unix", "connect: no such file or directory", "connection refused"} {
|
|
339
|
+
if strings.Contains(message, marker) {
|
|
340
|
+
return fmt.Errorf("%w; start the server with `relay-flow serve --background` (or use the guided init path)", err)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return err
|
|
344
|
+
}
|