relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -114,6 +114,17 @@ func (a *adapter) EnsureEnvironment(ctx context.Context, spec runner.RunSpec) (r
114
114
  return env, err
115
115
  }
116
116
 
117
+ func (a *adapter) SetEnvironmentStatus(ctx context.Context, env runner.Environment, status string) error {
118
+ slog.Debug("orca call", "op", "set-environment-status", "envID", env.ID, "status", status)
119
+ err := a.cli.SetWorktreeStatus(ctx, env.ID, status)
120
+ if err != nil {
121
+ slog.Info("orca outcome", "op", "set-environment-status", "envID", env.ID, "status", status, "result", "error", "error", sanitizeErr(err))
122
+ } else {
123
+ slog.Info("orca outcome", "op", "set-environment-status", "envID", env.ID, "status", status, "result", "ok")
124
+ }
125
+ return err
126
+ }
127
+
117
128
  // ensureEnvironment is the unlogged body factored out so the public method
118
129
  // can emit one outcome line (created/exists/error) without duplicate
119
130
  // logging on the inner re-reads.
@@ -183,26 +194,6 @@ func primaryBranch(w *orcacli.Worktree) string {
183
194
 
184
195
  // --- Terminals ---
185
196
 
186
- // FindTerminal returns the terminal titled exactly title in the environment
187
- // when it is live and usable; stale/disconnected records are treated as
188
- // absent.
189
- func (a *adapter) FindTerminal(ctx context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
190
- slog.Debug("orca call", "op", "find-terminal", "title", title, "envID", env.ID)
191
- terms, err := a.cli.ListTerminals(ctx, "id:"+env.ID)
192
- if err != nil {
193
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
194
- return runner.Terminal{}, false, err
195
- }
196
- for _, t := range terms {
197
- if t.Title == title && t.Connected {
198
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "found")
199
- return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
200
- }
201
- }
202
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "absent")
203
- return runner.Terminal{}, false, nil
204
- }
205
-
206
197
  // CloseTerminal closes one terminal by handle.
207
198
  func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
208
199
  slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
@@ -215,20 +206,29 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
215
206
  return err
216
207
  }
217
208
 
218
- // InspectTerminal addresses a persisted handle directly. Normal execution
219
- // never lists terminals or rediscovers by title.
220
- func (a *adapter) InspectTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
209
+ // FindTerminal addresses a persisted handle directly. Normal execution never
210
+ // lists terminals or rediscovers by title.
211
+ func (a *adapter) FindTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
212
+ slog.Debug("orca call", "op", "find-terminal", "title", terminal.Title, "handle", terminal.ID)
213
+ if terminal.ID == "" {
214
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
215
+ return runner.Terminal{}, false, nil
216
+ }
221
217
  t, err := a.cli.ShowTerminal(ctx, terminal.ID)
222
218
  if errors.Is(err, orcacli.ErrTerminalUnavailable) {
219
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
223
220
  return runner.Terminal{}, false, nil
224
221
  }
225
222
  if err != nil {
223
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
226
224
  return runner.Terminal{}, false, err
227
225
  }
228
226
  if !t.Connected {
227
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
229
228
  return runner.Terminal{}, false, nil
230
229
  }
231
- return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
230
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "found")
231
+ return runner.Terminal{ID: t.Handle, Title: terminal.Title}, true, nil
232
232
  }
233
233
 
234
234
  func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
@@ -242,51 +242,27 @@ func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, ti
242
242
  if err != nil {
243
243
  return runner.Terminal{}, err
244
244
  }
245
- if commandResumesSession(command) {
246
- t, showErr := a.cli.ShowTerminal(ctx, handle)
247
- if errors.Is(showErr, orcacli.ErrTerminalUnavailable) || (showErr == nil && !t.Connected) {
248
- _ = a.cli.CloseTerminal(ctx, handle)
249
- return runner.Terminal{}, runner.ErrSessionUnavailable
250
- }
251
- if showErr != nil {
252
- return runner.Terminal{}, showErr
253
- }
254
- }
255
245
  return runner.Terminal{ID: handle, Title: title}, nil
256
246
  }
257
247
 
