This commit is contained in:
2025-05-23 16:10:49 +04:00
parent 3f01074e3f
commit 29d0d25a3b
133 changed files with 346 additions and 168 deletions

View File

@@ -0,0 +1,212 @@
package playbook
import (
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
)
// State represents the parser state
type State int
const (
StateStart State = iota
StateCommentForActionMaybe
StateAction
StateOtherText
)
// PlayBookOptions contains options for creating a new PlayBook
type PlayBookOptions struct {
Text string
Path string
GitURL string
GitPull bool
GitBranch string
GitReset bool
Priority int
}
// AddText adds heroscript text to the playbook
func (p *PlayBook) AddText(text string, priority int) error {
// Normalize text
text = strings.ReplaceAll(text, "\t", " ")
var state State = StateStart
var action *Action
var comments []string
var paramsData []string
// Process each line
lines := strings.Split(text, "\n")
for _, line := range lines {
lineStrip := strings.TrimSpace(line)
if lineStrip == "" {
continue
}
// Handle action state
if state == StateAction {
if !strings.HasPrefix(line, " ") || lineStrip == "" || strings.HasPrefix(lineStrip, "!") {
state = StateStart
// End of action, parse params
if len(paramsData) > 0 {
params := strings.Join(paramsData, "\n")
err := action.Params.Parse(params)
if err != nil {
return err
}
// Remove ID from params if present
delete(action.Params.GetAll(), "id")
}
comments = []string{}
paramsData = []string{}
action = nil
} else {
paramsData = append(paramsData, line)
}
}
// Handle comment state
if state == StateCommentForActionMaybe {
if strings.HasPrefix(lineStrip, "//") {
comments = append(comments, strings.TrimLeft(lineStrip, "/ "))
} else {
if strings.HasPrefix(lineStrip, "!") {
state = StateStart
} else {
state = StateStart
p.OtherText += strings.Join(comments, "\n")
if !strings.HasSuffix(p.OtherText, "\n") {
p.OtherText += "\n"
}
comments = []string{}
}
}
}
// Handle start state
if state == StateStart {
if strings.HasPrefix(lineStrip, "!") && !strings.HasPrefix(lineStrip, "![") {
// Start a new action
state = StateAction
// Create new action
action = &Action{
ID: p.NrActions + 1,
Priority: priority,
Params: paramsparser.New(),
Result: paramsparser.New(),
}
p.NrActions++
// Set comments
action.Comments = strings.Join(comments, "\n")
comments = []string{}
paramsData = []string{}
// Parse action name
actionName := lineStrip
if strings.Contains(lineStrip, " ") {
actionName = strings.TrimSpace(strings.Split(lineStrip, " ")[0])
params := strings.TrimSpace(strings.Join(strings.Split(lineStrip, " ")[1:], " "))
if params != "" {
paramsData = append(paramsData, params)
}
}
// Determine action type
if strings.HasPrefix(actionName, "!!!!!") {
return ErrInvalidActionPrefix
} else if strings.HasPrefix(actionName, "!!!!") {
action.ActionType = ActionTypeWAL
} else if strings.HasPrefix(actionName, "!!!") {
action.ActionType = ActionTypeMacro
} else if strings.HasPrefix(actionName, "!!") {
action.ActionType = ActionTypeSAL
} else if strings.HasPrefix(actionName, "!") {
action.ActionType = ActionTypeDAL
}
// Remove prefix
actionName = strings.TrimLeft(actionName, "!")
// Split into actor and action name
parts := strings.Split(actionName, ".")
if len(parts) == 1 {
action.Actor = "core"
action.Name = tools.NameFix(parts[0])
} else if len(parts) == 2 {
action.Actor = tools.NameFix(parts[0])
action.Name = tools.NameFix(parts[1])
} else {
return ErrInvalidActionName
}
// Add action to playbook
p.Actions = append(p.Actions, action)
continue
} else if strings.HasPrefix(lineStrip, "//") {
state = StateCommentForActionMaybe
comments = append(comments, strings.TrimLeft(lineStrip, "/ "))
}
}
}
// Process the last action if needed
if state == StateAction && action != nil && action.ID != 0 {
if len(paramsData) > 0 {
params := strings.Join(paramsData, "\n")
err := action.Params.Parse(params)
if err != nil {
return err
}
// Remove ID from params if present
delete(action.Params.GetAll(), "id")
}
}
// Process the last comment if needed
if state == StateCommentForActionMaybe && len(comments) > 0 {
p.OtherText += strings.Join(comments, "\n")
}
return nil
}
// NewFromFile creates a new PlayBook from a file
func NewFromFile(path string, priority int) (*PlayBook, error) {
// This is a simplified version - in a real implementation, you'd read the file
// and handle different file types (md, hero, etc.)
// For now, we'll just create an empty playbook
pb := New()
// TODO: Implement file reading and parsing
return pb, nil
}
// Errors
var (
ErrInvalidActionPrefix = NewError("invalid action prefix")
ErrInvalidActionName = NewError("invalid action name")
)
// NewError creates a new error
func NewError(msg string) error {
return &PlayBookError{msg}
}
// PlayBookError represents a playbook error
type PlayBookError struct {
Msg string
}
// Error returns the error message
func (e *PlayBookError) Error() string {
return e.Msg
}

