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.
- package/README.md +187 -35
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- package/examples/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +5 -1
- package/examples/default-story-workflow.yaml +34 -19
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- package/internal/execution/temporal/activities.go +19 -0
- package/internal/harness/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +11 -2
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +57 -0
- package/internal/harness/pi/pi.go +9 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/repo/service.go +18 -8
- package/internal/task/beads/agent_env_test.go +52 -0
- package/internal/task/beads/beads.go +87 -7
- package/internal/task/beads/beads_test.go +78 -9
- package/internal/task/beads/repo_composition_test.go +47 -3
- package/internal/task/factory.go +31 -2
- package/internal/task/task.go +10 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
183
|
+
t.Fatal("repository label collision was accepted")
|
|
140
184
|
}
|
|
141
185
|
if len(svc.Registry().List()) != 1 {
|
|
142
|
-
t.Fatalf("registry after
|
|
186
|
+
t.Fatalf("registry after label collision = %d, want 1", len(svc.Registry().List()))
|
|
143
187
|
}
|
|
144
188
|
}
|
|
145
189
|
|
package/internal/task/factory.go
CHANGED
|
@@ -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
|
|
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.
|
package/internal/task/task.go
CHANGED
|
@@ -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
|