relay-flow 0.2.4-alpha → 0.2.6-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 (60) hide show
  1. package/README.md +29 -18
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/beads_composition_test.go +3 -3
  4. package/cmd/relay-flow/main.go +94 -13
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +98 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -0
  12. package/examples/config-reference.yaml +7 -3
  13. package/examples/minimal-beads-task-workflow.yaml +2 -1
  14. package/examples/workflow-reference.yaml +2 -1
  15. package/go.mod +37 -16
  16. package/go.sum +129 -61
  17. package/internal/config/machine.go +33 -1
  18. package/internal/config/machine_test.go +76 -0
  19. package/internal/execution/goworkflows/activities.go +19 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/engine_test.go +1 -1
  22. package/internal/execution/goworkflows/node_runtime_test.go +113 -4
  23. package/internal/execution/goworkflows/projection.go +47 -464
  24. package/internal/execution/projection/projection.go +867 -0
  25. package/internal/execution/projection/projection_test.go +347 -0
  26. package/internal/execution/temporal/activities.go +586 -0
  27. package/internal/execution/temporal/engine.go +384 -0
  28. package/internal/execution/temporal/engine_test.go +277 -0
  29. package/internal/execution/temporal/interpreter.go +736 -0
  30. package/internal/execution/temporal/operations.go +455 -0
  31. package/internal/execution/temporal/operations_test.go +101 -0
  32. package/internal/execution/temporal/recovery.go +194 -0
  33. package/internal/execution/temporal/recovery_runtime.go +41 -0
  34. package/internal/execution/temporal/recovery_test.go +102 -0
  35. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  36. package/internal/execution/temporal/spike_test.go +934 -0
  37. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  38. package/internal/harness/harness.go +5 -0
  39. package/internal/harness/opencode/opencode.go +14 -3
  40. package/internal/harness/opencode/opencode_test.go +1 -1
  41. package/internal/harness/opencode/repo_setup.go +1 -1
  42. package/internal/harness/opencode/task_env_test.go +57 -0
  43. package/internal/harness/pi/pi.go +58 -47
  44. package/internal/harness/pi/pi_test.go +26 -10
  45. package/internal/harness/pi/prompt_test.go +30 -1
  46. package/internal/harness/pi/task_env_test.go +51 -0
  47. package/internal/harness/pi/validation_test.go +27 -51
  48. package/internal/repo/service.go +18 -8
  49. package/internal/runner/herdr/herdr.go +14 -0
  50. package/internal/runner/herdr/herdr_test.go +20 -0
  51. package/internal/runner/orca/orca.go +33 -0
  52. package/internal/runner/orca/orca_test.go +33 -4
  53. package/internal/runner/runner.go +8 -0
  54. package/internal/task/beads/agent_env_test.go +52 -0
  55. package/internal/task/beads/beads.go +87 -7
  56. package/internal/task/beads/beads_test.go +78 -9
  57. package/internal/task/beads/repo_composition_test.go +47 -3
  58. package/internal/task/factory.go +31 -2
  59. package/internal/task/task.go +10 -0
  60. package/package.json +1 -1
@@ -39,9 +39,10 @@ func TestSanitizeErr(t *testing.T) {
39
39
 
40
40
  // fakeCLI is the orcacli.Client seam used by the Orca runner tests.
41
41
  type fakeCLI struct {
42
- repos []orcacli.Repo
43
- worktrees []orcacli.Worktree
44
- terminals map[string]orcacli.Terminal
42
+ repos []orcacli.Repo
43
+ worktrees []orcacli.Worktree
44
+ terminals map[string]orcacli.Terminal
45
+ listedTerminals []orcacli.Terminal
45
46
 
46
47
  createdBaseBranch string
47
48
  createdParent string
@@ -82,7 +83,7 @@ func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Termin
82
83
  }
83
84
  func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
84
85
  func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
85
- return nil, nil
86
+ return f.listedTerminals, nil
86
87
  }
