diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 474dbb57..0eaaa7dd 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -226,6 +226,7 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l audio -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline. By default, simulations run in text-only mode.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l yes -s y -d 'Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l view -r -d 'Open a pre-existing simulation' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l export -r -d 'Print the run with run `ID` and its exact per-job chat contexts as JSON. Nothing is run or polled: the run must already be finished' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d 'Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or "" to target the project\'s default agent (the one that auto-joins every room). Requires --scenarios.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 5da0a61b..938c7677 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -98,6 +98,10 @@ var simulateCommand = &cli.Command{ Name: "view", Usage: "Open a pre-existing simulation", }, + &cli.StringFlag{ + Name: "export", + Usage: "Print the run with run `ID` and its exact per-job chat contexts as JSON. Nothing is run or polled: the run must already be finished", + }, &cli.StringFlag{ Name: "agent-name", Usage: "Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or \"\" to target the project's default agent (the one that auto-joins every room). Requires --scenarios.", @@ -272,6 +276,16 @@ func buildTaskExists(projectDir string) (bool, error) { func runSimulate(ctx context.Context, cmd *cli.Command) error { pc := simulateProjectConfig + // --export is a one-shot read of a finished run, so it short-circuits + // every other flag: no agent, no run creation, no polling. + if cmd.IsSet("export") { + exportRunID := cmd.String("export") + if exportRunID == "" { + return fmt.Errorf("--export requires a run ID") + } + return exportSimulationRunJSON(ctx, pc, exportRunID) + } + numSimulations := int32(cmd.Int("num-simulations")) concurrency := int32(cmd.Int("concurrency")) runID := cmd.String("view") @@ -575,23 +589,40 @@ func dashboardBaseURL() string { return dashboardURL } -// viewCommandHint returns the command to re-open a simulation run, carrying -// over --server-url when the run lives somewhere other than the default cloud -// API (e.g. staging), so the printed command targets the same environment. -// The binary name comes from argv[0] so a renamed or path-qualified lk is -// reproduced verbatim. -func viewCommandHint(runID string) string { +// simulateCommandHint returns a `simulate` command targeting an existing run, +// carrying over --server-url when the run lives somewhere other than the +// default cloud API (e.g. staging), so the printed command targets the same +// environment. The binary name comes from argv[0] so a renamed or +// path-qualified lk is reproduced verbatim. +func simulateCommandHint(flag, runID string) string { binary := "lk" if len(os.Args) > 0 && os.Args[0] != "" { binary = os.Args[0] } - hint := binary + " agent simulate --view " + runID + hint := binary + " agent simulate " + flag + " " + runID if serverURL != cloudAPIServerURL { hint += " --server-url " + serverURL } return hint } +func viewCommandHint(runID string) string { + return simulateCommandHint("--view", runID) +} + +func exportCommandHint(runID string) string { + return simulateCommandHint("--export", runID) + " > " + runID + ".json" +} + +// In view mode the re-open hint would echo the command the user just ran, so +// only the export hint is worth printing. +func writeSimulationRunHints(w io.Writer, runID string, viewing bool) { + if !viewing { + fmt.Fprintf(w, "To re-open this simulation, run: %s\n", viewCommandHint(runID)) + } + fmt.Fprintf(w, "To export replay JSON, run: %s\n", exportCommandHint(runID)) +} + func simulationDashboardURL(projectID, runID string) string { if projectID == "" || runID == "" { return "" @@ -637,22 +668,27 @@ func simulationJobCounts(run *livekit.SimulationRun) (total, done, passed, faile return } -func decodeRunSummary(run *livekit.SimulationRun) *livekit.SimulationRunSummary { +func decodeRunSummaryStrict(run *livekit.SimulationRun) (*livekit.SimulationRunSummary, error) { if run == nil || len(run.SummaryZstd) == 0 { - return nil + return nil, nil } dec, err := zstd.NewReader(nil) if err != nil { - return nil + return nil, fmt.Errorf("create zstd decoder: %w", err) } defer dec.Close() raw, err := dec.DecodeAll(run.SummaryZstd, nil) if err != nil { - return nil + return nil, fmt.Errorf("decompress summary: %w", err) } summary := &livekit.SimulationRunSummary{} if err := proto.Unmarshal(raw, summary); err != nil { - return nil + return nil, fmt.Errorf("unmarshal summary: %w", err) } + return summary, nil +} + +func decodeRunSummary(run *livekit.SimulationRun) *livekit.SimulationRunSummary { + summary, _ := decodeRunSummaryStrict(run) return summary } diff --git a/cmd/lk/simulate_json.go b/cmd/lk/simulate_json.go new file mode 100644 index 00000000..11049bec --- /dev/null +++ b/cmd/lk/simulate_json.go @@ -0,0 +1,111 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/livekit/livekit-cli/v2/pkg/config" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +const simulationRunJSONVersion = 1 + +// simulationRunJSON is a wrapper only: run and summary stay verbatim protojson, +// so proto field additions reach the export without a change here. +type simulationRunJSON struct { + Version int `json:"version"` + Run json.RawMessage `json:"run"` + Summary json.RawMessage `json:"summary,omitempty"` +} + +// protojson randomizes whitespace; the outer encoder re-indents, normalizing it. +// EmitDefaultValues keeps zero-valued scalars in the export: a proto3 implicit-presence +// bool is indistinguishable from unset once omitted, so a consumer reading a dropped +// is_error has to guess it. +var simulationRunMarshaler = protojson.MarshalOptions{ + UseProtoNames: true, + EmitDefaultValues: true, +} + +func writeSimulationRunJSON(w io.Writer, run *livekit.SimulationRun) error { + if run == nil { + return fmt.Errorf("cannot export a nil simulation run") + } + + export := simulationRunJSON{Version: simulationRunJSONVersion} + + summary, err := decodeRunSummaryStrict(run) + if err != nil { + return fmt.Errorf("decode simulation run summary: %w", err) + } + if summary != nil { + encoded, err := simulationRunMarshaler.Marshal(summary) + if err != nil { + return fmt.Errorf("encode simulation run summary: %w", err) + } + export.Summary = encoded + } + + // The summary is exported decoded above; the compressed copy would only bloat stdout. + stripped := proto.Clone(run).(*livekit.SimulationRun) + stripped.SummaryZstd = nil + encoded, err := simulationRunMarshaler.Marshal(stripped) + if err != nil { + return fmt.Errorf("encode simulation run: %w", err) + } + export.Run = encoded + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(export); err != nil { + return fmt.Errorf("encode simulation run JSON: %w", err) + } + return nil +} + +// exportSimulationRunJSON exports an existing run in a single fetch. An +// unfinished run is an error rather than a wait, so stdout is either a +// complete export or nothing; an unknown run ID surfaces the API's not-found +// error. +func exportSimulationRunJSON(ctx context.Context, pc *config.ProjectConfig, runID string) error { + client := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) + + fetchCtx, cancel := context.WithTimeout(ctx, simulationAPITimeout) + defer cancel() + run, err := getSimulationRun(fetchCtx, client, runID) + if err != nil { + return err + } + + if !isTerminalRunStatus(run.GetStatus()) { + return fmt.Errorf( + "simulation run %s is still in progress (%s); follow it with %s", + runID, + strings.TrimPrefix(run.GetStatus().String(), "STATUS_"), + viewCommandHint(runID), + ) + } + + return writeSimulationRunJSON(out.ResultWriter(), run) +} diff --git a/cmd/lk/simulate_json_test.go b/cmd/lk/simulate_json_test.go new file mode 100644 index 00000000..1cd59032 --- /dev/null +++ b/cmd/lk/simulate_json_test.go @@ -0,0 +1,37 @@ +// 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 ( + "bytes" + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/stretchr/testify/require" +) + +func TestWriteSimulationRunJSONRejectsInvalidSummary(t *testing.T) { + run := &livekit.SimulationRun{ + Id: "run_123", + Status: livekit.SimulationRun_STATUS_COMPLETED, + SummaryZstd: []byte("not a zstd frame"), + } + var output bytes.Buffer + + err := writeSimulationRunJSON(&output, run) + + require.ErrorContains(t, err, "decode simulation run summary") + require.Empty(t, output.String()) +} diff --git a/cmd/lk/simulate_test.go b/cmd/lk/simulate_test.go index 13b57a53..6b178814 100644 --- a/cmd/lk/simulate_test.go +++ b/cmd/lk/simulate_test.go @@ -15,10 +15,12 @@ package main import ( + "context" "os" "testing" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestSimulateConfigWarnings(t *testing.T) { @@ -76,3 +78,15 @@ func TestViewCommandHintCarriesServerURL(t *testing.T) { "lk agent simulate --view run_123 --server-url https://cloud-api.staging.livekit.io", viewCommandHint("run_123")) } + +func TestRunSimulateRejectsEmptyExportRunID(t *testing.T) { + cmd := &cli.Command{ + Flags: []cli.Flag{ + &cli.StringFlag{Name: "export"}, + }, + Action: runSimulate, + } + + err := cmd.Run(context.Background(), []string{"lk", "--export="}) + require.EqualError(t, err, "--export requires a run ID") +} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 64e5d4b4..e2322381 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -82,11 +82,11 @@ func runSimulateTUI(config *simulateConfig) error { } if m.config.mode == modeView { - fmt.Fprintf(os.Stderr, "To re-open this simulation, run: %s\n", viewCommandHint(m.config.viewModeRunID)) + writeSimulationRunHints(os.Stderr, m.config.viewModeRunID, true) } 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: %s\n", viewCommandHint(m.runID)) + writeSimulationRunHints(os.Stderr, m.runID, false) } if runErr != nil {