Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions cmd/lk/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,12 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) {

// scenariosFile mirrors a scenarios.yaml; `userdata` is a nested mapping here
// and JSON-encoded into the proto's string field. Each `nodes` entry is a
// SimulatorNode in protojson form.
// SimulatorNode in protojson form. `include` names files whose nodes join this
// file's (see simulate_include.go).
type scenariosFile struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Include []string `yaml:"include,omitempty"`
Nodes map[string]any `yaml:"nodes,omitempty"`
Scenarios []yamlScenario `yaml:"scenarios"`
}
Expand Down Expand Up @@ -469,15 +471,19 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) {
return nil, fmt.Errorf("scenarios file has no id")
}
group := &livekit.ScenarioGroup{Id: f.ID, Name: f.Name}
names := make([]string, 0, len(f.Nodes))
for name := range f.Nodes {
nodes, err := resolveNodes(path, f.Nodes, f.Include)
if err != nil {
return nil, err
}
names := make([]string, 0, len(nodes))
for name := range nodes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
node := &livekit.SimulatorNode{}
if err := yamlValueToProto(f.Nodes[name], node); err != nil {
return nil, fmt.Errorf("node %q: %w", name, err)
if err := yamlValueToProto(nodes[name].value, node); err != nil {
return nil, fmt.Errorf("node %q (%s): %w", name, filepath.Base(nodes[name].file), err)
}
if group.Nodes == nil {
group.Nodes = map[string]*livekit.SimulatorNode{}
Expand Down
146 changes: 146 additions & 0 deletions cmd/lk/simulate_include.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"

"gopkg.in/yaml.v3"
)

// includedFile is a scenarios.yaml `include` target: it only contributes
// nodes, so running one file never runs scenarios it did not list itself.
type includedFile struct {
Include []string `yaml:"include"`
Nodes map[string]any `yaml:"nodes"`
}

// sourcedNode is a node in YAML form and the file that defines it.
type sourcedNode struct {
value any
file string
}

// nodeResolver merges the nodes of a scenarios file and everything it
// includes. Paths resolve relative to the file that names them, and each file
// loads once by its real path, so a file reached through two includes is not a
// duplicate.
type nodeResolver struct {
root string // directory errors name files relative to
loaded map[string]bool
chain []string // real paths from the root file to the one loading now
nodes map[string]sourcedNode
}

func resolveNodes(path string, nodes map[string]any, include []string) (map[string]sourcedNode, error) {
real, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, err
}
r := &nodeResolver{
root: filepath.Dir(real),
loaded: map[string]bool{real: true},
chain: []string{real},
nodes: map[string]sourcedNode{},
}
if err := r.add(real, nodes); err != nil {
return nil, err
}
if err := r.includeAll(real, include); err != nil {
return nil, err
}
return r.nodes, nil
}

func (r *nodeResolver) includeAll(from string, include []string) error {
for _, rel := range include {
path := rel
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(from), rel)
}
real, err := filepath.EvalSymlinks(path)
if err != nil {
return fmt.Errorf("%s: include %q: %w", r.name(from), rel, err)
}
if slices.Contains(r.chain, real) {
return fmt.Errorf("include cycle: %s", r.chainNames(append(r.chain, real)))
}
if r.loaded[real] {
continue
}
r.loaded[real] = true
if err := r.load(real); err != nil {
return err
}
}
return nil
}

func (r *nodeResolver) load(real string) error {
data, err := os.ReadFile(real)
if err != nil {
return fmt.Errorf("%s: %w", r.name(real), err)
}
var keys map[string]any
if err := yaml.Unmarshal(data, &keys); err != nil {
return fmt.Errorf("%s: %w", r.name(real), err)
}
for key := range keys {
if key != "nodes" && key != "include" {
return fmt.Errorf("%s: an included file holds only `nodes` and `include`, not %q", r.name(real), key)
}
}
var f includedFile
if err := yaml.Unmarshal(data, &f); err != nil {
return fmt.Errorf("%s: %w", r.name(real), err)
}
if err := r.add(real, f.Nodes); err != nil {
return err
}
r.chain = append(r.chain, real)
defer func() { r.chain = r.chain[:len(r.chain)-1] }()
return r.includeAll(real, f.Include)
}

func (r *nodeResolver) add(file string, nodes map[string]any) error {
for name, value := range nodes {
if prev, ok := r.nodes[name]; ok {
return fmt.Errorf("node %q is defined in both %s and %s", name, r.name(prev.file), r.name(file))
}
r.nodes[name] = sourcedNode{value: value, file: file}
}
return nil
}