258
- func commandResumesSession(command runner.Command) bool {
259
- for i, arg := range command.Args {
260
- if arg == "--session" && i+1 < len(command.Args) && command.Args[i+1] != "" {
261
- return true
262
- }
263
- }
264
- return false
265
- }
266
-
267
- // EnsureTerminal is idempotent: it returns the live terminal with the stable
268
- // title when present, otherwise creates one running command. The title
269
- // contains only <ticket>:<node>; visit metadata lives in the command's
270
- // environment, never the title.
271
- func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
248
+ // EnsureTerminal checks the persisted terminal, then creates a replacement
249
+ // only when that terminal is unavailable. The title is used only for creation.
250
+ func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
272
251
  slog.Debug("orca call", "op", "ensure-terminal", "title", title, "envID", env.ID)
273
- if t, ok, err := a.FindTerminal(ctx, env, title); err != nil {
252
+ if t, ok, err := a.FindTerminal(ctx, stored); err != nil {
274
253
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
275
254
  return runner.Terminal{}, err
276
255
  } else if ok {
277
256
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "exists")
278
257
  return t, nil
279
258
  }
280
- // The worktree display name is the ticket key (EnsureEnvironment names
281
- // it after the ticket).
282
- name := strings.SplitN(title, ":", 2)[0]
283
- handle, err := a.cli.CreateTerminal(ctx, name, title, shellCommand(command))
259
+ terminal, err := a.CreateTerminal(ctx, env, title, command)
284
260
  if err != nil {
285
261
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
286
262
  return runner.Terminal{}, err
287
263
  }
288
264
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "created")
289
- return runner.Terminal{ID: handle, Title: title}, nil
265
+ return terminal, nil
290
266
  }
291
267
 
292
268
  // sanitizeErr strips the leading "orca [args...]:" prefix from orcacli
@@ -41,9 +41,14 @@ func TestSanitizeErr(t *testing.T) {
41
41
  type fakeCLI struct {
42
42
  repos []orcacli.Repo
43
43
  worktrees []orcacli.Worktree
44
+ terminals map[string]orcacli.Terminal
44
45
 
45
46
  createdBaseBranch string
46
47
  createdParent string
48
+ status string
49
+ showHandles []string
50
+ createCommands []string
51
+ createN int
47
52
  }
48
53
 
49
54
  func (f *fakeCLI) ListRepos(context.Context) ([]orcacli.Repo, error) { return f.repos, nil }
@@ -62,16 +67,32 @@ func (f *fakeCLI) CreateWorktree(_ context.Context, ticketKey, repoID, parentWor
62
67
  })
63
68
  return nil
64
69
  }
70
+ func (f *fakeCLI) SetWorktreeStatus(_ context.Context, _, status string) error {
71
+ f.status = status
72
+ return nil
73
+ }
65
74
  func (f *fakeCLI) DeleteWorktree(context.Context, string) error { return nil }
66
- func (f *fakeCLI) ShowTerminal(context.Context, string) (orcacli.Terminal, error) {
67
- return orcacli.Terminal{}, orcacli.ErrTerminalUnavailable
75
+ func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Terminal, error) {
76
+ f.showHandles = append(f.showHandles, handle)
77
+ t, ok := f.terminals[handle]
78
+ if !ok {
79
+ return orcacli.Terminal{}, orcacli.ErrTerminalUnavailable
80
+ }
81
+ return t, nil
68
82
  }
69
83
  func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
70
84
  func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
71
85
  return nil, nil
72
86
  }
73
- func (f *fakeCLI) CreateTerminal(context.Context, string, string, string) (string, error) {
74
- return "", nil
87
+ func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command string) (string, error) {
88
+ f.createN++
89
+ f.createCommands = append(f.createCommands, command)
90
+ handle := "term-created-" + string(rune('0'+f.createN))
91
+ if f.terminals == nil {
92
+ f.terminals = map[string]orcacli.Terminal{}
93
+ }
94
+ f.terminals[handle] = orcacli.Terminal{Handle: handle, Title: title, Connected: true}
95
+ return handle, nil
75
96
  }
76
97
  func (f *fakeCLI) CloseTerminal(context.Context, string) error { return nil }
77
98
 
@@ -122,6 +143,124 @@ func TestEnsureEnvironment_BaseRefOverride(t *testing.T) {
122
143
  }
123
144
  }
124
145
 
