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.
- package/README.md +29 -18
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +7 -3
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +586 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +14 -3
- 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 +58 -47
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/repo/service.go +18 -8
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- 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
|
@@ -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
|
|
43
|
-
worktrees
|
|
44
|
-
terminals
|
|
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
|
|
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:
|
|
87
|
-
TaskScopeKey:
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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{
|
|
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
|
-
|
|
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
|