relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Package config loads and validates relay-flow workflow YAML files.
|
|
2
|
-
package config
|
|
3
|
-
|
|
4
|
-
import (
|
|
5
|
-
"bytes"
|
|
6
|
-
"fmt"
|
|
7
|
-
"os"
|
|
8
|
-
"regexp"
|
|
9
|
-
"strings"
|
|
10
|
-
|
|
11
|
-
"gopkg.in/yaml.v3"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
// StringList accepts either a scalar or a list of strings in YAML and
|
|
15
|
-
// normalizes both forms to []string, so a single value can be written as
|
|
16
|
-
// `closeOn: done` instead of `closeOn: [done]`.
|
|
17
|
-
type StringList []string
|
|
18
|
-
|
|
19
|
-
func (s *StringList) UnmarshalYAML(value *yaml.Node) error {
|
|
20
|
-
if value.Kind == yaml.ScalarNode {
|
|
21
|
-
if value.Tag != "!!str" {
|
|
22
|
-
return fmt.Errorf("expected string or list of strings")
|
|
23
|
-
}
|
|
24
|
-
*s = []string{value.Value}
|
|
25
|
-
return nil
|
|
26
|
-
}
|
|
27
|
-
var values []string
|
|
28
|
-
if err := value.Decode(&values); err != nil {
|
|
29
|
-
return err
|
|
30
|
-
}
|
|
31
|
-
*s = values
|
|
32
|
-
return nil
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Has reports membership (case-insensitive).
|
|
36
|
-
func (s StringList) Has(name string) bool {
|
|
37
|
-
for _, value := range s {
|
|
38
|
-
if strings.EqualFold(value, name) {
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// AdapterSpec selects a pluggable adapter (tasks or runner) by type name.
|
|
46
|
-
// Config is opaque to core: the adapter's factory unmarshals it strictly.
|
|
47
|
-
type AdapterSpec struct {
|
|
48
|
-
Type string `yaml:"type"`
|
|
49
|
-
Config map[string]any `yaml:"config"`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Node is one square on the board: a tracker state (When), the agent that
|
|
53
|
-
// works tickets in that state, and the outcome edges to other nodes.
|
|
54
|
-
type Node struct {
|
|
55
|
-
// Agent is the OpenCode agent serving this node. Empty = terminal node
|
|
56
|
-
// (no agent runs; must be listed in closeOn).
|
|
57
|
-
Agent string `yaml:"agent"`
|
|
58
|
-
// When is the tracker state string that routes tickets to this node
|
|
59
|
-
// (poll-time condition). Unique across the file, case-insensitive.
|
|
60
|
-
When string `yaml:"when"`
|
|
61
|
-
// OnSuccess / OnFailure are the outcome edges: node names the ticket
|
|
62
|
-
// moves to when its agent reports success/failure. Self-loops allowed.
|
|
63
|
-
OnSuccess string `yaml:"onSuccess"`
|
|
64
|
-
OnFailure string `yaml:"onFailure"`
|
|
65
|
-
// NudgePrompt is sent into the ticket's existing terminal when it
|
|
66
|
-
// lands back on this node (bounce). Supports {{ticket}} and {{node}}
|
|
67
|
-
// placeholders; defaults to DefaultNudgePrompt.
|
|
68
|
-
NudgePrompt string `yaml:"nudgePrompt"`
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// DefaultNudgePrompt is used when a node declares no nudgePrompt.
|
|
72
|
-
const DefaultNudgePrompt = "Ticket {{ticket}} is at node '{{node}}' again. Read the tracker's latest feedback, continue your work, and end your reply with the STATUS/SUMMARY block as before."
|
|
73
|
-
|
|
74
|
-
// Config is one workflow file: name, adapters, nodes, closeOn.
|
|
75
|
-
type Config struct {
|
|
76
|
-
// Name is the workflow's identity: server registry key, claim-label
|
|
77
|
-
// component (`wf:<name>`), CLI argument. CamelCase, unique per server.
|
|
78
|
-
Name string `yaml:"name"`
|
|
79
|
-
PollIntervalSeconds int `yaml:"pollIntervalSeconds"`
|
|
80
|
-
// Tasks selects the ticket-system adapter (jira, beads, ...).
|
|
81
|
-
Tasks AdapterSpec `yaml:"tasks"`
|
|
82
|
-
// Runner selects the execution backend (orca, tmux, ...).
|
|
83
|
-
Runner AdapterSpec `yaml:"runner"`
|
|
84
|
-
// CloseOn lists terminal nodes: tickets reaching them get their
|
|
85
|
-
// terminals closed. Nodes here must have no agent.
|
|
86
|
-
CloseOn StringList `yaml:"closeOn"`
|
|
87
|
-
// Nodes maps node names to their definitions. The graph: each agent
|
|
88
|
-
// node's edges point at other nodes; terminal nodes have no agent.
|
|
89
|
-
Nodes map[string]Node `yaml:"nodes"`
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
var namePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
|
93
|
-
|
|
94
|
-
// NodeForState returns the node whose When matches tracker state
|
|
95
|
-
// (case-insensitive), or "" if none.
|
|
96
|
-
func (c *Config) NodeForState(state string) string {
|
|
97
|
-
for name, n := range c.Nodes {
|
|
98
|
-
if strings.EqualFold(n.When, state) {
|
|
99
|
-
return name
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return ""
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Validate cross-checks the graph so broken configs fail at submit, before
|
|
106
|
-
// any goroutine starts.
|
|
107
|
-
func (c *Config) Validate() error {
|
|
108
|
-
if !namePattern.MatchString(c.Name) {
|
|
109
|
-
return fmt.Errorf("name %q must be camelCase with no spaces", c.Name)
|
|
110
|
-
}
|
|
111
|
-
if strings.TrimSpace(c.Tasks.Type) == "" {
|
|
112
|
-
return fmt.Errorf("tasks.type must not be empty")
|
|
113
|
-
}
|
|
114
|
-
if strings.TrimSpace(c.Runner.Type) == "" {
|
|
115
|
-
return fmt.Errorf("runner.type must not be empty")
|
|
116
|
-
}
|
|
117
|
-
if len(c.Nodes) == 0 {
|
|
118
|
-
return fmt.Errorf("nodes must not be empty")
|
|
119
|
-
}
|
|
120
|
-
if len(c.CloseOn) == 0 {
|
|
121
|
-
return fmt.Errorf("closeOn must not be empty (terminal nodes that close ticket terminals)")
|
|
122
|
-
}
|
|
123
|
-
for _, co := range c.CloseOn {
|
|
124
|
-
n, ok := c.Nodes[co]
|
|
125
|
-
if !ok {
|
|
126
|
-
return fmt.Errorf("closeOn: unknown node %q", co)
|
|
127
|
-
}
|
|
128
|
-
if n.Agent != "" {
|
|
129
|
-
return fmt.Errorf("closeOn: node %q has an agent; closeOn nodes must be terminal (no agent)", co)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
seenWhen := map[string]string{}
|
|
133
|
-
for name, n := range c.Nodes {
|
|
134
|
-
if strings.TrimSpace(n.When) == "" {
|
|
135
|
-
return fmt.Errorf("nodes[%s].when must not be empty", name)
|
|
136
|
-
}
|
|
137
|
-
key := strings.ToLower(strings.TrimSpace(n.When))
|
|
138
|
-
if other, dup := seenWhen[key]; dup {
|
|
139
|
-
return fmt.Errorf("nodes[%s].when %q duplicates nodes[%s].when", name, n.When, other)
|
|
140
|
-
}
|
|
141
|
-
seenWhen[key] = name
|
|
142
|
-
if n.Agent == "" {
|
|
143
|
-
// Agentless = human gate / pause node: no automation, no
|
|
144
|
-
// edges required. Whether it also closes terminals is
|
|
145
|
-
// controlled solely by closeOn.
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
if n.OnSuccess == "" {
|
|
149
|
-
return fmt.Errorf("nodes[%s].onSuccess must not be empty (agent nodes need both outcome edges)", name)
|
|
150
|
-
}
|
|
151
|
-
if n.OnFailure == "" {
|
|
152
|
-
return fmt.Errorf("nodes[%s].onFailure must not be empty (agent nodes need both outcome edges)", name)
|
|
153
|
-
}
|
|
154
|
-
if _, ok := c.Nodes[n.OnSuccess]; !ok {
|
|
155
|
-
return fmt.Errorf("nodes[%s].onSuccess: unknown node %q", name, n.OnSuccess)
|
|
156
|
-
}
|
|
157
|
-
if _, ok := c.Nodes[n.OnFailure]; !ok {
|
|
158
|
-
return fmt.Errorf("nodes[%s].onFailure: unknown node %q", name, n.OnFailure)
|
|
159
|
-
}
|
|
160
|
-
if n.NudgePrompt == "" {
|
|
161
|
-
n.NudgePrompt = DefaultNudgePrompt
|
|
162
|
-
c.Nodes[name] = n
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return nil
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Parse decodes and validates workflow YAML bytes. name is only used in
|
|
169
|
-
// error messages.
|
|
170
|
-
func Parse(name string, b []byte) (*Config, error) {
|
|
171
|
-
var c Config
|
|
172
|
-
dec := yaml.NewDecoder(bytes.NewReader(b))
|
|
173
|
-
dec.KnownFields(true)
|
|
174
|
-
if err := dec.Decode(&c); err != nil {
|
|
175
|
-
return nil, fmt.Errorf("parse config %s: %w", name, err)
|
|
176
|
-
}
|
|
177
|
-
if c.PollIntervalSeconds <= 0 {
|
|
178
|
-
c.PollIntervalSeconds = 15
|
|
179
|
-
}
|
|
180
|
-
if err := c.Validate(); err != nil {
|
|
181
|
-
return nil, fmt.Errorf("config %s: %w", name, err)
|
|
182
|
-
}
|
|
183
|
-
return &c, nil
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Load reads and parses a workflow YAML file.
|
|
187
|
-
func Load(path string) (*Config, error) {
|
|
188
|
-
b, err := os.ReadFile(path)
|
|
189
|
-
if err != nil {
|
|
190
|
-
return nil, fmt.Errorf("read config %s: %w", path, err)
|
|
191
|
-
}
|
|
192
|
-
return Parse(path, b)
|
|
193
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
package config
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"strings"
|
|
5
|
-
"testing"
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
const validYAML = `
|
|
9
|
-
name: xyzTaskFlow
|
|
10
|
-
pollIntervalSeconds: 15
|
|
11
|
-
tasks:
|
|
12
|
-
type: jira
|
|
13
|
-
config:
|
|
14
|
-
query: project = xyz
|
|
15
|
-
issueTypes: [Task]
|
|
16
|
-
runner:
|
|
17
|
-
type: orca
|
|
18
|
-
closeOn: [done]
|
|
19
|
-
nodes:
|
|
20
|
-
coding:
|
|
21
|
-
agent: build
|
|
22
|
-
when: "In Progress"
|
|
23
|
-
onSuccess: reviewing
|
|
24
|
-
onFailure: coding
|
|
25
|
-
reviewing:
|
|
26
|
-
agent: build
|
|
27
|
-
when: "In Review"
|
|
28
|
-
onSuccess: done
|
|
29
|
-
onFailure: coding
|
|
30
|
-
done:
|
|
31
|
-
when: "Done"
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
func TestParseValid(t *testing.T) {
|
|
35
|
-
cfg, err := Parse("test", []byte(validYAML))
|
|
36
|
-
if err != nil {
|
|
37
|
-
t.Fatalf("valid config rejected: %v", err)
|
|
38
|
-
}
|
|
39
|
-
if cfg.Name != "xyzTaskFlow" {
|
|
40
|
-
t.Errorf("name = %q", cfg.Name)
|
|
41
|
-
}
|
|
42
|
-
if cfg.PollIntervalSeconds != 15 {
|
|
43
|
-
t.Errorf("pollIntervalSeconds = %d", cfg.PollIntervalSeconds)
|
|
44
|
-
}
|
|
45
|
-
if cfg.Tasks.Type != "jira" || cfg.Runner.Type != "orca" {
|
|
46
|
-
t.Errorf("tasks=%q runner=%q", cfg.Tasks.Type, cfg.Runner.Type)
|
|
47
|
-
}
|
|
48
|
-
if cfg.Tasks.Config["query"] != "project = xyz" {
|
|
49
|
-
t.Errorf("opaque tasks config not preserved: %v", cfg.Tasks.Config)
|
|
50
|
-
}
|
|
51
|
-
if len(cfg.Nodes) != 3 {
|
|
52
|
-
t.Fatalf("nodes = %v", cfg.Nodes)
|
|
53
|
-
}
|
|
54
|
-
n := cfg.Nodes["coding"]
|
|
55
|
-
if n.Agent != "build" || n.When != "In Progress" || n.OnSuccess != "reviewing" || n.OnFailure != "coding" {
|
|
56
|
-
t.Errorf("coding node = %+v", n)
|
|
57
|
-
}
|
|
58
|
-
if cfg.Nodes["done"].Agent != "" {
|
|
59
|
-
t.Errorf("done should have no agent")
|
|
60
|
-
}
|
|
61
|
-
if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
|
|
62
|
-
t.Errorf("closeOn = %v", cfg.CloseOn)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
func TestParseDefaultPollInterval(t *testing.T) {
|
|
67
|
-
yaml := strings.Replace(validYAML, "pollIntervalSeconds: 15\n", "", 1)
|
|
68
|
-
cfg, err := Parse("test", []byte(yaml))
|
|
69
|
-
if err != nil {
|
|
70
|
-
t.Fatalf("%v", err)
|
|
71
|
-
}
|
|
72
|
-
if cfg.PollIntervalSeconds != 15 {
|
|
73
|
-
t.Errorf("default pollIntervalSeconds = %d, want 15", cfg.PollIntervalSeconds)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
func TestCloseOnScalar(t *testing.T) {
|
|
78
|
-
yaml := strings.Replace(validYAML, "closeOn: [done]", "closeOn: done", 1)
|
|
79
|
-
cfg, err := Parse("test", []byte(yaml))
|
|
80
|
-
if err != nil {
|
|
81
|
-
t.Fatalf("%v", err)
|
|
82
|
-
}
|
|
83
|
-
if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
|
|
84
|
-
t.Errorf("closeOn = %v", cfg.CloseOn)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
func TestParseErrors(t *testing.T) {
|
|
89
|
-
rep := func(old, new string) string { return strings.Replace(validYAML, old, new, 1) }
|
|
90
|
-
cases := []struct {
|
|
91
|
-
name string
|
|
92
|
-
yaml string
|
|
93
|
-
want string
|
|
94
|
-
}{
|
|
95
|
-
{"empty name", rep("name: xyzTaskFlow", "name: \"\""), "name"},
|
|
96
|
-
{"bad name", rep("name: xyzTaskFlow", "name: My Flow"), "camelCase"},
|
|
97
|
-
{"unknown top field", validYAML + "bogus: 1\n", "field bogus"},
|
|
98
|
-
{"unknown node field", rep("onFailure: coding\n reviewing:", "onFailure: coding\n bogus: 1\n reviewing:"), "field bogus"},
|
|
99
|
-
{"no tasks type", rep("type: jira", "type: \"\""), "tasks.type"},
|
|
100
|
-
{"no runner type", rep("type: orca", "type: \"\""), "runner.type"},
|
|
101
|
-
{"empty closeOn", rep("closeOn: [done]", "closeOn: []"), "closeOn"},
|
|
102
|
-
{"closeOn unknown node", rep("closeOn: [done]", "closeOn: [nope]"), "closeOn"},
|
|
103
|
-
{"closeOn node has agent", rep("closeOn: [done]", "closeOn: [coding]"), "closeOn"},
|
|
104
|
-
|
|
105
|
-
{"edge target missing", rep("onSuccess: reviewing", "onSuccess: nowhere"), "onSuccess"},
|
|
106
|
-
{"agent node missing onSuccess", rep(" onSuccess: reviewing\n", ""), "onSuccess"},
|
|
107
|
-
{"agent node missing onFailure", rep(" onFailure: coding\n reviewing", " reviewing"), "onFailure"},
|
|
108
|
-
{"dup when", rep("when: \"In Review\"", "when: \"In Progress\""), "duplicat"},
|
|
109
|
-
{"empty when", rep("when: \"In Progress\"", "when: \"\""), "when"},
|
|
110
|
-
{"no nodes", rep("nodes:\n", "nodes: {}\n#") + "", "nodes"},
|
|
111
|
-
}
|
|
112
|
-
for _, tc := range cases {
|
|
113
|
-
t.Run(tc.name, func(t *testing.T) {
|
|
114
|
-
yaml := tc.yaml
|
|
115
|
-
if tc.name == "no nodes" {
|
|
116
|
-
// strip the entire nodes block
|
|
117
|
-
yaml = validYAML[:strings.Index(validYAML, "nodes:")]
|
|
118
|
-
}
|
|
119
|
-
_, err := Parse("test", []byte(yaml))
|
|
120
|
-
if err == nil {
|
|
121
|
-
t.Fatalf("expected error containing %q, got nil", tc.want)
|
|
122
|
-
}
|
|
123
|
-
if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
|
|
124
|
-
t.Fatalf("error %q missing %q", err, tc.want)
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
func TestSelfLoopEdgeAllowed(t *testing.T) {
|
|
131
|
-
// coding.onFailure == coding: already in validYAML, must parse.
|
|
132
|
-
if _, err := Parse("test", []byte(validYAML)); err != nil {
|
|
133
|
-
t.Fatalf("%v", err)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
func TestNodeForState(t *testing.T) {
|
|
138
|
-
cfg, err := Parse("test", []byte(validYAML))
|
|
139
|
-
if err != nil {
|
|
140
|
-
t.Fatalf("%v", err)
|
|
141
|
-
}
|
|
142
|
-
if got := cfg.NodeForState("in progress"); got != "coding" {
|
|
143
|
-
t.Errorf("NodeForState(in progress) = %q, want coding", got)
|
|
144
|
-
}
|
|
145
|
-
if got := cfg.NodeForState("Done"); got != "done" {
|
|
146
|
-
t.Errorf("NodeForState(Done) = %q, want done", got)
|
|
147
|
-
}
|
|
148
|
-
if got := cfg.NodeForState("Backlog"); got != "" {
|
|
149
|
-
t.Errorf("NodeForState(Backlog) = %q, want empty", got)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
func TestDefaultNudgePrompt(t *testing.T) {
|
|
154
|
-
cfg, err := Parse("test", []byte(validYAML))
|
|
155
|
-
if err != nil {
|
|
156
|
-
t.Fatalf("%v", err)
|
|
157
|
-
}
|
|
158
|
-
p := cfg.Nodes["coding"].NudgePrompt
|
|
159
|
-
if !strings.Contains(p, "{{ticket}}") || !strings.Contains(p, "{{node}}") {
|
|
160
|
-
t.Errorf("default nudgePrompt missing templates: %q", p)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
// Package daemon runs one workflow's poll loop: list tickets from the
|
|
2
|
-
// tasks adapter, route each through the 3-way claim switch, and dispatch
|
|
3
|
-
// agent sessions through the runner adapter. It contains no tracker- or
|
|
4
|
-
// runner-specific logic — both arrive as interfaces.
|
|
5
|
-
package daemon
|
|
6
|
-
|
|
7
|
-
import (
|
|
8
|
-
"context"
|
|
9
|
-
"fmt"
|
|
10
|
-
"log"
|
|
11
|
-
"strings"
|
|
12
|
-
"sync"
|
|
13
|
-
"time"
|
|
14
|
-
|
|
15
|
-
"github.com/rajpopat27/relay-flow/internal/config"
|
|
16
|
-
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
|
-
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
// Daemon polls one workflow and dispatches tickets. Long-lived: one poll
|
|
21
|
-
// goroutine per submitted workflow; dispatch/bounce run as short-lived
|
|
22
|
-
// goroutines per ticket.
|
|
23
|
-
type Daemon struct {
|
|
24
|
-
cfg *config.Config
|
|
25
|
-
tasks tasks.Tasks
|
|
26
|
-
runner runner.Runner
|
|
27
|
-
repoID string
|
|
28
|
-
repoName string
|
|
29
|
-
dryRun bool
|
|
30
|
-
|
|
31
|
-
// nudged marks key → node for which a prompt/nudge was already
|
|
32
|
-
// delivered, so each node visit prompts exactly once. Cleared when a
|
|
33
|
-
// report moves the ticket (re-arming the next visit).
|
|
34
|
-
nudgedMu sync.Mutex
|
|
35
|
-
nudged map[string]string
|
|
36
|
-
|
|
37
|
-
wg sync.WaitGroup // tracks dispatch/bounce goroutines; Wait blocks tests
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// New builds a daemon for one validated workflow config.
|
|
41
|
-
func New(cfg *config.Config, tk tasks.Tasks, rn runner.Runner, repoID, repoName string, dryRun bool) *Daemon {
|
|
42
|
-
return &Daemon{
|
|
43
|
-
cfg: cfg, tasks: tk, runner: rn,
|
|
44
|
-
repoID: repoID, repoName: repoName, dryRun: dryRun,
|
|
45
|
-
nudged: map[string]string{},
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// PollLoop ticks until ctx is cancelled (server shutdown/remove).
|
|
50
|
-
func (d *Daemon) PollLoop(ctx context.Context) {
|
|
51
|
-
interval := time.Duration(d.cfg.PollIntervalSeconds) * time.Second
|
|
52
|
-
ticker := time.NewTicker(interval)
|
|
53
|
-
defer ticker.Stop()
|
|
54
|
-
d.PollOnce()
|
|
55
|
-
for {
|
|
56
|
-
select {
|
|
57
|
-
case <-ctx.Done():
|
|
58
|
-
return
|
|
59
|
-
case <-ticker.C:
|
|
60
|
-
d.PollOnce()
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Wait blocks until in-flight dispatch/bounce goroutines finish. Tests
|
|
66
|
-
// call it after PollOnce; production never needs it.
|
|
67
|
-
func (d *Daemon) Wait() { d.wg.Wait() }
|
|
68
|
-
|
|
69
|
-
// PollOnce lists tickets once and routes each:
|
|
70
|
-
//
|
|
71
|
-
// claimed by another workflow → skip (cross-workflow mutex)
|
|
72
|
-
// unmapped tracker state → log + skip
|
|
73
|
-
// node in closeOn → runner.Close (terminal teardown)
|
|
74
|
-
// claimed by me, not prompted → bounce: Find → Nudge (or respawn)
|
|
75
|
-
// unclaimed → dispatch: Claim → Spawn
|
|
76
|
-
func (d *Daemon) PollOnce() {
|
|
77
|
-
found, err := d.tasks.List()
|
|
78
|
-
if err != nil {
|
|
79
|
-
log.Printf("poll %s: %v", d.cfg.Name, err)
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
for _, t := range found {
|
|
83
|
-
switch {
|
|
84
|
-
case t.ClaimedBy != "" && t.ClaimedBy != d.cfg.Name:
|
|
85
|
-
// Foreign workflow owns it — never touch.
|
|
86
|
-
case t.Node == "":
|
|
87
|
-
log.Printf("poll %s: %s at unmapped state, skipping", d.cfg.Name, t.Key)
|
|
88
|
-
case d.cfg.CloseOn.Has(t.Node):
|
|
89
|
-
log.Printf("poll %s: %s at terminal node %q, closing terminals", d.cfg.Name, t.Key, t.Node)
|
|
90
|
-
if err := d.runner.Close(t); err != nil {
|
|
91
|
-
log.Printf("poll %s: close %s: %v", d.cfg.Name, t.Key, err)
|
|
92
|
-
}
|
|
93
|
-
d.ClearNudged(t.Key)
|
|
94
|
-
case d.cfg.Nodes[t.Node].Agent == "":
|
|
95
|
-
// Human gate: no automation. Claim it so foreign workflows
|
|
96
|
-
// leave it alone, then leave the ticket for the human.
|
|
97
|
-
if t.ClaimedBy == "" {
|
|
98
|
-
if err := d.tasks.Claim(t); err != nil {
|
|
99
|
-
log.Printf("poll %s: claim %s (gate node): %v", d.cfg.Name, t.Key, err)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
case t.ClaimedBy == d.cfg.Name:
|
|
103
|
-
d.wg.Add(1)
|
|
104
|
-
go d.bounce(t)
|
|
105
|
-
default:
|
|
106
|
-
d.wg.Add(1)
|
|
107
|
-
go d.dispatch(t)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// dispatch claims an unclaimed ticket and spawns its agent session.
|
|
113
|
-
func (d *Daemon) dispatch(t tasks.Ticket) {
|
|
114
|
-
defer d.wg.Done()
|
|
115
|
-
node := d.cfg.Nodes[t.Node]
|
|
116
|
-
if err := d.tasks.Claim(t); err != nil {
|
|
117
|
-
log.Printf("dispatch %s: claim: %v", t.Key, err)
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
prompt := initialPrompt(d.cfg, t.Node, t)
|
|
121
|
-
env := map[string]string{
|
|
122
|
-
"RELAY_FLOW_WORKFLOW": d.cfg.Name,
|
|
123
|
-
"RELAY_FLOW_TICKET": t.Key,
|
|
124
|
-
"RELAY_FLOW_NODE": t.Node,
|
|
125
|
-
"RELAY_FLOW_AGENT": node.Agent,
|
|
126
|
-
}
|
|
127
|
-
if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
|
|
128
|
-
log.Printf("dispatch %s: spawn: %v", t.Key, err)
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
d.markNudged(t.Key, t.Node)
|
|
132
|
-
log.Printf("dispatch %s: spawned %q for node %q", t.Key, node.Agent, t.Node)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// bounce handles a ticket this workflow claimed but has no in-memory
|
|
136
|
-
// record of (server restart): find the live terminal and nudge it, once
|
|
137
|
-
// per node visit. No terminal → spawn fresh (claim already held).
|
|
138
|
-
func (d *Daemon) bounce(t tasks.Ticket) {
|
|
139
|
-
defer d.wg.Done()
|
|
140
|
-
node := d.cfg.Nodes[t.Node]
|
|
141
|
-
sess, ok, err := d.runner.Find(t, t.Node)
|
|
142
|
-
if err != nil {
|
|
143
|
-
log.Printf("bounce %s: find: %v", t.Key, err)
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
if !ok {
|
|
147
|
-
// No live session: marker is irrelevant — always respawn (a
|
|
148
|
-
// terminal may have died after we marked it prompted).
|
|
149
|
-
d.ClearNudged(t.Key)
|
|
150
|
-
// Crash took the terminal with it: spawn a fresh session.
|
|
151
|
-
prompt := initialPrompt(d.cfg, t.Node, t)
|
|
152
|
-
env := map[string]string{
|
|
153
|
-
"RELAY_FLOW_WORKFLOW": d.cfg.Name,
|
|
154
|
-
"RELAY_FLOW_TICKET": t.Key,
|
|
155
|
-
"RELAY_FLOW_NODE": t.Node,
|
|
156
|
-
"RELAY_FLOW_AGENT": node.Agent,
|
|
157
|
-
}
|
|
158
|
-
if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
|
|
159
|
-
log.Printf("bounce %s: spawn: %v", t.Key, err)
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
d.markNudged(t.Key, t.Node)
|
|
163
|
-
log.Printf("bounce %s: no live session, spawned fresh for node %q", t.Key, t.Node)
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
if d.nudgedNode(t.Key) == t.Node {
|
|
167
|
-
return // session alive and already prompted for this visit
|
|
168
|
-
}
|
|
169
|
-
prompt := renderNudge(node.NudgePrompt, t.Key, t.Node)
|
|
170
|
-
if err := d.runner.Nudge(sess, prompt); err != nil {
|
|
171
|
-
log.Printf("bounce %s: nudge: %v (retry next poll)", t.Key, err)
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
d.markNudged(t.Key, t.Node)
|
|
175
|
-
log.Printf("bounce %s: nudged %q for node %q", t.Key, sess.Title, t.Node)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ClearNudged drops the prompted marker for a ticket — called when a
|
|
179
|
-
// report moves it to a new node, re-arming the next visit's nudge.
|
|
180
|
-
func (d *Daemon) ClearNudged(key string) {
|
|
181
|
-
d.nudgedMu.Lock()
|
|
182
|
-
delete(d.nudged, key)
|
|
183
|
-
d.nudgedMu.Unlock()
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
func (d *Daemon) markNudged(key, node string) {
|
|
187
|
-
d.nudgedMu.Lock()
|
|
188
|
-
d.nudged[key] = node
|
|
189
|
-
d.nudgedMu.Unlock()
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
func (d *Daemon) nudgedNode(key string) string {
|
|
193
|
-
d.nudgedMu.Lock()
|
|
194
|
-
defer d.nudgedMu.Unlock()
|
|
195
|
-
return d.nudged[key]
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// initialPrompt tells the agent which ticket it owns and the STATUS/
|
|
199
|
-
// SUMMARY handoff contract (statuses success|failure). The agent fetches
|
|
200
|
-
// ticket details itself via acli — nothing is injected here so feedback
|
|
201
|
-
// is always picked up fresh. Flattened to one line: the command is typed
|
|
202
|
-
// into a pty via keystroke simulation.
|
|
203
|
-
func initialPrompt(cfg *config.Config, nodeName string, t tasks.Ticket) string {
|
|
204
|
-
prompt := fmt.Sprintf(
|
|
205
|
-
"You have been assigned ticket %s. Run `acli jira workitem view %s --fields summary,description,comment --json` "+
|
|
206
|
-
"to fetch its details and full comment history. When you are done, end your final message with exactly: "+
|
|
207
|
-
"STATUS: <success or failure> SUMMARY: <one-line summary of what you did>",
|
|
208
|
-
t.Key, t.Key)
|
|
209
|
-
return strings.Join(strings.Fields(prompt), " ")
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// renderNudge applies {{ticket}}/{{node}} templating and flattens to one
|
|
213
|
-
// line (keystroke simulation submits on newline).
|
|
214
|
-
func renderNudge(tmpl, ticket, node string) string {
|
|
215
|
-
s := strings.ReplaceAll(tmpl, "{{ticket}}", ticket)
|
|
216
|
-
s = strings.ReplaceAll(s, "{{node}}", node)
|
|
217
|
-
return strings.Join(strings.Fields(s), " ")
|
|
218
|
-
}
|