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
@@ -0,0 +1,323 @@
1
+ // Package bdcli is the narrow subprocess client for the Beads bd CLI.
2
+ //
3
+ // Beads owns its storage and workspace selection. This package only executes
4
+ // bd commands with JSON output and translates the small portion of that JSON
5
+ // contract needed by the task-system adapter.
6
+ package bdcli
7
+
8
+ import (
9
+ "bytes"
10
+ "context"
11
+ "encoding/json"
12
+ "errors"
13
+ "fmt"
14
+ "io"
15
+ "os"
16
+ "os/exec"
17
+ "strings"
18
+ "sync"
19
+ )
20
+
21
+ // CLI executes bd in one repository and one configured Beads workspace.
22
+ // Commands for a workspace are serialized because a single bd workspace may
23
+ // be backed by an embedded database.
24
+ type CLI struct {
25
+ repoPath string
26
+ beadsDir string
27
+ mu sync.Mutex
28
+ }
29
+
30
+ // New constructs a CLI client for the registered code repository and Beads
31
+ // workspace. The paths are passed to every child process; this function does
32
+ // not initialize or otherwise inspect the workspace.
33
+ func New(repoPath, beadsDir string) *CLI {
34
+ return &CLI{repoPath: repoPath, beadsDir: beadsDir}
35
+ }
36
+
37
+ // Issue contains only the Beads issue fields relay-flow needs.
38
+ type Issue struct {
39
+ ID string `json:"id"`
40
+ Title string `json:"title"`
41
+ Description string `json:"description,omitempty"`
42
+ Status string `json:"status"`
43
+ IssueType string `json:"issue_type"`
44
+ Priority int `json:"priority"`
45
+ Assignee string `json:"assignee,omitempty"`
46
+ Labels []string `json:"labels,omitempty"`
47
+ Parent string `json:"parent,omitempty"`
48
+ }
49
+
50
+ // Comment is a Beads issue comment.
51
+ type Comment struct {
52
+ ID string `json:"id"`
53
+ Text string `json:"text"`
54
+ }
55
+
56
+ // Client is the narrow command seam consumed by the Beads task adapter.
57
+ type Client interface {
58
+ Probe(ctx context.Context) error
59
+
60
+ ListReady(ctx context.Context) ([]Issue, error)
61
+ ListClaimed(ctx context.Context) ([]Issue, error)
62
+
63
+ ListChildren(ctx context.Context, parentID string) ([]Issue, error)
64
+ Show(ctx context.Context, issueID string) (Issue, error)
65
+
66
+ ListComments(ctx context.Context, issueID string) ([]Comment, error)
67
+
68
+ CreateChild(
69
+ ctx context.Context,
70
+ parentID string,
71
+ title string,
72
+ description string,
73
+ label string,
74
+ ) (Issue, error)
75
+
76
+ Update(ctx context.Context, issueID string, input UpdateInput) error
77
+ AddComment(ctx context.Context, issueID, body string) error
78
+ }
79
+
80
+ // UpdateInput describes the supported bd update flags. Description is sent
81
+ // through stdin when non-nil so multiline mailbox descriptions are preserved.
82
+ type UpdateInput struct {
83
+ Description *string
84
+ Status string
85
+ Assignee string
86
+ AddLabels []string
87
+ ClearDefer bool
88
+ }
89
+
90
+ // CommandError reports a bd process that exited unsuccessfully. Stdout and
91
+ // stderr are kept separate because bd may emit useful diagnostics on either
92
+ // stream.
93
+ type CommandError struct {
94
+ Args []string
95
+ ExitCode int
96
+ Stderr string
97
+ Stdout string
98
+
99
+ cause error
100
+ }
101
+
102
+ func (e *CommandError) Error() string {
103
+ if e == nil {
104
+ return "bd command failed"
105
+ }
106
+ message := fmt.Sprintf("bd %v: exit status %d", e.Args, e.ExitCode)
107
+ if stderr := strings.TrimSpace(e.Stderr); stderr != "" {
108
+ message += ": " + stderr
109
+ }
110
+ return message
111
+ }
112
+
113
+ func (e *CommandError) Unwrap() error {
114
+ if e == nil {
115
+ return nil
116
+ }
117
+ return e.cause
118
+ }
119
+
120
+ // Probe performs a harmless bounded read to ensure that the selected bd
121
+ // binary can use the configured workspace.
122
+ func (c *CLI) Probe(ctx context.Context) error {
123
+ var issues []Issue
124
+ return c.runJSON(ctx, []string{"list", "--ready", "--limit", "1", "--no-parent", "--json"}, nil, &issues)
125
+ }
126
+
127
+ // ListReady lists ready top-level issues.
128
+ func (c *CLI) ListReady(ctx context.Context) ([]Issue, error) {
129
+ var issues []Issue
130
+ if err := c.runJSON(ctx, []string{"list", "--ready", "--no-parent", "--limit", "0", "--json"}, nil, &issues); err != nil {
131
+ return nil, err
132
+ }
133
+ return issues, nil
134
+ }
135
+
136
+ // ListClaimed lists active top-level issues carrying a relay-flow workflow
137
+ // claim. The deferred status is intentional: it is part of the selected bd
138
+ // command contract for this adapter.
139
+ func (c *CLI) ListClaimed(ctx context.Context) ([]Issue, error) {
140
+ var issues []Issue
141
+ if err := c.runJSON(ctx, []string{
142
+ "list", "--no-parent", "--status", "open,in_progress,blocked,deferred",
143
+ "--label-pattern", "wf:*", "--limit", "0", "--json",
144
+ }, nil, &issues); err != nil {
145
+ return nil, err
146
+ }
147
+ return issues, nil
148
+ }
149
+
150
+ // ListChildren lists every child issue of parentID, including closed issues.
151
+ func (c *CLI) ListChildren(ctx context.Context, parentID string) ([]Issue, error) {
152
+ var issues []Issue
153
+ if err := c.runJSON(ctx, []string{
154
+ "list", "--parent", parentID, "--all", "--limit", "0", "--json",
155
+ }, nil, &issues); err != nil {
156
+ return nil, err
157
+ }
158
+ return issues, nil
159
+ }
160
+
161
+ // Show reads one issue. The selected bd CLI returns a one-element JSON array
162
+ // for show, so an empty result is treated as an unusable response.
163
+ func (c *CLI) Show(ctx context.Context, issueID string) (Issue, error) {
164
+ var issues []Issue
165
+ if err := c.runJSON(ctx, []string{"show", issueID, "--json"}, nil, &issues); err != nil {
166
+ return Issue{}, err
167
+ }
168
+ if len(issues) == 0 {
169
+ return Issue{}, fmt.Errorf("bd show %q returned no issues", issueID)
170
+ }
171
+ return issues[0], nil
172
+ }
173
+
174
+ // ListComments lists comments for one issue.
175
+ func (c *CLI) ListComments(ctx context.Context, issueID string) ([]Comment, error) {
176
+ var comments []Comment
177
+ if err := c.runJSON(ctx, []string{"comments", issueID, "--json"}, nil, &comments); err != nil {
178
+ return nil, err
179
+ }
180
+ return comments, nil
181
+ }
182
+
183
+ // CreateChild creates a task child under parentID. The description is sent on
184
+ // stdin to preserve multiline text exactly.
185
+ func (c *CLI) CreateChild(ctx context.Context, parentID, title, description, label string) (Issue, error) {
186
+ var issue Issue
187
+ if err := c.runJSON(ctx, []string{
188
+ "create", title,
189
+ "--type", "task",
190
+ "--parent", parentID,
191
+ "--no-inherit-labels",
192
+ "--labels", label,
193
+ "--stdin", "--json",
194
+ }, strings.NewReader(description), &issue); err != nil {
195
+ return Issue{}, err
196
+ }
197
+ return issue, nil
198
+ }
199
+
200
+ // Update applies the requested bd update flags. The method intentionally does
201
+ // not add conditional status flags; status reconciliation belongs to the
202
+ // Beads adapter, which reads the issue before calling Update.
203
+ func (c *CLI) Update(ctx context.Context, issueID string, input UpdateInput) error {
204
+ args := []string{"update", issueID}
205
+ var stdin io.Reader
206
+ if input.Description != nil {
207
+ args = append(args, "--description=-")
208
+ stdin = strings.NewReader(*input.Description)
209
+ }
210
+ if input.Status != "" {
211
+ args = append(args, "--status", input.Status)
212
+ }
213
+ if input.Assignee != "" {
214
+ args = append(args, "--assignee", input.Assignee)
215
+ }
216
+ for _, label := range input.AddLabels {
217
+ args = append(args, "--add-label", label)
218
+ }
219
+ if input.ClearDefer {
220
+ args = append(args, "--defer", "")
221
+ }
222
+ if len(args) == 2 {
223
+ return errors.New("bd update requires at least one update field")
224
+ }
225
+ args = append(args, "--json")
226
+ var response any
227
+ return c.runJSON(ctx, args, stdin, &response)
228
+ }
229
+
230
+ // AddComment writes a comment through stdin so multiline content is not
231
+ // interpreted as shell or command-line text.
232
+ func (c *CLI) AddComment(ctx context.Context, issueID, body string) error {
233
+ var response any
234
+ return c.runJSON(ctx, []string{"comment", issueID, "--stdin", "--json"}, strings.NewReader(body), &response)
235
+ }
236
+
237
+ // run executes one serialized bd command and captures stdout and stderr
238
+ // independently.
239
+ func (c *CLI) run(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) {
240
+ c.mu.Lock()
241
+ defer c.mu.Unlock()
242
+
243
+ cmd := exec.CommandContext(ctx, "bd", args...)
244
+ cmd.Dir = c.repoPath
245
+ cmd.Env = commandEnvironment(c.repoPath, c.beadsDir)
246
+ cmd.Stdin = stdin
247
+
248
+ var stdout, stderr bytes.Buffer
249
+ cmd.Stdout = &stdout
250
+ cmd.Stderr = &stderr
251
+ if err := cmd.Run(); err != nil {
252
+ exitCode := -1
253
+ var exitErr *exec.ExitError
254
+ if errors.As(err, &exitErr) && exitErr.ProcessState != nil {
255
+ exitCode = exitErr.ExitCode()
256
+ }
257
+ return stdout.Bytes(), &CommandError{
258
+ Args: append([]string(nil), args...),
259
+ ExitCode: exitCode,
260
+ Stderr: stderr.String(),
261
+ Stdout: stdout.String(),
262
+ cause: err,
263
+ }
264
+ }
265
+ return stdout.Bytes(), nil
266
+ }
267
+
268
+ // runJSON runs bd and decodes its stdout. Informational text printed before
269
+ // the JSON value is tolerated; stderr remains available only on CommandError
270
+ // and is never treated as JSON input.
271
+ func (c *CLI) runJSON(ctx context.Context, args []string, stdin io.Reader, destination any) error {
272
+ data, err := c.run(ctx, args, stdin)
273
+ if err != nil {
274
+ return err
275
+ }
276
+ return parseJSON(data, destination)
277
+ }
278
+
279
+ // commandEnvironment preserves unrelated process environment while removing
280
+ // ambient Beads selectors that could redirect a child command. The configured
281
+ // workspace is always the sole BEADS_DIR entry.
282
+ func commandEnvironment(_ string, beadsDir string) []string {
283
+ ambient := os.Environ()
284
+ env := make([]string, 0, len(ambient)+1)
285
+ for _, entry := range ambient {
286
+ key, _, ok := strings.Cut(entry, "=")
287
+ if ok && (key == "BEADS_DIR" || key == "BEADS_DB" || key == "BD_DB") {
288
+ continue
289
+ }
290
+ env = append(env, entry)
291
+ }
292
+ env = append(env, "BEADS_DIR="+beadsDir)
293
+ return env
294
+ }
295
+
296
+ // parseJSON decodes the first valid JSON value in data, allowing informational
297
+ // lines before it. This keeps the parser tolerant of bd's human-facing notices
298
+ // without combining stderr with stdout.
299
+ func parseJSON(data []byte, destination any) error {
300
+ var lastErr error
301
+ for offset, value := range data {
302
+ if value != '{' && value != '[' {
303
+ continue
304
+ }
305
+ decoder := json.NewDecoder(bytes.NewReader(data[offset:]))
306
+ var raw json.RawMessage
307
+ if err := decoder.Decode(&raw); err != nil {
308
+ lastErr = err
309
+ continue
310
+ }
311
+ if err := json.Unmarshal(raw, destination); err != nil {
312
+ lastErr = err
313
+ continue
314
+ }
315
+ return nil
316
+ }
317
+ if lastErr == nil {
318
+ lastErr = errors.New("no JSON value found")
319
+ }
320
+ return fmt.Errorf("parse bd JSON: %w", lastErr)
321
+ }
322
+
323
+ var _ Client = (*CLI)(nil)
@@ -0,0 +1,297 @@
1
+ package bdcli
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func TestRunJSONUsesRepoDirectoryAndConfiguredWorkspace(t *testing.T) {
14
+ repoPath, beadsDir := newTestPaths(t)
15
+ installStrictFakeBD(t, repoPath, beadsDir)
16
+
17
+ var got []map[string]any
18
+ cli := New(repoPath, beadsDir)
19
+ if err := cli.runJSON(context.Background(), []string{"array", "--json"}, nil, &got); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if len(got) != 1 || got[0]["id"] != "demo-1" {
23
+ t.Fatalf("decoded array = %#v", got)
24
+ }
25
+ }
26
+
27
+ func TestCLIUsesExactReadCommands(t *testing.T) {
28
+ repoPath, beadsDir := newTestPaths(t)
29
+ installStrictFakeBD(t, repoPath, beadsDir)
30
+ cli := New(repoPath, beadsDir)
31
+ ctx := context.Background()
32
+
33
+ if err := cli.Probe(ctx); err != nil {
34
+ t.Fatalf("Probe: %v", err)
35
+ }
36
+ if issues, err := cli.ListReady(ctx); err != nil || len(issues) != 1 || issues[0].ID != "demo-1" {
37
+ t.Fatalf("ListReady = %#v, %v", issues, err)
38
+ }
39
+ if issues, err := cli.ListClaimed(ctx); err != nil || len(issues) != 1 || issues[0].ID != "demo-1" {
40
+ t.Fatalf("ListClaimed = %#v, %v", issues, err)
41
+ }
42
+ if issues, err := cli.ListChildren(ctx, "demo-parent"); err != nil || len(issues) != 1 || issues[0].Parent != "demo-parent" {
43
+ t.Fatalf("ListChildren = %#v, %v", issues, err)
44
+ }
45
+ if issue, err := cli.Show(ctx, "demo-parent"); err != nil || issue.ID != "demo-parent" {
46
+ t.Fatalf("Show = %#v, %v", issue, err)
47
+ }
48
+ if comments, err := cli.ListComments(ctx, "demo-parent"); err != nil || len(comments) != 1 || comments[0].Text != "existing comment" {
49
+ t.Fatalf("ListComments = %#v, %v", comments, err)
50
+ }
51
+ }
52
+
53
+ func TestCLIUsesExactWriteCommandsAndStdin(t *testing.T) {
54
+ repoPath, beadsDir := newTestPaths(t)
55
+ installStrictFakeBD(t, repoPath, beadsDir)
56
+ cli := New(repoPath, beadsDir)
57
+ ctx := context.Background()
58
+
59
+ if _, err := cli.CreateChild(ctx, "demo-parent", "demo-parent:implement", "mailbox description\nsecond line\n", "wf:implementation"); err != nil {
60
+ t.Fatalf("CreateChild: %v", err)
61
+ }
62
+ description := "reconciled description\nwith two lines\n"
63
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{
64
+ Description: &description,
65
+ AddLabels: []string{"wf:implementation"},
66
+ }); err != nil {
67
+ t.Fatalf("Update description/label: %v", err)
68
+ }
69
+ if err := cli.Update(ctx, "demo-parent", UpdateInput{AddLabels: []string{"wf:implementation"}}); err != nil {
70
+ t.Fatalf("Update claim label: %v", err)
71
+ }
72
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "in_progress"}); err != nil {
73
+ t.Fatalf("Update status: %v", err)
74
+ }
75
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "in_progress", Assignee: "dev@example.com"}); err != nil {
76
+ t.Fatalf("Update status and assignee: %v", err)
77
+ }
78
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Assignee: "dev@example.com"}); err != nil {
79
+ t.Fatalf("Update assignee only: %v", err)
80
+ }
81
+ if err := cli.AddComment(ctx, "demo-parent.1", "summary line\n\nsecond line\n"); err != nil {
82
+ t.Fatalf("AddComment: %v", err)
83
+ }
84
+ if err := cli.Update(ctx, "demo-parent", UpdateInput{Status: "open", ClearDefer: true}); err != nil {
85
+ t.Fatalf("Update recovery reset: %v", err)
86
+ }
87
+ }
88
+
89
+ func TestCLIUsesCloseAndReopenUpdateCommands(t *testing.T) {
90
+ repoPath, beadsDir := newTestPaths(t)
91
+ installStrictFakeBD(t, repoPath, beadsDir)
92
+ cli := New(repoPath, beadsDir)
93
+ ctx := context.Background()
94
+
95
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "closed"}); err != nil {
96
+ t.Fatalf("Close update: %v", err)
97
+ }
98
+ if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "open", ClearDefer: true}); err != nil {
99
+ t.Fatalf("Reopen update: %v", err)
100
+ }
101
+ }
102
+
103
+ func TestStrictFakeBDRejectsUnexpectedArgv(t *testing.T) {
104
+ repoPath, beadsDir := newTestPaths(t)
105
+ fake := installStrictFakeBD(t, repoPath, beadsDir)
106
+
107
+ cmd := exec.Command(fake, "array", "--limit", "1", "--json")
108
+ cmd.Dir = repoPath
109
+ cmd.Env = []string{
110
+ "PATH=" + os.Getenv("PATH"),
111
+ "BD_EXPECT_CWD=" + repoPath,
112
+ "BD_EXPECT_BEADS_DIR=" + beadsDir,
113
+ "BEADS_DIR=" + beadsDir,
114
+ "BD_FIXTURE_DIR=" + os.Getenv("BD_FIXTURE_DIR"),
115
+ }
116
+ if err := cmd.Run(); err == nil {
117
+ t.Fatal("strict fake accepted an unsupported argv")
118
+ }
119
+ }
120
+
121
+ func TestRunJSONDecodesObjectAndAcceptsEmptyArray(t *testing.T) {
122
+ repoPath, beadsDir := newTestPaths(t)
123
+ installStrictFakeBD(t, repoPath, beadsDir)
124
+ cli := New(repoPath, beadsDir)
125
+
126
+ var object map[string]any
127
+ if err := cli.runJSON(context.Background(), []string{"object", "demo-1", "--json"}, nil, &object); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if object["id"] != "demo-1" || object["title"] != "Shown issue" {
131
+ t.Fatalf("decoded object = %#v", object)
132
+ }
133
+
134
+ var empty []map[string]any
135
+ if err := cli.runJSON(context.Background(), []string{"empty", "--json"}, nil, &empty); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ if empty == nil || len(empty) != 0 {
139
+ t.Fatalf("decoded empty array = %#v", empty)
140
+ }
141
+ }
142
+
143
+ func TestRunJSONPreservesMultilineStdin(t *testing.T) {
144
+ repoPath, beadsDir := newTestPaths(t)
145
+ installStrictFakeBD(t, repoPath, beadsDir)
146
+
147
+ var got map[string]any
148
+ cli := New(repoPath, beadsDir)
149
+ stdin := strings.NewReader("first line\nsecond line\n\nfinal line\n")
150
+ if err := cli.runJSON(context.Background(), []string{"stdin", "--json"}, stdin, &got); err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ if got["stdin"] != "accepted" {
154
+ t.Fatalf("decoded stdin response = %#v", got)
155
+ }
156
+ }
157
+
158
+ func TestCommandEnvironmentIsolatesBeadsSelectors(t *testing.T) {
159
+ t.Setenv("BEADS_DIR", "/ambient/workspace")
160
+ t.Setenv("BEADS_DB", "/ambient/beads.db")
161
+ t.Setenv("BD_DB", "/ambient/legacy.db")
162
+ t.Setenv("UNRELATED_ENV", "preserve-me")
163
+
164
+ env := commandEnvironment("/repo", "/configured/workspace")
165
+ values := make(map[string][]string)
166
+ for _, entry := range env {
167
+ key, value, ok := strings.Cut(entry, "=")
168
+ if !ok {
169
+ continue
170
+ }
171
+ values[key] = append(values[key], value)
172
+ }
173
+
174
+ if got := values["BEADS_DIR"]; len(got) != 1 || got[0] != "/configured/workspace" {
175
+ t.Fatalf("BEADS_DIR entries = %#v", got)
176
+ }
177
+ for _, key := range []string{"BEADS_DB", "BD_DB"} {
178
+ if got := values[key]; len(got) != 0 {
179
+ t.Fatalf("ambient %s leaked into command environment: %#v", key, got)
180
+ }
181
+ }
182
+ if got := values["UNRELATED_ENV"]; len(got) != 1 || got[0] != "preserve-me" {
183
+ t.Fatalf("unrelated environment = %#v", got)
184
+ }
185
+ }
186
+
187
+ func TestParseJSONSkipsInformationalOutputBeforeValue(t *testing.T) {
188
+ var got map[string]string
189
+ if err := parseJSON([]byte("bd: using workspace\n{\"id\":\"demo-1\"}\n"), &got); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ if got["id"] != "demo-1" {
193
+ t.Fatalf("parsed value = %#v", got)
194
+ }
195
+ }
196
+
197
+ func TestRunJSONRejectsMalformedOutput(t *testing.T) {
198
+ repoPath, beadsDir := newTestPaths(t)
199
+ installStrictFakeBD(t, repoPath, beadsDir)
200
+
201
+ var got map[string]any
202
+ err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"malformed", "--json"}, nil, &got)
203
+ if err == nil {
204
+ t.Fatal("malformed JSON was accepted")
205
+ }
206
+ var commandErr *CommandError
207
+ if errors.As(err, &commandErr) {
208
+ t.Fatalf("malformed JSON reported as command failure: %#v", commandErr)
209
+ }
210
+ }
211
+
212
+ func TestRunReportsExitCodeStdoutAndStderrSeparately(t *testing.T) {
213
+ repoPath, beadsDir := newTestPaths(t)
214
+ installStrictFakeBD(t, repoPath, beadsDir)
215
+
216
+ _, err := New(repoPath, beadsDir).run(context.Background(), []string{"fail"}, nil)
217
+ if err == nil {
218
+ t.Fatal("ordinary non-zero bd exit was accepted")
219
+ }
220
+ var commandErr *CommandError
221
+ if !errors.As(err, &commandErr) {
222
+ t.Fatalf("error = %T %v, want CommandError", err, err)
223
+ }
224
+ if commandErr.ExitCode != 23 {
225
+ t.Fatalf("exit code = %d, want 23", commandErr.ExitCode)
226
+ }
227
+ if !strings.Contains(commandErr.Stdout, "partial stdout") {
228
+ t.Fatalf("stdout = %q", commandErr.Stdout)
229
+ }
230
+ if !strings.Contains(commandErr.Stderr, "failure detail") {
231
+ t.Fatalf("stderr = %q", commandErr.Stderr)
232
+ }
233
+ if len(commandErr.Args) != 1 || commandErr.Args[0] != "fail" {
234
+ t.Fatalf("command args = %#v", commandErr.Args)
235
+ }
236
+ }
237
+
238
+ func TestRunJSONDoesNotTreatStderrAsJSONInput(t *testing.T) {
239
+ repoPath, beadsDir := newTestPaths(t)
240
+ installStrictFakeBD(t, repoPath, beadsDir)
241
+
242
+ var got map[string]any
243
+ if err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"stderr", "--json"}, nil, &got); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ if got["id"] != "demo-1" {
247
+ t.Fatalf("decoded stderr response = %#v", got)
248
+ }
249
+ }
250
+
251
+ func TestRunJSONToleratesInformationalStdout(t *testing.T) {
252
+ repoPath, beadsDir := newTestPaths(t)
253
+ installStrictFakeBD(t, repoPath, beadsDir)
254
+
255
+ var got map[string]any
256
+ if err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"info", "--json"}, nil, &got); err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ if got["id"] != "demo-1" {
260
+ t.Fatalf("decoded informational response = %#v", got)
261
+ }
262
+ }
263
+
264
+ func newTestPaths(t *testing.T) (repoPath, beadsDir string) {
265
+ t.Helper()
266
+ repoPath = t.TempDir()
267
+ beadsDir = filepath.Join(t.TempDir(), ".beads")
268
+ if err := os.Mkdir(beadsDir, 0o755); err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ return repoPath, beadsDir
272
+ }
273
+
274
+ func installStrictFakeBD(t *testing.T, repoPath, beadsDir string) string {
275
+ t.Helper()
276
+ binDir := t.TempDir()
277
+ fake := filepath.Join(binDir, "bd")
278
+ script, err := os.ReadFile(filepath.Join("testdata", "strict-bd.sh"))
279
+ if err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ if err := os.WriteFile(fake, script, 0o755); err != nil {
283
+ t.Fatal(err)
284
+ }
285
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
286
+ t.Setenv("BD_EXPECT_CWD", repoPath)
287
+ t.Setenv("BD_EXPECT_BEADS_DIR", beadsDir)
288
+ fixtureDir, err := filepath.Abs("testdata")
289
+ if err != nil {
290
+ t.Fatal(err)
291
+ }
292
+ t.Setenv("BD_FIXTURE_DIR", fixtureDir)
293
+ t.Setenv("BEADS_DIR", "/ambient/workspace")
294
+ t.Setenv("BEADS_DB", "/ambient/beads.db")
295
+ t.Setenv("BD_DB", "/ambient/legacy.db")
296
+ return fake
297
+ }
@@ -0,0 +1 @@
1
+ [{"id":"demo-1","title":"Ready issue"}]
@@ -0,0 +1 @@
1
+ [{"id":"demo-parent.1","title":"demo-parent:implement","description":"Existing mailbox","status":"open","issue_type":"task","priority":2,"labels":["wf:implementation"],"parent":"demo-parent"}]
@@ -0,0 +1 @@
1
+ [{"id":"demo-1","title":"Claimed issue","description":"Claimed description","status":"in_progress","issue_type":"epic","priority":1,"labels":["wf:implementation"]}]
@@ -0,0 +1 @@
1
+ {"id":"comment-2","text":"summary line"}
@@ -0,0 +1 @@
1
+ [{"id":"comment-1","text":"existing comment"}]
@@ -0,0 +1 @@
1
+ {"id":"demo-parent.1","title":"demo-parent:implement","description":"mailbox description\nsecond line\n","status":"open","issue_type":"task","priority":2,"labels":["wf:implementation"],"parent":"demo-parent"}
@@ -0,0 +1 @@
1
+ {"id":"demo-1","title":"Shown issue"}
@@ -0,0 +1 @@
1
+ [{"id":"demo-1","title":"Ready issue","description":"Ready description","status":"open","issue_type":"epic","priority":1,"labels":[]}]
@@ -0,0 +1 @@
1
+ [{"id":"demo-parent","title":"Parent issue","description":"Parent description","status":"open","issue_type":"epic","priority":1,"labels":[]}]