relay-flow 0.2.5-alpha → 0.2.7-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.
@@ -9,6 +9,7 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
+ "github.com/rajpopat27/relay-flow/internal/config"
12
13
  "github.com/rajpopat27/relay-flow/internal/harness"
13
14
  "github.com/rajpopat27/relay-flow/internal/repo"
14
15
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -210,7 +211,7 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
210
211
  }); err != nil {
211
212
  t.Fatal(err)
212
213
  }
213
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
214
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
214
215
  nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-101"}}, Node: "implement", NodeVisitID: "visit-2"}
215
216
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-2", RepoName: "payments", Workflow: "basic", Ticket: "PAY-101", Node: "implement", Agent: "build", Title: "PAY-101:implement", Prompt: "work", NudgePrompt: "custom instructions"}
216
217
  if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{NodeVisitID: "visit-2"}); err != nil {
@@ -259,8 +260,8 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
259
260
  }
260
261
  fh := &runtimeTestHarness{}
261
262
  fr := &runtimeTestRunner{}
262
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
263
- nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
263
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
264
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments"}, Node: "implement", NodeVisitID: "visit-first"}
264
265
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
265
266
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
266
267
  t.Fatal(err)
@@ -273,6 +274,63 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
273
274
  }
274
275
  }
275
276
 
277
+ // TestEnsureNodeRuntimeSuppliesTaskSystemAgentEnv asserts the repo's task
278
+ // system supplies the agent workspace environment at launch time, so the
279
+ // harness/runner command addresses the same workspace as relay-flow. Adapters
280
+ // without the optional capability supply nothing.
281
+ func TestEnsureNodeRuntimeSuppliesTaskSystemAgentEnv(t *testing.T) {
282
+ ctx := context.Background()
283
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
284
+ defer db.Close()
285
+ p := &RunProjection{DB: db}
286
+ if err := p.migrate(); err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ id := run.ID("payments/basic/PAY-105")
290
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "5", Key: "PAY-105"}}, time.Now().UTC()); err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-first"); err != nil {
294
+ t.Fatal(err)
295
+ }
296
+ want := map[string]string{"BEADS_DIR": "/var/lib/beads/payments/.beads", "BEADS_DB": "", "BD_DB": ""}
297
+ fh := &runtimeTestHarness{}
298
+ a := &Activities{
299
+ Repos: runtimeTestRepos(&runtimeTestTaskSystem{agentEnv: want}),
300
+ Runner: &runtimeTestRunner{},
301
+ Harness: fh,
302
+ Runs: p,
303
+ }
304
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-105"}}, Node: "implement", NodeVisitID: "visit-first"}
305
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", RepoName: "payments", Node: "implement", Agent: "build", Title: "PAY-105:implement"}
306
+ if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{}); err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ if len(fh.taskEnvs) != 1 || !reflect.DeepEqual(fh.taskEnvs[0], want) {
310
+ t.Fatalf("launch TaskEnv = %#v, want [%#v]", fh.taskEnvs, want)
311
+ }
312
+
313
+ // An adapter without the optional capability supplies no environment.
314
+ plain := &runtimeTestHarness{}
315
+ b := &Activities{
316
+ Repos: runtimeTestRepos(&runtimeTestPlainTaskSystem{System: &runtimeTestTaskSystem{}}),
317
+ Runner: &runtimeTestRunner{},
318
+ Harness: plain,
319
+ Runs: p,
320
+ }
321
+ if err := p.updateNodeRuntimeVisit(ctx, id, "review", "visit-review"); err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ nw.Node, nw.NodeVisitID = "review", "visit-review"
325
+ spec.Node, spec.NodeVisitID, spec.Title = "review", "visit-review", "PAY-105:review"
326
+ if err := b.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{}); err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ if len(plain.taskEnvs) != 1 || plain.taskEnvs[0] != nil {
330
+ t.Fatalf("TaskEnv without a configured workspace = %#v, want [nil]", plain.taskEnvs)
331
+ }
332
+ }
333
+
276
334
  func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
277
335
  ctx := context.Background()
278
336
  db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
@@ -293,7 +351,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
293
351
  }
294
352
  fr := &runtimeTestRunner{live: true, sendErr: errors.New("send failed")}
295
353
  fh := &runtimeTestHarness{}
