diff --git a/cmd/root_cmd/push.go b/cmd/root_cmd/push.go index 7746eb7a2..60165319c 100644 --- a/cmd/root_cmd/push.go +++ b/cmd/root_cmd/push.go @@ -1,7 +1,10 @@ package root_cmd import ( + "context" "fmt" + "strconv" + "strings" "github.com/spf13/cobra" "github.com/tensorleap/leap-cli/pkg/analytics" @@ -15,351 +18,448 @@ import ( "github.com/tensorleap/leap-cli/pkg/workspace" ) +type pushInputs struct { + secretId string + modelVersionName string + codeVersionMessage string + modelType string + modelPath string + branch string + pythonVersion string + runEval bool + transformInput bool + noWait bool + batch string + overwriteVersionRef string + updateParts []string +} + +type pushState struct { + ctx context.Context + inputs *pushInputs + workspaceConfig *workspace.WorkspaceConfig + project *tensorleapapi.Project + isOverwrite bool + overwriteVersion *model.VersionInfo + properties map[string]interface{} +} + func NewPushCmd() *cobra.Command { + in := &pushInputs{} - var secretId string - var modelVersionName string - var codeVersionMessage string - var modelType string - var modelPath string - var branch string - var transformInput bool - var noWait bool - var pythonVersion string - var runEval bool - var batchSize int - var overwriteVersionRef string - var updateParts []string - - var cmd = &cobra.Command{ + cmd := &cobra.Command{ Use: "push", Short: "Push new or overwrite model version", Long: `Push new or overwrite model version into a project with its code integration. Examples: - # Push a model + # Push a new version (interactive — prompts for name and model file) leap push # Push and run evaluation after leap push -e - # Overwrite a version by id (non-interactive) and refresh metadata after eval - leap push --overwrite-version -e -u metadata + # Push a new version non-interactively + leap push -n my-model -m ./model.h5 + + # Overwrite an existing version by id, run eval (auto-detects changes) + leap push -o 6a16a0cf -e + + # Overwrite by name — picker opens if the name is ambiguous + leap push -o my-model -e + + # Overwrite + force a specific artifact refresh (implies --eval) + leap push -o my-model -u viz + + # Refresh multiple artifacts in one run + leap push -o my-model -u metadata -u insights `, RunE: func(cmd *cobra.Command, args []string) error { - if batchSize > 0 && !runEval { - return fmt.Errorf("--batch requires --eval") - } - if len(updateParts) > 0 && !runEval { - return fmt.Errorf("--update (-u) requires --eval (-e)") - } - - ctx, _, err := auth.RequireAuth(cmd.Context()) - if err != nil { - return err - } - - properties := map[string]interface{}{ - "secret_id": secretId, - "model_version_name": modelVersionName, - "code_version_message": codeVersionMessage, - "model_type": modelType, - "branch": branch, - "transform_input": transformInput, - "no_wait": noWait, - "python_version": pythonVersion, - "model_path": modelPath, - "overwrite_version_ref": overwriteVersionRef, - "update_parts": updateParts, - } - - analytics.SendEvent(analytics.EventCliProjectsPushStarted, properties) - - workspaceConfig, err := workspace.GetWorkspaceConfig() - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "get_workspace_config" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - currentProject, err := project.SyncProjectIdToWorkspaceConfig(ctx, workspaceConfig) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "sync_project" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - - var isOverwrite bool - var overwriteVersionInfo *model.VersionInfo - if overwriteVersionRef != "" { - overwriteVersionInfo, err = model.ResolveVersionInfoFromRef(ctx, currentProject.GetCid(), overwriteVersionRef) - if err != nil { - return err - } - isOverwrite = overwriteVersionInfo.HasModel || overwriteVersionInfo.HasUploadedModel - if !isOverwrite && len(modelPath) == 0 { - return fmt.Errorf("version %q has no model; set --model-path (-m)", overwriteVersionRef) - } - } else if len(modelPath) == 0 { - isOverwrite, overwriteVersionInfo, modelPath, err = model.AskUserForModelPathOrOverwrite(ctx, currentProject.GetCid()) - if err != nil { - return err - } - } else { - isOverwrite = false - } - - properties["is_overwrite"] = isOverwrite - var overwriteVersionId string - var overwriteVersionHasModel bool - if overwriteVersionInfo != nil { - properties["version_id"] = overwriteVersionInfo.VersionId - properties["version_name"] = overwriteVersionInfo.VersionName - overwriteVersionId = overwriteVersionInfo.VersionId - overwriteVersionHasModel = overwriteVersionInfo.HasModel - properties["version_has_model"] = overwriteVersionHasModel - - } - properties["model_path"] = modelPath - - overwriteVersionHasUploadedModel := false - if overwriteVersionInfo != nil { - overwriteVersionHasUploadedModel = overwriteVersionInfo.HasUploadedModel - } - - needsNewModel := !isOverwrite || (!overwriteVersionHasModel && !overwriteVersionHasUploadedModel) - if needsNewModel { - err := model.SelectModelType(&modelType, modelPath) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "select_model_type" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - } - if overwriteVersionInfo == nil { - defaultMessage := model.GetDefaultMessageFromModelPath(modelPath) - err = model.InitMessage(&modelVersionName, defaultMessage) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "init_message" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - } else { - modelVersionName = overwriteVersionInfo.VersionName - } - - branch, err = code.SyncBranchFromFlagAndConfig(branch, workspaceConfig) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "sync_branch" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - - secretId, err := secret.SyncSecretIdFromFlagAndConfig(ctx, secretId, workspaceConfig) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "sync_secret" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - - pythonVersion, err = code.SyncPythonVersionFromFlagAndConfig(ctx, pythonVersion, workspaceConfig) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "sync_python_version" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - - var evalBatchSize int - var updateActions []tensorleapapi.UpdateAction - runUpdateEvaluate := false - if len(updateParts) > 0 && !isOverwrite { - return fmt.Errorf("--update (-u) only applies when overwriting an existing version (use --overwrite-version or choose overwrite in the prompt)") - } - if runEval { - askBatchSize := func() error { - if batchSize > 0 { - evalBatchSize = batchSize - return nil - } - defaultBatchSize, err := model.GetLatestEvaluateBatchSize(ctx, currentProject.GetCid()) - if err != nil { - log.Warnf("failed to get latest evaluate batch size: %v", err) - } - evalBatchSize, err = model.AskForBatchSize(defaultBatchSize) - if err != nil { - return fmt.Errorf("failed to get batch size: %w", err) - } - return nil - } - - if isOverwrite { - updateActions, err = model.ParseUpdateActionsFromFlags(updateParts) - if err != nil { - return err - } - if len(updateActions) > 0 { - runUpdateEvaluate = true - } else { - hasEvalData, evalErr := model.HasEvaluatedAncestorOrSelf(ctx, currentProject.GetCid(), overwriteVersionId) - if evalErr != nil { - log.Warnf("failed to check evaluation data for override chain: %v", evalErr) - hasEvalData = true - } - if !hasEvalData { - log.Info("No evaluation data found in the override chain — running a fresh evaluate.") - if err := askBatchSize(); err != nil { - return err - } - } else { - plan, planErr := model.AskForEvaluatePlan() - if planErr != nil { - return fmt.Errorf("failed to get update plan: %w", planErr) - } - if plan.Kind == model.EvaluatePlanReset { - if err := askBatchSize(); err != nil { - return err - } - } else { - updateActions = plan.UpdateActions - runUpdateEvaluate = true - } - } - } - } else { - if err := askBatchSize(); err != nil { - return err - } - } - } - - close, tarGzFile, err := code.BundleCodeIntoTempFile(".", workspaceConfig) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "bundle_code" - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - defer close() - - pushed, codeSnapshotResponse, err := code.PushCode(ctx, tarGzFile, workspaceConfig.EntryFile, secretId, pythonVersion, modelVersionName, currentProject.GetCid(), branch, overwriteVersionId) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "push_code" - if codeSnapshotResponse != nil { - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - } - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - if pushed || !code.IsCodeEnded(&codeSnapshotResponse.CodeSnapshot) { - - ok, err := code.WaitForCodeIntegrationStatus(ctx, currentProject.GetCid(), codeSnapshotResponse.CodeSnapshot.Cid) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "wait_for_code_parsing" - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - if ok { - log.Info("Code parsed successfully") - } else { - properties["error"] = "code parsing failed" - properties["stage"] = "code_parsing" - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return fmt.Errorf("code parsing failed") - } - } else if code.IsCodeParseFailed(&codeSnapshotResponse.CodeSnapshot) { - properties["error"] = "latest code parsing failed" - properties["stage"] = "previous_code_parsing_failed" - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return fmt.Errorf("latest code parsing failed, add --force to push anyway") - } - if !isOverwrite { - importModelInfo, err := model.PrepareImportModelFromFilePath(ctx, currentProject.GetCid(), modelPath, transformInput, modelType) - if err != nil { - return err - } - _, err = model.ImportModel(ctx, currentProject.GetCid(), codeSnapshotResponse.VersionId, importModelInfo, !noWait) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "import_model" - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - return err - } - } else { - var importModelInfo *tensorleapapi.ImportModelInfo - if !overwriteVersionHasModel && !overwriteVersionHasUploadedModel { - importModelInfo, err = model.PrepareImportModelFromFilePath(ctx, currentProject.GetCid(), modelPath, transformInput, modelType) - if err != nil { - return err - } - } - _, err := model.OverrideModel(ctx, currentProject.GetCid(), codeSnapshotResponse.VersionId, !noWait, importModelInfo) - if err != nil { - properties["error"] = err.Error() - properties["stage"] = "override_model" - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - analytics.SendEvent(analytics.EventCliProjectsPushFailed, properties) - - return err - } - } - - properties["code_snapshot_id"] = codeSnapshotResponse.CodeSnapshot.Cid - properties["version_id"] = codeSnapshotResponse.VersionId - properties["project_id"] = currentProject.GetCid() - properties["final_secret_id"] = secretId - properties["final_python_version"] = pythonVersion - properties["final_model_type"] = modelType - properties["final_transform_input"] = transformInput - properties["final_wait"] = !noWait - properties["code_pushed"] = pushed - analytics.SendEvent(analytics.EventCliProjectsPushSuccess, properties) - - if runEval { - if runUpdateEvaluate { - err = model.RunUpdateEvaluateArtifact(ctx, currentProject.GetCid(), codeSnapshotResponse.VersionId, updateActions) - if err != nil { - return fmt.Errorf("failed to run update evaluate: %w", err) - } - } else { - err = model.RunEvaluate(ctx, currentProject.GetCid(), codeSnapshotResponse.VersionId, evalBatchSize) - if err != nil { - return fmt.Errorf("failed to run evaluation: %w", err) - } - } - } - - return nil + return runPush(cmd.Context(), in) }, } - cmd.Flags().StringVarP(&modelVersionName, "name", "n", "", "Model version name") - cmd.Flags().StringVar(&modelType, "type", "", "Type is the type of the model file [JSON_TF2 / ONNX / PB_TF2 / H5_TF2]") - cmd.Flags().StringVar(&branch, "branch", "", "Name of the branch [OPTIONAL]") - cmd.Flags().StringVar(&secretId, "secretId", "", "Secret id") - cmd.Flags().BoolVar(&transformInput, "transform-input", false, "Transpose the input data to channel-last format") - cmd.Flags().BoolVar(&noWait, "no-wait", false, "Do not wait for push to complete") - cmd.Flags().StringVarP(&modelPath, "model-path", "m", "", "Path to the model file") - cmd.Flags().BoolVarP(&runEval, "eval", "e", false, "Run evaluation on the model after push completes") - cmd.Flags().IntVar(&batchSize, "batch", 0, "Batch size for evaluation (only valid with --eval)") - cmd.Flags().StringVar(&overwriteVersionRef, "overwrite-version", "", "Overwrite this existing version (version id from UI/API; a name is accepted only if exactly one version in the project has that name)") - cmd.Flags().StringSliceVarP(&updateParts, "update", "u", nil, "With --eval on overwrite: artifact(s) to refresh (repeatable). Values: update_metadata, update_metric, update_insights, update_visualization") + cmd.Flags().StringVarP(&in.modelVersionName, "name", "n", "", "Model version name") + cmd.Flags().StringVar(&in.modelType, "type", "", "Type is the type of the model file [JSON_TF2 / ONNX / PB_TF2 / H5_TF2]") + cmd.Flags().StringVar(&in.branch, "branch", "", "Name of the branch [OPTIONAL]") + cmd.Flags().StringVar(&in.secretId, "secretId", "", "Secret id") + cmd.Flags().BoolVar(&in.transformInput, "transform-input", false, "Transpose the input data to channel-last format") + cmd.Flags().BoolVar(&in.noWait, "no-wait", false, "Do not wait for push to complete") + cmd.Flags().StringVarP(&in.modelPath, "model-path", "m", "", "Path to the model file") + cmd.Flags().BoolVarP(&in.runEval, "eval", "e", false, "Run evaluation on the model after push completes") + cmd.Flags().StringVarP(&in.batch, "batch", "b", "", "Batch size for evaluation: a number or 'latest' (requires --eval; if omitted, prompts with the latest as default)") + cmd.Flags().StringVarP(&in.overwriteVersionRef, "overwrite", "o", "", "Overwrite an existing version (id, or name — picker shown if name is ambiguous)") + cmd.Flags().StringVar(&in.overwriteVersionRef, "overwrite-version", "", "") + _ = cmd.Flags().MarkDeprecated("overwrite-version", "use --overwrite (-o) instead") + cmd.Flags().StringSliceVarP(&in.updateParts, "update", "u", nil, "Artifact(s) to refresh on overwrite (repeatable; implies --eval). Values: metadata, metric, insights, visualization (viz)") return cmd } + +func runPush(cmdCtx context.Context, in *pushInputs) error { + if err := validatePushInputs(in); err != nil { + return err + } + + ctx, _, err := auth.RequireAuth(cmdCtx) + if err != nil { + return err + } + + s, err := newPushState(ctx, in) + if err != nil { + return err + } + + analytics.SendEvent(analytics.EventCliProjectsPushStarted, s.properties) + + if err := s.resolveOverwriteTarget(); err != nil { + return err + } + if err := s.collectModelTypeAndName(); err != nil { + return err + } + if err := s.syncBranchSecretAndPython(); err != nil { + return err + } + + evalBatchSize, updateActions, runUpdateEvaluate, err := s.resolveEvalPlan() + if err != nil { + return err + } + + codeResp, codePushed, err := s.pushCodeAndWaitForParsing() + if err != nil { + return err + } + + if err := s.applyModelToVersion(codeResp.VersionId); err != nil { + return err + } + + s.sendSuccessEvent(codeResp, codePushed) + + return s.triggerEvaluate(codeResp.VersionId, evalBatchSize, updateActions, runUpdateEvaluate) +} + +func validatePushInputs(in *pushInputs) error { + if in.batch != "" && !in.runEval { + return fmt.Errorf("--batch requires --eval") + } + if len(in.updateParts) > 0 && !in.runEval { + in.runEval = true + } + return nil +} + +func newPushState(ctx context.Context, in *pushInputs) (*pushState, error) { + properties := map[string]interface{}{ + "secret_id": in.secretId, + "model_version_name": in.modelVersionName, + "code_version_message": in.codeVersionMessage, + "model_type": in.modelType, + "branch": in.branch, + "transform_input": in.transformInput, + "no_wait": in.noWait, + "python_version": in.pythonVersion, + "model_path": in.modelPath, + "overwrite_version_ref": in.overwriteVersionRef, + "update_parts": in.updateParts, + } + s := &pushState{ctx: ctx, inputs: in, properties: properties} + + workspaceConfig, err := workspace.GetWorkspaceConfig() + if err != nil { + return nil, s.fail("get_workspace_config", err) + } + s.workspaceConfig = workspaceConfig + + currentProject, err := project.SyncProjectIdToWorkspaceConfig(ctx, workspaceConfig) + if err != nil { + return nil, s.fail("sync_project", err) + } + s.project = currentProject + + return s, nil +} + +func (s *pushState) fail(stage string, err error) error { + s.properties["error"] = err.Error() + s.properties["stage"] = stage + analytics.SendEvent(analytics.EventCliProjectsPushFailed, s.properties) + return err +} + +func (s *pushState) projectId() string { + return s.project.GetCid() +} + +func (s *pushState) resolveOverwriteTarget() error { + in := s.inputs + if in.overwriteVersionRef != "" { + info, err := model.ResolveVersionInfoFromRef(s.ctx, s.projectId(), in.overwriteVersionRef) + if err != nil { + return err + } + model.PrintResolvedOverwriteTarget(in.overwriteVersionRef, info) + s.overwriteVersion = info + s.isOverwrite = info.HasModel || info.HasUploadedModel + if !s.isOverwrite && in.modelPath == "" { + return fmt.Errorf("version %q has no model; set --model-path (-m)", in.overwriteVersionRef) + } + } else if in.modelPath == "" { + isOverwrite, info, chosenPath, err := model.AskUserForModelPathOrOverwrite(s.ctx, s.projectId(), &in.modelVersionName, in.runEval) + if err != nil { + return err + } + s.isOverwrite = isOverwrite + s.overwriteVersion = info + in.modelPath = chosenPath + } + + s.properties["is_overwrite"] = s.isOverwrite + if s.overwriteVersion != nil { + s.properties["version_id"] = s.overwriteVersion.VersionId + s.properties["version_name"] = s.overwriteVersion.VersionName + s.properties["version_has_model"] = s.overwriteVersion.HasModel + } + s.properties["model_path"] = in.modelPath + return nil +} + +func (s *pushState) needsNewModel() bool { + if !s.isOverwrite { + return true + } + return !s.overwriteVersion.HasModel && !s.overwriteVersion.HasUploadedModel +} + +func (s *pushState) collectModelTypeAndName() error { + in := s.inputs + if s.needsNewModel() { + if err := model.SelectModelType(&in.modelType, in.modelPath); err != nil { + return s.fail("select_model_type", err) + } + } + if s.overwriteVersion == nil { + defaultMessage := model.GetDefaultMessageFromModelPath(in.modelPath) + if err := model.InitMessage(&in.modelVersionName, defaultMessage); err != nil { + return s.fail("init_message", err) + } + } else { + in.modelVersionName = s.overwriteVersion.VersionName + } + return nil +} + +func (s *pushState) syncBranchSecretAndPython() error { + in := s.inputs + branch, err := code.SyncBranchFromFlagAndConfig(in.branch, s.workspaceConfig) + if err != nil { + return s.fail("sync_branch", err) + } + in.branch = branch + + secretId, err := secret.SyncSecretIdFromFlagAndConfig(s.ctx, in.secretId, s.workspaceConfig) + if err != nil { + return s.fail("sync_secret", err) + } + in.secretId = secretId + + pythonVersion, err := code.SyncPythonVersionFromFlagAndConfig(s.ctx, in.pythonVersion, s.workspaceConfig) + if err != nil { + return s.fail("sync_python_version", err) + } + in.pythonVersion = pythonVersion + return nil +} + +func (s *pushState) resolveEvalPlan() (batchSize int, updateActions []tensorleapapi.UpdateAction, runUpdateEvaluate bool, err error) { + in := s.inputs + if len(in.updateParts) > 0 && !s.isOverwrite { + err = fmt.Errorf("--update (-u) only applies when overwriting an existing version (use --overwrite or choose overwrite in the prompt)") + return + } + if !in.runEval { + return + } + if !s.isOverwrite { + batchSize, err = s.askOrDefaultBatchSize() + return + } + + plan, planErr := s.resolveOverwriteEvalPlan() + if planErr != nil { + err = planErr + return + } + if plan.Kind == model.EvaluatePlanReset { + batchSize, err = s.askOrDefaultBatchSize() + return + } + updateActions = plan.UpdateActions + runUpdateEvaluate = true + return +} + +func (s *pushState) resolveOverwriteEvalPlan() (model.EvaluatePlan, error) { + if len(s.inputs.updateParts) > 0 { + parsed, err := model.ParseUpdateActionsFromFlags(s.inputs.updateParts) + if err != nil { + return model.EvaluatePlan{}, err + } + plan := model.PlanFromUpdateActions(parsed) + if plan.Kind == model.EvaluatePlanUpdate && !s.canUpdateEvaluate() { + log.Info("No evaluation data found in the override chain — running a fresh evaluate.") + return model.EvaluatePlan{Kind: model.EvaluatePlanReset}, nil + } + return plan, nil + } + if !s.canUpdateEvaluate() { + log.Info("No evaluation data found in the override chain — running a fresh evaluate.") + return model.EvaluatePlan{Kind: model.EvaluatePlanReset}, nil + } + plan, err := model.AskForEvaluatePlan() + if err != nil { + return model.EvaluatePlan{}, fmt.Errorf("failed to get update plan: %w", err) + } + return plan, nil +} + +func (s *pushState) canUpdateEvaluate() bool { + hasEvalData, err := model.HasEvaluatedAncestorOrSelf(s.ctx, s.projectId(), s.overwriteVersion.VersionId) + if err != nil { + log.Warnf("failed to check evaluation data for override chain: %v", err) + return true + } + return hasEvalData +} + +func (s *pushState) askOrDefaultBatchSize() (int, error) { + raw := strings.TrimSpace(s.inputs.batch) + if raw != "" && !strings.EqualFold(raw, "latest") { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 0, fmt.Errorf("--batch expects a positive integer or 'latest', got %q", s.inputs.batch) + } + return n, nil + } + latestBatchSize, err := model.GetLatestEvaluateBatchSize(s.ctx, s.projectId()) + if err != nil { + log.Warnf("failed to get latest evaluate batch size: %v", err) + } + if strings.EqualFold(raw, "latest") { + if latestBatchSize <= 0 { + return 0, fmt.Errorf("--batch latest: no previous evaluation found in this project; pass an explicit batch size") + } + return latestBatchSize, nil + } + chosen, err := model.AskForBatchSize(latestBatchSize) + if err != nil { + return 0, fmt.Errorf("failed to get batch size: %w", err) + } + return chosen, nil +} + +func (s *pushState) pushCodeAndWaitForParsing() (*tensorleapapi.PushCodeSnapshotResponse, bool, error) { + in := s.inputs + closeBundle, tarGzFile, err := code.BundleCodeIntoTempFile(".", s.workspaceConfig) + if err != nil { + return nil, false, s.fail("bundle_code", err) + } + defer closeBundle() + + overwriteVersionId := "" + if s.overwriteVersion != nil { + overwriteVersionId = s.overwriteVersion.VersionId + } + pushed, codeResp, err := code.PushCode(s.ctx, tarGzFile, s.workspaceConfig.EntryFile, in.secretId, in.pythonVersion, in.modelVersionName, s.projectId(), in.branch, overwriteVersionId) + if err != nil { + s.tagCodeResp(codeResp) + return codeResp, false, s.fail("push_code", err) + } + + if pushed || !code.IsCodeEnded(&codeResp.CodeSnapshot) { + ok, waitErr := code.WaitForCodeIntegrationStatus(s.ctx, s.projectId(), codeResp.CodeSnapshot.Cid) + if waitErr != nil { + s.tagCodeResp(codeResp) + return codeResp, pushed, s.fail("wait_for_code_parsing", waitErr) + } + if !ok { + s.tagCodeResp(codeResp) + return codeResp, pushed, s.fail("code_parsing", fmt.Errorf("code parsing failed")) + } + log.Info("Code parsed successfully") + } else if code.IsCodeParseFailed(&codeResp.CodeSnapshot) { + s.tagCodeResp(codeResp) + return codeResp, pushed, s.fail("previous_code_parsing_failed", fmt.Errorf("latest code parsing failed, add --force to push anyway")) + } + return codeResp, pushed, nil +} + +func (s *pushState) tagCodeResp(codeResp *tensorleapapi.PushCodeSnapshotResponse) { + if codeResp == nil { + return + } + s.properties["code_snapshot_id"] = codeResp.CodeSnapshot.Cid + s.properties["version_id"] = codeResp.VersionId +} + +func (s *pushState) applyModelToVersion(versionId string) error { + in := s.inputs + if !s.isOverwrite { + importModelInfo, err := model.PrepareImportModelFromFilePath(s.ctx, s.projectId(), in.modelPath, in.transformInput, in.modelType) + if err != nil { + return err + } + if _, err = model.ImportModel(s.ctx, s.projectId(), versionId, importModelInfo, !in.noWait); err != nil { + s.properties["code_snapshot_id"] = versionId + s.properties["version_id"] = versionId + return s.fail("import_model", err) + } + return nil + } + + var importModelInfo *tensorleapapi.ImportModelInfo + if !s.overwriteVersion.HasModel && !s.overwriteVersion.HasUploadedModel { + var err error + importModelInfo, err = model.PrepareImportModelFromFilePath(s.ctx, s.projectId(), in.modelPath, in.transformInput, in.modelType) + if err != nil { + return err + } + } + if _, err := model.OverrideModel(s.ctx, s.projectId(), versionId, !in.noWait, importModelInfo); err != nil { + s.properties["version_id"] = versionId + return s.fail("override_model", err) + } + return nil +} + +func (s *pushState) sendSuccessEvent(codeResp *tensorleapapi.PushCodeSnapshotResponse, codePushed bool) { + in := s.inputs + s.properties["code_snapshot_id"] = codeResp.CodeSnapshot.Cid + s.properties["version_id"] = codeResp.VersionId + s.properties["project_id"] = s.projectId() + s.properties["final_secret_id"] = in.secretId + s.properties["final_python_version"] = in.pythonVersion + s.properties["final_model_type"] = in.modelType + s.properties["final_transform_input"] = in.transformInput + s.properties["final_wait"] = !in.noWait + s.properties["code_pushed"] = codePushed + analytics.SendEvent(analytics.EventCliProjectsPushSuccess, s.properties) +} + +func (s *pushState) triggerEvaluate(versionId string, batchSize int, updateActions []tensorleapapi.UpdateAction, runUpdateEvaluate bool) error { + if !s.inputs.runEval { + return nil + } + if runUpdateEvaluate { + if err := model.RunUpdateEvaluateArtifact(s.ctx, s.projectId(), versionId, updateActions); err != nil { + return fmt.Errorf("failed to run update evaluate: %w", err) + } + return nil + } + if err := model.RunEvaluate(s.ctx, s.projectId(), versionId, batchSize); err != nil { + return fmt.Errorf("failed to run evaluation: %w", err) + } + return nil +} diff --git a/docs/push-examples.md b/docs/push-examples.md new file mode 100644 index 000000000..f05549060 --- /dev/null +++ b/docs/push-examples.md @@ -0,0 +1,126 @@ +# `leap push` — quick examples + +A tour of the new `leap push` ergonomics. The legacy spellings still +work (back-compat); these are the shorter / nicer paths. + +## Push a new version + +```bash +leap push # interactive: asks name, then model file +leap push -n my-model -m model.h5 # non-interactive +leap push -e # push, then evaluate +leap push -e --batch 32 # eval with a specific batch size +``` + +The interactive flow now asks for the **version name first**, then the +model file — matches the way you think about a push: "what am I +calling it → which file is it". + +## Overwrite an existing version + +```bash +leap push -o 6a16a0cf # by id +leap push -o my-model # by name (picker opens if ambiguous) +``` + +The CLI echoes the resolved target before doing anything destructive: + +``` +Overwriting my-model (id 6a16a0cf, matched "my-model") +``` + +If the name matches multiple versions, an interactive picker shows +each with its id + status: + +``` +? Multiple versions named "model" — pick one + ▸ #02 model 6a16a0cf evaluated 2m ago + #01 model 6a16c86c evaluated 3d ago +``` + +In a non-TTY context (CI) the picker is replaced by an error that +lists the candidate ids so you can re-run with the exact one. + +## Overwrite + run an evaluation + +```bash +leap push -o my-model -e +``` + +You'll get a multi-select prompt for what you changed (only edits the +engine can't auto-detect): + +``` +? What did you change in your code? (auto-detected: added meta/viz, metric-direction, insight-config) + [Use arrows to move, space to select, type to filter] + [ ] Edited an existing metadata + [ ] Edited an existing visualization + [ ] Added or edited a metric + [ ] Force re-run insights +``` + +Then a plan summary: + +``` +This will: + • Update visualizations + • Auto-detect added meta/viz, metric-direction, insight-config +``` + +## Skip the prompt — name the artifacts explicitly + +`--update` (`-u`) tells `leap push` exactly which artifacts to refresh. +It **implies `--eval`** — you no longer need both flags. + +```bash +leap push -o my-model -u viz # regenerate visualizations only +leap push -o my-model -u metadata -u insights # refresh both +leap push -o my-model -u metric # full re-evaluation triggered by metric change +``` + +Accepted values (case-insensitive): + +| short | full (still accepted) | +| -------------- | --------------------- | +| `metadata` | `update_metadata` | +| `metric` | `update_metric` | +| `insights` | `update_insights` | +| `visualization` / `viz` | `update_visualization` | + +## Overwrite without evaluating + +```bash +leap push -o my-model # no --eval, no --update +``` + +When you overwrite without `--eval`, the CLI reminds you that +evaluation isn't automatic: + +``` +NOTE: Overwriting replaces the version. Use the update-evaluate dialog + in the UI to re-evaluate (or re-run with --eval). +``` + +The UI's update-evaluate dialog lets you drive the same diff-based +refresh interactively, post-push. + +## Deprecated flag still works + +```bash +leap push --overwrite-version 6a16a0cf -e +``` + +→ prints `Flag --overwrite-version has been deprecated, use --overwrite (-o) instead` +and proceeds normally. Update your scripts at your leisure. + +## Cheat sheet + +| goal | command | +| ------------------------------------------------ | --------------------------------------------- | +| Push a new version | `leap push` | +| Push + eval | `leap push -e` | +| Overwrite by id | `leap push -o ` | +| Overwrite by name | `leap push -o ` | +| Overwrite + force-refresh viz | `leap push -o -u viz` | +| Overwrite + refresh metadata and insights | `leap push -o -u metadata -u insights` | +| Overwrite + full re-eval (auto-detect) | `leap push -o -e` | diff --git a/pkg/model/evaluate.go b/pkg/model/evaluate.go index b41fc61bd..b693e9d11 100644 --- a/pkg/model/evaluate.go +++ b/pkg/model/evaluate.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/AlecAivazis/survey/v2" + "github.com/jedib0t/go-pretty/v6/text" "github.com/tensorleap/leap-cli/pkg/api" "github.com/tensorleap/leap-cli/pkg/log" "github.com/tensorleap/leap-cli/pkg/tensorleapapi" @@ -73,7 +74,22 @@ func AskForBatchSize(defaultBatchSize int) (int, error) { return batchSize, nil } +// updateActionAliases maps user-facing short tokens to API enum values. +// The full `update_*` spellings are still accepted via the fall-through +// to `tensorleapapi.NewUpdateActionFromValue` below — so existing +// scripts / CI invocations don't break. +var updateActionAliases = map[string]tensorleapapi.UpdateAction{ + "metadata": tensorleapapi.UPDATEACTION_UPDATE_METADATA, + "metric": tensorleapapi.UPDATEACTION_UPDATE_METRIC, + "insights": tensorleapapi.UPDATEACTION_UPDATE_INSIGHTS, + "visualization": tensorleapapi.UPDATEACTION_UPDATE_VISUALIZATION, + "viz": tensorleapapi.UPDATEACTION_UPDATE_VISUALIZATION, +} + // ParseUpdateActionsFromFlags maps CLI tokens to API update actions. +// Accepts both the short aliases (metadata, metric, insights, viz, +// visualization) and the full API enum spellings (update_metadata, …) +// so old `-u update_foo` invocations keep working. func ParseUpdateActionsFromFlags(parts []string) ([]tensorleapapi.UpdateAction, error) { if len(parts) == 0 { return nil, nil @@ -85,15 +101,19 @@ func ParseUpdateActionsFromFlags(parts []string) ([]tensorleapapi.UpdateAction, if p == "" { continue } - act, err := tensorleapapi.NewUpdateActionFromValue(p) - if err != nil { - return nil, fmt.Errorf("invalid --update value %q: %w (allowed: update_metadata, update_metric, update_insights, update_visualization)", raw, err) + var act tensorleapapi.UpdateAction + if alias, ok := updateActionAliases[p]; ok { + act = alias + } else if full, ferr := tensorleapapi.NewUpdateActionFromValue(p); ferr == nil { + act = *full + } else { + return nil, fmt.Errorf("invalid --update value %q (allowed: metadata, metric, insights, visualization, viz)", raw) } - if _, ok := seen[*act]; ok { + if _, ok := seen[act]; ok { continue } - seen[*act] = struct{}{} - out = append(out, *act) + seen[act] = struct{}{} + out = append(out, act) } if len(out) == 0 { return nil, fmt.Errorf("no valid --update values after parsing") @@ -141,10 +161,19 @@ type changeOption struct { } var changeOptions = []changeOption{ - {key: ChangeMetadataUpdate, label: "Edited a metadata", hint: "same column, new values"}, - {key: ChangeVisualizationUpdate, label: "Edited a visualization", hint: "same visualizer, new code"}, - {key: ChangeMetricsAddOrUpdate, label: "Added or edited a metric", hint: "new metric, or changed values"}, - {key: ChangeForceInsights, label: "Reinforce insights", hint: "force re-run insights even if nothing else changed"}, + {key: ChangeMetadataUpdate, label: "Edited an existing metadata"}, + {key: ChangeVisualizationUpdate, label: "Edited an existing visualization"}, + {key: ChangeMetricsAddOrUpdate, label: "Added or edited a metric"}, + {key: ChangeForceInsights, label: "Force re-run insights"}, +} + +func PlanFromUpdateActions(actions []tensorleapapi.UpdateAction) EvaluatePlan { + for _, a := range actions { + if a == tensorleapapi.UPDATEACTION_UPDATE_METADATA || a == tensorleapapi.UPDATEACTION_UPDATE_METRIC { + return EvaluatePlan{Kind: EvaluatePlanReset} + } + } + return EvaluatePlan{Kind: EvaluatePlanUpdate, UpdateActions: actions} } func planUpdateEvaluate(selected map[ChangeKey]bool) EvaluatePlan { @@ -170,18 +199,45 @@ func planUpdateEvaluate(selected map[ChangeKey]bool) EvaluatePlan { return EvaluatePlan{Kind: EvaluatePlanUpdate, UpdateActions: actions} } +func init() { + // Push the inline `[Use arrows to move, space to select, type to + // filter]` hint onto its own line below the question. The default + // `MultiSelectQuestionTemplate` glues it to the message with two + // leading spaces, which crowds long-ish messages (ours includes + // an auto-detect parenthetical) into a single hard-to-scan line. + // The only edit vs the upstream template is the whitespace before + // the `[Use arrows…` block: `{{- " "}}` (two spaces, leading + // dash strips the prior newline) → `{{ "\n " }}` (newline + + // two spaces, no leading dash so the newline survives). + survey.MultiSelectQuestionTemplate = ` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} + {{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}} + {{- color "reset"}} + {{- " "}}{{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{color "reset"}}{{end}} +{{end}} +{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} +{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} +{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} +{{- else }} + {{ "\n " }}{{- color "cyan"}}[Use arrows to move, {{color "cyan+b"}}space{{color "cyan"}} to select,{{- if not .Config.RemoveSelectAll }} to all,{{end}}{{- if not .Config.RemoveSelectNone }} to none,{{end}} type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} + {{- "\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end}}` +} + // AskForEvaluatePlan prompts the user for which artifacts changed and returns the resolved plan. func AskForEvaluatePlan() (EvaluatePlan, error) { // What the engine actually auto-detects today: added metadata // columns, added visualizers, metric direction flips, and // compute_insights flag changes. Edits to existing items (same // name, new behavior) are NOT visible to the engine — they're - // what this prompt is for. - log.Info("Auto-detected — no need to select:") - log.Info(" • Added metadata or visualizations") - log.Info(" • Metric direction or insight-config changes") - log.Info("Tell us about edits to things that already existed:") - + // what this prompt is for. The auto-detect summary is in-prompt + // (below the question), not a separate log line, to keep the + // terminal footprint minimal. labels := make([]string, len(changeOptions)) labelToKey := make(map[string]ChangeKey, len(changeOptions)) for i, opt := range changeOptions { @@ -195,10 +251,20 @@ func AskForEvaluatePlan() (EvaluatePlan, error) { var selectedLabels []string prompt := &survey.MultiSelect{ - Message: "What did you change in your code?", + Message: fmt.Sprintf( + "What did you change in your code? (%s: added meta/viz, metric-direction, insight-config)", + text.Bold.Sprint("auto-detected"), + ), Options: labels, } - if err := survey.AskOne(prompt, &selectedLabels); err != nil { + // Drop the ` to all` / ` to none` hints — they bloat + // the inline instruction line and the 4-option list doesn't need a + // power-user shortcut for select-all. + if err := survey.AskOne( + prompt, &selectedLabels, + survey.WithRemoveSelectAll(), + survey.WithRemoveSelectNone(), + ); err != nil { return EvaluatePlan{}, err } @@ -235,27 +301,22 @@ func FormatEvaluatePlan(plan EvaluatePlan) []string { return out } -// PrintEvaluatePlan logs the resolved plan as a bulleted list. +// PrintEvaluatePlan logs the resolved plan as a bulleted list using a +// single "This will:" verb across all branches so the output stays +// consistent regardless of which path planUpdateEvaluate took. The +// update path always appends an auto-detect line so the user sees the +// full picture (their selections + what the engine catches on its own). func PrintEvaluatePlan(plan EvaluatePlan) { - items := FormatEvaluatePlan(plan) - if plan.Kind == EvaluatePlanUpdate && len(items) == 0 { - // No forced actions — the engine's auto-detect phases decide - // at runtime what (if anything) actually runs. - log.Info("Will run:") - log.Info(" • Auto-detect (engine decides)") - return - } - if len(items) == 0 { - return - } if plan.Kind == EvaluatePlanReset { - log.Info("Will run:") - } else { - log.Info("Will update:") + log.Info("This will:") + log.Info(" • Re-evaluate (full)") + return } - for _, item := range items { + log.Info("This will:") + for _, item := range FormatEvaluatePlan(plan) { log.Infof(" • %s", item) } + log.Info(" • Auto-detect added meta/viz, metric-direction, insight-config") } func hasOwnEvalData(v *tensorleapapi.SlimVersion) bool { diff --git a/pkg/model/utils.go b/pkg/model/utils.go index 494f710af..80159f1ad 100644 --- a/pkg/model/utils.go +++ b/pkg/model/utils.go @@ -105,8 +105,8 @@ type VersionInfo struct { Status VersionStatus } -func AskUserForModelPathOrOverwrite(ctx context.Context, projectId string) (isOverwrite bool, overwriteVersionInfo *VersionInfo, modelPath string, err error) { - overwriteVersionInfo, err = AskUserForNewVersionOrSelectExistingVersion(ctx, projectId) +func AskUserForModelPathOrOverwrite(ctx context.Context, projectId string, currentName *string, runEval bool) (isOverwrite bool, overwriteVersionInfo *VersionInfo, modelPath string, err error) { + overwriteVersionInfo, err = AskUserForNewVersionOrSelectExistingVersion(ctx, projectId, runEval) if err != nil { return false, nil, "", err } @@ -115,15 +115,23 @@ func AskUserForModelPathOrOverwrite(ctx context.Context, projectId string) (isOv return true, overwriteVersionInfo, "", nil } log.Warn("The selected version does not have a model, you will be asked for a model path to import") + modelPath, err = AskUserForModelPath(ctx) + if err != nil { + return false, nil, "", err + } + return false, overwriteVersionInfo, modelPath, nil + } + if err = InitMessage(currentName, ""); err != nil { + return false, nil, "", err } modelPath, err = AskUserForModelPath(ctx) if err != nil { return false, nil, "", err } - return false, overwriteVersionInfo, modelPath, nil + return false, nil, modelPath, nil } -func AskUserForNewVersionOrSelectExistingVersion(ctx context.Context, projectId string) (*VersionInfo, error) { +func AskUserForNewVersionOrSelectExistingVersion(ctx context.Context, projectId string, runEval bool) (*VersionInfo, error) { versions, err := GetSlimActiveVersions(ctx, projectId) if err != nil { @@ -165,10 +173,17 @@ func AskUserForNewVersionOrSelectExistingVersion(ctx context.Context, projectId hasOptions := len(options) > 1 if hasOptions { - fmt.Println(text.FgYellow.Sprint("\n\n NOTE: When overwriting with --eval, we auto-detect what changed and re-run only what's needed \n\n")) + // Only surface the eval hint when the caller didn't pass --eval: + // with --eval the diff is auto-detected and patched / re-evaluated + // for them; without --eval we want users to know that overwriting + // alone just replaces the version and they can drive evaluation + // from the UI afterwards. + if !runEval { + fmt.Println(text.FgYellow.Sprint("\n\n NOTE: Overwriting replaces the version. Use the update-evaluate dialog in the UI to re-evaluate (or re-run with --eval). \n\n")) + } prompt := &survey.Select{ - Message: "Create new, or overwrite existing", + Message: "Push as new, or overwrite existing", Options: options, Default: selectedIndex, } @@ -195,13 +210,12 @@ func versionInfoFromSlim(version *tensorleapapi.SlimVersion, runsStatusesPerVers } } -// ResolveVersionInfoFromRef finds a project version by id (cid). A version name is accepted only when it matches exactly one version in the project. func ResolveVersionInfoFromRef(ctx context.Context, projectId, versionRef string) (*VersionInfo, error) { versionRef = strings.TrimSpace(versionRef) if versionRef == "" { return nil, fmt.Errorf("version reference is empty") } - versions, err := GetVersions(ctx, projectId) + allVersions, err := GetVersions(ctx, projectId) if err != nil { return nil, err } @@ -209,37 +223,94 @@ func ResolveVersionInfoFromRef(ctx context.Context, projectId, versionRef string if err != nil { return nil, err } - for i := range versions { - v := &versions[i] + for i := range allVersions { + v := &allVersions[i] if v.GetCid() == versionRef { return versionInfoFromSlim(v, runsStatusesPerVersionId), nil } } - var nameMatches []*tensorleapapi.SlimVersion - for i := range versions { - v := &versions[i] - if v.GetNotes() == versionRef { - nameMatches = append(nameMatches, v) + var activeNameMatches []*tensorleapapi.SlimVersion + for i := range allVersions { + v := &allVersions[i] + if v.GetIsActive() && v.GetNotes() == versionRef { + activeNameMatches = append(activeNameMatches, v) } } - switch len(nameMatches) { + switch len(activeNameMatches) { case 0: - return nil, fmt.Errorf("no version found for %q (use the version id from the UI or API)", versionRef) + return nil, fmt.Errorf("no active version found for %q (use the version id from the UI or API)", versionRef) case 1: - return versionInfoFromSlim(nameMatches[0], runsStatusesPerVersionId), nil + return versionInfoFromSlim(activeNameMatches[0], runsStatusesPerVersionId), nil default: + return pickAmbiguousVersion(versionRef, activeNameMatches, runsStatusesPerVersionId) + } +} + +func PrintResolvedOverwriteTarget(originalRef string, info *VersionInfo) { + if info == nil { + return + } + id := info.VersionId + if len(id) > 8 { + id = id[:8] + } + displayName := info.VersionName + if displayName == "" { + displayName = "(unnamed)" + } + if originalRef == info.VersionName || originalRef == info.VersionId { + log.Infof("Overwriting %s (id %s)", displayName, id) + } else { + log.Infof("Overwriting %s (id %s, matched %q)", displayName, id, originalRef) + } +} + +func pickAmbiguousVersion( + versionRef string, + nameMatches []*tensorleapapi.SlimVersion, + runsStatusesPerVersionId map[string][]tensorleapapi.RunProcess, +) (*VersionInfo, error) { + maxLen := 0 + for _, v := range nameMatches { + if len(v.GetNotes()) > maxLen { + maxLen = len(v.GetNotes()) + } + } + options := make([]string, len(nameMatches)) + infos := make([]VersionInfo, len(nameMatches)) + for i, v := range nameMatches { + status, hasModel, hasUploadedModel := CalcVersionStatus(v, runsStatusesPerVersionId[v.GetCid()]) + options[i] = FormatVersionDisplayName(v, status, maxLen) + infos[i] = VersionInfo{ + VersionId: v.GetCid(), + VersionName: v.GetNotes(), + HasModel: hasModel, + HasUploadedModel: hasUploadedModel, + Status: status, + } + } + + idx := 0 + prompt := &survey.Select{ + Message: fmt.Sprintf("Multiple versions named %q — pick one", versionRef), + Options: options, + Default: idx, + } + if err := survey.AskOne(prompt, &idx); err != nil { + // Non-TTY (script) or user-cancel — surface the candidate ids + // so the caller can re-run with --overwrite . ids := make([]string, 0, len(nameMatches)) for _, v := range nameMatches { ids = append(ids, v.GetCid()) } const maxList = 12 if len(ids) > maxList { - ids = ids[:maxList] - ids = append(ids, "...") + ids = append(ids[:maxList], "...") } - return nil, fmt.Errorf("version name %q is not unique in this project (%d versions); use --overwrite-version with a version id. Example ids: %s", + return nil, fmt.Errorf("version name %q is not unique (%d matches); use --overwrite with a version id. Candidates: %s", versionRef, len(nameMatches), strings.Join(ids, ", ")) } + return &infos[idx], nil } func AskUserForModelPath(ctx context.Context) (modelPath string, err error) {