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
|
@@ -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.7-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
|