From 4e3c87234dd02eb98f0000b8440bdc534a19cfc3 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 09:48:28 -0800 Subject: [PATCH 1/9] feat: add Redeploy wrapper method to DeploymentsClient Add a Redeploy method that calls the generated Goa client's Redeploy endpoint, following the same pattern as the existing GetDeployment, GetLatestDeployment, and GetActiveDeployment wrappers. Co-Authored-By: Claude Opus 4.6 --- cli/internal/api/deployments.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 820910ffe..7fea5ce30 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -209,3 +209,24 @@ func (c *DeploymentsClient) Evolve( return result, nil } + +// Redeploy triggers a redeployment of an existing deployment. +func (c *DeploymentsClient) Redeploy( + ctx context.Context, + apiKey secret.Secret, + projectSlug string, + deploymentID string, +) (*types.Deployment, error) { + key := apiKey.Reveal() + result, err := c.client.Redeploy(ctx, &deployments.RedeployPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + DeploymentID: deploymentID, + SessionToken: nil, + }) + if err != nil { + return nil, fmt.Errorf("api error: %w", err) + } + + return result.Deployment, nil +} From b3212580113feabddaea436f878aea5dd62af585 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 09:48:33 -0800 Subject: [PATCH 2/9] feat: add RedeployDeployment chainable workflow method Add a RedeployDeployment method to the Workflow struct that triggers a redeployment of the currently loaded deployment. Follows the standard workflow pattern: checks Failed(), validates preconditions, calls the API client, and updates s.Deployment with the result. Co-Authored-By: Claude Opus 4.6 --- cli/internal/workflow/workflow.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cli/internal/workflow/workflow.go b/cli/internal/workflow/workflow.go index e7782059c..e00df3371 100644 --- a/cli/internal/workflow/workflow.go +++ b/cli/internal/workflow/workflow.go @@ -287,6 +287,29 @@ func (s *Workflow) LoadActiveDeployment( return s } +func (s *Workflow) RedeployDeployment(ctx context.Context) *Workflow { + if s.Failed() { + return s + } + + if s.Deployment == nil { + return s.Fail(fmt.Errorf("redeploy failed: no deployment loaded")) + } + + result, err := s.DeploymentsClient.Redeploy( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + s.Deployment.ID, + ) + if err != nil { + return s.Fail(fmt.Errorf("redeploy deployment '%s': %w", s.Deployment.ID, err)) + } + + s.Deployment = result + return s +} + func (s *Workflow) ListToolsets(ctx context.Context) *Workflow { if s.Failed() { return s From f4c7e68d142a75fa03988198b2da9f7b68505a51 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 09:52:16 -0800 Subject: [PATCH 3/9] feat: add `gram redeploy` CLI command Create the redeploy command that clones an existing deployment with the same assets. Supports --id to target a specific deployment (defaults to latest), --skip-poll to return immediately, and --json for machine- readable output. Register the command in the app's command list. Co-Authored-By: Claude Opus 4.6 --- cli/internal/app/app.go | 1 + cli/internal/app/redeploy.go | 142 +++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 cli/internal/app/redeploy.go diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index f84f670b8..a0fcf109b 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -35,6 +35,7 @@ func newApp() *cli.App { newStageCommand(), newInstallCommand(), newUpdateCommand(), + newRedeployCommand(), }, Flags: []cli.Flag{ flags.APIKey(), diff --git a/cli/internal/app/redeploy.go b/cli/internal/app/redeploy.go new file mode 100644 index 000000000..707f2bfd3 --- /dev/null +++ b/cli/internal/app/redeploy.go @@ -0,0 +1,142 @@ +package app + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/flags" + "github.com/speakeasy-api/gram/cli/internal/profile" + "github.com/speakeasy-api/gram/cli/internal/workflow" + "github.com/urfave/cli/v2" +) + +func newRedeployCommand() *cli.Command { + return &cli.Command{ + Name: "redeploy", + Usage: "Redeploy an existing deployment", + Description: ` +Redeploy an existing deployment by cloning it with the same assets. + +If no deployment ID is provided, redeploys the latest deployment.`, + Flags: []cli.Flag{ + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), + flags.Org(), + &cli.StringFlag{ + Name: "id", + Usage: "The deployment ID to redeploy (if not provided, redeploys the latest deployment)", + }, + &cli.BoolFlag{ + Name: "latest", + Usage: "Explicitly redeploy the latest deployment (this is the default behavior)", + }, + &cli.BoolFlag{ + Name: "skip-poll", + Usage: "Skip polling for deployment completion and return immediately", + Value: false, + }, + flags.JSON(), + }, + Action: func(c *cli.Context) error { + ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) + defer cancel() + + logger := logging.PullLogger(ctx) + prof := profile.FromContext(ctx) + deploymentID := c.String("id") + skipPoll := c.Bool("skip-poll") + jsonOutput := c.Bool("json") + + workflowParams, err := workflow.ResolveParams(c, prof) + if err != nil { + return fmt.Errorf("failed to resolve workflow params: %w", err) + } + + wf := workflow.New(ctx, logger, workflowParams) + + // Load the target deployment + if deploymentID != "" { + wf.LoadDeploymentByID(ctx, deploymentID) + } else { + wf.LoadLatestDeployment(ctx) + } + if wf.Failed() { + return fmt.Errorf("failed to load deployment: %w", wf.Err) + } + + originalID := wf.Deployment.ID + logger.InfoContext(ctx, "Redeploying deployment", slog.String("deployment_id", originalID)) + + // Trigger the redeploy + wf.RedeployDeployment(ctx) + if wf.Failed() { + return fmt.Errorf("failed to redeploy: %w", wf.Err) + } + + newID := wf.Deployment.ID + logger.InfoContext(ctx, + "New deployment created", + slog.String("deployment_id", newID), + slog.String("cloned_from", originalID), + ) + + // Poll for completion + if !skipPoll { + wf.Poll(ctx) + if wf.Failed() { + return fmt.Errorf("deployment polling failed: %w", wf.Err) + } + } + + // Output result + if jsonOutput { + jsonData, err := json.MarshalIndent(wf.Deployment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal deployment to JSON: %w", err) + } + fmt.Println(string(jsonData)) + return nil + } + + logsURL := fmt.Sprintf("%s://%s/%s/%s/deployments/%s", + workflowParams.APIURL.Scheme, + workflowParams.APIURL.Host, + workflowParams.OrgSlug, + workflowParams.ProjectSlug, + newID, + ) + + switch wf.Deployment.Status { + case "completed": + logger.InfoContext(ctx, "Deployment succeeded", + slog.String("deployment_id", newID), + slog.String("logs_url", logsURL), + ) + fmt.Printf("\nView deployment: %s\n", logsURL) + openDeploymentURL(logger, ctx, logsURL) + case "failed": + logger.ErrorContext(ctx, "Deployment failed", + slog.String("deployment_id", newID), + slog.String("logs_url", logsURL), + ) + fmt.Printf("\nView deployment logs: %s\n", logsURL) + openDeploymentURL(logger, ctx, logsURL) + return fmt.Errorf("deployment failed") + default: + logger.InfoContext(ctx, "Deployment is still in progress", + slog.String("deployment_id", newID), + slog.String("status", wf.Deployment.Status), + ) + fmt.Printf("\nView deployment: %s\n", logsURL) + } + + return nil + }, + } +} From e06bca1faaa77e159f6a1dd60d59f77ae9b6065b Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 09:57:42 -0800 Subject: [PATCH 4/9] refactor: remove dead --latest flag, reuse printDeploymentStatusJSON - Remove --latest flag that was declared but never read (latest is already the default when --id is omitted) - Reuse printDeploymentStatusJSON from status.go instead of duplicating the JSON marshal logic Co-Authored-By: Claude Opus 4.6 --- cli/internal/app/redeploy.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/cli/internal/app/redeploy.go b/cli/internal/app/redeploy.go index 707f2bfd3..02c36acea 100644 --- a/cli/internal/app/redeploy.go +++ b/cli/internal/app/redeploy.go @@ -1,7 +1,6 @@ package app import ( - "encoding/json" "fmt" "log/slog" "os" @@ -32,10 +31,6 @@ If no deployment ID is provided, redeploys the latest deployment.`, Name: "id", Usage: "The deployment ID to redeploy (if not provided, redeploys the latest deployment)", }, - &cli.BoolFlag{ - Name: "latest", - Usage: "Explicitly redeploy the latest deployment (this is the default behavior)", - }, &cli.BoolFlag{ Name: "skip-poll", Usage: "Skip polling for deployment completion and return immediately", @@ -96,12 +91,7 @@ If no deployment ID is provided, redeploys the latest deployment.`, // Output result if jsonOutput { - jsonData, err := json.MarshalIndent(wf.Deployment, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal deployment to JSON: %w", err) - } - fmt.Println(string(jsonData)) - return nil + return printDeploymentStatusJSON(wf.Deployment) } logsURL := fmt.Sprintf("%s://%s/%s/%s/deployments/%s", From 71fc688fde40e38f0325b9b41acfc5b2ff12fa20 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 14:09:07 -0800 Subject: [PATCH 5/9] fix(dashboard): show redeploy button on deployment detail page for failed deploys The RedeployButton component was returning null for failed deployments that weren't the active deployment. This meant users had to navigate back to the deployments list to retry a failed deployment. Co-Authored-By: Claude Opus 4.6 --- .../dashboard/src/pages/deployments/deployment/Deployment.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx index 0adc95ab6..15043a4d0 100644 --- a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx +++ b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx @@ -161,6 +161,9 @@ const HeadingSection = () => { } else if (deployment.status === "completed") { if (isPending) buttonText = "Rolling Back..."; else buttonText = "Roll Back"; + } else if (deployment.status === "failed") { + if (isPending) buttonText = "Retrying Deployment"; + else buttonText = "Retry Deployment"; } else return null; return ( From 0b2befbf5a1545fb4cc9af4064c774d8f14fd0f2 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 14:26:53 -0800 Subject: [PATCH 6/9] fix(dashboard): make Deployments button visible on sources page with error state Upgraded DeploymentsButton from tertiary to secondary variant so it's actually visible. When deployment errors exist, shows "Deployment Errors" with destructive styling linking to the failed deployment. Removed inline error badge from title in favor of this more prominent CTA button. Co-Authored-By: Claude Opus 4.6 --- .../src/components/sources/Sources.tsx | 75 ++++++------------- 1 file changed, 23 insertions(+), 52 deletions(-) diff --git a/client/dashboard/src/components/sources/Sources.tsx b/client/dashboard/src/components/sources/Sources.tsx index 4e115f855..23b330bc7 100644 --- a/client/dashboard/src/components/sources/Sources.tsx +++ b/client/dashboard/src/components/sources/Sources.tsx @@ -1,10 +1,7 @@ import { Page } from "@/components/page-layout"; -import { SimpleTooltip } from "@/components/ui/tooltip"; import { useSdkClient } from "@/contexts/Sdk"; import { useTelemetry } from "@/contexts/Telemetry"; -import { cn } from "@/lib/utils"; import { useInfiniteListMCPCatalog } from "@/pages/catalog/hooks"; -import { useDeploymentLogsSummary } from "@/pages/deployments/deployment/Deployment"; import { useRoutes } from "@/routes"; import { useLatestDeployment, @@ -249,20 +246,7 @@ export default function Sources() { return ( <> - - - Sources - {failedDeployment.hasFailures && failedDeployment.deployment && ( - - - Deployment errors - - )} - - + Sources {isFunctionsEnabled ? "OpenAPI documents, Gram Functions, and third-party MCP servers providing tools for your project" @@ -433,46 +417,33 @@ const useUnusedAssetIds = () => { function DeploymentsButton({ deploymentId }: { deploymentId?: string }) { const routes = useRoutes(); - const { data: deploymentResult } = useLatestDeployment(); - const deployment = deploymentResult?.deployment; - const deploymentLogsSummary = useDeploymentLogsSummary(deploymentId); - - const hasErrors = deploymentLogsSummary && deploymentLogsSummary.errors > 0; - const deploymentFailed = deployment?.status === "failed"; - - const icon = hasErrors ? ( - - ) : ( - - ); - - let tooltip = "View deployment history"; - if (deployment && deploymentLogsSummary) { - tooltip = deploymentFailed - ? "Latest deployment failed" - : "Latest deployment succeeded"; + const failedDeployment = useFailedDeploymentSources(); - if (deploymentLogsSummary.skipped > 0) { - tooltip += ` (${deploymentLogsSummary.skipped} operations skipped)`; - } + if (failedDeployment.hasFailures && deploymentId) { + return ( + + + + + + ); } return ( - - - - - + + + ); } From 6e878c4ebff9c0754faa33090fcab422b80ea18d Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 14:51:03 -0800 Subject: [PATCH 7/9] fix(dashboard): show redeploy button on every deployment page Replace the conditional that hid the button for non-completed/non-failed deployments with a catch-all "Redeploy" label so users can redeploy any deployment regardless of status. Co-Authored-By: Claude Opus 4.6 --- .../src/pages/deployments/deployment/Deployment.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx index 15043a4d0..88ac57644 100644 --- a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx +++ b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx @@ -161,10 +161,10 @@ const HeadingSection = () => { } else if (deployment.status === "completed") { if (isPending) buttonText = "Rolling Back..."; else buttonText = "Roll Back"; - } else if (deployment.status === "failed") { - if (isPending) buttonText = "Retrying Deployment"; - else buttonText = "Retry Deployment"; - } else return null; + } else { + if (isPending) buttonText = "Redeploying..."; + else buttonText = "Redeploy"; + } return ( - - - ); - } - - return ( - - - - + ); + } + + return ( + + + ); } From b53f25d7b631d67109886682d67c724b177327dc Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 27 Feb 2026 16:16:45 -0800 Subject: [PATCH 9/9] fix(dashboard): remove unused failedDeployment hook call, add changeset Remove dead useFailedDeploymentSources() call from Sources component (now only called inside DeploymentsButton). Fixes eslint no-unused-vars CI failure. Add changeset for CLI and dashboard changes. Co-Authored-By: Claude Opus 4.6 --- .changeset/bold-oak-redeploy.md | 8 ++++++++ client/dashboard/src/components/sources/Sources.tsx | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/bold-oak-redeploy.md diff --git a/.changeset/bold-oak-redeploy.md b/.changeset/bold-oak-redeploy.md new file mode 100644 index 000000000..0097b6005 --- /dev/null +++ b/.changeset/bold-oak-redeploy.md @@ -0,0 +1,8 @@ +--- +"dashboard": patch +"cli": patch +--- + +feat(cli): add `gram redeploy` command to clone and redeploy existing deployments + +fix(dashboard): show redeploy button on every deployment detail page and add visible Deployments navigation to sources page diff --git a/client/dashboard/src/components/sources/Sources.tsx b/client/dashboard/src/components/sources/Sources.tsx index 2a5f3cab4..3c43a87d4 100644 --- a/client/dashboard/src/components/sources/Sources.tsx +++ b/client/dashboard/src/components/sources/Sources.tsx @@ -103,7 +103,6 @@ export default function Sources() { const deployment = deploymentResult?.deployment; const assetsCausingFailure = useUnusedAssetIds(); - const failedDeployment = useFailedDeploymentSources(); const { dialogState, openRemoveSource,