View File

@@ -0,0 +1,301 @@
package playbook
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"sort"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
)
// ActionType represents the type of action
type ActionType int
const (
ActionTypeUnknown ActionType = iota
ActionTypeDAL
ActionTypeSAL
ActionTypeWAL
ActionTypeMacro
)
// Action represents a single action in a heroscript
type Action struct {
ID int
CID string
Name string
Actor string
Priority int
Params *paramsparser.ParamsParser
Result *paramsparser.ParamsParser
ActionType ActionType
Comments string
Done bool
}
// PlayBook represents a collection of actions
type PlayBook struct {
Actions []*Action
Priorities map[int][]int // key is priority, value is list of action indices
OtherText string // text outside of actions
Result string
NrActions int
Done []int
}
// NewAction creates a new action and adds it to the playbook
func (p *PlayBook) NewAction(cid, name, actor string, priority int, actionType ActionType) *Action {
p.NrActions++
action := &Action{
ID: p.NrActions,
CID: cid,
Name: name,
Actor: actor,
Priority: priority,
ActionType: actionType,
Params: paramsparser.New(),
Result: paramsparser.New(),
}
p.Actions = append(p.Actions, action)
return action
}
// New creates a new PlayBook
func New() *PlayBook {
return &PlayBook{
Actions: make([]*Action, 0),
Priorities: make(map[int][]int),
NrActions: 0,
Done: make([]int, 0),
}
}
// NewFromText creates a new PlayBook from heroscript text
func NewFromText(text string) (*PlayBook, error) {
pb := New()
err := pb.AddText(text, 10) // Default priority 10
if err != nil {
return nil, err
}
return pb, nil
}
// String returns the heroscript representation of the action
func (a *Action) String() string {
out := a.HeroScript()
if a.Result != nil && len(a.Result.GetAll()) > 0 {
out += "\n\nResult:\n"
// Indent the result
resultParams := a.Result.GetAll()
for k, v := range resultParams {
out += " " + k + ": '" + v + "'\n"
}
}
return out
}
// HeroScript returns the heroscript representation of the action
func (a *Action) HeroScript() string {
var out strings.Builder
// Add comments if any
if a.Comments != "" {
lines := strings.Split(a.Comments, "\n")
for _, line := range lines {
out.WriteString("// " + line + "\n")
}
}
// Add action type prefix
switch a.ActionType {
case ActionTypeDAL:
out.WriteString("!")
case ActionTypeSAL:
out.WriteString("!!")
case ActionTypeMacro:
out.WriteString("!!!")
default:
out.WriteString("!!") // Default to SAL
}
// Add actor and name
if a.Actor != "" {
out.WriteString(a.Actor + ".")
}
out.WriteString(a.Name + " ")
// Add ID if present
if a.ID > 0 {
out.WriteString(fmt.Sprintf("id:%d ", a.ID))
}
// Add parameters
if a.Params != nil && len(a.Params.GetAll()) > 0 {
params := a.Params.GetAll()
firstLine := true
for k, v := range params {
if firstLine {
out.WriteString(k + ":'" + v + "'\n")
firstLine = false
} else {
out.WriteString(" " + k + ":'" + v + "'\n")
}
}
}
return out.String()
}
// HashKey returns a unique hash for the action
func (a *Action) HashKey() string {
h := sha1.New()
h.Write([]byte(a.HeroScript()))
return hex.EncodeToString(h.Sum(nil))
}
// HashKey returns a unique hash for the playbook
func (p *PlayBook) HashKey() string {
h := sha1.New()
for _, action := range p.Actions {
h.Write([]byte(action.HashKey()))
}
return hex.EncodeToString(h.Sum(nil))
}
// HeroScript returns the heroscript representation of the playbook
func (p *PlayBook) HeroScript(showDone bool) string {
var out strings.Builder
actions, _ := p.ActionsSorted(false)
for _, action := range actions {
if !showDone && action.Done {
continue
}
out.WriteString(action.HeroScript() + "\n")
}
if p.OtherText != "" {
out.WriteString(p.OtherText)
}
return out.String()
}
// ActionsSorted returns the actions sorted by priority
func (p *PlayBook) ActionsSorted(prioOnly bool) ([]*Action, error) {
var result []*Action
// If no priorities are set, return all actions
if len(p.Priorities) == 0 {
return p.Actions, nil
}
// Get all priority numbers and sort them
var priorities []int
for prio := range p.Priorities {
priorities = append(priorities, prio)
}
sort.Ints(priorities)
// Add actions in priority order
for _, prio := range priorities {
if prioOnly && prio > 49 {
continue
}
actionIDs := p.Priorities[prio]
for _, id := range actionIDs {
action, err := p.GetAction(id, "", "")
if err != nil {
return nil, err
}
result = append(result, action)
}
}
return result, nil
}
// GetAction finds an action by ID, actor, or name
func (p *PlayBook) GetAction(id int, actor, name string) (*Action, error) {
actions, err := p.FindActions(id, actor, name, ActionTypeUnknown)
if err != nil {
return nil, err
}
if len(actions) == 1 {
return actions[0], nil
} else if len(actions) == 0 {
return nil, fmt.Errorf("couldn't find action with id: %d, actor: %s, name: %s", id, actor, name)
} else {
return nil, fmt.Errorf("multiple actions found with id: %d, actor: %s, name: %s", id, actor, name)
}
}
// FindActions finds actions based on criteria
func (p *PlayBook) FindActions(id int, actor, name string, actionType ActionType) ([]*Action, error) {
var result []*Action
for _, a := range p.Actions {
// If ID is specified, return only the action with that ID
if id != 0 {
if a.ID == id {
return []*Action{a}, nil
}
continue
}
// Filter by actor if specified
if actor != "" && a.Actor != actor {
continue
}
// Filter by name if specified
if name != "" && a.Name != name {
continue
}
// Filter by actionType if specified
if actionType != ActionTypeUnknown && a.ActionType != actionType {
continue
}
// If the action passes all filters, add it to the result
result = append(result, a)
}
return result, nil
}
// ActionExists checks if an action exists
func (p *PlayBook) ActionExists(id int, actor, name string) bool {
actions, err := p.FindActions(id, actor, name, ActionTypeUnknown)
if err != nil || len(actions) == 0 {
return false
}
return true
}
// String returns a string representation of the playbook
func (p *PlayBook) String() string {
return p.HeroScript(true)
}
// EmptyCheck checks if there are any actions left to execute
func (p *PlayBook) EmptyCheck() error {
var undoneActions []*Action
for _, a := range p.Actions {
if !a.Done {
undoneActions = append(undoneActions, a)
}
}
if len(undoneActions) > 0 {
return fmt.Errorf("there are actions left to execute: %d", len(undoneActions))
}
return nil
}