146
+ func TestSetEnvironmentStatus(t *testing.T) {
147
+ fx := &fakeCLI{}
148
+ a, err := New(fx, config.RawValues{})
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if err := a.SetEnvironmentStatus(context.Background(), runner.Environment{ID: "wt-PAY-1"}, runner.WorkspaceStatusInReview); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ if fx.status != runner.WorkspaceStatusInReview {
156
+ t.Fatalf("status = %q, want %q", fx.status, runner.WorkspaceStatusInReview)
157
+ }
158
+ }
159
+
160
+ func TestFindTerminalUsesPersistedID(t *testing.T) {
161
+ fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
162
+ "term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
163
+ }}
164
+ a, err := New(fx, config.RawValues{})
165
+ if err != nil {
166
+ t.Fatal(err)
167
+ }
168
+
169
+ got, ok, err := a.FindTerminal(context.Background(), runner.Terminal{ID: "term-stored", Title: "PAY-1:implement"})
170
+ if err != nil || !ok || got.ID != "term-stored" {
171
+ t.Fatalf("FindTerminal = %+v, %v, %v", got, ok, err)
172
+ }
173
+ if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-stored" {
174
+ t.Fatalf("ShowTerminal handles = %v, want [term-stored]", fx.showHandles)
175
+ }
176
+ }
177
+
178
+ func TestFindTerminalReturnsOnlyLiveUsable(t *testing.T) {
179
+ fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
180
+ "term-dead": {Handle: "term-dead", Title: "PAY-1:implement", Connected: false},
181
+ }}
182
+ a, err := New(fx, config.RawValues{})
183
+ if err != nil {
184
+ t.Fatal(err)
185
+ }
186
+
187
+ if _, ok, err := a.FindTerminal(context.Background(), runner.Terminal{}); err != nil || ok {
188
+ t.Fatalf("FindTerminal(empty) ok=%v err=%v, want absent", ok, err)
189
+ }
190
+ if _, ok, err := a.FindTerminal(context.Background(), runner.Terminal{ID: "term-dead"}); err != nil || ok {
191
+ t.Fatalf("FindTerminal(dead) ok=%v err=%v, want absent", ok, err)
192
+ }
193
+ if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-dead" {
194
+ t.Fatalf("ShowTerminal handles = %v, want [term-dead]", fx.showHandles)
195
+ }
196
+ }
197
+
198
+ func TestEnsureTerminalFindsBeforeCreate(t *testing.T) {
199
+ fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
200
+ "term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
201
+ }}
202
+ a, err := New(fx, config.RawValues{})
203
+ if err != nil {
204
+ t.Fatal(err)
205
+ }
206
+ stored := runner.Terminal{ID: "term-stored", Title: "PAY-1:implement"}
207
+
208
+ got, err := a.EnsureTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, stored, "PAY-1:implement", runner.Command{Executable: "opencode"})
209
+ if err != nil {
210
+ t.Fatal(err)
211
+ }
212
+ if got.ID != stored.ID || fx.createN != 0 {
213
+ t.Fatalf("EnsureTerminal = %+v, creates=%d; want stored terminal and no create", got, fx.createN)
214
+ }
215
+ }
216
+
217
+ func TestEnsureTerminalCreatesWhenStoredTerminalUnavailable(t *testing.T) {
218
+ fx := &fakeCLI{}
219
+ a, err := New(fx, config.RawValues{})
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ command := runner.Command{Executable: "custom-harness", Args: []string{"--session", "opaque", "--prompt", "work", "--agent", "review"}}
224
+
225
+ got, err := a.EnsureTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, runner.Terminal{ID: "term-stale"}, "PAY-1:implement", command)
226
+ if err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ if got.ID != "term-created-1" || fx.createN != 1 {
230
+ t.Fatalf("EnsureTerminal = %+v, creates=%d", got, fx.createN)
231
+ }
232
+ if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-stale" {
233
+ t.Fatalf("ShowTerminal handles = %v, want [term-stale]", fx.showHandles)
234
+ }
235
+ if len(fx.createCommands) != 1 || fx.createCommands[0] != shellCommand(command) {
236
+ t.Fatalf("CreateTerminal commands = %v, want opaque command unchanged", fx.createCommands)
237
+ }
238
+ }
239
+
240
+ func TestCreateTerminalAlwaysCreatesAndTreatsCommandAsOpaque(t *testing.T) {
241
+ fx := &fakeCLI{}
242
+ a, err := New(fx, config.RawValues{})
243
+ if err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ command := runner.Command{Executable: "custom-harness", Args: []string{"--session", "opaque"}}
247
+
248
+ first, err := a.CreateTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, "PAY-1:implement", command)
249
+ if err != nil {
250
+ t.Fatal(err)
251
+ }
252
+ second, err := a.CreateTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, "PAY-1:implement", command)
253
+ if err != nil {
254
+ t.Fatal(err)
255
+ }
256
+ if first.ID == second.ID || fx.createN != 2 {
257
+ t.Fatalf("CreateTerminal IDs = %q, %q; creates=%d", first.ID, second.ID, fx.createN)
258
+ }
259
+ if len(fx.showHandles) != 0 {
260
+ t.Fatalf("CreateTerminal parsed resume syntax and inspected terminals: %v", fx.showHandles)
261
+ }
262
+ }
263
+
125
264
  // An existing ticket branch must win even over a configured baseRef. Passing
