relay-flow 0.2.5-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 +9 -3
- 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/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
package/README.md
CHANGED
|
@@ -177,7 +177,13 @@ bd init \
|
|
|
177
177
|
# Example external workspace: /var/lib/beads/payments/.beads
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
For the relay-flow installation used by this repository, the configured Beads workspace is:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
/home/raj/.beads/relay-flow/.beads
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Register the code repository and its Beads workspace separately. `beadsDir` is required for every Beads repo and must name an existing directory. It selects the Beads workspace/database; it is not the code repository path or a workflow filter:
|
|
181
187
|
|
|
182
188
|
```sh
|
|
183
189
|
# Local/embedded workspace
|
|
@@ -193,7 +199,7 @@ relay-flow repo register \
|
|
|
193
199
|
--set beadsDir=/var/lib/beads/payments/.beads
|
|
194
200
|
```
|
|
195
201
|
|
|
196
|
-
The registered `--path` remains the code/runner repository. Every `bd` command runs with that path as its working directory and the configured `beadsDir` as `BEADS_DIR`, even when unrelated Beads selector variables exist in the relay-flow environment.
|
|
202
|
+
The registered `--path` remains the code/runner repository. Every `bd` command runs with that path as its working directory and the configured `beadsDir` as `BEADS_DIR`, even when unrelated Beads selector variables exist in the relay-flow environment. Multiple code repositories may share one Beads/Dolt workspace when their derived repository labels differ. Relay-flow derives one reserved label, `repo:<lowercase-registered-name>`, for each repository; names must use letters, numbers, `.`, `_`, or `-`, and be at most 250 characters so the derived label stays within Beads' 255-character limit. The label is not entered in workflow configuration. A parent must carry exactly one such repository label to be eligible for that repo's poller: missing or ambiguous `repo:` labels are ignored before workflow filters and routing. Workflow ownership labels remain independent `wf:<workflow>` labels. A collision between derived labels in the same workspace is rejected before the second repository is registered. A Beads prefix such as `payments-...` is optional and only makes issue IDs recognizable—it is not a component, workspace selector, poller selector, or database isolation mechanism.
|
|
197
203
|
|
|
198
204
|
Beads workflow filters are structured and evaluated in relay-flow. For example, `examples/beads-workflow.yaml` uses the Beads status and issue-type fields:
|
|
199
205
|
|
|
@@ -211,7 +217,7 @@ Filter values are exact and are not translated between providers.
|
|
|
211
217
|
|
|
212
218
|
The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, a work-node mailbox to `in_progress`, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
|
|
213
219
|
|
|
214
|
-
Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow
|
|
220
|
+
Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow; each registered code repository still supplies its `beadsDir`, and repositories that share that workspace are isolated by their derived `repo:` labels.
|
|
215
221
|
|
|
216
222
|
### Submit a workflow
|
|
217
223
|
|
|
@@ -361,9 +361,9 @@ issue_json() {
|
|
|
361
361
|
case "$issue" in
|
|
362
362
|
demo-parent)
|
|
363
363
|
if [ -f "$BD_STATE/claimed" ]; then
|
|
364
|
-
labels='["wf:beadsComposition"]'
|
|
364
|
+
labels='["repo:payments","wf:beadsComposition"]'
|
|
365
365
|
else
|
|
366
|
-
labels='[]'
|
|
366
|
+
labels='["repo:payments"]'
|
|
367
367
|
fi
|
|
368
368
|
printf '[{"id":"demo-parent","title":"Composition parent","status":"%s","issue_type":"epic","priority":1,"labels":%s}]\n' "$status" "$labels"
|
|
369
369
|
;;
|
|
@@ -385,7 +385,7 @@ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --no-pare
|
|
|
385
385
|
if [ -f "$BD_STATE/claimed" ]; then
|
|
386
386
|
printf '[]\n'
|
|
387
387
|
else
|
|
388
|
-
printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":[]}]\n'
|
|
388
|
+
printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":["repo:payments"]}]\n'
|
|
389
389
|
fi
|
|
390
390
|
exit 0
|
|
391
391
|
fi
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# relay-flow repo register --name payments --path /work/payments \
|
|
5
5
|
# --set beadsDir=/work/payments/.beads
|
|
6
6
|
#
|
|
7
|
+
# Relay-flow derives the reserved repo:payments label from the registered
|
|
8
|
+
# repository name. Parents without exactly one matching repo: label are
|
|
9
|
+
# ignored before workflow filters; wf:<workflow> remains a separate claim.
|
|
7
10
|
# The Beads prefix is optional and is not a workspace/component selector.
|
|
8
11
|
name: beadsStoryFlow
|
|
9
12
|
repos:
|
|
@@ -118,7 +118,11 @@ repos:
|
|
|
118
118
|
#
|
|
119
119
|
# Beads uses the shared taskConfig fields (filters, assignee, transitionTo,
|
|
120
120
|
# templates) but does not accept Jira-only project/component fields. beadsDir
|
|
121
|
-
# is required at repo scope and must not be supplied only at root scope.
|
|
121
|
+
# is required at repo scope and must not be supplied only at root scope. The
|
|
122
|
+
# registered code path is the bd working directory; beadsDir selects the
|
|
123
|
+
# workspace/database. Shared workspaces isolate parents with the derived
|
|
124
|
+
# reserved repo:<lowercase-repo-name> label (the registered name may be at
|
|
125
|
+
# most 250 ASCII characters), not with workflow labels.
|
|
122
126
|
#
|
|
123
127
|
# Pi variant:
|
|
124
128
|
# harnessPlugin: pi
|
|
@@ -44,6 +44,21 @@ func (a *Activities) runSpec(w run.Work) runner.RunSpec {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// agentEnv returns the repo task system's agent workspace environment, or nil
|
|
48
|
+
// when the adapter exposes none. It is resolved at launch time so no workspace
|
|
49
|
+
// value is carried in durable workflow history.
|
|
50
|
+
func (a *Activities) agentEnv(repoName string) (map[string]string, error) {
|
|
51
|
+
sys, err := a.taskSystem(repoName)
|
|
52
|
+
if err != nil {
|
|
53
|
+
return nil, err
|
|
54
|
+
}
|
|
55
|
+
provider, ok := sys.(task.AgentEnvironment)
|
|
56
|
+
if !ok {
|
|
57
|
+
return nil, nil
|
|
58
|
+
}
|
|
59
|
+
return provider.AgentEnv(), nil
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// EnsureMailboxes ensures one mailbox per work node and returns the
|
|
48
63
|
// node-to-mailbox map.
|
|
49
64
|
func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
@@ -220,6 +235,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
220
235
|
if err != nil {
|
|
221
236
|
return err
|
|
222
237
|
}
|
|
238
|
+
spec.TaskEnv, err = a.agentEnv(nw.Repo)
|
|
239
|
+
if err != nil {
|
|
240
|
+
return err
|
|
241
|
+
}
|
|
223
242
|
cmd, err := a.Harness.BuildCommand(spec)
|
|
224
243
|
if err != nil {
|
|
225
244
|
return err
|
|
@@ -71,7 +71,7 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
|
71
71
|
if err != nil {
|
|
72
72
|
t.Fatal(err)
|
|
73
73
|
}
|
|
74
|
-
want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
|
|
74
|
+
want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
|
|
75
75
|
if prompt != want {
|
|
76
76
|
t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
77
77
|
}
|
|
@@ -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
|
|
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.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.6-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -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
|
+
}
|
package/internal/repo/service.go
CHANGED
|
@@ -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
|
|
90
|
-
//
|
|
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
|
|
120
|
-
// against every registered repo
|
|
121
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|