Skip to content

Commit 74d461e

Browse files
committed
simulate: Node agent support, --view mode, and TUI/start UX fixes
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 <runID>`: 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.
1 parent 6cece30 commit 74d461e

3 files changed

Lines changed: 124 additions & 25 deletions

File tree

cmd/lk/simulate.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"io"
2525
"math/rand"
2626
"os"
27+
"path/filepath"
2728
"time"
2829

2930
"github.com/mattn/go-isatty"
@@ -81,6 +82,10 @@ var simulateCommand = &cli.Command{
8182
Name: "config",
8283
Usage: "Path to simulation config `FILE`",
8384
},
85+
&cli.StringFlag{
86+
Name: "view",
87+
Usage: "Open a pre-existing simulation",
88+
},
8489
},
8590
}
8691

@@ -111,6 +116,7 @@ type simulateConfig struct {
111116
entrypoint string
112117
cfg *simulationConfig
113118
scenarioGroupID string
119+
viewModeRunID string
114120
}
115121

116122
// simulateMode represents how scenarios are sourced.
@@ -121,6 +127,7 @@ const (
121127
modeScenarioGroup
122128
modeGenerateFromDescription
123129
modeGenerateFromSource
130+
modeView
124131
)
125132

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

157+
type PackageJSON struct {
158+
Scripts map[string]string `json:"scripts"`
159+
}
160+
161+
// buildTaskExists reports whether package.json defines a "build" script. Such a
162+
// task usually means the entrypoint path is nontrivial (todo: check dist/main.js).
163+
func buildTaskExists(projectDir string) (bool, error) {
164+
data, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
165+
if err != nil {
166+
return false, err
167+
}
168+
169+
var pkg PackageJSON
170+
if err := json.Unmarshal(data, &pkg); err != nil {
171+
return false, err
172+
}
173+
174+
_, ok := pkg.Scripts["build"]
175+
return ok, nil
176+
}
177+
150178
func runSimulate(ctx context.Context, cmd *cli.Command) error {
151179
pc := simulateProjectConfig
152180

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

164192
numSimulations := int32(cmd.Int("num-simulations"))
165193
scenarioGroupID := cmd.String("scenario-group-id")
194+
runID := cmd.String("view")
166195
agentName := generateAgentName()
167196

168197
var mode simulateMode
169198
switch {
199+
case runID != "":
200+
mode = modeView
170201
case cfg != nil && len(cfg.Scenarios) > 0:
171202
mode = modeInlineScenarios
172203
case scenarioGroupID != "":
@@ -181,11 +212,21 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
181212
if err != nil {
182213
return err
183214
}
184-
if !projectType.IsPython() {
185-
return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType)
215+
216+
entrypointArg := cmd.Args().First()
217+
218+
// check if a script called "build" exists in the package.json, if so, refuse to discover the
219+
// entrypoint: build tasks usually mean the entrypoint path is nontrivial (e.g. dist/main.js)
220+
if projectType.IsNode() && entrypointArg == "" {
221+
buildTaskDoesExist, err := buildTaskExists(projectDir)
222+
if err != nil {
223+
return err
224+
} else if buildTaskDoesExist {
225+
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")
226+
}
186227
}
187228

188-
entrypoint, err := findEntrypoint(projectDir, cmd.Args().First(), projectType)
229+
entrypoint, err := findEntrypoint(projectDir, entrypointArg, projectType)
189230
if err != nil {
190231
return err
191232
}
@@ -205,6 +246,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
205246
entrypoint: entrypoint,
206247
cfg: cfg,
207248
scenarioGroupID: scenarioGroupID,
249+
viewModeRunID: runID,
208250
}
209251

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

225267
func startSimulationAgent(c *simulateConfig, forwardOutput io.Writer) (*AgentProcess, error) {
268+
args := []string{
269+
"start",
270+
"--url", c.pc.URL,
271+
"--api-key", c.pc.APIKey,
272+
"--api-secret", c.pc.APISecret,
273+
"--log-level", normalizeLogLevel(c.projectType, "DEBUG"),
274+
}
275+
276+
// --log-format is a Python-only flag; the Node CLI doesn't accept it.
277+
if c.projectType.IsPython() {
278+
args = append(args, "--log-format", "colored")
279+
}
280+
226281
return startAgent(AgentStartConfig{
227282
Dir: c.projectDir,
228283
Entrypoint: c.entrypoint,
229284
ProjectType: c.projectType,
230-
CLIArgs: []string{
231-
"start",
232-
"--url", c.pc.URL,
233-
"--api-key", c.pc.APIKey,
234-
"--api-secret", c.pc.APISecret,
235-
"--log-level", "DEBUG",
236-
"--log-format", "colored",
237-
},
285+
CLIArgs: args,
238286
Env: []string{
239287
"LIVEKIT_AGENT_NAME=" + c.agentName,
240288
"LIVEKIT_URL=" + c.pc.URL,

cmd/lk/simulate_subprocess.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func checkTypeStrippingSupport(dir, nodeBin string) error {
134134
// order. Forward slashes are valid on all platforms.
135135
func defaultEntrypoints(projectType agentfs.ProjectType) []string {
136136
if projectType.IsNode() {
137-
return []string{"agent.ts", "agent.js"}
137+
return []string{"main.ts", "src/main.js"}
138138
}
139139
return []string{"agent.py"}
140140
}
@@ -144,7 +144,7 @@ func defaultEntrypoints(projectType agentfs.ProjectType) []string {
144144
// user's working directory.
145145
func fallbackEntrypoints(projectType agentfs.ProjectType) []string {
146146
if projectType.IsNode() {
147-
return []string{"src/agent.ts", "src/agent.js"}
147+
return []string{"src/main.ts", "src/main.js"}
148148
}
149149
return []string{"src/agent.py"}
150150
}
@@ -449,6 +449,41 @@ func (ap *AgentProcess) RecentRoomLogsByPrefix(n int, roomName string) []string
449449

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

452+
// agentExitDetail surfaces the agent's own output and the log path when the
453+
// worker exits early or never registers.
454+
func agentExitDetail(ap *AgentProcess) string {
455+
logs := ap.RecentLogs(0)
456+
457+
var b strings.Builder
458+
459+
if len(logs) == 0 {
460+
b.WriteString("Agent exited with no output.")
461+
} else if tail := lastNonEmptyLines(logs, 12); len(tail) > 0 {
462+
for i, l := range tail {
463+
tail[i] = ansiEscapeRe.ReplaceAllString(l, "")
464+
}
465+
b.WriteString("Agent output:\n " + strings.Join(tail, "\n "))
466+
}
467+
468+
if ap.LogPath != "" {
469+
if b.Len() > 0 {
470+
b.WriteString("\n\n")
471+
}
472+
b.WriteString("Full log: " + ap.LogPath)
473+
}
474+
return b.String()
475+
}
476+
477+
func lastNonEmptyLines(lines []string, n int) []string {
478+
var out []string
479+
for i := len(lines) - 1; i >= 0 && len(out) < n; i-- {
480+
if strings.TrimSpace(lines[i]) != "" {
481+
out = append([]string{lines[i]}, out...)
482+
}
483+
}
484+
return out
485+
}
486+
452487
func extractLogRoom(line string) string {
453488
clean := ansiEscapeRe.ReplaceAllString(line, "")
454489
idx := strings.LastIndex(clean, "{")

cmd/lk/simulate_tui.go

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ func runSimulateTUI(config *simulateConfig) error {
4848
fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url)
4949
}
5050

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

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

191+
if m.config.mode == modeView {
192+
ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout)
193+
defer cancel()
194+
run, err := getSimulationRun(ctx, m.config.client, m.config.viewModeRunID)
195+
if err != nil {
196+
m.err = err
197+
}
198+
m.run = run
199+
m.setupDone = true
200+
return nil
201+
}
202+
187203
m.steps = []step{
188204
{label: "Starting agent", status: "running"},
189205
{label: "Creating simulation", status: "pending"},
@@ -238,14 +254,11 @@ func (m *simulateModel) waitAgentReadyCmd() tea.Cmd {
238254
select {
239255
case <-m.agent.Ready():
240256
return agentReadyMsg{elapsed: time.Since(stepStart)}
241-
case err := <-m.agent.Done():
242-
if err != nil {
243-
return agentReadyMsg{err: fmt.Errorf("agent exited before registering: %w", err)}
244-
}
245-
return agentReadyMsg{err: fmt.Errorf("agent exited before registering")}
257+
case <-m.agent.Done():
258+
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))}
246259
case <-timeout.C:
247260
m.agent.Kill()
248-
return agentReadyMsg{err: fmt.Errorf("timed out waiting for agent to register (%s)", agentRegisterTimeout)}
261+
return agentReadyMsg{err: fmt.Errorf("timed out after %s waiting for the agent to register.\n\n%s", agentRegisterTimeout, agentExitDetail(m.agent))}
249262
case <-m.setupCtx.Done():
250263
return agentReadyMsg{err: m.setupCtx.Err()}
251264
}
@@ -321,7 +334,10 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321334
case agentReadyMsg:
322335
if msg.err != nil {
323336
m.failSetupStep(msg.err)
324-
return m, nil
337+
if m.setupCancel != nil {
338+
m.setupCancel()
339+
}
340+
return m, tea.Quit
325341
}
326342
m.advanceSetupStep(msg.elapsed)
327343
return m, m.createSimulationCmd()
@@ -521,7 +537,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
521537
m.logPinned = false
522538
}
523539
}
524-
case "enter":
540+
case "enter", "right":
525541
if m.detailJobID == "" {
526542
jobs := m.filteredJobs()
527543
if m.cursor >= 0 && m.cursor < len(jobs) {
@@ -537,7 +553,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
537553
return m, tea.Batch(m.save.fetchGroupsCmd(), saveSpinnerTickCmd())
538554
}
539555
}
540-
case "esc", "backspace":
556+
case "esc", "left", "backspace":
541557
if m.detailJobID != "" {
542558
m.detailJobID = ""
543559
m.detailScrollOff = 0
@@ -1457,7 +1473,7 @@ func firstMeaningfulLine(text string) string {
14571473
func (m *simulateModel) renderHint() string {
14581474
var parts []string
14591475
if m.detailJobID != "" {
1460-
parts = append(parts, "↑↓ scroll · ESC back · s save scenario")
1476+
parts = append(parts, "↑↓ scroll · ESC/← back · s save scenario")
14611477
if m.hasLogs() {
14621478
if m.showLogs {
14631479
parts = append(parts, "Ctrl+L hide logs")
@@ -1466,7 +1482,7 @@ func (m *simulateModel) renderHint() string {
14661482
}
14671483
}
14681484
} else {
1469-
parts = append(parts, "↑↓ navigate · ENTER detail · d description")
1485+
parts = append(parts, "↑↓ navigate · ENTER/→ detail · d description")
14701486
if m.hasLogs() {
14711487
if m.showLogs {
14721488
parts = append(parts, "PgUp/PgDn scroll logs · Ctrl+L hide logs")

0 commit comments

Comments
 (0)