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
70 changes: 59 additions & 11 deletions cmd/lk/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"io"
"math/rand"
"os"
"path/filepath"
"time"

"github.com/mattn/go-isatty"
Expand Down Expand Up @@ -81,6 +82,10 @@ var simulateCommand = &cli.Command{
Name: "config",
Usage: "Path to simulation config `FILE`",
},
&cli.StringFlag{
Name: "view",
Usage: "Open a pre-existing simulation",
},
},
}

Expand Down Expand Up @@ -111,6 +116,7 @@ type simulateConfig struct {
entrypoint string
cfg *simulationConfig
scenarioGroupID string
viewModeRunID string
}

// simulateMode represents how scenarios are sourced.
Expand All @@ -121,6 +127,7 @@ const (
modeScenarioGroup
modeGenerateFromDescription
modeGenerateFromSource
modeView
)

func loadSimulationConfig(path string) (*simulationConfig, error) {
Expand All @@ -147,6 +154,27 @@ func generateAgentName() string {
return "simulation-" + string(b)
}

type PackageJSON struct {
Scripts map[string]string `json:"scripts"`
}

// buildTaskExists reports whether package.json defines a "build" script. Such a
// task usually means the entrypoint path is nontrivial (todo: check dist/main.js).
func buildTaskExists(projectDir string) (bool, error) {
data, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
if err != nil {
return false, err
}

var pkg PackageJSON
if err := json.Unmarshal(data, &pkg); err != nil {
return false, err
}

_, ok := pkg.Scripts["build"]
return ok, nil
}

func runSimulate(ctx context.Context, cmd *cli.Command) error {
pc := simulateProjectConfig

Expand All @@ -163,10 +191,13 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {

numSimulations := int32(cmd.Int("num-simulations"))
scenarioGroupID := cmd.String("scenario-group-id")
runID := cmd.String("view")
agentName := generateAgentName()

var mode simulateMode
switch {
case runID != "":
mode = modeView
case cfg != nil && len(cfg.Scenarios) > 0:
mode = modeInlineScenarios
case scenarioGroupID != "":
Expand All @@ -181,11 +212,21 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}
if !projectType.IsPython() {
return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType)

entrypointArg := cmd.Args().First()

// check if a script called "build" exists in the package.json, if so, refuse to discover the
// entrypoint: build tasks usually mean the entrypoint path is nontrivial (e.g. dist/main.js)
if projectType.IsNode() && entrypointArg == "" {
buildTaskDoesExist, err := buildTaskExists(projectDir)
if err != nil {
return err
} else if buildTaskDoesExist {
return fmt.Errorf("you currently have a build task in your package.json, but no entrypoint was explicitly given; so you must add an entrypoint to the simulate cli")
}
}

entrypoint, err := findEntrypoint(projectDir, cmd.Args().First(), projectType)
entrypoint, err := findEntrypoint(projectDir, entrypointArg, projectType)
if err != nil {
return err
}
Expand All @@ -205,6 +246,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
entrypoint: entrypoint,
cfg: cfg,
scenarioGroupID: scenarioGroupID,
viewModeRunID: runID,
}

if !isInteractive() {
Expand All @@ -223,18 +265,24 @@ func isInteractive() bool {
// --- Shared lifecycle functions used by both TUI and CI modes ---

func startSimulationAgent(c *simulateConfig, forwardOutput io.Writer) (*AgentProcess, error) {
args := []string{
"start",
"--url", c.pc.URL,
"--api-key", c.pc.APIKey,
"--api-secret", c.pc.APISecret,
"--log-level", normalizeLogLevel(c.projectType, "DEBUG"),
}

// --log-format is a Python-only flag; the Node CLI doesn't accept it.
if c.projectType.IsPython() {
args = append(args, "--log-format", "colored")
}

return startAgent(AgentStartConfig{
Dir: c.projectDir,
Entrypoint: c.entrypoint,
ProjectType: c.projectType,
CLIArgs: []string{
"start",
"--url", c.pc.URL,
"--api-key", c.pc.APIKey,
"--api-secret", c.pc.APISecret,
"--log-level", "DEBUG",
"--log-format", "colored",
},
CLIArgs: args,
Env: []string{
"LIVEKIT_AGENT_NAME=" + c.agentName,
"LIVEKIT_URL=" + c.pc.URL,
Expand Down
39 changes: 37 additions & 2 deletions cmd/lk/simulate_subprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func checkTypeStrippingSupport(dir, nodeBin string) error {
// order. Forward slashes are valid on all platforms.
func defaultEntrypoints(projectType agentfs.ProjectType) []string {
if projectType.IsNode() {
return []string{"agent.ts", "agent.js"}
return []string{"main.ts", "src/main.js"}
}
return []string{"agent.py"}
}
Expand All @@ -144,7 +144,7 @@ func defaultEntrypoints(projectType agentfs.ProjectType) []string {
// user's working directory.
func fallbackEntrypoints(projectType agentfs.ProjectType) []string {
if projectType.IsNode() {
return []string{"src/agent.ts", "src/agent.js"}
return []string{"src/main.ts", "src/main.js"}
}
return []string{"src/agent.py"}
}
Expand Down Expand Up @@ -449,6 +449,41 @@ func (ap *AgentProcess) RecentRoomLogsByPrefix(n int, roomName string) []string

var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)

// agentExitDetail surfaces the agent's own output and the log path when the
// worker exits early or never registers.
func agentExitDetail(ap *AgentProcess) string {
logs := ap.RecentLogs(0)

var b strings.Builder

if len(logs) == 0 {
b.WriteString("Agent exited with no output.")
} else if tail := lastNonEmptyLines(logs, 12); len(tail) > 0 {
for i, l := range tail {
tail[i] = ansiEscapeRe.ReplaceAllString(l, "")
}
b.WriteString("Agent output:\n " + strings.Join(tail, "\n "))
}

if ap.LogPath != "" {
if b.Len() > 0 {
b.WriteString("\n\n")
}
b.WriteString("Full log: " + ap.LogPath)
}
return b.String()
}

func lastNonEmptyLines(lines []string, n int) []string {
var out []string
for i := len(lines) - 1; i >= 0 && len(out) < n; i-- {
if strings.TrimSpace(lines[i]) != "" {
out = append([]string{lines[i]}, out...)
}
}
return out
}

func extractLogRoom(line string) string {
clean := ansiEscapeRe.ReplaceAllString(line, "")
idx := strings.LastIndex(clean, "{")
Expand Down
40 changes: 28 additions & 12 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ func runSimulateTUI(config *simulateConfig) error {
fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url)
}

if m.runID != "" && !m.runFinished {
if m.config.mode == modeView {
fmt.Fprintf(os.Stderr, "To re-open this simulation, run: lk agent simulate --view %s\n", m.config.viewModeRunID)
} else if m.runID != "" && !m.runFinished {
cancelSimulationRun(config.client, m.runID)
} else if m.runID != "" {
fmt.Fprintf(os.Stderr, "To re-open this simulation, run: lk agent simulate --view %s\n", m.runID)
}

if m.err != nil && m.err != context.Canceled {
Expand Down Expand Up @@ -184,6 +188,18 @@ func (m *simulateModel) Init() tea.Cmd {
func (m *simulateModel) runSetup() tea.Cmd {
c := m.config

if m.config.mode == modeView {
ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout)
defer cancel()
run, err := getSimulationRun(ctx, m.config.client, m.config.viewModeRunID)
if err != nil {
m.err = err
}
m.run = run
m.setupDone = true
return nil
}

m.steps = []step{
{label: "Starting agent", status: "running"},
{label: "Creating simulation", status: "pending"},
Expand Down Expand Up @@ -238,14 +254,11 @@ func (m *simulateModel) waitAgentReadyCmd() tea.Cmd {
select {
case <-m.agent.Ready():
return agentReadyMsg{elapsed: time.Since(stepStart)}
case err := <-m.agent.Done():
if err != nil {
return agentReadyMsg{err: fmt.Errorf("agent exited before registering: %w", err)}
}
return agentReadyMsg{err: fmt.Errorf("agent exited before registering")}
case <-m.agent.Done():
return agentReadyMsg{err: fmt.Errorf("the agent exited before registering.\n\nCommand used to start agent: %s\n\n%s", m.agent.cmd.String(), agentExitDetail(m.agent))}
case <-timeout.C:
m.agent.Kill()
return agentReadyMsg{err: fmt.Errorf("timed out waiting for agent to register (%s)", agentRegisterTimeout)}
return agentReadyMsg{err: fmt.Errorf("timed out after %s waiting for the agent to register.\n\n%s", agentRegisterTimeout, agentExitDetail(m.agent))}
case <-m.setupCtx.Done():
return agentReadyMsg{err: m.setupCtx.Err()}
}
Expand Down Expand Up @@ -321,7 +334,10 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case agentReadyMsg:
if msg.err != nil {
m.failSetupStep(msg.err)
return m, nil
if m.setupCancel != nil {
m.setupCancel()
}
return m, tea.Quit
}
m.advanceSetupStep(msg.elapsed)
return m, m.createSimulationCmd()
Expand Down Expand Up @@ -521,7 +537,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.logPinned = false
}
}
case "enter":
case "enter", "right":
if m.detailJobID == "" {
jobs := m.filteredJobs()
if m.cursor >= 0 && m.cursor < len(jobs) {
Expand All @@ -537,7 +553,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.save.fetchGroupsCmd(), saveSpinnerTickCmd())
}
}
case "esc", "backspace":
case "esc", "left", "backspace":
if m.detailJobID != "" {
m.detailJobID = ""
m.detailScrollOff = 0
Expand Down Expand Up @@ -1457,7 +1473,7 @@ func firstMeaningfulLine(text string) string {
func (m *simulateModel) renderHint() string {
var parts []string
if m.detailJobID != "" {
parts = append(parts, "↑↓ scroll · ESC back · s save scenario")
parts = append(parts, "↑↓ scroll · ESC/← back · s save scenario")
if m.hasLogs() {
if m.showLogs {
parts = append(parts, "Ctrl+L hide logs")
Expand All @@ -1466,7 +1482,7 @@ func (m *simulateModel) renderHint() string {
}
}
} else {
parts = append(parts, "↑↓ navigate · ENTER detail · d description")
parts = append(parts, "↑↓ navigate · ENTER/→ detail · d description")
if m.hasLogs() {
if m.showLogs {
parts = append(parts, "PgUp/PgDn scroll logs · Ctrl+L hide logs")
Expand Down