87
88
  func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command string) (string, error) {
88
89
  f.createN++
@@ -157,6 +158,34 @@ func TestSetEnvironmentStatus(t *testing.T) {
157
158
  }
158
159
  }
159
160
 
161
+ func TestDiscoverTerminalByStableTitle(t *testing.T) {
162
+ fx := &fakeCLI{
163
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
164
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
165
+ listedTerminals: []orcacli.Terminal{
166
+ {Handle: "term-other", Title: "PAY-1:other", Connected: true},
167
+ {Handle: "term-node", Title: "PAY-1:implement", Connected: true},
168
+ },
169
+ }
170
+ a, err := New(fx, config.RawValues{})
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ discoverer, ok := a.(runner.TerminalDiscoverer)
175
+ if !ok {
176
+ t.Fatal("Orca runner does not expose recovery terminal discovery")
177
+ }
178
+ got, found, err := discoverer.DiscoverTerminal(context.Background(), runner.RunSpec{
179
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
180
+ }, "PAY-1:implement")
181
+ if err != nil || !found || got.ID != "term-node" || got.Title != "PAY-1:implement" {
182
+ t.Fatalf("DiscoverTerminal = %+v, %v, %v", got, found, err)
183
+ }
184
+ if fx.createN != 0 {
185
+ t.Fatalf("terminal discovery created a terminal: %d", fx.createN)
186
+ }
187
+ }
188
+
160
189
  func TestFindTerminalUsesPersistedID(t *testing.T) {
161
190
  fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
162
191
  "term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
@@ -56,6 +56,14 @@ type RunSpec struct {
56
56
  //
57
57
  // Terminal titles are stable and minimal: only "<ticket>:<node>" — never
58
58
  // nodeVisitID, workflow name, agent name, or other changing metadata.
59
+ // TerminalDiscoverer is an optional recovery capability. It discovers a
60
+ // currently live run-owned terminal by its stable title without creating a
61
+ // terminal or changing runner state. The core Runner contract remains
62
+ // unchanged for adapters that do not support title discovery.
63
+ type TerminalDiscoverer interface {
64
+ DiscoverTerminal(ctx context.Context, spec RunSpec, title string) (Terminal, bool, error)
65
+ }
66
+
59
67
  type Runner interface {
60
68
  DiscoverRepos(ctx context.Context) ([]RepoCandidate, error)
61
69
  ValidateRepo(ctx context.Context, name, path string) error
@@ -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
@@ -194,17 +194,86 @@ func TestExtractWorkflowClaimsKeepsOnlyWorkflowLabels(t *testing.T) {
194
194
  }
195
195
  }
196
196
 
197
+ func TestRepositoryLabelIsStableAndValidated(t *testing.T) {
198
+ got, err := RepositoryLabel("Payments_API")
199
+ if err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ if got != "repo:payments_api" {
203
+ t.Fatalf("RepositoryLabel = %q, want repo:payments_api", got)
204
+ }
205
+ for _, name := range []string{"", " payments", "payments/api", "payments api"} {
206
+ if _, err := RepositoryLabel(name); err == nil {
207
+ t.Fatalf("RepositoryLabel accepted invalid name %q", name)
208
+ }
209
+ }
210
+ }
211
+
212
+ func TestRepositoryLabelEnforcesMaximumLength(t *testing.T) {
213
+ validName := strings.Repeat("a", 250)
214
+ label, err := RepositoryLabel(validName)
215
+ if err != nil {
216
+ t.Fatalf("maximum-length repository name rejected: %v", err)
217
+ }
218
+ if len(label) != maxRepositoryLabelLen {
219
+ t.Fatalf("maximum-length label has length %d, want %d", len(label), maxRepositoryLabelLen)
220
+ }
221
+
222
+ tooLong := strings.Repeat("a", 251)
223
+ if _, err := RepositoryLabel(tooLong); err == nil || !strings.Contains(err.Error(), "maximum is 255") {
224
+ t.Fatalf("overlong repository name error = %v, want the 255-character validation", err)
225
+ }
226
+ }
227
+
228
+ func TestRegistrationKeyAndNewSystemRejectOverlongRepositoryName(t *testing.T) {
229
+ name := strings.Repeat("a", 251)
230
+ workspace := t.TempDir()
231
+ if _, err := task.RegistrationKey("beads", task.RepoRegistrationSpec{
232
+ Name: name,
233
+ RepoConfig: config.RawValues{"beadsDir": workspace},
234
+ }); err == nil {
235
+ t.Fatal("registration identity accepted an overlong repository name")
236
+ }
237
+
238
+ _, err := newSystem(context.Background(), task.RepoSpec{
239
+ Name: name,
240
+ Path: t.TempDir(),
241
+ RepoConfig: config.RawValues{"beadsDir": filepath.Join(workspace, "missing")},
242
+ })
243
+ if err == nil || !strings.Contains(err.Error(), "maximum is 255") {
244
+ t.Fatalf("newSystem overlong-name error = %v, want the label validation before workspace probing", err)
245
+ }
246
+ }
247
+
248
+ func TestPollFiltersMissingOtherAndAmbiguousRepositoryLabels(t *testing.T) {
249
+ fake := &pollClient{ready: []bdcli.Issue{
250
+ {ID: "payments-1", Title: "payments", Labels: []string{"repo:payments", "wf:implementation"}},
251
+ {ID: "unowned-1", Title: "unowned", Labels: []string{"wf:implementation"}},
252
+ {ID: "platform-1", Title: "platform", Labels: []string{"repo:platform", "wf:implementation"}},
253
+ {ID: "ambiguous-1", Title: "ambiguous", Labels: []string{"repo:payments", "repo:platform", "wf:implementation"}},
254
+ {ID: "duplicate-1", Title: "duplicate", Labels: []string{"repo:payments", "repo:payments"}},
255
+ }}
256
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if len(got) != 1 || got[0].ID != "payments-1" {
261
+ t.Fatalf("Poll = %+v, want only the payments-labelled parent", got)
262
+ }
263
+ if got[0].WorkflowClaims[0] != "wf:implementation" {
264
+ t.Fatalf("workflow labels were not retained after repository filtering: %+v", got[0])
265
+ }
266
+ }
267
+
197
268
  func TestPollDeduplicatesReadyAndClaimedParentsByIssueID(t *testing.T) {
198
- ready := bdcli.Issue{ID: "demo-a1b2", Title: "ready copy", Status: "open", IssueType: "epic"}
199
- claimedDuplicate := bdcli.Issue{ID: "demo-a1b2", Title: "claimed copy", Status: "in_progress", IssueType: "epic", Labels: []string{"wf:implementation"}}
200
- claimedOnly := bdcli.Issue{ID: "demo-c3d4", Title: "claimed only", Status: "blocked", IssueType: "task", Labels: []string{"wf:review"}}
269
+ ready := bdcli.Issue{ID: "demo-a1b2", Title: "ready copy", Status: "open", IssueType: "epic", Labels: []string{"repo:payments"}}
270
+ claimedDuplicate := bdcli.Issue{ID: "demo-a1b2", Title: "claimed copy", Status: "in_progress", IssueType: "epic", Labels: []string{"repo:payments", "wf:implementation"}}
271
+ claimedOnly := bdcli.Issue{ID: "demo-c3d4", Title: "claimed only", Status: "blocked", IssueType: "task", Labels: []string{"repo:payments", "wf:review"}}
201
272
  fake := &pollClient{
202
273
  ready: []bdcli.Issue{ready},
203
274
  claimed: []bdcli.Issue{claimedDuplicate, claimedOnly},
204
275
  }
205
- sys := &system{cli: fake}
206
-
207
- got, err := sys.Poll(context.Background())
276
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
208
277
  if err != nil {
209
278
  t.Fatal(err)
210
279
  }
@@ -230,10 +299,10 @@ func TestPollDeduplicatesReadyAndClaimedParentsByIssueID(t *testing.T) {
230
299
  }
231
300
 
232
301
  func TestNormalizedChildIsExcludedFromPolling(t *testing.T) {
233
- parent := bdcli.Issue{ID: "demo-parent", Title: "parent", Status: "open", IssueType: "epic"}
234
- child := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement", Status: "open", IssueType: "task", Parent: "demo-parent"}
302
+ parent := bdcli.Issue{ID: "demo-parent", Title: "parent", Status: "open", IssueType: "epic", Labels: []string{"repo:payments"}}
303
+ child := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement", Status: "open", IssueType: "task", Parent: "demo-parent", Labels: []string{"repo:payments"}}
235
304
  fake := &pollClient{ready: []bdcli.Issue{child, parent}, claimed: []bdcli.Issue{child}}
236
- got, err := (&system{cli: fake}).Poll(context.Background())
305
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
237
306
  if err != nil {
238
307
  t.Fatal(err)
239
308
  }
@@ -99,7 +99,7 @@ func TestBeadsRepoRegistrationUsesIndependentSystemsAndPollers(t *testing.T) {
99
99
  assertBDInvocationReachedRepoAndWorkspace(t, logPath, platformPath, platformBeads)
100
100
  }
101
101
 
102
- func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
102
+ func TestBeadsRepoRegistrationAllowsSharedCanonicalWorkspaceWithDistinctLabels(t *testing.T) {
103
103
  root := t.TempDir()
104
104
  installRepoCompositionFakeBD(t)
105
105
  configPath := filepath.Join(root, "config.yaml")
@@ -135,11 +135,55 @@ func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
135
135
  if _, err := svc.Register(context.Background(), repo.RegisterInput{
136
136
  Name: "platform", Path: secondPath,
137
137
  TaskConfig: config.RawValues{"beadsDir": filepath.Join(workspace, ".")},
138
+ }); err != nil {
139
+ t.Fatalf("second repo sharing canonical beadsDir with a distinct label was rejected: %v", err)
140
+ }
141
+ if len(svc.Registry().List()) != 2 {
142
+ t.Fatalf("registry after shared workspace registration = %d, want 2", len(svc.Registry().List()))
143
+ }
144
+ }
145
+
146
+ func TestBeadsRepoRegistrationRejectsRepositoryLabelCollision(t *testing.T) {
147
+ root := t.TempDir()
148
+ installRepoCompositionFakeBD(t)
149
+ configPath := filepath.Join(root, "config.yaml")
150
+ if err := config.SaveMachine(configPath, &config.Machine{
151
+ TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
152
+ Repos: map[string]config.Repo{},
153
+ }); err != nil {
154
+ t.Fatal(err)
155
+ }
156
+ firstPath := filepath.Join(root, "code", "payments")
157
+ secondPath := filepath.Join(root, "code", "PAYMENTS")
158
+ workspace := filepath.Join(root, "beads", "shared", ".beads")
159
+ for _, path := range []string{firstPath, secondPath, workspace} {
160
+ if err := os.MkdirAll(path, 0o755); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ }
164
+
165
+ svc := repo.NewService(repo.ServiceConfig{
166
+ ConfigPath: configPath,
167
+ TaskPlugin: "beads",
168
+ Runner: &compositionRunner{},
169
+ Harness: &compositionHarness{},
170
+ Active: &compositionActive{},
171
+ Workflows: &compositionWorkflowRefs{},
172
+ })
173
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
174
+ Name: "payments", Path: firstPath,
175
+ TaskConfig: config.RawValues{"beadsDir": workspace},
176
+ }); err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
180
+ Name: "PAYMENTS", Path: secondPath,
181
+ TaskConfig: config.RawValues{"beadsDir": workspace},
138
182
  }); err == nil {
139
- t.Fatal("second repo sharing canonical beadsDir was accepted")
183
+ t.Fatal("repository label collision was accepted")
140
184
  }
141
185
  if len(svc.Registry().List()) != 1 {
142
- t.Fatalf("registry after duplicate scope rejection = %d, want 1", len(svc.Registry().List()))
186
+ t.Fatalf("registry after label collision = %d, want 1", len(svc.Registry().List()))
143
187
  }
144
188
  }