296
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
354
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
297
355
  nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-102"}}, Node: "implement", NodeVisitID: "visit-new", Mailbox: task.Mailbox{Key: "PAY-234", Node: "implement"}}
298
356
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-new", Node: "implement", Agent: "build", Title: "PAY-102:implement", Prompt: "work", NudgePrompt: "Read the latest review feedback."}
299
357
  if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live-old", SessionID: "session-old", NodeVisitID: "visit-old"}); err != nil {
@@ -499,6 +557,7 @@ type runtimeTestHarness struct {
499
557
  prompts []string
500
558
  resumeIDs []string
501
559
  rendered []harness.PromptKind
560
+ taskEnvs []map[string]string
502
561
  }
503
562
 
504
563
  func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
@@ -522,9 +581,59 @@ func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Comma
522
581
  h.buildCalls++
523
582
  h.prompts = append(h.prompts, spec.Prompt)
524
583
  h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
584
+ h.taskEnvs = append(h.taskEnvs, spec.TaskEnv)
525
585
  return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
526
586
  }
527
587
 
588
+ // runtimeTestTaskSystem is a repo-bound task system stub. Only the optional
589
+ // task.AgentEnvironment capability is exercised here; every other method is an
590
+ // unused stub for the node runtime tests.
591
+ type runtimeTestTaskSystem struct {
592
+ agentEnv map[string]string
593
+ }
594
+
595
+ func (s *runtimeTestTaskSystem) AgentEnv() map[string]string { return s.agentEnv }
596
+
597
+ func (*runtimeTestTaskSystem) Poll(context.Context) ([]task.Ticket, error) { return nil, nil }
598
+ func (*runtimeTestTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
599
+ return func(task.Ticket) bool { return true }, nil
600
+ }
601
+ func (*runtimeTestTaskSystem) Claim(context.Context, task.TicketRef, string) error { return nil }
602
+ func (*runtimeTestTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
603
+ return nil
604
+ }
605
+ func (*runtimeTestTaskSystem) RenderText(task.TextKind, task.TextData) (string, error) {
606
+ return "", nil
607
+ }
608
+ func (*runtimeTestTaskSystem) EnsureMailboxes(context.Context, task.TicketRef, string, []task.MailboxSpec) (map[string]task.Mailbox, error) {
609
+ return map[string]task.Mailbox{}, nil
610
+ }
611
+ func (*runtimeTestTaskSystem) ApplyTaskConfig(context.Context, task.Target, config.RawValues) error {
612
+ return nil
613
+ }
614
+ func (*runtimeTestTaskSystem) CompleteMailbox(context.Context, task.Mailbox) error { return nil }
615
+ func (*runtimeTestTaskSystem) HasComment(context.Context, task.Target, string) (bool, error) {
616
+ return false, nil
617
+ }
618
+ func (*runtimeTestTaskSystem) Comment(context.Context, task.Target, string, string) error {
619
+ return nil
620
+ }
621
+ func (*runtimeTestTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
622
+ return nil
623
+ }
624
+
625
+ // runtimeTestPlainTaskSystem forwards the task.System contract without
626
+ // exposing the optional AgentEnvironment capability.
627
+ type runtimeTestPlainTaskSystem struct {
628
+ task.System
629
+ }
630
+
631
+ func runtimeTestRepos(sys task.System) *repo.Registry {
632
+ reg := &repo.Registry{}
633
+ reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
634
+ return reg
635
+ }
636
+
528
637
  func openProjectionDB(t *testing.T, path string) *sql.DB {
529
638
  t.Helper()
530
639
  db, err := sql.Open("sqlite", path)
@@ -40,6 +40,21 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
40
40
  return rp.TaskSystem, nil
41
41
  }
42
42
 
