From 52b734ee0205872fab342c6f5ef8b4c328948c49 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Mon, 22 Jun 2026 18:01:00 +0900 Subject: [PATCH 01/10] feat(cli): use the async project deploy API [RED-644] Switch `checkly deploy` from the synchronous POST /next-v2/projects/deploy to the async POST /v1/projects/deploy, then poll GET /v1/projects/deployments/{id}/completion to completion. This removes the API-gateway request-timeout ceiling that caused large projects to fail with a 504 even though the deploy was still running. - rest/projects.ts: deploy() submits the deployment and awaits completion (looping the long-poll completion endpoint, which 408s while in progress); dry runs still return the preview diff synchronously. Adds ProjectDeployment / ProjectDeployFailedError types, getDeployment(), and awaitDeploymentCompletion() with best-effort progress reporting. - commands/deploy.ts: show a spinner with live progress during the deploy. The deploy() return shape ({ data: { project, diff } }) is unchanged for callers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/commands/deploy.ts | 18 ++- .../cli/src/rest/__tests__/projects.spec.ts | 127 ++++++++++++++++++ packages/cli/src/rest/projects.ts | 109 ++++++++++++++- 3 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/rest/__tests__/projects.spec.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 61f4f0c27..a803f2cbc 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -239,7 +239,20 @@ export default class Deploy extends AuthCommand { } try { - const { data } = await api.projects.deploy({ ...projectPayload, repoInfo }, { dryRun: preview, scheduleOnDeploy }) + if (!preview) { + this.style.actionStart('Deploying project') + } + const { data } = await api.projects.deploy( + { ...projectPayload, repoInfo }, + { + dryRun: preview, + scheduleOnDeploy, + onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`), + }, + ) + if (!preview) { + this.style.actionSuccess() + } if (preview || output) { this.log(this.formatPreview(data, project, verbose)) } @@ -258,6 +271,9 @@ export default class Deploy extends AuthCommand { }) } } catch (err: any) { + if (!preview) { + this.style.actionFailure() + } this.style.longError(`Your project could not be deployed.`, err) this.exit(1) } diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts new file mode 100644 index 000000000..3b032683e --- /dev/null +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { AxiosInstance } from 'axios' +import Projects, { ProjectDeployFailedError, type ProjectSync } from '../projects.js' +import { RequestTimeoutError } from '../errors.js' + +function makeAxiosMock (): AxiosInstance { + return { + get: vi.fn(), + post: vi.fn(), + } as unknown as AxiosInstance +} + +const sync: ProjectSync = { + project: { name: 'My Project', logicalId: 'my-project' }, + resources: [], + repoInfo: null, +} + +const timeout = () => + new RequestTimeoutError({ statusCode: 408, error: 'Request Time-out', message: 'still in progress' }) + +describe('Projects.deploy', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + api = makeAxiosMock() + projects = new Projects(api) + }) + + it('returns the preview diff synchronously for a dry run (no polling)', async () => { + const preview = { project: sync.project, diff: [{ logicalId: 'c1', type: 'check', action: 'CREATE' }] } + vi.mocked(api.post).mockResolvedValue({ data: preview }) + + const { data } = await projects.deploy(sync, { dryRun: true }) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true', + sync, + expect.objectContaining({ transformRequest: expect.any(Function) }), + ) + expect(api.get).not.toHaveBeenCalled() + expect(data).toEqual(preview) + }) + + it('submits async, polls completion, and returns the applied diff', async () => { + const applied = { project: sync.project, diff: [{ logicalId: 'c1', type: 'check', action: 'CREATE' }] } + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) + + const { data } = await projects.deploy(sync, { dryRun: false }) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/deploy?dryRun=false&scheduleOnDeploy=true', + sync, + expect.objectContaining({ transformRequest: expect.any(Function) }), + ) + expect(api.get).toHaveBeenCalledWith('/v1/projects/deployments/dep-1/completion?maxWaitSeconds=30') + expect(data).toEqual(applied) + }) + + it('keeps polling on a 408 timeout and reports progress in between', async () => { + const applied = { project: sync.project, diff: [] } + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + + let completionCalls = 0 + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('/completion')) { + completionCalls += 1 + if (completionCalls === 1) { + return Promise.reject(timeout()) + } + return Promise.resolve({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) as never + } + // Snapshot fetch for progress. + return Promise.resolve({ data: { id: 'dep-1', status: 'RUNNING', progress: 42 } }) as never + }) + + const onProgress = vi.fn() + const { data } = await projects.deploy(sync, { dryRun: false, onProgress }) + + expect(completionCalls).toBe(2) + expect(onProgress).toHaveBeenCalledWith(42) + expect(api.get).toHaveBeenCalledWith('/v1/projects/deployments/dep-1') + expect(data).toEqual(applied) + }) + + it('does not report progress when the snapshot has no numeric progress', async () => { + const applied = { project: sync.project, diff: [] } + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + + let completionCalls = 0 + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('/completion')) { + completionCalls += 1 + if (completionCalls === 1) { + return Promise.reject(timeout()) + } + return Promise.resolve({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) as never + } + // Snapshot without a progress value (e.g. not yet started). + return Promise.resolve({ data: { id: 'dep-1', status: 'PENDING' } }) as never + }) + + const onProgress = vi.fn() + await projects.deploy(sync, { dryRun: false, onProgress }) + + expect(onProgress).not.toHaveBeenCalled() + }) + + it('throws ProjectDeployFailedError with the deployment error message on failure', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue({ + data: { id: 'dep-1', status: 'FAILED', result: null, error: { code: 'PLAN_LIMITS_EXCEEDED', message: 'Too many checks.' } }, + }) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployFailedError) + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('Too many checks.') + }) + + it('re-throws non-timeout errors from the completion poll', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockRejectedValue(new Error('network down')) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('network down') + }) +}) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 26f069996..58537b7b6 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -2,7 +2,7 @@ import { type AxiosInstance } from 'axios' import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' -import { ConflictError, ForbiddenError, NotFoundError } from './errors.js' +import { ConflictError, ForbiddenError, NotFoundError, RequestTimeoutError } from './errors.js' export interface Project { name: string @@ -75,6 +75,30 @@ export interface ProjectDeployResponse { diff: Array } +export type ProjectDeploymentStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' + +export interface ProjectDeployment { + id: string + logicalId: string + status: ProjectDeploymentStatus + dryRun: boolean + /** Opaque progress percentage (0-100). */ + progress: number + error: { code: string, message: string } | null + /** The applied { project, diff }; present once the deployment has succeeded. */ + result: ProjectDeployResponse | null + createdAt: string + startedAt: string | null + endedAt: string | null +} + +export class ProjectDeployFailedError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectDeployFailedError' + } +} + export interface ImportPlanFilter { type: 'include' | 'exclude' resource?: { @@ -208,12 +232,89 @@ class Projects { } } - deploy (resources: ProjectSync, { dryRun = false, scheduleOnDeploy = true } = {}) { - return this.api.post( - `/next-v2/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, + /** + * Deploy a project. The deployment runs asynchronously on the backend: this + * submits it, then polls to completion so large projects are no longer bound + * by the API gateway request timeout. A dry run returns the preview diff + * synchronously without starting a deployment. + * + * @throws {ProjectDeployFailedError} If the deployment finishes unsuccessfully. + */ + async deploy ( + resources: ProjectSync, + { dryRun = false, scheduleOnDeploy = true, onProgress }: { + dryRun?: boolean + scheduleOnDeploy?: boolean + onProgress?: (progress: number) => void + } = {}, + ): Promise<{ data: ProjectDeployResponse }> { + const { data } = await this.api.post( + `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, resources, { transformRequest: compressJSONPayload }, ) + + // A dry run responds synchronously with the preview diff. + if (dryRun) { + return { data: data as ProjectDeployResponse } + } + + // A real deploy responds with a deployment to follow to completion. + const deployment = data as ProjectDeployment + const completed = await this.awaitDeploymentCompletion(deployment.id, { onProgress }) + + if (completed.status !== 'SUCCEEDED' || completed.result === null) { + throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') + } + + return { data: completed.result } + } + + getDeployment (deploymentId: string) { + return this.api.get(`/v1/projects/deployments/${encodeURIComponent(deploymentId)}`) + } + + /** + * Poll the completion endpoint until the deployment reaches a final state. The + * endpoint waits up to maxWaitSeconds and then returns 408 (RequestTimeoutError); + * we keep calling it until the deployment finishes. + * + * Termination: the loop ends when the deployment reaches a final state, or when + * any non-408 error occurs — including the connection being lost (which surfaces + * as MissingResponseError, not RequestTimeoutError). A reachable backend always + * yields a final state because its reaper eventually finalizes a stuck deploy. + * + * While waiting, `onProgress` is invoked with the latest progress percentage + * (best-effort: snapshot-fetch failures and absent progress are ignored). + */ + async awaitDeploymentCompletion ( + deploymentId: string, + { onProgress }: { onProgress?: (progress: number) => void } = {}, + ): Promise { + const deploymentIdParam = encodeURIComponent(deploymentId) + for (;;) { + try { + const { data } = await this.api.get( + `/v1/projects/deployments/${deploymentIdParam}/completion?maxWaitSeconds=30`, + ) + return data + } catch (err) { + if (!(err instanceof RequestTimeoutError)) { + throw err + } + // Still in progress. Surface progress (best-effort) and keep waiting. + if (onProgress !== undefined) { + try { + const { data } = await this.getDeployment(deploymentId) + if (typeof data.progress === 'number') { + onProgress(data.progress) + } + } catch { + // Ignore progress-fetch failures; the next completion poll retries. + } + } + } + } } /** From 9ba2ba82c670b4f18eeaeacddab54e045ae263fe Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Mon, 22 Jun 2026 18:54:11 +0900 Subject: [PATCH 02/10] feat(cli): follow deploys via the SSE progress stream [RED-644] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the completion long-poll (which only refreshed progress ~every 30s) with the backend's Server-Sent Events stream: deploy() now follows GET /v1/projects/deployments/{id}/events, surfacing each `progress` frame to onProgress for a smooth bar and resolving on the terminal `complete` frame. A single authenticated GET — no client polling. - streamDeploymentEvents() reconnects (bounded) when the stream drops before a terminal frame, covering both a clean EOF and a socket error (ECONNRESET) — the common mid-deploy interruption; the server is stateless so resuming needs no cursor. - openEventStream() buffers an HTTP error body (the response is a stream, so the interceptor can't classify it) and re-runs the classifier to surface the typed error (NotFoundError, etc.). - Drops the snapshot-on-408 progress hack. dryRun stays the synchronous preview. Requires the backend SSE route to be deployed first. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- .../cli/src/rest/__tests__/projects.spec.ts | 138 ++++++++------ packages/cli/src/rest/projects.ts | 168 ++++++++++++++---- 2 files changed, 213 insertions(+), 93 deletions(-) diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 3b032683e..db3a50751 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Readable } from 'node:stream' import type { AxiosInstance } from 'axios' import Projects, { ProjectDeployFailedError, type ProjectSync } from '../projects.js' -import { RequestTimeoutError } from '../errors.js' +import { NotFoundError } from '../errors.js' function makeAxiosMock (): AxiosInstance { return { @@ -16,8 +17,17 @@ const sync: ProjectSync = { repoInfo: null, } -const timeout = () => - new RequestTimeoutError({ statusCode: 408, error: 'Request Time-out', message: 'still in progress' }) +// Build an SSE frame and a readable stream that emits the given frames then ends. +const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) }) +// A stream that drops with a socket error before any terminal frame. +const erroringStream = () => { + const stream = new Readable({ read () {} }) + setImmediate(() => stream.destroy(new Error('ECONNRESET'))) + return { data: stream } +} + +const applied = { project: sync.project, diff: [{ logicalId: 'check-1', type: 'check', action: 'CREATE' }] } describe('Projects.deploy', () => { let api: AxiosInstance @@ -28,7 +38,7 @@ describe('Projects.deploy', () => { projects = new Projects(api) }) - it('returns the preview diff synchronously for a dry run (no polling)', async () => { + it('returns the preview diff synchronously for a dry run (no stream)', async () => { const preview = { project: sync.project, diff: [{ logicalId: 'c1', type: 'check', action: 'CREATE' }] } vi.mocked(api.post).mockResolvedValue({ data: preview }) @@ -43,82 +53,96 @@ describe('Projects.deploy', () => { expect(data).toEqual(preview) }) - it('submits async, polls completion, and returns the applied diff', async () => { - const applied = { project: sync.project, diff: [{ logicalId: 'c1', type: 'check', action: 'CREATE' }] } + it('submits async, follows the SSE stream, reports progress, and returns the applied diff', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) - vi.mocked(api.get).mockResolvedValue({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) + vi.mocked(api.get).mockResolvedValue( + sseStream( + sse('progress', { status: 'RUNNING', progress: 40 }), + sse('complete', { id: 'dep-1', status: 'SUCCEEDED', progress: 100, result: applied, error: null }), + ), + ) - const { data } = await projects.deploy(sync, { dryRun: false }) + const onProgress = vi.fn() + const { data } = await projects.deploy(sync, { dryRun: false, onProgress }) - expect(api.post).toHaveBeenCalledWith( - '/v1/projects/deploy?dryRun=false&scheduleOnDeploy=true', - sync, - expect.objectContaining({ transformRequest: expect.any(Function) }), + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/deployments/dep-1/events', + expect.objectContaining({ responseType: 'stream', headers: { Accept: 'text/event-stream' } }), ) - expect(api.get).toHaveBeenCalledWith('/v1/projects/deployments/dep-1/completion?maxWaitSeconds=30') + expect(onProgress).toHaveBeenCalledWith(40) expect(data).toEqual(applied) }) - it('keeps polling on a 408 timeout and reports progress in between', async () => { - const applied = { project: sync.project, diff: [] } + it('throws ProjectDeployFailedError when the terminal event is not SUCCEEDED', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + // A fresh stream per call (the assertions below deploy twice). + vi.mocked(api.get).mockImplementation(() => + Promise.resolve( + sseStream( + sse('complete', { + id: 'dep-1', + status: 'FAILED', + result: null, + error: { code: 'PLAN_LIMITS_EXCEEDED', message: 'Too many checks.' }, + }), + ), + ), + ) - let completionCalls = 0 - vi.mocked(api.get).mockImplementation((url: string) => { - if (url.includes('/completion')) { - completionCalls += 1 - if (completionCalls === 1) { - return Promise.reject(timeout()) - } - return Promise.resolve({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) as never - } - // Snapshot fetch for progress. - return Promise.resolve({ data: { id: 'dep-1', status: 'RUNNING', progress: 42 } }) as never - }) + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployFailedError) + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('Too many checks.') + }) - const onProgress = vi.fn() - const { data } = await projects.deploy(sync, { dryRun: false, onProgress }) + it('throws when the stream emits an error event', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue(sseStream(sse('error', { message: 'stream blew up' }))) - expect(completionCalls).toBe(2) - expect(onProgress).toHaveBeenCalledWith(42) - expect(api.get).toHaveBeenCalledWith('/v1/projects/deployments/dep-1') - expect(data).toEqual(applied) + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('stream blew up') }) - it('does not report progress when the snapshot has no numeric progress', async () => { - const applied = { project: sync.project, diff: [] } + it('reconnects when the stream ends before a terminal event', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get) + .mockResolvedValueOnce(sseStream(sse('progress', { progress: 10 }))) // ends, no terminal + .mockResolvedValueOnce(sseStream(sse('complete', { status: 'SUCCEEDED', result: applied }))) - let completionCalls = 0 - vi.mocked(api.get).mockImplementation((url: string) => { - if (url.includes('/completion')) { - completionCalls += 1 - if (completionCalls === 1) { - return Promise.reject(timeout()) - } - return Promise.resolve({ data: { id: 'dep-1', status: 'SUCCEEDED', result: applied } }) as never - } - // Snapshot without a progress value (e.g. not yet started). - return Promise.resolve({ data: { id: 'dep-1', status: 'PENDING' } }) as never - }) + const { data } = await projects.deploy(sync, { dryRun: false }) - const onProgress = vi.fn() - await projects.deploy(sync, { dryRun: false, onProgress }) + expect(api.get).toHaveBeenCalledTimes(2) + expect(data).toEqual(applied) + }) + + it('reconnects after a socket error before a terminal event', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get) + .mockImplementationOnce(() => Promise.resolve(erroringStream()) as never) + .mockImplementationOnce(() => Promise.resolve(sseStream(sse('complete', { status: 'SUCCEEDED', result: applied }))) as never) + + const { data } = await projects.deploy(sync, { dryRun: false }) - expect(onProgress).not.toHaveBeenCalled() + expect(api.get).toHaveBeenCalledTimes(2) + expect(data).toEqual(applied) }) - it('throws ProjectDeployFailedError with the deployment error message on failure', async () => { + it('propagates a typed connect error without reconnecting', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) - vi.mocked(api.get).mockResolvedValue({ - data: { id: 'dep-1', status: 'FAILED', result: null, error: { code: 'PLAN_LIMITS_EXCEEDED', message: 'Too many checks.' } }, - }) + vi.mocked(api.get).mockRejectedValue( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'No such project deployment.' }), + ) - await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployFailedError) - await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('Too many checks.') + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(NotFoundError) + expect(api.get).toHaveBeenCalledTimes(1) + }) + + it('gives up after exhausting reconnects', async () => { + // Fresh stream per (re)connect; never emits a terminal event. + vi.mocked(api.get).mockImplementation(() => Promise.resolve(sseStream(sse('progress', { progress: 10 })))) + + await expect(projects.streamDeploymentEvents('dep-1', { maxReconnects: 2 })).rejects.toThrow() + expect(api.get).toHaveBeenCalledTimes(3) // initial + 2 reconnects }) - it('re-throws non-timeout errors from the completion poll', async () => { + it('propagates a non-stream error from the initial connect', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) vi.mocked(api.get).mockRejectedValue(new Error('network down')) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 58537b7b6..1d601506e 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -1,8 +1,9 @@ -import { type AxiosInstance } from 'axios' +import { type AxiosInstance, isAxiosError } from 'axios' +import { Readable } from 'node:stream' import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' -import { ConflictError, ForbiddenError, NotFoundError, RequestTimeoutError } from './errors.js' +import { ConflictError, ForbiddenError, handleErrorResponse, NotFoundError } from './errors.js' export interface Project { name: string @@ -99,6 +100,53 @@ export class ProjectDeployFailedError extends Error { } } +/** Internal: the SSE stream ended before a terminal event (eligible for reconnect). */ +class DeploymentStreamInterruptedError extends Error { + constructor () { + super('The deployment event stream ended before completion.') + this.name = 'DeploymentStreamInterruptedError' + } +} + +interface SseFrame { + event: string + data: any +} + +function streamToString (stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', chunk => chunks.push(Buffer.from(chunk))) + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + stream.on('error', reject) + }) +} + +/** Parse one SSE frame ("event: x\ndata: {...}"), assuming LF line endings. + * Returns null for keep-alive comments or frames without parseable JSON data. */ +function parseSseFrame (raw: string): SseFrame | null { + let event = 'message' + const dataLines: string[] = [] + for (const line of raw.split('\n')) { + if (line.startsWith(':')) { + continue // keep-alive comment + } + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim() + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trim()) + } + } + if (dataLines.length === 0) { + return null + } + try { + return { event, data: JSON.parse(dataLines.join('\n')) } + } catch { + return null + } +} + export interface ImportPlanFilter { type: 'include' | 'exclude' resource?: { @@ -234,9 +282,9 @@ class Projects { /** * Deploy a project. The deployment runs asynchronously on the backend: this - * submits it, then polls to completion so large projects are no longer bound - * by the API gateway request timeout. A dry run returns the preview diff - * synchronously without starting a deployment. + * submits it, then follows its progress stream to completion, so large projects + * are no longer bound by the API gateway request timeout. A dry run returns the + * preview diff synchronously without starting a deployment. * * @throws {ProjectDeployFailedError} If the deployment finishes unsuccessfully. */ @@ -261,7 +309,7 @@ class Projects { // A real deploy responds with a deployment to follow to completion. const deployment = data as ProjectDeployment - const completed = await this.awaitDeploymentCompletion(deployment.id, { onProgress }) + const completed = await this.streamDeploymentEvents(deployment.id, { onProgress }) if (completed.status !== 'SUCCEEDED' || completed.result === null) { throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') @@ -275,46 +323,94 @@ class Projects { } /** - * Poll the completion endpoint until the deployment reaches a final state. The - * endpoint waits up to maxWaitSeconds and then returns 408 (RequestTimeoutError); - * we keep calling it until the deployment finishes. - * - * Termination: the loop ends when the deployment reaches a final state, or when - * any non-408 error occurs — including the connection being lost (which surfaces - * as MissingResponseError, not RequestTimeoutError). A reachable backend always - * yields a final state because its reaper eventually finalizes a stuck deploy. - * - * While waiting, `onProgress` is invoked with the latest progress percentage - * (best-effort: snapshot-fetch failures and absent progress are ignored). + * Follow a deployment to completion over its Server-Sent Events stream, + * invoking `onProgress` as progress frames arrive and resolving with the final + * deployment on the terminal `complete` frame. If the stream drops before a + * terminal frame (a transient network blip), it reconnects up to `maxReconnects` + * times — the server is stateless and re-reads current state, so resuming needs + * no cursor. */ - async awaitDeploymentCompletion ( + async streamDeploymentEvents ( deploymentId: string, - { onProgress }: { onProgress?: (progress: number) => void } = {}, + { onProgress, maxReconnects = 5 }: { onProgress?: (progress: number) => void, maxReconnects?: number } = {}, ): Promise { - const deploymentIdParam = encodeURIComponent(deploymentId) + let reconnects = 0 for (;;) { try { - const { data } = await this.api.get( - `/v1/projects/deployments/${deploymentIdParam}/completion?maxWaitSeconds=30`, - ) - return data + return await this.consumeEventStream(deploymentId, onProgress) } catch (err) { - if (!(err instanceof RequestTimeoutError)) { - throw err + if (err instanceof DeploymentStreamInterruptedError && reconnects < maxReconnects) { + reconnects += 1 + continue + } + throw err + } + } + } + + private async openEventStream (deploymentId: string): Promise { + try { + const { data } = await this.api.get( + `/v1/projects/deployments/${encodeURIComponent(deploymentId)}/events`, + { responseType: 'stream', headers: { Accept: 'text/event-stream' } }, + ) + return data + } catch (err) { + // On an HTTP error the body arrives as an unparsed stream (responseType + // 'stream'), so the response interceptor couldn't classify it. Buffer it and + // re-run the classifier to surface the typed error (NotFoundError, etc.). + if (isAxiosError(err) && err.response && err.response.data instanceof Readable) { + err.response.data = await streamToString(err.response.data) + handleErrorResponse(err) + } + throw err + } + } + + private async consumeEventStream ( + deploymentId: string, + onProgress?: (progress: number) => void, + ): Promise { + const stream = await this.openEventStream(deploymentId) + + return new Promise((resolve, reject) => { + let buffer = '' + let settled = false + const settle = (action: () => void) => { + if (settled) { + return } - // Still in progress. Surface progress (best-effort) and keep waiting. - if (onProgress !== undefined) { - try { - const { data } = await this.getDeployment(deploymentId) - if (typeof data.progress === 'number') { - onProgress(data.progress) + settled = true + stream.destroy() + action() + } + + stream.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8') + let boundary = buffer.indexOf('\n\n') + while (boundary !== -1) { + const frame = parseSseFrame(buffer.slice(0, boundary)) + buffer = buffer.slice(boundary + 2) + if (frame?.event === 'progress') { + if (onProgress !== undefined && typeof frame.data?.progress === 'number') { + onProgress(frame.data.progress) } - } catch { - // Ignore progress-fetch failures; the next completion poll retries. + } else if (frame?.event === 'complete') { + settle(() => resolve(frame.data as ProjectDeployment)) + } else if (frame?.event === 'error') { + const message = typeof frame.data?.message === 'string' + ? frame.data.message + : 'The deployment event stream reported an error.' + settle(() => reject(new ProjectDeployFailedError(message))) } + boundary = buffer.indexOf('\n\n') } - } - } + }) + // Both a clean EOF before a terminal frame and a socket error (the common + // mid-deploy drop, e.g. ECONNRESET) are interruptions eligible for reconnect. + stream.on('end', () => settle(() => reject(new DeploymentStreamInterruptedError()))) + stream.on('error', () => settle(() => reject(new DeploymentStreamInterruptedError()))) + }) } /** From d91ffa95fd88da279382a744945c05b9dc0dfe36 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 23 Jun 2026 15:59:13 +0900 Subject: [PATCH 03/10] feat(cli): address deployments under their project [RED-644] Follow a deploy via /v1/projects/{logicalId}/deployments/{id}/events, threading the project logicalId (URL-encoded) through the deployment-following calls. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- .../cli/src/rest/__tests__/projects.spec.ts | 4 ++-- packages/cli/src/rest/projects.ts | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index db3a50751..614752009 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -66,7 +66,7 @@ describe('Projects.deploy', () => { const { data } = await projects.deploy(sync, { dryRun: false, onProgress }) expect(api.get).toHaveBeenCalledWith( - '/v1/projects/deployments/dep-1/events', + '/v1/projects/my-project/deployments/dep-1/events', expect.objectContaining({ responseType: 'stream', headers: { Accept: 'text/event-stream' } }), ) expect(onProgress).toHaveBeenCalledWith(40) @@ -138,7 +138,7 @@ describe('Projects.deploy', () => { // Fresh stream per (re)connect; never emits a terminal event. vi.mocked(api.get).mockImplementation(() => Promise.resolve(sseStream(sse('progress', { progress: 10 })))) - await expect(projects.streamDeploymentEvents('dep-1', { maxReconnects: 2 })).rejects.toThrow() + await expect(projects.streamDeploymentEvents('my-project', 'dep-1', { maxReconnects: 2 })).rejects.toThrow() expect(api.get).toHaveBeenCalledTimes(3) // initial + 2 reconnects }) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 1d601506e..e85426bf2 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -309,7 +309,7 @@ class Projects { // A real deploy responds with a deployment to follow to completion. const deployment = data as ProjectDeployment - const completed = await this.streamDeploymentEvents(deployment.id, { onProgress }) + const completed = await this.streamDeploymentEvents(resources.project.logicalId, deployment.id, { onProgress }) if (completed.status !== 'SUCCEEDED' || completed.result === null) { throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') @@ -318,8 +318,10 @@ class Projects { return { data: completed.result } } - getDeployment (deploymentId: string) { - return this.api.get(`/v1/projects/deployments/${encodeURIComponent(deploymentId)}`) + getDeployment (logicalId: string, deploymentId: string) { + return this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}`, + ) } /** @@ -331,13 +333,14 @@ class Projects { * no cursor. */ async streamDeploymentEvents ( + logicalId: string, deploymentId: string, { onProgress, maxReconnects = 5 }: { onProgress?: (progress: number) => void, maxReconnects?: number } = {}, ): Promise { let reconnects = 0 for (;;) { try { - return await this.consumeEventStream(deploymentId, onProgress) + return await this.consumeEventStream(logicalId, deploymentId, onProgress) } catch (err) { if (err instanceof DeploymentStreamInterruptedError && reconnects < maxReconnects) { reconnects += 1 @@ -348,10 +351,10 @@ class Projects { } } - private async openEventStream (deploymentId: string): Promise { + private async openEventStream (logicalId: string, deploymentId: string): Promise { try { const { data } = await this.api.get( - `/v1/projects/deployments/${encodeURIComponent(deploymentId)}/events`, + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/events`, { responseType: 'stream', headers: { Accept: 'text/event-stream' } }, ) return data @@ -368,10 +371,11 @@ class Projects { } private async consumeEventStream ( + logicalId: string, deploymentId: string, onProgress?: (progress: number) => void, ): Promise { - const stream = await this.openEventStream(deploymentId) + const stream = await this.openEventStream(logicalId, deploymentId) return new Promise((resolve, reject) => { let buffer = '' From c0932cd4c05763fecb64f44d1961bf2085f0a2bb Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 15:18:07 +0900 Subject: [PATCH 04/10] feat(deploy): add --cancel-in-progress-deployment to preempt an in-flight deploy [RED-644] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the async deploy endpoint returns 409 (a deployment is already in progress for the project), the CLI previously errored out. With the new opt-in flag, the CLI instead cancels the in-flight deployment, waits for it to finish unwinding, and retries — so a new deploy can preempt an older one instead of waiting. - New boolean flag --cancel-in-progress-deployment (default false). It is a dedicated flag, NOT --force (which only skips the confirmation prompt). - On a 409 with the flag set, deploy() cancels the specific in-flight deployment (from the conflict's deploymentId), long-polls the completion endpoint to a final state, then retries — bounded to avoid an unbounded cancel war, and treating a vanished predecessor (404) as "slot free". - awaitDeploymentCompletion floors its poll cadence so a server returning 408 immediately can't become a tight request loop. - A "Waiting for an in-progress deployment to finish…" status message is shown during the wait; a 409 without the flag now prints an actionable hint. - rest/errors.ts: type the deploymentId carried on a 409 ErrorData. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/commands/deploy.ts | 17 ++- .../cli/src/rest/__tests__/projects.spec.ts | 112 +++++++++++++++++- packages/cli/src/rest/errors.ts | 5 + packages/cli/src/rest/projects.ts | 109 ++++++++++++++++- 4 files changed, 239 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index a803f2cbc..caed478ba 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -16,6 +16,7 @@ import { splitConfigFilePath, getGitInformation } from '../services/util.js' import commonMessages from '../messages/common-messages.js' import { forceFlag } from '../helpers/flags.js' import { ProjectDeployResponse } from '../rest/projects.js' +import { ConflictError } from '../rest/errors.js' import { uploadSnapshots } from '../services/snapshot-service.js' import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js' import { Runtime } from '../runtimes/index.js' @@ -56,6 +57,10 @@ export default class Deploy extends AuthCommand { allowNo: true, }), 'force': forceFlag(), + 'cancel-in-progress-deployment': Flags.boolean({ + description: 'If a deployment for this project is already in progress, cancel it and deploy instead of failing.', + default: false, + }), 'config': Flags.string({ char: 'c', description: commonMessages.configFile, @@ -83,6 +88,7 @@ export default class Deploy extends AuthCommand { const { force, preview, + 'cancel-in-progress-deployment': cancelInProgress, 'schedule-on-deploy': scheduleOnDeploy, output: outputFlag, verbose, @@ -247,7 +253,9 @@ export default class Deploy extends AuthCommand { { dryRun: preview, scheduleOnDeploy, + cancelInProgress, onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`), + onStatus: preview ? undefined : message => this.style.actionStatus(message), }, ) if (!preview) { @@ -274,7 +282,14 @@ export default class Deploy extends AuthCommand { if (!preview) { this.style.actionFailure() } - this.style.longError(`Your project could not be deployed.`, err) + if (err instanceof ConflictError) { + this.style.longError( + 'A deployment for this project is already in progress.', + 'Wait for it to finish, or pass --cancel-in-progress-deployment to cancel it and deploy.', + ) + } else { + this.style.longError(`Your project could not be deployed.`, err) + } this.exit(1) } } diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 614752009..5ca112325 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Readable } from 'node:stream' import type { AxiosInstance } from 'axios' import Projects, { ProjectDeployFailedError, type ProjectSync } from '../projects.js' -import { NotFoundError } from '../errors.js' +import { ConflictError, NotFoundError, RequestTimeoutError } from '../errors.js' function makeAxiosMock (): AxiosInstance { return { @@ -149,3 +149,113 @@ describe('Projects.deploy', () => { await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('network down') }) }) + +const conflict = (deploymentId: string) => + new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment for this project is already in progress.', + deploymentId, + }) + +describe('Projects.deploy cancel-in-progress', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + api = makeAxiosMock() + projects = new Projects(api) + }) + + it('propagates the 409 without cancelling when cancelInProgress is not set', async () => { + vi.mocked(api.post).mockRejectedValue(conflict('old-dep')) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ConflictError) + // Only the deploy POST — no cancel POST was attempted. + expect(api.post).toHaveBeenCalledTimes(1) + expect(api.get).not.toHaveBeenCalled() + }) + + it('cancels the in-flight deployment, waits, and retries when cancelInProgress is set', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.resolve({ data: { id: 'old-dep', status: 'RUNNING' } }) as never + } + deployPosts += 1 + // First deploy collides with an in-flight deployment; the retry succeeds. + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => + (url.includes('/completion') + ? Promise.resolve({ data: { id: 'old-dep', status: 'CANCELLED' } }) + : Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })))) as never, + ) + + const onStatus = vi.fn() + const { data } = await projects.deploy(sync, { cancelInProgress: true, onStatus }) + + expect(api.post).toHaveBeenCalledWith('/v1/projects/my-project/deployments/old-dep/cancel') + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/old-dep/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) + expect(onStatus).toHaveBeenCalled() + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + }) + + it('proceeds with the retry when the in-flight deployment is already gone (404 on cancel)', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.reject( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'No such project deployment.' }), + ) as never + } + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })) as never, + ) + + const { data } = await projects.deploy(sync, { cancelInProgress: true }) + + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + }) + + it('awaitDeploymentCompletion keeps polling on 408 until a final state', async () => { + vi.mocked(api.get) + .mockRejectedValueOnce( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }), + ) + .mockResolvedValueOnce({ data: { id: 'dep-1', status: 'CANCELLED' } }) + + const result = await projects.awaitDeploymentCompletion('my-project', 'dep-1', { minPollIntervalMs: 0 }) + + expect(result.status).toBe('CANCELLED') + expect(api.get).toHaveBeenCalledTimes(2) + }) + + it('gives up with the conflict after exhausting cancel attempts on repeated conflicts', async () => { + // The deploy POST always conflicts; cancel + completion always succeed, so the + // cancel→wait→retry loop runs to its cap and then surfaces the conflict. + vi.mocked(api.post).mockImplementation((url: string) => + (url.includes('/cancel') + ? Promise.resolve({ data: { id: 'x', status: 'RUNNING' } }) + : Promise.reject(conflict('x'))) as never, + ) + vi.mocked(api.get).mockResolvedValue({ data: { id: 'x', status: 'CANCELLED' } } as never) + + await expect(projects.deploy(sync, { cancelInProgress: true })).rejects.toThrow(ConflictError) + // Initial attempt + 5 retries = 6 deploy POSTs; 5 cancels in between. + const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) + expect(cancelCalls).toHaveLength(5) + }) +}) diff --git a/packages/cli/src/rest/errors.ts b/packages/cli/src/rest/errors.ts index eb0b0f156..c22065620 100644 --- a/packages/cli/src/rest/errors.ts +++ b/packages/cli/src/rest/errors.ts @@ -114,6 +114,11 @@ export interface ErrorData { error: string errorCode?: string message: string + /** + * Set on a 409 from the async project-deploy endpoint: the id of the in-flight + * deployment that blocked this one. Lets the client attach to or cancel it. + */ + deploymentId?: string } function isErrorData (value: any): value is ErrorData { diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index e85426bf2..1c8313ced 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -1,9 +1,10 @@ import { type AxiosInstance, isAxiosError } from 'axios' import { Readable } from 'node:stream' +import { setTimeout as sleep } from 'node:timers/promises' import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' -import { ConflictError, ForbiddenError, handleErrorResponse, NotFoundError } from './errors.js' +import { ConflictError, ForbiddenError, handleErrorResponse, NotFoundError, RequestTimeoutError } from './errors.js' export interface Project { name: string @@ -290,11 +291,52 @@ class Projects { */ async deploy ( resources: ProjectSync, - { dryRun = false, scheduleOnDeploy = true, onProgress }: { + { dryRun = false, scheduleOnDeploy = true, cancelInProgress = false, onProgress, onStatus }: { dryRun?: boolean scheduleOnDeploy?: boolean + /** + * On a 409 (another deployment is already in progress), cancel that + * deployment and retry instead of failing. + */ + cancelInProgress?: boolean onProgress?: (progress: number) => void + /** Human-readable status updates (e.g. while waiting on a predecessor). */ + onStatus?: (message: string) => void } = {}, + ): Promise<{ data: ProjectDeployResponse }> { + const logicalId = resources.project.logicalId + + // A freed slot can be taken by a third party between our cancel and retry, + // yielding a fresh 409 for a different deployment, so cancel→wait→retry may + // repeat. Bound it to avoid an unbounded cancel war. + const MAX_CANCEL_ATTEMPTS = 5 + for (let attempt = 0; ; attempt++) { + try { + return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, onProgress }) + } catch (err) { + if ( + !cancelInProgress + || dryRun + || !(err instanceof ConflictError) + || typeof err.data.deploymentId !== 'string' + || attempt >= MAX_CANCEL_ATTEMPTS + ) { + throw err + } + // Cancel the specific in-flight deployment we collided with, wait for it + // to finish unwinding, then retry our deploy. + await this.cancelInProgressDeployment(logicalId, err.data.deploymentId, onStatus) + } + } + } + + private async submitDeployment ( + resources: ProjectSync, + { dryRun, scheduleOnDeploy, onProgress }: { + dryRun: boolean + scheduleOnDeploy: boolean + onProgress?: (progress: number) => void + }, ): Promise<{ data: ProjectDeployResponse }> { const { data } = await this.api.post( `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, @@ -318,12 +360,75 @@ class Projects { return { data: completed.result } } + /** + * Cancel an in-flight deployment and wait for it to reach a final state, so a + * fresh deploy can take its slot. The predecessor's rollback can briefly hold + * row locks, so we wait for it to be fully final before returning. + */ + private async cancelInProgressDeployment ( + logicalId: string, + deploymentId: string, + onStatus?: (message: string) => void, + ): Promise { + onStatus?.('Waiting for an in-progress deployment to finish before deploying…') + try { + await this.cancelDeployment(logicalId, deploymentId) + await this.awaitDeploymentCompletion(logicalId, deploymentId) + } catch (err) { + // The predecessor no longer exists (it finished and was cleaned up between + // our collision and now): its slot is free, so just proceed to retry. + if (!(err instanceof NotFoundError)) { + throw err + } + } + } + getDeployment (logicalId: string, deploymentId: string) { return this.api.get( `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}`, ) } + /** Request cancellation of an in-flight deployment (idempotent on the server). */ + cancelDeployment (logicalId: string, deploymentId: string) { + return this.api.post( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/cancel`, + ) + } + + /** + * Long-poll the completion endpoint until the deployment reaches a final state, + * returning it. The server blocks up to `maxWaitSeconds` and returns 408 when + * that elapses (the deployment is still running); we keep calling until a final + * state or the overall `deadlineMs` is hit. + */ + async awaitDeploymentCompletion ( + logicalId: string, + deploymentId: string, + { maxWaitSeconds = 30, deadlineMs = 5 * 60_000, minPollIntervalMs = 1_000 }: + { maxWaitSeconds?: number, deadlineMs?: number, minPollIntervalMs?: number } = {}, + ): Promise { + const startedAt = Date.now() + for (;;) { + try { + const { data } = await this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/completion`, + { params: { maxWaitSeconds } }, + ) + return data + } catch (err) { + // 408 = still running after the server-side wait window; keep waiting. + if (err instanceof RequestTimeoutError && Date.now() - startedAt < deadlineMs) { + // Floor the cadence so a server that returns 408 immediately (rather + // than long-polling for maxWaitSeconds) can't become a tight loop. + await sleep(minPollIntervalMs) + continue + } + throw err + } + } + } + /** * Follow a deployment to completion over its Server-Sent Events stream, * invoking `onProgress` as progress frames arrive and resolving with the final From 99173888f7857dfa044e7bbb316cbe4c6e688e08 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 16:12:06 +0900 Subject: [PATCH 05/10] feat(deploy): expose cancelRequestedAt on the deployment type [RED-644] Mirror the API response shape: the deployment now carries cancelRequestedAt (null unless a cancellation has been requested). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/rest/projects.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 1c8313ced..5632a88ee 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -92,6 +92,8 @@ export interface ProjectDeployment { createdAt: string startedAt: string | null endedAt: string | null + /** When cancellation was requested for this deployment, or null if it was not. */ + cancelRequestedAt: string | null } export class ProjectDeployFailedError extends Error { From 64c6d31aa2c3339dc2f28eadf56eb7fb2ea63fa9 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 17:04:33 +0900 Subject: [PATCH 06/10] feat(deploy): wait for an in-progress deployment by default, then deploy [RED-644] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a 409 (a deployment is already in progress for the project) failed the deploy. Now `checkly deploy` waits for the in-progress deployment to finish and then deploys — the same flow as --cancel-in-progress-deployment minus the cancel step. The flag now means "cancel it instead of waiting". - On a 409, deploy() resolves the conflict and retries: it long-polls the completion endpoint until the predecessor reaches a final state (optionally cancelling it first with the flag), and only then re-POSTs the deploy — once. The payload is never re-uploaded while the predecessor is still running. - awaitDeploymentCompletion is a single long-poll; the wait/retry cadence lives in deploy()/resolveInProgressDeployment, bounded by an overall ~30-min deadline after which the 409 surfaces with a clear message. - A predecessor whose worker died is finalized by the backend reaper, after which the completion poll returns and we deploy. - Update the flag description and the conflict message to match. Depends on the backend returning 409 immediately on a concurrent deploy (previously the request hung), without which neither the wait nor the cancel path could start. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/commands/deploy.ts | 9 +- .../cli/src/rest/__tests__/projects.spec.ts | 125 +++++++++++++---- packages/cli/src/rest/projects.ts | 126 +++++++++++------- 3 files changed, 180 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index caed478ba..53717bc3c 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -58,7 +58,7 @@ export default class Deploy extends AuthCommand { }), 'force': forceFlag(), 'cancel-in-progress-deployment': Flags.boolean({ - description: 'If a deployment for this project is already in progress, cancel it and deploy instead of failing.', + description: 'If a deployment for this project is already in progress, cancel it instead of waiting for it to finish.', default: false, }), 'config': Flags.string({ @@ -283,9 +283,12 @@ export default class Deploy extends AuthCommand { this.style.actionFailure() } if (err instanceof ConflictError) { + // deploy() waits-and-retries behind an in-progress deployment, so a 409 + // only reaches here once that wait exceeded its deadline. this.style.longError( - 'A deployment for this project is already in progress.', - 'Wait for it to finish, or pass --cancel-in-progress-deployment to cancel it and deploy.', + 'A deployment for this project is still in progress.', + 'We waited but it did not finish in time. Please try again, or pass ' + + '--cancel-in-progress-deployment to cancel it.', ) } else { this.style.longError(`Your project could not be deployed.`, err) diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 5ca112325..9bf057ee2 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -167,13 +167,66 @@ describe('Projects.deploy cancel-in-progress', () => { projects = new Projects(api) }) - it('propagates the 409 without cancelling when cancelInProgress is not set', async () => { - vi.mocked(api.post).mockRejectedValue(conflict('old-dep')) + it('waits for the in-progress deployment then re-POSTs to success (no flag, no cancel)', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + // Without the flag we must never cancel the predecessor. + expect(url).not.toContain('/cancel') + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => + (url.includes('/completion') + ? Promise.resolve({ data: { id: 'old-dep', status: 'SUCCEEDED' } }) + : Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })))) as never, + ) - await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ConflictError) - // Only the deploy POST — no cancel POST was attempted. - expect(api.post).toHaveBeenCalledTimes(1) - expect(api.get).not.toHaveBeenCalled() + const onStatus = vi.fn() + const { data } = await projects.deploy(sync, { onStatus }) + + // Waited on the predecessor's completion, then re-POSTed. + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/old-dep/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) + const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) + expect(cancelCalls).toHaveLength(0) + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + expect(onStatus).toHaveBeenCalled() + }) + + it('does not re-POST while the predecessor is still running; re-POSTs once it is final', async () => { + let deployPosts = 0 + let completionPolls = 0 + vi.mocked(api.post).mockImplementation(() => { + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('/completion')) { + completionPolls += 1 + // Still running for the first two long-poll windows, then final. + return (completionPolls < 3 + ? Promise.reject( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }), + ) + : Promise.resolve({ data: { id: 'old-dep', status: 'SUCCEEDED' } })) as never + } + return Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied }))) as never + }) + + const { data } = await projects.deploy(sync) + + // Polled three times (two 408s + final) but the payload was POSTed only twice: + // the initial collision and a single re-POST after the predecessor was final. + expect(completionPolls).toBe(3) + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) }) it('cancels the in-flight deployment, waits, and retries when cancelInProgress is set', async () => { @@ -230,32 +283,52 @@ describe('Projects.deploy cancel-in-progress', () => { expect(data).toEqual(applied) }) - it('awaitDeploymentCompletion keeps polling on 408 until a final state', async () => { - vi.mocked(api.get) - .mockRejectedValueOnce( - new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }), - ) - .mockResolvedValueOnce({ data: { id: 'dep-1', status: 'CANCELLED' } }) + it('awaitDeploymentCompletion does a single long-poll and returns the final deployment', async () => { + vi.mocked(api.get).mockResolvedValue({ data: { id: 'dep-1', status: 'CANCELLED' } } as never) - const result = await projects.awaitDeploymentCompletion('my-project', 'dep-1', { minPollIntervalMs: 0 }) + const result = await projects.awaitDeploymentCompletion('my-project', 'dep-1') expect(result.status).toBe('CANCELLED') - expect(api.get).toHaveBeenCalledTimes(2) + expect(api.get).toHaveBeenCalledTimes(1) + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/dep-1/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) }) - it('gives up with the conflict after exhausting cancel attempts on repeated conflicts', async () => { - // The deploy POST always conflicts; cancel + completion always succeed, so the - // cancel→wait→retry loop runs to its cap and then surfaces the conflict. - vi.mocked(api.post).mockImplementation((url: string) => - (url.includes('/cancel') - ? Promise.resolve({ data: { id: 'x', status: 'RUNNING' } }) - : Promise.reject(conflict('x'))) as never, + it('gives up and surfaces the conflict once the overall wait deadline is exceeded', async () => { + // Every POST conflicts and the predecessor never finishes (completion 408s). + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.resolve({ data: { id: 'x', status: 'RUNNING' } }) as never + } + deployPosts += 1 + return Promise.reject(conflict('x')) as never + }) + vi.mocked(api.get).mockRejectedValue( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }) as never, ) - vi.mocked(api.get).mockResolvedValue({ data: { id: 'x', status: 'CANCELLED' } } as never) - await expect(projects.deploy(sync, { cancelInProgress: true })).rejects.toThrow(ConflictError) - // Initial attempt + 5 retries = 6 deploy POSTs; 5 cancels in between. - const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) - expect(cancelCalls).toHaveLength(5) + // Advance the (mocked) clock 20 min on every Date.now() call, so the wait + // deadline (30 min) is crossed after a couple of checks regardless of the + // exact call count — robust to incidental Date.now() usage. + const base = Date.now() + let clock = base + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + const value = clock + clock += 20 * 60_000 + return value + }) + try { + await expect(projects.deploy(sync, { cancelInProgress: true })).rejects.toThrow(ConflictError) + // One wait round before the deadline trips: initial POST + one retry, with + // one cancel between. + expect(deployPosts).toBe(2) + const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) + expect(cancelCalls).toHaveLength(1) + } finally { + nowSpy.mockRestore() + } }) }) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 5632a88ee..97b0287a5 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -1,6 +1,5 @@ import { type AxiosInstance, isAxiosError } from 'axios' import { Readable } from 'node:stream' -import { setTimeout as sleep } from 'node:timers/promises' import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' @@ -224,6 +223,11 @@ export class InvalidImportPlanStateError extends Error { } } +// How long deploy() will keep waiting-and-retrying behind an in-progress +// deployment before giving up and surfacing the 409. Generous, since a large +// predecessor deploy can legitimately run for many minutes. +const DEPLOY_CONFLICT_WAIT_DEADLINE_MS = 30 * 60_000 + class Projects { api: AxiosInstance constructor (api: AxiosInstance) { @@ -298,7 +302,7 @@ class Projects { scheduleOnDeploy?: boolean /** * On a 409 (another deployment is already in progress), cancel that - * deployment and retry instead of failing. + * deployment instead of waiting for it to finish, then retry. */ cancelInProgress?: boolean onProgress?: (progress: number) => void @@ -308,26 +312,31 @@ class Projects { ): Promise<{ data: ProjectDeployResponse }> { const logicalId = resources.project.logicalId - // A freed slot can be taken by a third party between our cancel and retry, - // yielding a fresh 409 for a different deployment, so cancel→wait→retry may - // repeat. Bound it to avoid an unbounded cancel war. - const MAX_CANCEL_ATTEMPTS = 5 - for (let attempt = 0; ; attempt++) { + // On a 409 the project already has a deployment in progress. By default we + // wait for it to finish then retry; with cancelInProgress we cancel it first. + // resolveInProgressDeployment only returns once the predecessor has reached a + // final state, so we re-POST the (potentially large) payload exactly once per + // predecessor — never while it is still running. Bound by an overall deadline + // so a stuck predecessor can't make us wait forever. + const deadlineAt = Date.now() + DEPLOY_CONFLICT_WAIT_DEADLINE_MS + for (;;) { try { return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, onProgress }) } catch (err) { if ( - !cancelInProgress - || dryRun + dryRun || !(err instanceof ConflictError) || typeof err.data.deploymentId !== 'string' - || attempt >= MAX_CANCEL_ATTEMPTS + || Date.now() >= deadlineAt ) { throw err } - // Cancel the specific in-flight deployment we collided with, wait for it - // to finish unwinding, then retry our deploy. - await this.cancelInProgressDeployment(logicalId, err.data.deploymentId, onStatus) + await this.resolveInProgressDeployment(logicalId, err.data.deploymentId, { + cancel: cancelInProgress, + onStatus, + deadlineAt, + }) + // loop → re-POST, now that the predecessor has reached a final state } } } @@ -363,23 +372,53 @@ class Projects { } /** - * Cancel an in-flight deployment and wait for it to reach a final state, so a - * fresh deploy can take its slot. The predecessor's rollback can briefly hold - * row locks, so we wait for it to be fully final before returning. + * Resolve a collision with an in-progress deployment so the caller can retry: + * optionally cancel it, then wait until it reaches a final state (or is gone) + * before returning — so the caller re-POSTs only when the slot is actually + * free, never re-uploading the payload while the predecessor is still running. + * Returns early if the overall `deadlineAt` passes (the caller then re-POSTs + * once and surfaces the conflict). */ - private async cancelInProgressDeployment ( + private async resolveInProgressDeployment ( logicalId: string, deploymentId: string, - onStatus?: (message: string) => void, + { cancel, onStatus, deadlineAt }: { cancel: boolean, onStatus?: (message: string) => void, deadlineAt: number }, ): Promise { - onStatus?.('Waiting for an in-progress deployment to finish before deploying…') - try { - await this.cancelDeployment(logicalId, deploymentId) - await this.awaitDeploymentCompletion(logicalId, deploymentId) - } catch (err) { - // The predecessor no longer exists (it finished and was cleaned up between - // our collision and now): its slot is free, so just proceed to retry. - if (!(err instanceof NotFoundError)) { + if (cancel) { + onStatus?.('Cancelling the in-progress deployment…') + try { + await this.cancelDeployment(logicalId, deploymentId) + } catch (err) { + // Already gone → nothing to cancel; proceed to retry. + if (!(err instanceof NotFoundError)) { + throw err + } + return + } + } else { + onStatus?.('Waiting for an in-progress deployment to finish…') + } + + // Poll the completion endpoint until the predecessor is final. Pacing comes + // from the server-side long-poll (~maxWaitSeconds per call), so this is not a + // busy loop; the deadline bounds the total wait. + for (;;) { + try { + await this.awaitDeploymentCompletion(logicalId, deploymentId) + return // reached a final state → slot free + } catch (err) { + if (err instanceof NotFoundError) { + return // gone → slot free + } + // 408 = still running after the long-poll window. Keep waiting unless the + // overall deadline has passed, in which case return and let the caller + // re-POST once and surface the conflict. + if (err instanceof RequestTimeoutError) { + if (Date.now() >= deadlineAt) { + return + } + continue + } throw err } } @@ -399,36 +438,21 @@ class Projects { } /** - * Long-poll the completion endpoint until the deployment reaches a final state, - * returning it. The server blocks up to `maxWaitSeconds` and returns 408 when - * that elapses (the deployment is still running); we keep calling until a final - * state or the overall `deadlineMs` is hit. + * Long-poll the completion endpoint once: the server blocks up to + * `maxWaitSeconds` and returns the deployment when it reaches a final state, or + * 408 (`RequestTimeoutError`) if it is still running when that window elapses. + * The retry cadence lives in the caller, not here. */ async awaitDeploymentCompletion ( logicalId: string, deploymentId: string, - { maxWaitSeconds = 30, deadlineMs = 5 * 60_000, minPollIntervalMs = 1_000 }: - { maxWaitSeconds?: number, deadlineMs?: number, minPollIntervalMs?: number } = {}, + { maxWaitSeconds = 30 }: { maxWaitSeconds?: number } = {}, ): Promise { - const startedAt = Date.now() - for (;;) { - try { - const { data } = await this.api.get( - `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/completion`, - { params: { maxWaitSeconds } }, - ) - return data - } catch (err) { - // 408 = still running after the server-side wait window; keep waiting. - if (err instanceof RequestTimeoutError && Date.now() - startedAt < deadlineMs) { - // Floor the cadence so a server that returns 408 immediately (rather - // than long-polling for maxWaitSeconds) can't become a tight loop. - await sleep(minPollIntervalMs) - continue - } - throw err - } - } + const { data } = await this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/completion`, + { params: { maxWaitSeconds } }, + ) + return data } /** From 999d6b99c4011269409317876f176a0d3de705da Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 17:57:57 +0900 Subject: [PATCH 07/10] fix(deploy): clearly report a cancelled deployment [RED-644] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment that was cancelled surfaced as the generic "Your project could not be deployed. / The deployment did not complete successfully." — giving no hint that it was cancelled. - Add ProjectDeployCancelledError; submitDeployment throws it when the deploy stream completes as CANCELLED (a reliable signal, independent of any backend error text), before the generic non-SUCCEEDED failure path. - The deploy command reports it distinctly: title "Your deployment was cancelled." with the body "A newer deployment may have cancelled yours. Try deploying again if you still need to apply your changes." Still an error (❌, exit 1) — only the messaging changes. - Also reword the in-progress (409 past wait deadline) message so the body stands on its own instead of leaning on the title. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/commands/deploy.ts | 10 ++++++---- packages/cli/src/rest/__tests__/projects.spec.ts | 11 ++++++++++- packages/cli/src/rest/projects.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 53717bc3c..53277c613 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -15,7 +15,7 @@ import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util.js' import commonMessages from '../messages/common-messages.js' import { forceFlag } from '../helpers/flags.js' -import { ProjectDeployResponse } from '../rest/projects.js' +import { ProjectDeployResponse, ProjectDeployCancelledError } from '../rest/projects.js' import { ConflictError } from '../rest/errors.js' import { uploadSnapshots } from '../services/snapshot-service.js' import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js' @@ -282,13 +282,15 @@ export default class Deploy extends AuthCommand { if (!preview) { this.style.actionFailure() } - if (err instanceof ConflictError) { + if (err instanceof ProjectDeployCancelledError) { + this.style.longError('Your deployment was cancelled.', err.message) + } else if (err instanceof ConflictError) { // deploy() waits-and-retries behind an in-progress deployment, so a 409 // only reaches here once that wait exceeded its deadline. this.style.longError( 'A deployment for this project is still in progress.', - 'We waited but it did not finish in time. Please try again, or pass ' - + '--cancel-in-progress-deployment to cancel it.', + 'Try again later, or re-run with `--cancel-in-progress-deployment` to ' + + 'cancel the running deployment and deploy now.', ) } else { this.style.longError(`Your project could not be deployed.`, err) diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 9bf057ee2..6297240e5 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Readable } from 'node:stream' import type { AxiosInstance } from 'axios' -import Projects, { ProjectDeployFailedError, type ProjectSync } from '../projects.js' +import Projects, { ProjectDeployCancelledError, ProjectDeployFailedError, type ProjectSync } from '../projects.js' import { ConflictError, NotFoundError, RequestTimeoutError } from '../errors.js' function makeAxiosMock (): AxiosInstance { @@ -93,6 +93,15 @@ describe('Projects.deploy', () => { await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('Too many checks.') }) + it('throws ProjectDeployCancelledError when the deployment is cancelled', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'dep-1', status: 'CANCELLED', result: null, error: null })), + ) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployCancelledError) + }) + it('throws when the stream emits an error event', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) vi.mocked(api.get).mockResolvedValue(sseStream(sse('error', { message: 'stream blew up' }))) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 97b0287a5..bb45fd3a7 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -102,6 +102,14 @@ export class ProjectDeployFailedError extends Error { } } +/** The deployment was cancelled before it finished (e.g. superseded by a newer deploy). */ +export class ProjectDeployCancelledError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectDeployCancelledError' + } +} + /** Internal: the SSE stream ended before a terminal event (eligible for reconnect). */ class DeploymentStreamInterruptedError extends Error { constructor () { @@ -364,6 +372,12 @@ class Projects { const deployment = data as ProjectDeployment const completed = await this.streamDeploymentEvents(resources.project.logicalId, deployment.id, { onProgress }) + if (completed.status === 'CANCELLED') { + throw new ProjectDeployCancelledError( + 'A newer deployment may have cancelled yours. Try deploying again if you still need to apply your changes.', + ) + } + if (completed.status !== 'SUCCEEDED' || completed.result === null) { throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') } From 67c44c89c51d5b0037724f3750d26dabdf4b52d3 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 20:01:03 +0900 Subject: [PATCH 08/10] style(deploy): plain "..." instead of unicode ellipsis in status messages [RED-644] Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/rest/projects.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index bb45fd3a7..aecb4ac1f 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -399,7 +399,7 @@ class Projects { { cancel, onStatus, deadlineAt }: { cancel: boolean, onStatus?: (message: string) => void, deadlineAt: number }, ): Promise { if (cancel) { - onStatus?.('Cancelling the in-progress deployment…') + onStatus?.('Cancelling the in-progress deployment...') try { await this.cancelDeployment(logicalId, deploymentId) } catch (err) { @@ -410,7 +410,7 @@ class Projects { return } } else { - onStatus?.('Waiting for an in-progress deployment to finish…') + onStatus?.('Waiting for an in-progress deployment to finish...') } // Poll the completion endpoint until the predecessor is final. Pacing comes From 7950be47c7c69d970c2e739c3e07532f5155c095 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 24 Jun 2026 20:04:44 +0900 Subject: [PATCH 09/10] style(deploy): "an in-progress deployment" in the cancel status message [RED-644] Matches the wait message; "the" implied a deployment the user hadn't been told about. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/rest/projects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index aecb4ac1f..d967f79c9 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -399,7 +399,7 @@ class Projects { { cancel, onStatus, deadlineAt }: { cancel: boolean, onStatus?: (message: string) => void, deadlineAt: number }, ): Promise { if (cancel) { - onStatus?.('Cancelling the in-progress deployment...') + onStatus?.('Cancelling an in-progress deployment...') try { await this.cancelDeployment(logicalId, deploymentId) } catch (err) { From 5885fae942f26860ae41a4ac019b7b138643e8f8 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 25 Jun 2026 02:01:04 +0900 Subject: [PATCH 10/10] feat(deploy): type the deploy-result project with id + createdAt/updatedAt The async/sync deploy response's `result.project` was typed as the bare `Project` (name/logicalId/repoUrl). Add a `DeployedProject` type matching the API's deploy-result shape: the `id` it always returned plus the newly added camelCase `createdAt`/`updatedAt` timestamps. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --- packages/cli/src/rest/projects.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index d967f79c9..8c5bde6c3 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -71,8 +71,17 @@ export interface ProjectSync { repoInfo: GitInformation | null } +// The project echoed back in a deploy result: identity fields + timestamps. The +// timestamps are camelCase to match the deployment envelope (the project CRUD +// endpoints return snake_case — see ProjectResponse). +export interface DeployedProject extends Project { + id: string + createdAt: string + updatedAt: string | null +} + export interface ProjectDeployResponse { - project: Project + project: DeployedProject diff: Array }