-
Notifications
You must be signed in to change notification settings - Fork 19
fix(cli): improve branch provisioning recovery and reconciliation #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
7fa4829
552cf50
34b302e
42f9be6
ea18b81
bbc5c78
d500422
e9851d9
40acf52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||
| import type { Command } from 'commander'; | ||||||
| import * as clack from '@clack/prompts'; | ||||||
| import { createBranchApi, getBranchApi } from '../../lib/api/platform.js'; | ||||||
| import { createBranchApi, getBranchApi, listBranchesApi } from '../../lib/api/platform.js'; | ||||||
| import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; | ||||||
| import { requireAuth } from '../../lib/credentials.js'; | ||||||
| import { getProjectConfig } from '../../lib/config.js'; | ||||||
|
|
@@ -11,14 +11,48 @@ import type { Branch, BranchMode } from '../../types.js'; | |||||
|
|
||||||
| const POLL_INTERVAL_MS = 3_000; | ||||||
| const POLL_TIMEOUT_MS = 5 * 60 * 1_000; | ||||||
| const HEALTH_CHECK_INTERVAL_MS = 5_000; | ||||||
| const HEALTH_CHECK_TIMEOUT_MS = 15 * 60 * 1_000; | ||||||
|
|
||||||
| async function waitForDataPlaneReady(branch: Branch, spinner: ReturnType<typeof clack.spinner> | null): Promise<void> { | ||||||
| const healthUrl = `https://${branch.appkey}.${branch.region}.insforge.app/api/health`; | ||||||
| const start = Date.now(); | ||||||
| let lastError: string | null = null; | ||||||
|
|
||||||
| while (Date.now() - start < HEALTH_CHECK_TIMEOUT_MS) { | ||||||
| try { | ||||||
| spinner?.message(`Waiting for data plane to be ready (${Math.ceil((HEALTH_CHECK_TIMEOUT_MS - (Date.now() - start)) / 60000)} min left)...`); | ||||||
| const res = await fetch(healthUrl, { method: 'GET', signal: AbortSignal.timeout(10_000) }); | ||||||
| if (res.ok) { | ||||||
| const data = await res.json().catch(() => ({})); | ||||||
| if (data.status === 'healthy' || data.status === 'ok') { | ||||||
| return; | ||||||
| } | ||||||
| lastError = `Health check returned status: ${data.status}`; | ||||||
| } else { | ||||||
| lastError = `Health check failed: ${res.status} ${res.statusText}`; | ||||||
| } | ||||||
| } catch (err) { | ||||||
| lastError = err instanceof Error ? err.message : String(err); | ||||||
| } | ||||||
| await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS)); | ||||||
| } | ||||||
| throw new CLIError( | ||||||
| `Branch data plane did not become ready within 15 minutes. Last error: ${lastError}. ` + | ||||||
| `The branch may still be provisioning. Run \`insforge branch list\` to check status.`, | ||||||
| 1, | ||||||
| 'BRANCH_DATA_PLANE_TIMEOUT' | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| export function registerBranchCreateCommand(branch: Command): void { | ||||||
| branch | ||||||
| .command('create <name>') | ||||||
| .description('Create a branch from the currently linked project') | ||||||
| .option('--mode <mode>', 'full | schema-only', 'full') | ||||||
| .option('--no-switch', 'Do not auto-switch context after creation') | ||||||
| .action(async (name: string, opts: { mode: string; switch: boolean }, cmd) => { | ||||||
| .option('--wait-ready', 'Wait for the branch data plane to be fully ready (up to 15 min)', true) | ||||||
|
greptile-apps[bot] marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Every Prompt for AI agents
Suggested change
|
||||||
| .action(async (name: string, opts: { mode: string; switch: boolean; waitReady: boolean }, cmd) => { | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
| const { json, apiUrl } = getRootOpts(cmd); | ||||||
| try { | ||||||
| await requireAuth(apiUrl); | ||||||
|
|
@@ -62,6 +96,13 @@ export function registerBranchCreateCommand(branch: Command): void { | |||||
| ready = await pollUntilReady(created.id, apiUrl, spinner); | ||||||
| provisioned = ready.branch_state === 'ready'; | ||||||
|
|
||||||
| // If the branch is ready and wait-ready is enabled, wait for the data plane to be healthy | ||||||
| if (provisioned && opts.waitReady) { | ||||||
| spinner?.message('Branch control plane ready. Waiting for data plane to be healthy...'); | ||||||
| await waitForDataPlaneReady(ready, spinner); | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
| spinner?.message('Data plane is ready.'); | ||||||
| } | ||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||
|
|
||||||
| if (provisioned && opts.switch) { | ||||||
| spinner?.message('Branch ready. Switching context...'); | ||||||
| // silent: true always — the spinner owns user-facing output, and | ||||||
|
|
@@ -75,6 +116,54 @@ export function registerBranchCreateCommand(branch: Command): void { | |||||
| spinner?.stop(`Branch '${name}' is in '${ready.branch_state}' state`); | ||||||
| } | ||||||
| } catch (err) { | ||||||
| // Check if this is a network error (fetch failed, ECONNRESET, etc.) | ||||||
| // Match both raw undici error messages AND the formatted output of | ||||||
| // formatFetchError (used by platformFetch), so reconciliation is | ||||||
| // reachable regardless of which layer surfaces the error. | ||||||
| // If so, attempt to reconcile by checking if the branch was actually created | ||||||
| const isNetworkError = err instanceof CLIError && | ||||||
| (err.message.includes('fetch failed') || | ||||||
| err.message.includes('ECONNRESET') || | ||||||
| err.message.includes('ETIMEDOUT') || | ||||||
| err.message.includes('ENOTFOUND') || | ||||||
| err.message.includes('ECONNREFUSED') || | ||||||
| err.message.includes('UND_ERR_CONNECT_TIMEOUT') || | ||||||
| err.message.includes('UND_ERR_SOCKET') || | ||||||
| err.message.includes('timeout') || | ||||||
| // Formatted messages from formatFetchError (used by platformFetch) | ||||||
| err.message.includes('was reset') || | ||||||
| err.message.includes('was refused') || | ||||||
| err.message.includes('timed out') || | ||||||
| err.message.includes('Cannot resolve') || | ||||||
| err.message.includes('Network error contacting') || | ||||||
| err.message.includes('TLS certificate error') || | ||||||
| err.code === 'BRANCH_DATA_PLANE_TIMEOUT'); | ||||||
|
Comment on lines
+179
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| if (!provisioned && isNetworkError) { | ||||||
| try { | ||||||
| // Attempt reconciliation: check if branch exists in branch list | ||||||
| // listBranchesApi handles undefined apiUrl (uses default platform URL) | ||||||
| const branches = await listBranchesApi(project.project_id, apiUrl); | ||||||
| const createdBranch = branches.find(b => b.name === name); | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
| if (createdBranch) { | ||||||
| // Branch exists server-side despite network error | ||||||
| spinner?.stop( | ||||||
| `Connection was interrupted, but branch '${name}' was created server-side (state: ${createdBranch.branch_state}). ` + | ||||||
| `It may still be provisioning. Run \`insforge branch list\` to check status.`, | ||||||
| 1 | ||||||
| ); | ||||||
| // Output the branch info in JSON mode so automation can parse it | ||||||
| if (json) { | ||||||
| outputJson({ branch: createdBranch, reconciled: true }); | ||||||
| } | ||||||
| await shutdownAnalytics(); | ||||||
| return; | ||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||
| } | ||||||
| } catch (reconcileErr) { | ||||||
| // Reconciliation failed, fall through to original error | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| if (provisioned) { | ||||||
| spinner?.stop( | ||||||
| `Branch '${name}' is ready, but switching context failed — run \`insforge branch switch ${name}\` to retry`, | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.