43
+ // agentEnv returns the repo task system's agent workspace environment, or nil
44
+ // when the adapter exposes none. It is resolved at launch time so no workspace
45
+ // value is carried in durable workflow history.
46
+ func (a *Activities) agentEnv(repoName string) (map[string]string, error) {
47
+ sys, err := a.taskSystem(repoName)
48
+ if err != nil {
49
+ return nil, err
50
+ }
51
+ provider, ok := sys.(task.AgentEnvironment)
52
+ if !ok {
53
+ return nil, nil
54
+ }
55
+ return provider.AgentEnv(), nil
56
+ }
57
+
43
58
  func (a *Activities) runSpec(w run.Work) runner.RunSpec {
44
59
  return runner.RunSpec{
45
60
  RunID: w.RunID,
@@ -228,6 +243,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
228
243
  if err != nil {
229
244
  return NodeRuntime{}, err
230
245
  }
246
+ spec.TaskEnv, err = a.agentEnv(nw.Repo)
247
+ if err != nil {
248
+ return NodeRuntime{}, err
249
+ }
231
250
  cmd, err := a.Harness.BuildCommand(spec)
232
251
  if err != nil {
233
252
  return NodeRuntime{}, err
@@ -56,6 +56,11 @@ type LaunchSpec struct {
56
56
  PromptData PromptData
57
57
  NextSteps []workflow.Route
58
58
  ResumeID string
59
+ // TaskEnv is the task-system-owned workspace environment for the agent
60
+ // process, supplied by the repo's task adapter. The harness adds it to the
61
+ // launch command environment without interpreting it; relay-flow's own
62
+ // variables always win on conflict.
63
+ TaskEnv map[string]string
59
64
  }
60
65
 
61
66
  type Harness interface {
@@ -3,7 +3,8 @@
3
3
  // their stable <ticket>:<node> title, and builds the structured command
4
4
  // the runner executes. The OpenCode runtime plugin (TypeScript, under
5
5
  // plugin/) owns message parsing, title pinning, nudge-on-invalid for
6
- // agent nodes, silence for HITL, and exact-report JSON retry with the
6
+ // agent nodes and for non-empty invalid HITL output, silence for missing
7
+ // or aborted HITL output, and exact-report JSON retry with the
7
8
  // shared backoff constants mirrored in TypeScript.
8
9
  package opencode
9
10
 
@@ -186,7 +187,13 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
186
187
  if err != nil {
187
188
  return runner.Command{}, err
188
189
  }
189
- env := map[string]string{
190
+ env := map[string]string{}
191
+ // Task-system workspace values are applied first so relay-flow's own
192
+ // variables always win on conflict.
193
+ for key, value := range spec.TaskEnv {
194
+ env[key] = value
195
+ }
196
+ for key, value := range map[string]string{
190
197
  "RELAY_FLOW_HOME": root,
191
198
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
192
199
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -196,6 +203,8 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
196
203
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
197
204
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
198
205
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
206
+ } {
207
+ env[key] = value
199
208
  }
200
209
  args := []string{}
201
210
  if spec.ResumeID != "" {
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.2.5-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.2.7-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.5-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.7-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -0,0 +1,57 @@
1
+ package opencode_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/harness"
7
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
8
+ )
9
+
10
+ // TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
11
+ // workspace environment reaches the runner command so agent task commands
12
+ // address the same workspace as relay-flow, and that relay-flow's own
13
+ // variables cannot be overwritten by the task system.
14
+ func TestBuildCommandCarriesTaskSystemEnvironment(t *testing.T) {
15
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
16
+
17
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
18
+ Agent: "build",
19
+ Node: "implement",
20
+ Prompt: "implement the ticket",
21
+ TaskEnv: map[string]string{
22
+ "BEADS_DIR": "/var/lib/beads/payments/.beads",
23
+ "BEADS_DB": "",
24
+ "BD_DB": "",
25
+ "RELAY_FLOW_NODE": "hijacked",
26
+ },
27
+ })
28
+ if err != nil {
29
+ t.Fatalf("BuildCommand: %v", err)
30
+ }
31
+ if cmd.Env["BEADS_DIR"] != "/var/lib/beads/payments/.beads" {
32
+ t.Fatalf("BEADS_DIR = %q", cmd.Env["BEADS_DIR"])
33
+ }
34
+ for _, key := range []string{"BEADS_DB", "BD_DB"} {
35
+ value, ok := cmd.Env[key]
36
+ if !ok || value != "" {
37
+ t.Fatalf("%s = %q, present=%v; want empty selector", key, value, ok)
38
+ }
39
+ }
40
+ if cmd.Env["RELAY_FLOW_NODE"] != "implement" {
41
+ t.Fatalf("RELAY_FLOW_NODE = %q, want implement", cmd.Env["RELAY_FLOW_NODE"])
42
+ }
43
+ }
44
+
45
+ // TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly asserts an adapter
46
+ // that exposes no workspace environment adds nothing to the command.
47
+ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
48
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
49
+
50
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{Agent: "build", Prompt: "work"})
51
+ if err != nil {
52
+ t.Fatalf("BuildCommand: %v", err)
53
+ }
54
+ if len(cmd.Env) != 9 {
55
+ t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
56
+ }
57
+ }
@@ -189,7 +189,13 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
189
189
  if err != nil {
190
190
  return runner.Command{}, err
191
191
  }
192
- env := map[string]string{
192
+ env := map[string]string{}
193
+ // Task-system workspace values are applied first so relay-flow's own
194
+ // variables always win on conflict.
195
+ for key, value := range spec.TaskEnv {
196
+ env[key] = value
197
+ }
198
+ for key, value := range map[string]string{
193
199
  "RELAY_FLOW_HOME": root,
194
200
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
195
201
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -199,6 +205,8 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
199
205
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
200
206
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
201
207
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
208
+ } {
209
+ env[key] = value
202
210
  }
203
211
  args := []string{"--name", spec.Title}
204
212
  if spec.Agent != "default" {
@@ -0,0 +1,51 @@
1
+ package pi
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ // TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
8
+ // workspace environment reaches the runner command so agent task commands
9
+ // address the same workspace as relay-flow, and that relay-flow's own
10
+ // variables cannot be overwritten by the task system.
11
+ func TestBuildCommandCarriesTaskSystemEnvironment(t *testing.T) {
12
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
13
+
14
+ spec := launchSpec(t)
15
+ spec.TaskEnv = map[string]string{
16
+ "BEADS_DIR": "/var/lib/beads/payments/.beads",
17
+ "BEADS_DB": "",
18
+ "BD_DB": "",
19
+ "RELAY_FLOW_NODE": "hijacked",
20
+ }
21
+ cmd, err := (&Harness{}).BuildCommand(spec)
22
+ if err != nil {
23
+ t.Fatalf("BuildCommand: %v", err)
24
+ }
25
+ if cmd.Env["BEADS_DIR"] != "/var/lib/beads/payments/.beads" {
26
+ t.Fatalf("BEADS_DIR = %q", cmd.Env["BEADS_DIR"])
27
+ }
28
+ for _, key := range []string{"BEADS_DB", "BD_DB"} {
29
+ value, ok := cmd.Env[key]
30
+ if !ok || value != "" {
31
+ t.Fatalf("%s = %q, present=%v; want empty selector", key, value, ok)
32
+ }
33
+ }
34
+ if cmd.Env["RELAY_FLOW_NODE"] != spec.Node {
35
+ t.Fatalf("RELAY_FLOW_NODE = %q, want %q", cmd.Env["RELAY_FLOW_NODE"], spec.Node)
36
+ }
37
+ }
38
+
39
+ // TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly asserts an adapter
40
+ // that exposes no workspace environment adds nothing to the command.
41
+ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
42
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
43
+
44
+ cmd, err := (&Harness{}).BuildCommand(launchSpec(t))
45
+ if err != nil {
46
+ t.Fatalf("BuildCommand: %v", err)
47
+ }
48
+ if len(cmd.Env) != 9 {
49
+ t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
50
+ }
51
+ }
@@ -86,8 +86,8 @@ type RegisterInput struct {
86
86
  }
87
87
 
88
88
  // Register validates the runner repo, required task values, task-system
89
- // connectivity, duplicate names, canonical paths, and duplicate canonical
90
- // task scope before atomically writing machine config.
89
+ // connectivity, duplicate names, canonical paths, and the task plugin's
90
+ // registration identity before atomically writing machine config.
91
91
  func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, error) {
92
92
  if input.Name == "" {
93
93
  return Info{}, fmt.Errorf("repo: name is required")
@@ -116,19 +116,29 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
116
116
  return Info{}, fmt.Errorf("repo %q: required task config key %q is missing", input.Name, k)
117
117
  }
118
118
  }
119
- // Duplicate canonical task scope: compute this repo's scope and compare
120
- // against every registered repo's scope.
121
- newScope, err := task.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, input.TaskConfig)
119
+ // Duplicate registration identity: compute this repo's identity and compare
120
+ // against every registered repo. Most plugins use their physical task scope;
121
+ // Beads includes its derived repository label so distinct repositories may
122
+ // share one workspace safely.
123
+ newScope, err := task.RegistrationKey(s.taskPlugin, task.RepoRegistrationSpec{
124
+ Name: input.Name,
125
+ RootConfig: cfg.TaskConfig,
126
+ RepoConfig: input.TaskConfig,
127
+ })
122
128
  if err != nil {
123
129
  return Info{}, fmt.Errorf("repo %q: %w", input.Name, err)
124
130
  }