126
265
  // any other base would make Orca hit its branch-name collision behavior.
127
266
  func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {
@@ -45,6 +45,7 @@ type Client interface {
45
45
  ListRepos(ctx context.Context) ([]Repo, error)
46
46
  ListWorktrees(ctx context.Context) ([]Worktree, error)
47
47
  CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error
48
+ SetWorktreeStatus(ctx context.Context, worktreeID, status string) error
48
49
  DeleteWorktree(ctx context.Context, worktreeID string) error
49
50
  ShowTerminal(ctx context.Context, handle string) (Terminal, error)
50
51
  SendTerminal(ctx context.Context, handle, text string) error
@@ -90,6 +91,10 @@ func (CLI) CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktree
90
91
  "--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
91
92
  }
92
93
 
94
+ func (CLI) SetWorktreeStatus(ctx context.Context, worktreeID, status string) error {
95
+ return run(ctx, "worktree", "set", "--worktree", "id:"+worktreeID, "--workspace-status", status, "--json")
96
+ }
97
+
93
98
  // FindExistingBranch returns the first local or remote-tracking branch whose
94
99
  // short ref contains ticketKey. The returned ref is suitable for Orca's
95
100
  // --base-branch argument.
@@ -33,6 +33,9 @@ func TestCLIContractsAgainstCapturedRealOutput(t *testing.T) {
33
33
  if err := cli.CreateWorktree(ctx, "PAY-101", "repo-1", "wt-main", "origin/alice/PAY-101"); err != nil {
34
34
  t.Fatal(err)
35
35
  }
36
+ if err := cli.SetWorktreeStatus(ctx, "wt-PAY-101", "in-review"); err != nil {
37
+ t.Fatal(err)
38
+ }
36
39
  if err := cli.DeleteWorktree(ctx, "wt-PAY-101"); err != nil {
37
40
  t.Fatal(err)
38
41
  }
@@ -8,6 +8,8 @@ elif [ "$#" -eq 3 ] && [ "$1" = worktree ] && [ "$2" = list ] && [ "$3" = --json
8
8
  fixture=worktree-list.json
9
9
  elif [ "$#" -eq 11 ] && [ "$1" = worktree ] && [ "$2" = create ] && [ "$3" = --name ] && [ "$4" = PAY-101 ] && [ "$5" = --repo ] && [ "$6" = id:repo-1 ] && [ "$7" = --parent-worktree ] && [ "$8" = worktree:wt-main ] && [ "$9" = --base-branch ] && [ "${10}" = origin/alice/PAY-101 ] && [ "${11}" = --json ]; then
10
10
  fixture=worktree-create.json
11
+ elif [ "$#" -eq 7 ] && [ "$1" = worktree ] && [ "$2" = set ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --workspace-status ] && [ "$6" = in-review ] && [ "$7" = --json ]; then
12
+ fixture=worktree-create.json
11
13
  elif [ "$#" -eq 5 ] && [ "$1" = worktree ] && [ "$2" = rm ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --json ]; then
12
14
  fixture=worktree-remove.json
13
15
  elif [ "$#" -eq 6 ] && [ "$1" = terminal ] && [ "$2" = list ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --include-visual-layouts ] && [ "$6" = --json ]; then
@@ -13,6 +13,12 @@ import (
13
13
 
14
14
  var ErrSessionUnavailable = errors.New("stored session unavailable")
15
15
 
16
+ const (
17
+ WorkspaceStatusInProgress = "in-progress"
18
+ WorkspaceStatusInReview = "in-review"
19
+ WorkspaceStatusCompleted = "completed"
20
+ )
21
+
16
22
  type RepoCandidate struct {
17
23
  Name string `json:"name"`
18
24
  Path string `json:"path"`
@@ -42,10 +48,11 @@ type RunSpec struct {
42
48
  }
43
49
 
44
50
  // Runner owns ticket-scoped environments, terminals, and liveness.
45
- // FindTerminal returns only a live usable terminal. CloseTerminals closes
46
- // agent terminals while preserving the environment/workspace; CleanupRun
47
- // removes all runner-owned run resources at end. Both reconstruct identity
48
- // from RunSpec without SQLite IDs.
51
+ // FindTerminal checks a persisted terminal handle against the external runner
52
+ // and returns only a live usable terminal. CloseTerminals closes agent terminals
53
+ // while preserving the environment/workspace; CleanupRun removes all
54
+ // runner-owned run resources at end. Both reconstruct identity from RunSpec
55
+ // without SQLite IDs.
49
56
  //
50
57
  // Terminal titles are stable and minimal: only "<ticket>:<node>" — never
51
58
  // nodeVisitID, workflow name, agent name, or other changing metadata.
@@ -53,12 +60,12 @@ type Runner interface {
53
60
  DiscoverRepos(ctx context.Context) ([]RepoCandidate, error)
54
61
  ValidateRepo(ctx context.Context, name, path string) error
55
62
  EnsureEnvironment(ctx context.Context, spec RunSpec) (Environment, error)
56
- InspectTerminal(ctx context.Context, terminal Terminal) (Terminal, bool, error)
57
- SendTerminal(ctx context.Context, terminal Terminal, text string) error
63
+ SetEnvironmentStatus(ctx context.Context, env Environment, status string) error
64
+ FindTerminal(ctx context.Context, terminal Terminal) (Terminal, bool, error)
58
65
  CreateTerminal(ctx context.Context, env Environment, title string, command Command) (Terminal, error)
59
- FindTerminal(ctx context.Context, env Environment, title string) (Terminal, bool, error)
66
+ EnsureTerminal(ctx context.Context, env Environment, terminal Terminal, title string, command Command) (Terminal, error)
67
+ SendTerminal(ctx context.Context, terminal Terminal, text string) error
60
68
  CloseTerminal(ctx context.Context, terminal Terminal) error
61
- EnsureTerminal(ctx context.Context, env Environment, title string, command Command) (Terminal, error)
62
69
  CloseTerminals(ctx context.Context, spec RunSpec) error
63
70
  CleanupRun(ctx context.Context, spec RunSpec) error
64
71
  }
@@ -0,0 +1,48 @@
1
+ package task_test
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/task"
14
+ )
15
+
16
+ const alternateAuthPlugin = "alternate-auth-format-test"
17
+
18
+ var registerAlternateAuth sync.Once
19
+
20
+ func TestAlternativeTaskPluginOwnsCredentialsFormat(t *testing.T) {
21
+ registerAlternateAuth.Do(func() {
22
+ task.Register(alternateAuthPlugin, task.Factory{
23
+ RequiredRepoKeys: func() []string { return nil },
24
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "alternate", nil },
25
+ Auth: func(_ context.Context, _ []string, stdin io.Reader) error {
26
+ key, err := io.ReadAll(stdin)
27
+ if err != nil {
28
+ return err
29
+ }
30
+ return os.WriteFile(filepath.Join(os.Getenv("RELAY_FLOW_HOME"), "credentials.yaml"),
31
+ []byte("alternateApiKey: "+strings.TrimSpace(string(key))+"\n"), 0o600)
32
+ },
33
+ New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
34
+ })
35
+ })
36
+ root := t.TempDir()
37
+ t.Setenv("RELAY_FLOW_HOME", root)
38
+ if err := task.Auth(context.Background(), alternateAuthPlugin, nil, strings.NewReader("different-secret")); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ raw, err := os.ReadFile(filepath.Join(root, "credentials.yaml"))
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if string(raw) != "alternateApiKey: different-secret\n" {
46
+ t.Fatalf("alternate credentials = %q", raw)
47
+ }
48
+ }