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.
@@ -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.5-alpha",
3
+ "version": "0.2.7-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",