// name is how errors refer to a file: relative to the root file's directory,
// with forward slashes as in `include`.
func (r *nodeResolver) name(real string) string {
if rel, err := filepath.Rel(r.root, real); err == nil {
return filepath.ToSlash(rel)
}
return real
}

func (r *nodeResolver) chainNames(chain []string) string {
names := make([]string, len(chain))
for i, p := range chain {
names[i] = r.name(p)
}
return strings.Join(names, " -> ")
}
146 changes: 146 additions & 0 deletions cmd/lk/simulate_include_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"os"
"path/filepath"
"slices"
"testing"

"github.com/stretchr/testify/require"
)

// writeFiles writes each name -> content under one temp dir and returns it.
func writeFiles(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
return dir
}

const includingScenarios = `
id: SCNG_dental
include: [nodes/menus.yaml, nodes/people.yaml]
nodes:
greeting: { say: "Hi, you've reached Sam.", then: livekit/voicemail }
scenarios:
- id: SCN_a
label: menu-to-sam
start: menu
agent_expectations: Presses 1, then confirms with Sam.
`

func TestIncludedNodesJoinTheGroup(t *testing.T) {
dir := writeFiles(t, map[string]string{
"dental.yaml": includingScenarios,
// nested, with a path relative to the including file
"nodes/menus.yaml": `
include: [common.yaml]
nodes:
menu:
say: "For Sam, press 1."
routes: [{ match: "1", to: sam }]
timeout: { after: 8s, to: goodbye }
`,
// includes common.yaml too, so dental.yaml reaches it twice
"nodes/people.yaml": `
include: [common.yaml]
nodes:
sam: { instructions: You are Sam. }
`,
"nodes/common.yaml": `
nodes:
goodbye: { say: Goodbye., then: livekit/hangup }
`,
})

group, err := loadScenarioGroup(filepath.Join(dir, "dental.yaml"))
require.NoError(t, err)

names := make([]string, 0, len(group.Nodes))
for name := range group.Nodes {
names = append(names, name)
}
slices.Sort(names)
require.Equal(t, []string{"goodbye", "greeting", "menu", "sam"}, names)
require.Equal(t, "sam", group.Nodes["menu"].Routes[0].To)
require.Equal(t, "menu", group.Scenarios[0].Start)
}

func TestIncludeErrors(t *testing.T) {
cases := []struct {
name string
files map[string]string
want string
}{
{
name: "a cycle prints the include chain",
files: map[string]string{
"dental.yaml": includingScenarios,
"nodes/menus.yaml": "include: [common.yaml]\nnodes: { menu: { say: hi, then: livekit/hangup } }\n",
"nodes/common.yaml": "include: [menus.yaml]\n",
"nodes/people.yaml": "nodes: { sam: { instructions: x } }\n",
},
want: "include cycle: dental.yaml -> nodes/menus.yaml -> nodes/common.yaml -> nodes/menus.yaml",
},
{
name: "a node defined in two files names both",
files: map[string]string{
"dental.yaml": includingScenarios,
"nodes/menus.yaml": "nodes: { menu: { say: hi, then: livekit/hangup } }\n",
"nodes/people.yaml": "nodes: { menu: { say: hello, then: livekit/hangup } }\n",
},
want: `node "menu" is defined in both nodes/menus.yaml and nodes/people.yaml`,
},
{
name: "an included file cannot set scenarios",
files: map[string]string{
"dental.yaml": includingScenarios,
"nodes/menus.yaml": "nodes: { menu: { say: hi, then: livekit/hangup } }\nscenarios: []\n",
"nodes/people.yaml": "nodes: { sam: { instructions: x } }\n",
},
want: "nodes/menus.yaml: an included file holds only `nodes` and `include`, not \"scenarios\"",
},
{
name: "a missing include path",
files: map[string]string{
"dental.yaml": includingScenarios,
"nodes/menus.yaml": "nodes: { menu: { say: hi, then: livekit/hangup } }\n",
},
want: `dental.yaml: include "nodes/people.yaml"`,
},
{
name: "a bad included node names its file",
files: map[string]string{
"dental.yaml": includingScenarios,
"nodes/menus.yaml": "nodes: { menu: { sya: hi } }\n",
"nodes/people.yaml": "nodes: { sam: { instructions: x } }\n",
},
want: `node "menu" (menus.yaml)`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := writeFiles(t, c.files)
_, err := loadScenarioGroup(filepath.Join(dir, "dental.yaml"))
require.ErrorContains(t, err, c.want)
})
}
}
Loading