145
189
 
@@ -20,13 +20,25 @@ type RepoSpec struct {
20
20
  RepoConfig config.RawValues
21
21
  }
22
22
 
23
+ // RepoRegistrationSpec carries the values a task plugin may need to derive a
24
+ // registration identity. Unlike RepoSpec, it does not include the code path
25
+ // or construct a task System.
26
+ type RepoRegistrationSpec struct {
27
+ Name string
28
+ RootConfig config.RawValues
29
+ RepoConfig config.RawValues
30
+ }
31
+
23
32
  // Factory constructs a task System. RequiredRepoKeys returns the explicit
24
33
  // repo YAML keys needed at registration. TaskScopeKey derives an opaque
25
34
  // canonical physical task scope (such as Jira site/project/component) used
26
- // to reject duplicate scope registration.
35
+ // to reject duplicate scope registration. RegistrationKey optionally derives
36
+ // the complete registration identity when a plugin permits sharing a
37
+ // physical scope with distinct logical repositories.
27
38
  type Factory struct {
28
39
  RequiredRepoKeys func() []string
29
40
  TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
+ RegistrationKey func(RepoRegistrationSpec) (string, error)
30
42
  Auth func(context.Context, []string, io.Reader) error
31
43
  DefaultConfig func() config.RawValues
32
44
  ValidateTextConfig func(config.RawValues) error
@@ -122,7 +134,10 @@ func RequiredRepoKeys(name string) ([]string, error) {
122
134
  return f.RequiredRepoKeys(), nil
123
135
  }
124
136
 
125
- // TaskScopeKey derives the canonical task scope for the named plugin.
137
+ // TaskScopeKey derives the canonical physical task scope for the named
138
+ // plugin. It is retained separately from RegistrationKey so callers that
139
+ // need provider workspace identity do not have to know about logical repo
140
+ // ownership.
126
141
  func TaskScopeKey(name string, rootConfig, repoConfig config.RawValues) (string, error) {
127
142
  f, err := lookup(name)
128
143
  if err != nil {
@@ -131,6 +146,20 @@ func TaskScopeKey(name string, rootConfig, repoConfig config.RawValues) (string,
131
146
  return f.TaskScopeKey(rootConfig, repoConfig)
132
147
  }
133
148
 
149
+ // RegistrationKey derives the identity used to reject conflicting repo
150
+ // registrations. Plugins that do not provide a custom key fall back to their
151
+ // physical TaskScopeKey, preserving the original duplicate-scope behavior.
152
+ func RegistrationKey(name string, spec RepoRegistrationSpec) (string, error) {
153
+ f, err := lookup(name)
154
+ if err != nil {
155
+ return "", err
156
+ }
157
+ if f.RegistrationKey != nil {
158
+ return f.RegistrationKey(spec)
159
+ }
160
+ return f.TaskScopeKey(spec.RootConfig, spec.RepoConfig)
161
+ }
162
+
134
163
  // ValidateName returns an error listing registered names when name is not
135
164
  // a registered plugin. Used by `relay-flow init` to reject unknown plugin
136
165
  // selections without constructing a System.
@@ -99,6 +99,16 @@ type System interface {
99
99
  ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
100
100
  }
101
101
 
102
+ // AgentEnvironment is an optional adapter capability. It returns the
103
+ // task-system workspace environment an agent process must receive so agent
104
+ // task commands address the same workspace as relay-flow. Values are
105
+ // workspace selectors only, never credentials; an empty value explicitly
106
+ // neutralizes an ambient selector. Core forwards the map through the harness
107
+ // launch spec without learning adapter vocabulary.
108
+ type AgentEnvironment interface {
109
+ AgentEnv() map[string]string
110
+ }
111
+
102
112
  // RestartPreparer is an optional adapter capability used only by explicit
103
113
  // run restarts. It reopens relay-owned mailbox state while preserving all
104
114
  // comments, labels, and descriptions. A human-owned incompatible state must
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.4-alpha",
3
+ "version": "0.2.6-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",