125
131
  for name, r := range cfg.Repos {
126
- scope, err := task.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, r.TaskConfig)
132
+ scope, err := task.RegistrationKey(s.taskPlugin, task.RepoRegistrationSpec{
133
+ Name: name,
134
+ RootConfig: cfg.TaskConfig,
135
+ RepoConfig: r.TaskConfig,
136
+ })
127
137
  if err != nil {
128
- return Info{}, fmt.Errorf("repo %q: derive scope of registered repo %q: %w", input.Name, name, err)
138
+ return Info{}, fmt.Errorf("repo %q: derive registration identity of registered repo %q: %w", input.Name, name, err)
129
139
  }
130
140
  if scope == newScope {
131
- return Info{}, fmt.Errorf("repo %q: task scope already used by repo %q", input.Name, name)
141
+ return Info{}, fmt.Errorf("repo %q: task registration identity already used by repo %q", input.Name, name)
132
142
  }
133
143
  }
134
144
  // Runner validates the repo.
@@ -0,0 +1,52 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "reflect"
8
+ "testing"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ )
13
+
14
+ // TestAgentEnvSelectsRegisteredWorkspace asserts that a repo-bound Beads
15
+ // system exposes the configured workspace to agent processes and neutralizes
16
+ // ambient Beads selectors, mirroring the adapter's own child-command
17
+ // isolation.
18
+ func TestAgentEnvSelectsRegisteredWorkspace(t *testing.T) {
19
+ installRepoCompositionFakeBD(t)
20
+ root := t.TempDir()
21
+ codePath := filepath.Join(root, "code", "payments")
22
+ beadsDir := filepath.Join(root, "beads", "payments", ".beads")
23
+ for _, path := range []string{codePath, beadsDir} {
24
+ if err := os.MkdirAll(path, 0o755); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ }
28
+
29
+ sys, err := newSystem(context.Background(), task.RepoSpec{
30
+ Name: "payments", Path: codePath,
31
+ RepoConfig: config.RawValues{"beadsDir": beadsDir},
32
+ })
33
+ if err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ provider, ok := sys.(task.AgentEnvironment)
37
+ if !ok {
38
+ t.Fatal("Beads system does not expose task.AgentEnvironment")
39
+ }
40
+ canonical, err := canonicalBeadsDir(beadsDir)
41
+ if err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ want := map[string]string{
45
+ "BEADS_DIR": canonical,
46
+ "BEADS_DB": "",
47
+ "BD_DB": "",
48
+ }
49
+ if got := provider.AgentEnv(); !reflect.DeepEqual(got, want) {
50
+ t.Fatalf("AgentEnv() = %#v, want %#v", got, want)
51
+ }
52
+ }
@@ -83,8 +83,19 @@ func DefaultConfig() config.RawValues {
83
83
 
84
84
  func init() {
85
85
  task.Register("beads", task.Factory{
86
- RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
- TaskScopeKey: beadsTaskScopeKey,
86
+ RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
+ TaskScopeKey: beadsTaskScopeKey,
88
+ RegistrationKey: func(spec task.RepoRegistrationSpec) (string, error) {
89
+ label, err := RepositoryLabel(spec.Name)
90
+ if err != nil {
91
+ return "", err
92
+ }
93
+ scope, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
94
+ if err != nil {
95
+ return "", err
96
+ }
97
+ return scope + "\x00" + label, nil
98
+ },
88
99
  Auth: beadsAuth,
89
100
  DefaultConfig: DefaultConfig,
90
101
  ValidateTextConfig: validateTextConfig,
@@ -98,16 +109,70 @@ const (
98
109
  statusOpen = "open"
99
110
  statusInProgress = "in_progress"
100
111
  statusClosed = "closed"
112
+
113
+ repositoryLabelPrefix = "repo:"
114
+ maxRepositoryLabelLen = 255
101
115
  )
102
116
 
117
+ var repositoryNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
118
+
119
+ // RepositoryLabel derives the reserved Beads label used to isolate one
120
+ // registered code repository from other repositories sharing a workspace.
121
+ // Repository names are normalized to lower case so labels remain stable and
122
+ // two names that differ only by case cannot claim the same Beads issues.
123
+ func RepositoryLabel(name string) (string, error) {
124
+ if name == "" || strings.TrimSpace(name) != name {
125
+ return "", errors.New("repository name must be non-empty and must not have surrounding whitespace")
126
+ }
127
+ if !repositoryNamePattern.MatchString(name) {
128
+ return "", fmt.Errorf("repository name %q cannot derive a Beads label; use only letters, numbers, '.', '_' or '-'", name)
129
+ }
130
+ label := repositoryLabelPrefix + strings.ToLower(name)
131
+ if len(label) > maxRepositoryLabelLen {
132
+ return "", fmt.Errorf("repository name %q derives Beads label of %d characters; maximum is %d", name, len(label), maxRepositoryLabelLen)
133
+ }
134
+ return label, nil
135
+ }
136
+
137
+ func hasRepositoryLabel(labels []string, want string) bool {
138
+ if want == "" {
139
+ return false
140
+ }
141
+ found := ""
142
+ count := 0
143
+ for _, label := range labels {
144
+ if !strings.HasPrefix(label, repositoryLabelPrefix) {
145
+ continue
146
+ }
147
+ count++
148
+ found = label
149
+ }
150
+ return count == 1 && found == want
151
+ }
152
+
103
153
  // system is one repo-bound Beads task system. Its CLI client serializes
104
154
  // commands for the selected workspace, making the system safe for concurrent
105
155
  // Repo Poller and durable-activity use.
106
156
  type system struct {
107
- cli bdcli.Client
108
- mailboxMu sync.Mutex
109
- base config.RawValues
110
- effective Config
157
+ cli bdcli.Client
158
+ repositoryLabel string
159
+ mailboxMu sync.Mutex
160
+ base config.RawValues
161
+ effective Config
162
+ beadsDir string
163
+ }
164
+
165
+ // AgentEnv returns the Beads selector environment an agent process needs to
166
+ // address the registered workspace. The configured workspace is the sole
167
+ // BEADS_DIR value; the other selectors are emitted empty so an ambient
168
+ // BEADS_DB or BD_DB cannot redirect the agent's bd commands. This mirrors the
169
+ // adapter's own child-process isolation in bdcli.
170
+ func (s *system) AgentEnv() map[string]string {
171
+ return map[string]string{
172
+ "BEADS_DIR": s.beadsDir,
173
+ "BEADS_DB": "",
174
+ "BD_DB": "",
175
+ }
111
176
  }
112
177
 
113
178
  // beadsTaskScopeKey returns the canonical physical Beads workspace. The
@@ -157,6 +222,10 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
157
222
  if strings.TrimSpace(spec.Name) == "" {
158
223
  return nil, errors.New("beads: repo name is required")
159
224
  }
225
+ repositoryLabel, err := RepositoryLabel(spec.Name)
226
+ if err != nil {
227
+ return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
228
+ }
160
229
  // Validate the repo-scoped key before merging with root values. This keeps a
161
230
  // root beadsDir from silently satisfying repository registration.
162
231
  beadsDir, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
@@ -175,7 +244,13 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
175
244
  if err := cli.Probe(ctx); err != nil {
176
245
  return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
177
246
  }
178
- return &system{cli: cli, base: merged, effective: cfg}, nil
247
+ return &system{
248
+ cli: cli,
249
+ repositoryLabel: repositoryLabel,
250
+ base: merged,
251
+ effective: cfg,
252
+ beadsDir: beadsDir,
253
+ }, nil
179
254
  }
180
255
 
181
256
  func decodeConfig(raw config.RawValues) (Config, error) {
@@ -275,6 +350,11 @@ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
275
350
  if strings.TrimSpace(issue.Parent) != "" {
276
351
  continue
277
352
  }
353
+ // Missing and ambiguous repository labels are deliberately ignored before
354
+ // the normalized ticket reaches workflow matching or routing.
355
+ if !hasRepositoryLabel(issue.Labels, s.repositoryLabel) {
356
+ continue
357
+ }
278
358
  tickets = append(tickets, issueToTicket(issue))
279
359
  }
280
360
  return tickets, nil