From 74d461e9cdb010a9901dd09d0667234a111fd7b9 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 16 Jun 2026 13:39:36 -0400 Subject: [PATCH] simulate: Node agent support, --view mode, and TUI/start UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports our simulate work onto the Node-support branch: - Node agent start: normalizeLogLevel, --log-format only for Python, main.ts/main.js entrypoints, and refuse entrypoint discovery when package.json defines a build task; drop the Python-only guard. - `lk agent simulate --view `: open a pre-existing simulation and print the reopen command on close. - TUI: ←/→ arrow navigation in/out of simulation detail, richer agent-exit errors (start command + recent output), and quit when the agent fails to register. --- cmd/lk/simulate.go | 70 +++++++++++++++++++++++++++++------ cmd/lk/simulate_subprocess.go | 39 ++++++++++++++++++- cmd/lk/simulate_tui.go | 40 ++++++++++++++------ 3 files changed, 124 insertions(+), 25 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index d33df528..9a3c2bee 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -24,6 +24,7 @@ import ( "io" "math/rand" "os" + "path/filepath" "time" "github.com/mattn/go-isatty" @@ -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", + }, }, } @@ -111,6 +116,7 @@ type simulateConfig struct { entrypoint string cfg *simulationConfig scenarioGroupID string + viewModeRunID string } // simulateMode represents how scenarios are sourced. @@ -121,6 +127,7 @@ const ( modeScenarioGroup modeGenerateFromDescription modeGenerateFromSource + modeView ) func loadSimulationConfig(path string) (*simulationConfig, error) { @@ -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 @@ -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 != "": @@ -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 } @@ -205,6 +246,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { entrypoint: entrypoint, cfg: cfg, scenarioGroupID: scenarioGroupID, + viewModeRunID: runID, } if !isInteractive() { @@ -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, diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index d1bf90c1..77a660f3 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -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"} } @@ -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"} } @@ -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, "{") diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 30cef4d5..85d30f0f 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -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 { @@ -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"}, @@ -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()} } @@ -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() @@ -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) { @@ -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 @@ -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") @@ -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")