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.
Files changed (52) hide show
  1. package/README.md +76 -37
  2. package/cmd/relay-flow/commands_test.go +199 -19
  3. package/cmd/relay-flow/main.go +183 -144
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +454 -0
  7. package/cmd/relay-flow/serve.go +5 -2
  8. package/examples/config-reference.yaml +7 -0
  9. package/go.mod +12 -8
  10. package/go.sum +24 -17
  11. package/internal/execution/goworkflows/engine_test.go +3 -3
  12. package/internal/harness/opencode/opencode_test.go +4 -4
  13. package/internal/harness/opencode/repo_setup.go +1 -1
  14. package/internal/harness/pi/prompt_test.go +3 -3
  15. package/internal/repo/service.go +47 -3
  16. package/internal/repo/service_test.go +36 -1
  17. package/internal/runner/herdr/herdr.go +103 -4
  18. package/internal/runner/herdr/herdr_test.go +72 -2
  19. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  20. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  21. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  22. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  23. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  24. package/internal/runner/orca/orca.go +68 -9
  25. package/internal/runner/orca/orca_test.go +134 -2
  26. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  27. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  28. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  29. package/internal/runner/runner.go +9 -0
  30. package/internal/server/api_test.go +65 -0
  31. package/internal/server/client.go +40 -2
  32. package/internal/server/fixture_test.go +27 -16
  33. package/internal/server/server.go +80 -4
  34. package/internal/task/beads/beads.go +7 -2
  35. package/internal/task/beads/beads_test.go +13 -0
  36. package/internal/task/factory.go +9 -7
  37. package/internal/task/jira/auth_test.go +9 -1
  38. package/internal/task/jira/filters_test.go +2 -2
  39. package/internal/task/jira/helpers_test.go +13 -0
  40. package/internal/task/jira/jira.go +233 -44
  41. package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
  42. package/internal/task/jira/rest/client.go +73 -31
  43. package/internal/task/jira/rest/client_test.go +25 -0
  44. package/internal/task/jira/status_defaults_test.go +134 -0
  45. package/internal/task/jira/templates_test.go +2 -2
  46. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  47. package/internal/task/jira/transition_defaults_test.go +8 -12
  48. package/internal/task/jira/validation_test.go +3 -1
  49. package/internal/task/registration.go +59 -0
  50. package/internal/workflow/workflow.go +4 -1
  51. package/internal/workflow/workflow_test.go +4 -4
  52. 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(&register),
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
+ }