View File

@@ -0,0 +1,211 @@
package playbook
import (
"strings"
"testing"
)
const testText1 = `
//comment for the action
!!mailclient.configure host:localhost
name: 'myname'
port:25
secure: 1
reset:1
description:'
a description can be multiline
like this
'
`
func TestParse(t *testing.T) {
pb, err := NewFromText(testText1)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
if len(pb.Actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(pb.Actions))
}
action := pb.Actions[0]
if action.Actor != "mailclient" {
t.Errorf("Expected actor 'mailclient', got '%s'", action.Actor)
}
if action.Name != "configure" {
t.Errorf("Expected name 'configure', got '%s'", action.Name)
}
if action.Comments != "comment for the action" {
t.Errorf("Expected comment 'comment for the action', got '%s'", action.Comments)
}
// Test params
name := action.Params.Get("name")
if name != "myname" {
t.Errorf("Expected name 'myname', got '%s'", name)
}
host := action.Params.Get("host")
if host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", host)
}
port, err := action.Params.GetInt("port")
if err != nil || port != 25 {
t.Errorf("Expected port 25, got %d, error: %v", port, err)
}
secure := action.Params.GetBool("secure")
if !secure {
t.Errorf("Expected secure to be true, got false")
}
reset := action.Params.GetBool("reset")
if !reset {
t.Errorf("Expected reset to be true, got false")
}
// Test multiline description
desc := action.Params.Get("description")
// Just check that the description contains the expected text
if !strings.Contains(desc, "a description can be multiline") || !strings.Contains(desc, "like this") {
t.Errorf("Description doesn't contain expected content: '%s'", desc)
}
}
func TestHeroScript(t *testing.T) {
pb, err := NewFromText(testText1)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
// Generate heroscript
script := pb.HeroScript(true)
// Parse the generated script again
pb2, err := NewFromText(script)
if err != nil {
t.Fatalf("Failed to parse generated script: %v", err)
}
// Verify the actions are the same
if len(pb2.Actions) != len(pb.Actions) {
t.Errorf("Expected %d actions, got %d", len(pb.Actions), len(pb2.Actions))
}
// Verify the actions have the same actor and name
if pb.Actions[0].Actor != pb2.Actions[0].Actor || pb.Actions[0].Name != pb2.Actions[0].Name {
t.Errorf("Actions don't match: %s.%s vs %s.%s",
pb.Actions[0].Actor, pb.Actions[0].Name,
pb2.Actions[0].Actor, pb2.Actions[0].Name)
}
// Verify the parameters are the same
params1 := pb.Actions[0].Params.GetAll()
params2 := pb2.Actions[0].Params.GetAll()
// Check that all keys in params1 exist in params2
for k, v1 := range params1 {
v2, exists := params2[k]
if !exists {
t.Errorf("Key %s missing in generated script", k)
continue
}
// For multiline strings, just check that they contain the same content
if strings.Contains(v1, "\n") {
if !strings.Contains(v2, "description") || !strings.Contains(v2, "multiline") {
t.Errorf("Multiline value for key %s doesn't match: '%s' vs '%s'", k, v1, v2)
}
} else if v1 != v2 {
t.Errorf("Value for key %s doesn't match: '%s' vs '%s'", k, v1, v2)
}
}
}
func TestSpacedValues(t *testing.T) {
const spacedValuesText = `
!!mailclient.configure
name: 'myname'
host: 'localhost'
port: 25
secure: 1
description: 'This is a description'
`
pb, err := NewFromText(spacedValuesText)
if err != nil {
t.Fatalf("Failed to parse text with spaces between colon and quoted values: %v", err)
}
if len(pb.Actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(pb.Actions))
}
action := pb.Actions[0]
if action.Actor != "mailclient" || action.Name != "configure" {
t.Errorf("Action incorrect: %s.%s", action.Actor, action.Name)
}
// Test params with spaces after colon
name := action.Params.Get("name")
if name != "myname" {
t.Errorf("Expected name 'myname', got '%s'", name)
}
host := action.Params.Get("host")
if host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", host)
}
desc := action.Params.Get("description")
if desc != "This is a description" {
t.Errorf("Expected description 'This is a description', got '%s'", desc)
}
}
func TestMultipleActions(t *testing.T) {
const multipleActionsText = `
!!mailclient.configure
name:'myname'
host:'localhost'
!!system.update
force:1
packages:'git,curl,wget'
`
pb, err := NewFromText(multipleActionsText)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
if len(pb.Actions) != 2 {
t.Errorf("Expected 2 actions, got %d", len(pb.Actions))
}
// Check first action
action1 := pb.Actions[0]
if action1.Actor != "mailclient" || action1.Name != "configure" {
t.Errorf("First action incorrect: %s.%s", action1.Actor, action1.Name)
}
// Check second action
action2 := pb.Actions[1]
if action2.Actor != "system" || action2.Name != "update" {
t.Errorf("Second action incorrect: %s.%s", action2.Actor, action2.Name)
}
force := action2.Params.GetBool("force")
if !force {
t.Errorf("Expected force to be true, got false")
}
packages := action2.Params.Get("packages")
if packages != "git,curl,wget" {
t.Errorf("Expected packages 'git,curl,wget', got '%s'", packages)
}
}