From 4d21f3fe44a7c40ac8c15fc819cc8133bd4d1acb Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:04:29 -0400 Subject: [PATCH 1/9] sync drapi and pipelines infrastructure with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 From adeb8314aa9af476508b68a36f22270f02c18d7c Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:04:29 -0400 Subject: [PATCH 2/9] sync drapi and pipelines infrastructure with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 From b9d7ed848f40d738a19f52fad67841b00ba7ef07 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 20 May 2026 14:12:23 -0400 Subject: [PATCH 3/9] [CMPT-5391] add dr pipelines run subcommand group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the run subcommand group for triggering and inspecting pipeline executions (create/list/get/status/cancel). - internal/pipelines/run.go: CreateRun, ListRuns, GetRun, RunStatus, CancelRun — API client for the /dispatches endpoints - cmd/pipelines/run/: run create/list/get/status/cancel + runutil output - docs: run section in pipelines.md, run endpoints in pipelines-reference.md Wire-level endpoints still use "dispatches"; CLI output uses run_id / covalent_run_id per the vocabulary migration in the codebase. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/cmd.go | 2 + cmd/pipeline/cmd_test.go | 1 + cmd/pipeline/run/cancel/cmd.go | 69 +++++++++ cmd/pipeline/run/cancel/cmd_test.go | 56 +++++++ cmd/pipeline/run/cmd.go | 49 ++++++ cmd/pipeline/run/cmd_test.go | 41 +++++ cmd/pipeline/run/create/cmd.go | 87 +++++++++++ cmd/pipeline/run/create/cmd_test.go | 67 ++++++++ cmd/pipeline/run/get/cmd.go | 93 ++++++++++++ cmd/pipeline/run/get/cmd_test.go | 66 ++++++++ cmd/pipeline/run/list/cmd.go | 82 ++++++++++ cmd/pipeline/run/list/cmd_test.go | 61 ++++++++ cmd/pipeline/run/runutil/render.go | 193 ++++++++++++++++++++++++ cmd/pipeline/run/runutil/render_test.go | 166 ++++++++++++++++++++ cmd/pipeline/run/status/cmd.go | 93 ++++++++++++ cmd/pipeline/run/status/cmd_test.go | 61 ++++++++ docs/commands/README.md | 9 +- docs/commands/pipeline.md | 23 ++- docs/commands/pipelines-reference.md | 20 +++ internal/pipeline/run.go | 168 +++++++++++++++++++++ internal/pipeline/run_test.go | 168 +++++++++++++++++++++ 21 files changed, 1572 insertions(+), 3 deletions(-) create mode 100644 cmd/pipeline/run/cancel/cmd.go create mode 100644 cmd/pipeline/run/cancel/cmd_test.go create mode 100644 cmd/pipeline/run/cmd.go create mode 100644 cmd/pipeline/run/cmd_test.go create mode 100644 cmd/pipeline/run/create/cmd.go create mode 100644 cmd/pipeline/run/create/cmd_test.go create mode 100644 cmd/pipeline/run/get/cmd.go create mode 100644 cmd/pipeline/run/get/cmd_test.go create mode 100644 cmd/pipeline/run/list/cmd.go create mode 100644 cmd/pipeline/run/list/cmd_test.go create mode 100644 cmd/pipeline/run/runutil/render.go create mode 100644 cmd/pipeline/run/runutil/render_test.go create mode 100644 cmd/pipeline/run/status/cmd.go create mode 100644 cmd/pipeline/run/status/cmd_test.go create mode 100644 internal/pipeline/run.go create mode 100644 internal/pipeline/run_test.go diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 7eb787d95..2c650b8d8 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -21,6 +21,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/graph" "github.com/datarobot/cli/cmd/pipeline/list" "github.com/datarobot/cli/cmd/pipeline/lock" + "github.com/datarobot/cli/cmd/pipeline/run" "github.com/datarobot/cli/cmd/pipeline/update" "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" @@ -51,6 +52,7 @@ input payloads, runs, and recurring schedules.`, lock.Cmd(), version.Cmd(), graph.Cmd(), + run.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index bf4e5c022..db8a31203 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -63,6 +63,7 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { "lock": false, "version": false, "graph": false, + "run": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go new file mode 100644 index 000000000..57f2ca57d --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd.go @@ -0,0 +1,69 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 cancel + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pipeline run", + Long: `Request cancellation of an in-flight run. + +The API rejects cancellation if the run has already reached a terminal +state (COMPLETED, FAILED, CANCELLED). + +Example: + dr pipelines run cancel --pipeline + dr pipelines run cancel --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.CancelRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Cancelled run: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + return cmd +} diff --git a/cmd/pipeline/run/cancel/cmd_test.go b/cmd/pipeline/run/cancel/cmd_test.go new file mode 100644 index 000000000..582095425 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 cancel + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "cancel", Cmd().Name()) +} diff --git a/cmd/pipeline/run/cmd.go b/cmd/pipeline/run/cmd.go new file mode 100644 index 000000000..268ae3d72 --- /dev/null +++ b/cmd/pipeline/run/cmd.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 run + +import ( + "github.com/datarobot/cli/cmd/pipeline/run/cancel" + "github.com/datarobot/cli/cmd/pipeline/run/create" + "github.com/datarobot/cli/cmd/pipeline/run/get" + "github.com/datarobot/cli/cmd/pipeline/run/list" + "github.com/datarobot/cli/cmd/pipeline/run/status" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines run`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Manage pipeline runs", + Long: `Trigger and inspect runs (single executions) of a pipeline. + +Runs come in two scopes: + - draft : executes against the in-flight draft of a pipeline + - locked : executes against a specific frozen version + +When --version is supplied, the locked scope is selected automatically.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + status.Cmd(), + cancel.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/run/cmd_test.go b/cmd/pipeline/run/cmd_test.go new file mode 100644 index 000000000..ce2c30cff --- /dev/null +++ b/cmd/pipeline/run/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 run + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "status": false, + "cancel": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go new file mode 100644 index 000000000..2f2604152 --- /dev/null +++ b/cmd/pipeline/run/create/cmd.go @@ -0,0 +1,87 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 create + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + inputID string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Trigger a pipeline run", + Long: `Trigger a new run (single execution) of a pipeline. + +The run is created in PENDING state. Use ` + "`dr pipelines run get`" + ` +or ` + "`dr pipelines run status`" + ` to follow its progress. + +Example: + dr pipelines run create --pipeline --input + dr pipelines run create --pipeline --version=2 --input --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + if inputID == "" { + return errors.New("--input is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.CreateRun(flags.PipelineID, scope, version, inputID) + if err != nil { + return err + } + + if outputFormat == "json" { + return runutil.PrintRunJSON(*result) + } + + runutil.PrintRunHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipeline/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go new file mode 100644 index 000000000..c4cac589d --- /dev/null +++ b/cmd/pipeline/run/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "--input") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--scope", "draft", "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "input", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go new file mode 100644 index 000000000..9a013e8fe --- /dev/null +++ b/cmd/pipeline/run/get/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline run", + Long: `Display the full record for a single run. + +Example: + dr pipelines run get --pipeline + dr pipelines run get --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + if outputFormat == "json" { + return runutil.PrintRunJSON(*result) + } + + runutil.PrintRunHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGetError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go new file mode 100644 index 000000000..8a69bba87 --- /dev/null +++ b/cmd/pipeline/run/get/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "d-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go new file mode 100644 index 000000000..e818a0d5f --- /dev/null +++ b/cmd/pipeline/run/list/cmd.go @@ -0,0 +1,82 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 list + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline runs", + Long: `List runs for a pipeline. + +Example: + dr pipelines run list --pipeline + dr pipelines run list --pipeline --version=2 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListRuns(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return runutil.PrintRunListJSON(items) + } + + runutil.PrintRunListHuman(items) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipeline/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go new file mode 100644 index 000000000..63eac2187 --- /dev/null +++ b/cmd/pipeline/run/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/runutil/render.go b/cmd/pipeline/run/runutil/render.go new file mode 100644 index 000000000..5f56170d6 --- /dev/null +++ b/cmd/pipeline/run/runutil/render.go @@ -0,0 +1,193 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 runutil holds the rendering helpers shared by the +// `dr pipelines run` verbs. Living in a sibling package keeps the +// parent `run` package free of cycles. + +package runutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" +) + +// runJSON is the CLI-facing shape used for `--output json`. It mirrors +// pipeline.Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through pipeline.Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r pipeline.Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// runStatusJSON mirrors pipeline.RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s pipeline.RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. +func PrintRunJSON(r pipeline.Run) error { + data, err := json.MarshalIndent(toRunJSON(r), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r pipeline.Run) { + scope := "draft" + versionDisplay := "\u2014" + + if r.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Run ID: " + r.RunID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + r.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) + fmt.Println(tui.BaseTextStyle.Render("Input ID: " + r.InputID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + r.Status)) + fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + r.TriggeredBy)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) + + if r.ErrorDetail != "" { + fmt.Println(tui.BaseTextStyle.Render("Error: " + r.ErrorDetail)) + } + + fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt)) +} + +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. +func PrintRunListJSON(items []pipeline.Run) error { + view := make([]runJSON, len(items)) + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a tabular summary of runs. +func PrintRunListHuman(items []pipeline.Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "RUN_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") + + for _, r := range items { + scope := "draft" + ver := "\u2014" + + if r.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*r.VersionID) + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", + r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt, + ) + } + + _ = writer.Flush() +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. +func PrintStatusJSON(s pipeline.RunStatus) error { + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s pipeline.RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Run ID: " + s.RunID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) +} diff --git a/cmd/pipeline/run/runutil/render_test.go b/cmd/pipeline/run/runutil/render_test.go new file mode 100644 index 000000000..6f1c9a36b --- /dev/null +++ b/cmd/pipeline/run/runutil/render_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 runutil + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func intPtr(v int) *int { return &v } + +func sampleDraftRun() pipeline.Run { + return pipeline.Run{ + RunID: "d-1", + PipelineID: "pl-1", + InputID: "in-1", + TriggeredBy: "user@example.com", + Status: pipeline.RunStatusPending, + CreatedAt: "2026-04-29T10:00:00Z", + UpdatedAt: "2026-04-29T10:00:00Z", + } +} + +func sampleLockedRun() pipeline.Run { + r := sampleDraftRun() + r.VersionID = intPtr(3) + r.CovalentDispatchID = "cov-xyz" + r.Status = pipeline.RunStatusRunning + + return r +} + +func TestPrintRunJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintRunJSON(sampleDraftRun())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "d-1", parsed["run_id"]) + assert.Equal(t, "PENDING", parsed["status"]) + _, hasLegacy := parsed["dispatch_id"] + assert.Falsef(t, hasLegacy, "expected legacy dispatch_id key to be absent") +} + +func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { + output := captureStdout(t, func() { PrintRunHuman(sampleDraftRun()) }) + assert.Contains(t, output, "Run ID: d-1") + assert.Contains(t, output, "Scope: draft") + assert.Contains(t, output, "Version: \u2014") + assert.Contains(t, output, "Covalent Run: \u2014") + assert.Contains(t, output, "Status: PENDING") +} + +func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { + r := sampleLockedRun() + r.Status = pipeline.RunStatusFailed + r.ErrorDetail = "boom" + + output := captureStdout(t, func() { PrintRunHuman(r) }) + assert.Contains(t, output, "Scope: locked") + assert.Contains(t, output, "Version: v3") + assert.Contains(t, output, "Covalent Run: cov-xyz") + assert.Contains(t, output, "Error: boom") +} + +func TestPrintRunListJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintRunListJSON([]pipeline.Run{sampleDraftRun()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "d-1", parsed[0]["run_id"]) +} + +func TestPrintRunListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintRunListHuman(nil) }) + assert.Contains(t, output, "No runs found") +} + +func TestPrintRunListHuman_RendersTable(t *testing.T) { + output := captureStdout(t, func() { + PrintRunListHuman([]pipeline.Run{sampleDraftRun(), sampleLockedRun()}) + }) + + assert.Contains(t, output, "RUN_ID") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "TRIGGER") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "v3") + assert.Contains(t, output, "PENDING") + assert.Contains(t, output, "RUNNING") +} + +func TestPrintStatusJSON(t *testing.T) { + status := pipeline.RunStatus{ + RunID: "d-1", + Status: pipeline.RunStatusCompleted, + CovalentDispatchID: "cov-xyz", + } + + output := captureStdout(t, func() { + require.NoError(t, PrintStatusJSON(status)) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "COMPLETED", parsed["status"]) + assert.Equal(t, "cov-xyz", parsed["covalent_run_id"]) + assert.Equal(t, "d-1", parsed["run_id"]) +} + +func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { + output := captureStdout(t, func() { + PrintStatusHuman(pipeline.RunStatus{RunID: "d-1", Status: "PENDING"}) + }) + + assert.Contains(t, output, "Run ID: d-1") + assert.Contains(t, output, "Status: PENDING") + assert.Contains(t, output, "Covalent Run: \u2014") +} diff --git a/cmd/pipeline/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go new file mode 100644 index 000000000..f2cc67fa0 --- /dev/null +++ b/cmd/pipeline/run/status/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 status + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "status ", + Short: "Get the lightweight status of a pipeline run", + Long: `Poll a run's current status without re-downloading the full record. + +Example: + dr pipelines run status --pipeline + dr pipelines run status --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRunStatus(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleStatusError(err, args[0]) + } + + if outputFormat == "json" { + return runutil.PrintStatusJSON(*result) + } + + runutil.PrintStatusHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleStatusError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go new file mode 100644 index 000000000..9efb4a236 --- /dev/null +++ b/cmd/pipeline/run/status/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 status + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestHandleStatusError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleStatusError(httpErr, "d-1")) +} + +func TestHandleStatusError_PropagatesOther(t *testing.T) { + err := handleStatusError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/docs/commands/README.md b/docs/commands/README.md index f91e74838..4a4e0692a 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -85,7 +85,13 @@ dr │ ├── version Inspect pipeline versions │ │ ├── list List versions of a pipeline │ │ └── get Display details of a single pipeline version -│ └── graph Display the pipeline/task DAG of a pipeline +│ ├── graph Display the pipeline/task DAG of a pipeline +│ └── run Trigger and inspect pipeline executions +│ ├── create Trigger a run from an input +│ ├── list List runs for a pipeline +│ ├── get Display a single run +│ ├── status Lightweight run status (for polling) +│ └── cancel Cancel a running run └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -256,6 +262,7 @@ For detailed documentation on each command, see: - `lock`—promote a draft pipeline to locked mode. - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). + - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index eff859b1f..35515ee0a 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -78,6 +78,7 @@ dr pipeline lock | `dr pipeline lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | | `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | | `dr pipeline graph` | `…/graph` (draft or locked) | Render the pipeline/task DAG. | +| `dr pipeline run …` | `…/dispatches` and `…/{id}` | Trigger, inspect, and cancel runs. | ## Subcommands @@ -300,13 +301,31 @@ dr pipeline version get --pipeline 2 dr pipeline graph --pipeline --version=2 --output-format json ``` +### `run` + +Trigger, inspect, and cancel pipeline executions. + +```bash +dr pipelines run create --pipeline --input # draft +dr pipelines run create --pipeline --version=N --input # locked +dr pipelines run list --pipeline [--scope|--version] +dr pipelines run get --pipeline [--scope|--version] +dr pipelines run status --pipeline [--scope|--version] +dr pipelines run cancel --pipeline [--scope|--version] +``` + +`run status` is a lighter-weight call intended for polling — returns just +the run ID, status, and Covalent dispatch ID. + +`run cancel` returns `409 Conflict` if the run is already terminal. + ## Error handling | Status | Cause | |--------|--------------------------------------------------------------------------------| | `400` | Invalid Python file or mismatched pipeline name. | -| `404` | The provided `` or version does not exist. | -| `409` | Tried to update a `locked` pipeline. | +| `404` | The provided ``, version, or run does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal run. | ## See also diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index b783eab14..164dd16c4 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -86,6 +86,22 @@ exercising a local API stub that doesn't implement `/version/`. --- +## Runs (`dr pipelines run …`) + +Same draft/locked scope rules as graph. The wire-level URLs still use the legacy +term `dispatches` / `dispatch_id`, but the CLI's `--output json` remaps these to +`run_id` / `covalent_run_id`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run cancel --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -100,3 +116,7 @@ exercising a local API stub that doesn't implement `/version/`. | `GET /pipelines/{id}/versions/{ver}` | `dr pipeline version get` | | `GET /pipelines/{id}/graph` | `dr pipeline graph` (draft) | | `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipeline graph` (locked) | +| `POST /pipelines/{id}/dispatches` | `dr pipeline run create` (draft) | +| `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | +| `GET /pipelines/{id}/dispatches/{run_id}` | `dr pipeline run get` (draft) | +| `DELETE /pipelines/{id}/dispatches/{run_id}` | `dr pipeline run cancel` (draft) | diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go new file mode 100644 index 000000000..b2b36654e --- /dev/null +++ b/internal/pipeline/run.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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. + +// run.go wraps the pipeline run endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. +// +// The wire format and server URL paths still use the legacy term +// "dispatch" (e.g. /dispatches, dispatch_id). JSON tags and endpoint +// segments are preserved to keep the API contract intact while the Go +// surface is renamed to "run" to match the new product vocabulary. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" +) + +// Run lifecycle states (mirrors PipelineDispatchStatus on the wire). +const ( + RunStatusPending = "PENDING" + RunStatusRunning = "RUNNING" + RunStatusCompleted = "COMPLETED" + RunStatusFailed = "FAILED" + RunStatusCancelled = "CANCELLED" + RunStatusErrored = "ERRORED" +) + +// Run mirrors PipelineDispatchResponse from the pipelines-api. JSON tags +// (`dispatch_id`, `covalent_dispatch_id`) track the current API wire +// format, which has not been renamed to "run" yet. +type Run struct { + RunID string `json:"dispatch_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// RunStatus mirrors PipelineDispatchStatusResponse — the lightweight +// polling-friendly shape returned by GET .../status. +// +// See Run for the rationale on the legacy `dispatch_id` / +// `covalent_dispatch_id` JSON tags. +type RunStatus struct { + RunID string `json:"dispatch_id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` +} + +// RunCreateRequest mirrors PipelineDispatchCreateRequest. +type RunCreateRequest struct { + InputID string `json:"input_id"` +} + +// CreateRun starts a new run for the given input. Returns the +// freshly-created Run (status PENDING). +func CreateRun(pipelineID string, scope Scope, version *int, inputID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := RunCreateRequest{InputID: inputID} + + var result Run + + err = doJSON(http.MethodPost, endpoint, body, "create run", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListRuns returns a paginated slice of runs for the given scope. +func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ([]Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var runs []Run + + err = doJSON(http.MethodGet, endpoint, nil, "runs", &runs) + if err != nil { + return nil, err + } + + return runs, nil +} + +// GetRun fetches a single run by id within the given scope. +func GetRun(pipelineID string, scope Scope, version *int, runID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return nil, err + } + + var run Run + + err = doJSON(http.MethodGet, endpoint, nil, "run", &run) + if err != nil { + return nil, err + } + + return &run, nil +} + +// GetRunStatus calls the lightweight GET .../status endpoint useful for +// polling without re-downloading the full run record. +func GetRunStatus(pipelineID string, scope Scope, version *int, runID string) (*RunStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID+"/status") + if err != nil { + return nil, err + } + + var status RunStatus + + err = doJSON(http.MethodGet, endpoint, nil, "run status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelRun issues a DELETE on a run, transitioning it to CANCELLED if +// it is still in a non-terminal state. +func CancelRun(pipelineID string, scope Scope, version *int, runID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel run") +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go new file mode 100644 index 000000000..aab69dcea --- /dev/null +++ b/internal/pipeline/run_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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 pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateRun_DraftURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + + var body RunCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body.InputID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateRun("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "d-1", got.RunID) + assert.Equal(t, RunStatusPending, got.Status) +} + +func TestCreateRun_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","version_id":2,"input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateRun("p-1", ScopeLocked, &v, "in-1") + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListRuns_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"RUNNING"}]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListRuns("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, RunStatusRunning, items[0].Status) +} + +func TestGetRun_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"COMPLETED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetRun("p-1", ScopeDraft, nil, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusCompleted, got.Status) +} + +func TestGetRunStatus_StatusEndpointURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","status":"RUNNING","covalent_dispatch_id":"cov-x"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := GetRunStatus("p-1", ScopeLocked, &v, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusRunning, got.Status) + assert.Equal(t, "cov-x", got.CovalentDispatchID) +} + +func TestCancelRun_DeletesDraftURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, CancelRun("p-1", ScopeDraft, nil, "d-1")) +} + +func TestCancelRun_PropagatesConflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already terminal"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := CancelRun("p-1", ScopeDraft, nil, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} From 2f5087b13c954cf589d5a098977718b64d983948 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:10:52 -0400 Subject: [PATCH 4/9] sync run structs, output, and commands with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/run/cancel/cmd.go | 4 +- cmd/pipeline/run/cmd.go | 2 +- cmd/pipeline/run/create/cmd_test.go | 4 +- cmd/pipeline/run/get/cmd_test.go | 2 +- cmd/pipeline/run/list/cmd_test.go | 4 +- cmd/pipeline/run/runutil/render.go | 193 ------------------ cmd/pipeline/run/runutil/render_test.go | 166 ---------------- cmd/pipeline/run/status/cmd_test.go | 2 +- internal/pipeline/run.go | 38 ++-- internal/pipeline/run_output.go | 254 ++++++++++++++++++++++++ internal/pipeline/run_test.go | 10 +- 11 files changed, 285 insertions(+), 394 deletions(-) delete mode 100644 cmd/pipeline/run/runutil/render.go delete mode 100644 cmd/pipeline/run/runutil/render_test.go create mode 100644 internal/pipeline/run_output.go diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go index 57f2ca57d..b6345b1e9 100644 --- a/cmd/pipeline/run/cancel/cmd.go +++ b/cmd/pipeline/run/cancel/cmd.go @@ -37,8 +37,8 @@ The API rejects cancellation if the run has already reached a terminal state (COMPLETED, FAILED, CANCELLED). Example: - dr pipelines run cancel --pipeline - dr pipelines run cancel --pipeline --version=2 `, + dr pipeline run cancel --pipeline + dr pipeline run cancel --pipeline --version=2 `, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipeline/run/cmd.go b/cmd/pipeline/run/cmd.go index 268ae3d72..bd2bc50da 100644 --- a/cmd/pipeline/run/cmd.go +++ b/cmd/pipeline/run/cmd.go @@ -23,7 +23,7 @@ import ( "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines run`. +// Cmd returns the parent command for `dr pipeline run`. func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "run", diff --git a/cmd/pipeline/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go index c4cac589d..925cc6e26 100644 --- a/cmd/pipeline/run/create/cmd_test.go +++ b/cmd/pipeline/run/create/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -61,7 +61,7 @@ func TestCmd_RejectsBadScopeCombo(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "input", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "input", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipeline/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go index 8a69bba87..a7fe10af8 100644 --- a/cmd/pipeline/run/get/cmd_test.go +++ b/cmd/pipeline/run/get/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "d-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipeline/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go index 63eac2187..5ccb8e15a 100644 --- a/cmd/pipeline/run/list/cmd_test.go +++ b/cmd/pipeline/run/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -55,7 +55,7 @@ func TestCmd_RejectsBadScopeCombo(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipeline/run/runutil/render.go b/cmd/pipeline/run/runutil/render.go deleted file mode 100644 index 5f56170d6..000000000 --- a/cmd/pipeline/run/runutil/render.go +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// 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 runutil holds the rendering helpers shared by the -// `dr pipelines run` verbs. Living in a sibling package keeps the -// parent `run` package free of cycles. - -package runutil - -import ( - "encoding/json" - "fmt" - "os" - "strconv" - "text/tabwriter" - - "github.com/datarobot/cli/internal/pipeline" - "github.com/datarobot/cli/tui" -) - -// runJSON is the CLI-facing shape used for `--output json`. It mirrors -// pipeline.Run but renames the wire-level fields to the CLI's `run` -// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens -// through pipeline.Run, which keeps the API wire tags intact. -type runJSON struct { - RunID string `json:"run_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - InputID string `json:"input_id"` - CovalentRunID string `json:"covalent_run_id,omitempty"` - TriggeredBy string `json:"triggered_by"` - Status string `json:"status"` - ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -func toRunJSON(r pipeline.Run) runJSON { - return runJSON{ - RunID: r.RunID, - PipelineID: r.PipelineID, - VersionID: r.VersionID, - InputID: r.InputID, - CovalentRunID: r.CovalentDispatchID, - TriggeredBy: r.TriggeredBy, - Status: r.Status, - ErrorDetail: r.ErrorDetail, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } -} - -// runStatusJSON mirrors pipeline.RunStatus with CLI-vocabulary keys. -type runStatusJSON struct { - RunID string `json:"run_id"` - Status string `json:"status"` - CovalentRunID string `json:"covalent_run_id,omitempty"` -} - -func toRunStatusJSON(s pipeline.RunStatus) runStatusJSON { - return runStatusJSON{ - RunID: s.RunID, - Status: s.Status, - CovalentRunID: s.CovalentDispatchID, - } -} - -// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. -func PrintRunJSON(r pipeline.Run) error { - data, err := json.MarshalIndent(toRunJSON(r), "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintRunHuman renders a single run in a human-friendly form. -func PrintRunHuman(r pipeline.Run) { - scope := "draft" - versionDisplay := "\u2014" - - if r.VersionID != nil { - scope = "locked" - versionDisplay = "v" + strconv.Itoa(*r.VersionID) - } - - covalent := r.CovalentDispatchID - if covalent == "" { - covalent = "\u2014" - } - - fmt.Println(tui.BaseTextStyle.Render("Run ID: " + r.RunID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + r.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) - fmt.Println(tui.BaseTextStyle.Render("Input ID: " + r.InputID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + r.Status)) - fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + r.TriggeredBy)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) - - if r.ErrorDetail != "" { - fmt.Println(tui.BaseTextStyle.Render("Error: " + r.ErrorDetail)) - } - - fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt)) -} - -// PrintRunListJSON marshals a list of runs as indented JSON using -// CLI-vocabulary keys. -func PrintRunListJSON(items []pipeline.Run) error { - view := make([]runJSON, len(items)) - for i, r := range items { - view[i] = toRunJSON(r) - } - - data, err := json.MarshalIndent(view, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintRunListHuman renders a tabular summary of runs. -func PrintRunListHuman(items []pipeline.Run) { - if len(items) == 0 { - fmt.Println(tui.DimStyle.Render("No runs found")) - - return - } - - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - - fmt.Fprintln(writer, "RUN_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") - - for _, r := range items { - scope := "draft" - ver := "\u2014" - - if r.VersionID != nil { - scope = "locked" - ver = "v" + strconv.Itoa(*r.VersionID) - } - - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", - r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt, - ) - } - - _ = writer.Flush() -} - -// PrintStatusJSON marshals a lightweight status response as indented JSON -// using CLI-vocabulary keys. -func PrintStatusJSON(s pipeline.RunStatus) error { - data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintStatusHuman renders a lightweight status response. -func PrintStatusHuman(s pipeline.RunStatus) { - covalent := s.CovalentDispatchID - if covalent == "" { - covalent = "\u2014" - } - - fmt.Println(tui.BaseTextStyle.Render("Run ID: " + s.RunID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) -} diff --git a/cmd/pipeline/run/runutil/render_test.go b/cmd/pipeline/run/runutil/render_test.go deleted file mode 100644 index 6f1c9a36b..000000000 --- a/cmd/pipeline/run/runutil/render_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// 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 runutil - -import ( - "bytes" - "encoding/json" - "io" - "os" - "testing" - - "github.com/datarobot/cli/internal/pipeline" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - - os.Stdout = old - - var buf bytes.Buffer - - _, _ = io.Copy(&buf, r) - - return buf.String() -} - -func intPtr(v int) *int { return &v } - -func sampleDraftRun() pipeline.Run { - return pipeline.Run{ - RunID: "d-1", - PipelineID: "pl-1", - InputID: "in-1", - TriggeredBy: "user@example.com", - Status: pipeline.RunStatusPending, - CreatedAt: "2026-04-29T10:00:00Z", - UpdatedAt: "2026-04-29T10:00:00Z", - } -} - -func sampleLockedRun() pipeline.Run { - r := sampleDraftRun() - r.VersionID = intPtr(3) - r.CovalentDispatchID = "cov-xyz" - r.Status = pipeline.RunStatusRunning - - return r -} - -func TestPrintRunJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintRunJSON(sampleDraftRun())) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "d-1", parsed["run_id"]) - assert.Equal(t, "PENDING", parsed["status"]) - _, hasLegacy := parsed["dispatch_id"] - assert.Falsef(t, hasLegacy, "expected legacy dispatch_id key to be absent") -} - -func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { - output := captureStdout(t, func() { PrintRunHuman(sampleDraftRun()) }) - assert.Contains(t, output, "Run ID: d-1") - assert.Contains(t, output, "Scope: draft") - assert.Contains(t, output, "Version: \u2014") - assert.Contains(t, output, "Covalent Run: \u2014") - assert.Contains(t, output, "Status: PENDING") -} - -func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { - r := sampleLockedRun() - r.Status = pipeline.RunStatusFailed - r.ErrorDetail = "boom" - - output := captureStdout(t, func() { PrintRunHuman(r) }) - assert.Contains(t, output, "Scope: locked") - assert.Contains(t, output, "Version: v3") - assert.Contains(t, output, "Covalent Run: cov-xyz") - assert.Contains(t, output, "Error: boom") -} - -func TestPrintRunListJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintRunListJSON([]pipeline.Run{sampleDraftRun()})) - }) - - var parsed []map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - require.Len(t, parsed, 1) - assert.Equal(t, "d-1", parsed[0]["run_id"]) -} - -func TestPrintRunListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintRunListHuman(nil) }) - assert.Contains(t, output, "No runs found") -} - -func TestPrintRunListHuman_RendersTable(t *testing.T) { - output := captureStdout(t, func() { - PrintRunListHuman([]pipeline.Run{sampleDraftRun(), sampleLockedRun()}) - }) - - assert.Contains(t, output, "RUN_ID") - assert.Contains(t, output, "STATUS") - assert.Contains(t, output, "TRIGGER") - assert.Contains(t, output, "draft") - assert.Contains(t, output, "locked") - assert.Contains(t, output, "v3") - assert.Contains(t, output, "PENDING") - assert.Contains(t, output, "RUNNING") -} - -func TestPrintStatusJSON(t *testing.T) { - status := pipeline.RunStatus{ - RunID: "d-1", - Status: pipeline.RunStatusCompleted, - CovalentDispatchID: "cov-xyz", - } - - output := captureStdout(t, func() { - require.NoError(t, PrintStatusJSON(status)) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "COMPLETED", parsed["status"]) - assert.Equal(t, "cov-xyz", parsed["covalent_run_id"]) - assert.Equal(t, "d-1", parsed["run_id"]) -} - -func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { - output := captureStdout(t, func() { - PrintStatusHuman(pipeline.RunStatus{RunID: "d-1", Status: "PENDING"}) - }) - - assert.Contains(t, output, "Run ID: d-1") - assert.Contains(t, output, "Status: PENDING") - assert.Contains(t, output, "Covalent Run: \u2014") -} diff --git a/cmd/pipeline/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go index 9efb4a236..061cb698b 100644 --- a/cmd/pipeline/run/status/cmd_test.go +++ b/cmd/pipeline/run/status/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "d-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index b2b36654e..dbc7d0a35 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "strconv" + "time" ) // Run lifecycle states (mirrors PipelineDispatchStatus on the wire). @@ -39,31 +40,26 @@ const ( RunStatusErrored = "ERRORED" ) -// Run mirrors PipelineDispatchResponse from the pipelines-api. JSON tags -// (`dispatch_id`, `covalent_dispatch_id`) track the current API wire -// format, which has not been renamed to "run" yet. +// Run mirrors PipelineDispatchResponse from the pipelines-api. type Run struct { - RunID string `json:"dispatch_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - InputID string `json:"input_id"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` - TriggeredBy string `json:"triggered_by"` - Status string `json:"status"` - ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + RunID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + InputID string `json:"inputId"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` + TriggeredBy string `json:"triggeredBy"` + Status string `json:"status"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // RunStatus mirrors PipelineDispatchStatusResponse — the lightweight // polling-friendly shape returned by GET .../status. -// -// See Run for the rationale on the legacy `dispatch_id` / -// `covalent_dispatch_id` JSON tags. type RunStatus struct { - RunID string `json:"dispatch_id"` + RunID string `json:"id"` Status string `json:"status"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` } // RunCreateRequest mirrors PipelineDispatchCreateRequest. @@ -111,14 +107,14 @@ func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ( endpoint = endpoint + "?" + encoded } - var runs []Run + var page DataPage[Run] - err = doJSON(http.MethodGet, endpoint, nil, "runs", &runs) + err = doJSON(http.MethodGet, endpoint, nil, "runs", &page) if err != nil { return nil, err } - return runs, nil + return page.Data, nil } // GetRun fetches a single run by id within the given scope. diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go new file mode 100644 index 000000000..562f2e42c --- /dev/null +++ b/internal/pipeline/run_output.go @@ -0,0 +1,254 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// 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. + +// run_output.go holds the rendering helpers shared by the +// `dr pipelines run` verbs. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// runJSON is the CLI-facing shape used for `--output-format json`. It mirrors +// Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// runStatusJSON mirrors RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// RenderRun routes a single run to JSON or human output. +func RenderRun(format OutputFormat, r Run) error { + if format == OutputFormatJSON { + return PrintRunJSON(r) + } + + PrintRunHuman(r) + + return nil +} + +// RenderRuns routes a list of runs to JSON or human output. +func RenderRuns(format OutputFormat, items []Run) error { + if format == OutputFormatJSON { + return PrintRunListJSON(items) + } + + PrintRunListHuman(items) + + return nil +} + +// RenderRunStatus routes a run status to JSON or human output. +func RenderRunStatus(format OutputFormat, s RunStatus) error { + if format == OutputFormatJSON { + return PrintStatusJSON(s) + } + + PrintStatusHuman(s) + + return nil +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. +func PrintRunJSON(r Run) error { + data, err := json.MarshalIndent(toRunJSON(r), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r Run) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", r.RunID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", r.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "Input ID:\t%s\n", r.InputID) + fmt.Fprintf(w, "Status:\t%s\n", r.Status) + fmt.Fprintf(w, "Triggered By:\t%s\n", r.TriggeredBy) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + if r.ErrorDetail != "" { + fmt.Fprintf(w, "Error:\t%s\n", r.ErrorDetail) + } + + fmt.Fprintf(w, "Created:\t%s\n", r.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", r.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. +func PrintRunListJSON(items []Run) error { + view := make([]runJSON, len(items)) + + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a lipgloss table summary of runs. +func PrintRunListHuman(items []Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"RUN ID", "SCOPE", "VERSION", "STATUS", "TRIGGER", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, r := range items { + scope := "draft" + ver := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*r.VersionID) + } + + t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. +func PrintStatusJSON(s RunStatus) error { + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", s.RunID) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + w.Flush() +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go index aab69dcea..3fd14228d 100644 --- a/internal/pipeline/run_test.go +++ b/internal/pipeline/run_test.go @@ -37,7 +37,7 @@ func TestCreateRun_DraftURLAndBody(t *testing.T) { assert.Equal(t, "in-1", body.InputID) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) })) defer srv.Close() @@ -57,7 +57,7 @@ func TestCreateRun_LockedURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","version_id":2,"input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","versionId":2,"inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) })) defer srv.Close() @@ -80,7 +80,7 @@ func TestListRuns_QueryAndDecode(t *testing.T) { assert.Equal(t, "5", r.URL.Query().Get("limit")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"RUNNING"}]`)) + _, _ = w.Write([]byte(`{"data":[{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"RUNNING"}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -100,7 +100,7 @@ func TestGetRun_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"COMPLETED"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"COMPLETED"}`)) })) defer srv.Close() @@ -119,7 +119,7 @@ func TestGetRunStatus_StatusEndpointURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","status":"RUNNING","covalent_dispatch_id":"cov-x"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","status":"RUNNING","covalentDispatchId":"cov-x"}`)) })) defer srv.Close() From 0b157cd70f03c32c9a7ee05883152067aa1d3338 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 14:01:03 -0400 Subject: [PATCH 5/9] fix internal/pipeline import paths in cmd/pipeline/run subcommands Apply sunny/pipelines versions of run subcommand files and update all import paths to use internal/pipeline (singular) after rename. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/run/create/cmd.go | 26 +++++++------------------- cmd/pipeline/run/get/cmd.go | 21 +++++---------------- cmd/pipeline/run/list/cmd.go | 22 +++++----------------- cmd/pipeline/run/status/cmd.go | 21 +++++---------------- 4 files changed, 22 insertions(+), 68 deletions(-) diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go index 2f2604152..cbf5d957d 100644 --- a/cmd/pipeline/run/create/cmd.go +++ b/cmd/pipeline/run/create/cmd.go @@ -16,9 +16,7 @@ package create import ( "errors" - "fmt" - "github.com/datarobot/cli/cmd/pipeline/run/runutil" "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags inputID string - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -37,20 +35,16 @@ func Cmd() *cobra.Command { Short: "Trigger a pipeline run", Long: `Trigger a new run (single execution) of a pipeline. -The run is created in PENDING state. Use ` + "`dr pipelines run get`" + ` -or ` + "`dr pipelines run status`" + ` to follow its progress. +The run is created in PENDING state. Use ` + "`dr pipeline run get`" + ` +or ` + "`dr pipeline run status`" + ` to follow its progress. Example: - dr pipelines run create --pipeline --input - dr pipelines run create --pipeline --version=2 --input --output json`, + dr pipeline run create --pipeline --input + dr pipeline run create --pipeline --version=2 --input --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -69,19 +63,13 @@ Example: return err } - if outputFormat == "json" { - return runutil.PrintRunJSON(*result) - } - - runutil.PrintRunHuman(*result) - - return nil + return pipeline.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go index 9a013e8fe..0ad1caf1c 100644 --- a/cmd/pipeline/run/get/cmd.go +++ b/cmd/pipeline/run/get/cmd.go @@ -19,7 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipeline/run/runutil" "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -31,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -40,16 +39,12 @@ func Cmd() *cobra.Command { Long: `Display the full record for a single run. Example: - dr pipelines run get --pipeline - dr pipelines run get --pipeline --version=2 --output json`, + dr pipeline run get --pipeline + dr pipeline run get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +59,12 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return runutil.PrintRunJSON(*result) - } - - runutil.PrintRunHuman(*result) - - return nil + return pipeline.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go index e818a0d5f..3f9ed238c 100644 --- a/cmd/pipeline/run/list/cmd.go +++ b/cmd/pipeline/run/list/cmd.go @@ -16,9 +16,7 @@ package list import ( "errors" - "fmt" - "github.com/datarobot/cli/cmd/pipeline/run/runutil" "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -39,16 +37,12 @@ func Cmd() *cobra.Command { Long: `List runs for a pipeline. Example: - dr pipelines run list --pipeline - dr pipelines run list --pipeline --version=2 --output json`, + dr pipeline run list --pipeline + dr pipeline run list --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -63,20 +57,14 @@ Example: return err } - if outputFormat == "json" { - return runutil.PrintRunListJSON(items) - } - - runutil.PrintRunListHuman(items) - - return nil + return pipeline.RenderRuns(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go index f2cc67fa0..4ac8e3d87 100644 --- a/cmd/pipeline/run/status/cmd.go +++ b/cmd/pipeline/run/status/cmd.go @@ -19,7 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipeline/run/runutil" "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -31,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -40,16 +39,12 @@ func Cmd() *cobra.Command { Long: `Poll a run's current status without re-downloading the full record. Example: - dr pipelines run status --pipeline - dr pipelines run status --pipeline --version=2 --output json`, + dr pipeline run status --pipeline + dr pipeline run status --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +59,12 @@ Example: return handleStatusError(err, args[0]) } - if outputFormat == "json" { - return runutil.PrintStatusJSON(*result) - } - - runutil.PrintStatusHuman(*result) - - return nil + return pipeline.RenderRunStatus(outputFormat, *result) }, } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } From 32f10bac6e2f6fb496978de47218898fc49392db Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 14:50:13 -0400 Subject: [PATCH 6/9] [CMPT-5391] add telemetry to run commands Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/run/cancel/cmd.go | 10 ++++++++++ cmd/pipeline/run/create/cmd.go | 10 ++++++++++ cmd/pipeline/run/get/cmd.go | 11 +++++++++++ cmd/pipeline/run/list/cmd.go | 12 ++++++++++++ cmd/pipeline/run/status/cmd.go | 11 +++++++++++ 5 files changed, 54 insertions(+) diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go index b6345b1e9..890797011 100644 --- a/cmd/pipeline/run/cancel/cmd.go +++ b/cmd/pipeline/run/cancel/cmd.go @@ -21,6 +21,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -65,5 +66,14 @@ Example: flags.Bind(cmd) + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + return cmd } diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go index cbf5d957d..664107984 100644 --- a/cmd/pipeline/run/create/cmd.go +++ b/cmd/pipeline/run/create/cmd.go @@ -20,6 +20,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" ) @@ -71,5 +72,14 @@ Example: cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go index 0ad1caf1c..9a1d03e73 100644 --- a/cmd/pipeline/run/get/cmd.go +++ b/cmd/pipeline/run/get/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -66,6 +67,16 @@ Example: flags.Bind(cmd) pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go index 3f9ed238c..7ec6959a4 100644 --- a/cmd/pipeline/run/list/cmd.go +++ b/cmd/pipeline/run/list/cmd.go @@ -20,6 +20,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" ) @@ -66,5 +67,16 @@ Example: cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go index 4ac8e3d87..f0a1de70d 100644 --- a/cmd/pipeline/run/status/cmd.go +++ b/cmd/pipeline/run/status/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -66,6 +67,16 @@ Example: flags.Bind(cmd) pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + return cmd } From f82043a61a5cf5289c0c66543314d23489a7a18a Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:19:21 -0400 Subject: [PATCH 7/9] [CMPT-5391] address PR feedback: MarkFlagRequired, 404 del suppression, limit defaults, version prefix Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/run/create/cmd.go | 12 ++---------- cmd/pipeline/run/list/cmd.go | 9 ++------- internal/pipeline/run_output.go | 4 ++-- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go index 664107984..d452e1772 100644 --- a/cmd/pipeline/run/create/cmd.go +++ b/cmd/pipeline/run/create/cmd.go @@ -15,8 +15,6 @@ package create import ( - "errors" - "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" @@ -46,14 +44,6 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if flags.PipelineID == "" { - return errors.New("--pipeline is required") - } - - if inputID == "" { - return errors.New("--input is required") - } - scope, version, err := flags.Resolve(cmd) if err != nil { return err @@ -69,7 +59,9 @@ Example: } flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") + _ = cmd.MarkFlagRequired("input") pipeline.AddOutputFlag(cmd, &outputFormat) telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go index 7ec6959a4..c67178072 100644 --- a/cmd/pipeline/run/list/cmd.go +++ b/cmd/pipeline/run/list/cmd.go @@ -15,8 +15,6 @@ package list import ( - "errors" - "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" @@ -44,10 +42,6 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if flags.PipelineID == "" { - return errors.New("--pipeline is required") - } - scope, version, err := flags.Resolve(cmd) if err != nil { return err @@ -63,8 +57,9 @@ Example: } flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") - cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of runs to return") pipeline.AddOutputFlag(cmd, &outputFormat) telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go index 562f2e42c..ab094c92d 100644 --- a/internal/pipeline/run_output.go +++ b/internal/pipeline/run_output.go @@ -129,7 +129,7 @@ func PrintRunHuman(r Run) { if r.VersionID != nil { scope = "locked" - versionDisplay = "v" + strconv.Itoa(*r.VersionID) + versionDisplay = strconv.Itoa(*r.VersionID) } covalent := r.CovalentDispatchID @@ -215,7 +215,7 @@ func PrintRunListHuman(items []Run) { if r.VersionID != nil { scope = "locked" - ver = "v" + strconv.Itoa(*r.VersionID) + ver = strconv.Itoa(*r.VersionID) } t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) From 9efa141a19e999f464d86d428f3c87b59cc22257 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:27:29 -0400 Subject: [PATCH 8/9] [CMPT-5391] fix test assertions for MarkFlagRequired error format Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/run/create/cmd_test.go | 4 ++-- cmd/pipeline/run/list/cmd_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/pipeline/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go index 925cc6e26..1b65852bb 100644 --- a/cmd/pipeline/run/create/cmd_test.go +++ b/cmd/pipeline/run/create/cmd_test.go @@ -43,13 +43,13 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_RejectsMissingPipeline(t *testing.T) { err := runCmd(t, "--input", "in-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--pipeline") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsMissingInput(t *testing.T) { err := runCmd(t, "--pipeline", "p") require.Error(t, err) - assert.Contains(t, err.Error(), "--input") + assert.Contains(t, err.Error(), "input") } func TestCmd_RejectsBadScopeCombo(t *testing.T) { diff --git a/cmd/pipeline/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go index 5ccb8e15a..c39ace176 100644 --- a/cmd/pipeline/run/list/cmd_test.go +++ b/cmd/pipeline/run/list/cmd_test.go @@ -43,7 +43,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_RejectsMissingPipeline(t *testing.T) { err := runCmd(t) require.Error(t, err) - assert.Contains(t, err.Error(), "--pipeline") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsBadScopeCombo(t *testing.T) { From 9d6597fd634719c39997de6044bf20d5983bb561 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 1 Jun 2026 10:58:42 -0400 Subject: [PATCH 9/9] [CMPT-5391] update run docs: --output -> --output-format Co-Authored-By: Claude Sonnet 4.6 --- docs/commands/pipelines-reference.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 164dd16c4..5de2e1720 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -89,15 +89,15 @@ exercising a local API stub that doesn't implement `/version/`. ## Runs (`dr pipelines run …`) Same draft/locked scope rules as graph. The wire-level URLs still use the legacy -term `dispatches` / `dispatch_id`, but the CLI's `--output json` remaps these to +term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps these to `run_id` / `covalent_run_id`. | Command | API endpoint | Usage | Inputs | |---|---|---|---| -| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | -| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output-format json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output-format json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format json`. | +| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | | `dr pipelines run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run cancel --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | ---