Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 112 additions & 1 deletion src/commands/branch/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ vi.mock('../../lib/api/platform.js', () => ({
branch_created_at: new Date().toISOString(),
branch_metadata: { mode: 'full' },
})),
listBranchesApi: vi.fn(async () => []),
}));

// The data-plane readiness probe. It MUST be mocked: unmocked it makes a real
// request to a fake host and then polls for minutes.
vi.mock('../../lib/api/oss.js', () => ({
probeBackendHealth: vi.fn(async () => ({ reachable: true, status: 200 })),
}));

vi.mock('../../lib/credentials.js', () => ({
requireAuth: vi.fn(async () => ({ accessToken: 'tok', userId: 'u' })),
}));

vi.mock('../../lib/config.js', () => ({
buildOssHost: (appkey: string, region: string) => `https://${appkey}.${region}.insforge.app`,
getProjectConfig: vi.fn(),
saveProjectConfig: vi.fn(),
getLocalConfigDir: () => '/tmp/.insforge',
Expand Down Expand Up @@ -61,11 +69,16 @@ vi.mock('@clack/prompts', () => ({
}));

describe('branch create', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
spinnerMock.start.mockReset();
spinnerMock.message.mockReset();
spinnerMock.stop.mockReset();
// clearAllMocks clears CALLS but keeps implementations, so a test that made
// the branch unreachable would otherwise leave every later test polling for
// the full readiness budget.
const { probeBackendHealth } = await import('../../lib/api/oss.js');
(probeBackendHealth as Mock).mockResolvedValue({ reachable: true, status: 200 });
});

it('rejects when no project linked', async () => {
Expand Down Expand Up @@ -280,4 +293,102 @@ describe('branch create', () => {
expect.objectContaining({ name: 'feat-x', json: false, silent: true }),
);
});
it('does not report success while the branch host is not serving yet', async () => {
// 'ready' is a control-plane state. Reporting success on it alone is what
// makes the user's NEXT command fail against a host that resets.
const { getProjectConfig } = await import('../../lib/config.js');
(getProjectConfig as Mock).mockReturnValue({
project_id: 'p1',
project_name: 'parent',
org_id: 'o1',
});
const { probeBackendHealth } = await import('../../lib/api/oss.js');
(probeBackendHealth as Mock).mockResolvedValue({
reachable: false,
status: null,
detail: 'Connection to p1ky-x9p.us-east.insforge.app was reset.',
});
vi.useFakeTimers();
const program = new Command().exitOverride();
program.option('--json').option('--api-url <url>').option('-y, --yes');
registerBranchCreateCommand(program);
const run = program
.parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' })
.catch(() => {});
await vi.runAllTimersAsync();
await run;
vi.useRealTimers();
const stopped = spinnerMock.stop.mock.calls.at(-1);
expect(String(stopped?.[0])).toContain('not serving yet');
expect(stopped?.[1]).toBe(1);
});

it('adopts a branch that was created despite a transport failure', async () => {
// createBranchApi carries no idempotency key, so a reset on the RESPONSE
// leg leaves a real, billing branch behind. Giving up here orphans it.
const { getProjectConfig } = await import('../../lib/config.js');
(getProjectConfig as Mock).mockReturnValue({
project_id: 'p1',
project_name: 'parent',
org_id: 'o1',
});
const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js');
(createBranchApi as Mock).mockRejectedValueOnce(
new Error('Connection to api.insforge.dev was reset.'),
);
(listBranchesApi as Mock).mockResolvedValueOnce([
{
id: 'branch-id',
parent_project_id: 'p1',
organization_id: 'o1',
name: 'feat-x',
appkey: 'p1ky-x9p',
region: 'us-east',
branch_state: 'creating',
branch_created_at: new Date().toISOString(),
branch_metadata: { mode: 'schema-only' },
},
]);
const program = new Command().exitOverride();
program.option('--json').option('--api-url <url>').option('-y, --yes');
registerBranchCreateCommand(program);
await program
.parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' })
.catch(() => {});
expect(listBranchesApi as Mock).toHaveBeenCalledWith('p1', undefined);
// The run continued instead of exiting as a failed creation.
expect(String(spinnerMock.stop.mock.calls.at(-1)?.[0])).not.toContain('creation failed');
});

it('rethrows the original error when nothing was actually created', async () => {
const { getProjectConfig } = await import('../../lib/config.js');
(getProjectConfig as Mock).mockReturnValue({
project_id: 'p1',
project_name: 'parent',
org_id: 'o1',
});
const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js');
(createBranchApi as Mock).mockRejectedValueOnce(new Error('boom'));
(listBranchesApi as Mock).mockResolvedValueOnce([]);
const program = new Command().exitOverride();
program.option('--json').option('--api-url <url>').option('-y, --yes');
registerBranchCreateCommand(program);
let exitCode: number | undefined;
const origExit = process.exit;
process.exit = ((code?: number) => {
exitCode = code;
throw new Error('__exit__');
}) as typeof process.exit;
const origStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
await program
.parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' })
.catch(() => {});
} finally {
process.exit = origExit;
process.stderr.write = origStderr;
}
expect(exitCode).toBe(1);
});
});
89 changes: 85 additions & 4 deletions src/commands/branch/create.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
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 { probeBackendHealth } from '../../lib/api/oss.js';
import { CLIError, getRootOpts, handleError } from '../../lib/errors.js';
import { requireAuth } from '../../lib/credentials.js';
import { getProjectConfig } from '../../lib/config.js';
import { buildOssHost, getProjectConfig } from '../../lib/config.js';
import { outputJson, outputInfo } from '../../lib/output.js';
import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js';
import { runBranchSwitch } from './switch.js';
import type { Branch, BranchMode } from '../../types.js';

const POLL_INTERVAL_MS = 3_000;
const POLL_TIMEOUT_MS = 5 * 60 * 1_000;
// `branch_state` reaching 'ready' and the branch's own host answering are two
// different events, and the gap between them has been measured in MINUTES
// (2 min and 11.5 min on ap-southeast). A 5-minute ceiling reported the slower
// one as "still creating" when it was simply not finished yet, so the budget
// now covers the observed range with headroom.
const POLL_TIMEOUT_MS = 15 * 60 * 1_000;
// Once the control plane says ready, wait for the data plane too. Until this
// passes, every subsequent command against the branch fails.
const HEALTH_TIMEOUT_MS = 10 * 60 * 1_000;
const HEALTH_INTERVAL_MS = 5_000;

export function registerBranchCreateCommand(branch: Command): void {
branch
Expand Down Expand Up @@ -53,7 +63,7 @@ export function registerBranchCreateCommand(branch: Command): void {
let provisioned = false;
try {
spinner?.start(`Creating branch '${name}'...`);
const created = await createBranchApi(project.project_id, { mode, name }, apiUrl);
const created = await createBranchOrAdopt(project.project_id, { mode, name }, apiUrl);
captureEvent(project.project_id, 'cli_branch_create', {
mode,
parent_project_id: project.project_id,
Expand All @@ -62,6 +72,19 @@ export function registerBranchCreateCommand(branch: Command): void {
ready = await pollUntilReady(created.id, apiUrl, spinner);
provisioned = ready.branch_state === 'ready';

// 'ready' is a control-plane state: it means the provisioning job
// returned, not that the branch answers. Confirm the data plane
// before reporting success, otherwise the very next command the user
// runs — including the auto-switch below — hits a host that resets.
if (provisioned) {
spinner?.message('Branch ready. Waiting for it to start serving...');
const serving = await waitUntilServing(ready, spinner);
if (!serving) {
provisioned = false;
ready = { ...ready, branch_state: ready.branch_state };
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}

if (provisioned && opts.switch) {
spinner?.message('Branch ready. Switching context...');
// silent: true always — the spinner owns user-facing output, and
Expand All @@ -71,6 +94,11 @@ export function registerBranchCreateCommand(branch: Command): void {
spinner?.stop(`Branch '${name}' is ready and active`);
} else if (provisioned) {
spinner?.stop(`Branch '${name}' is ready`);
} else if (ready.branch_state === 'ready') {
spinner?.stop(
`Branch '${name}' reports ready but is not serving yet — retry your next command shortly`,
1,
);
} else {
spinner?.stop(`Branch '${name}' is in '${ready.branch_state}' state`);
}
Expand Down Expand Up @@ -107,6 +135,59 @@ export function registerBranchCreateCommand(branch: Command): void {
});
}

/**
* Create the branch, and if the request fails at the TRANSPORT layer, check
* whether it was created anyway before giving up.
*
* `createBranchApi` carries no idempotency key, and a reset on the RESPONSE leg
* leaves a fully created, billing branch behind while the CLI exits non-zero.
* The caller then has no id, no name in the output, and no reason to believe
* anything exists — so the branch is silently orphaned. `branch list` is
* authoritative here, and it is a control-plane call, so it still works while
* the branch's own host is unreachable.
*/
async function createBranchOrAdopt(
parentId: string,
body: { mode: BranchMode; name: string },
apiUrl: string | undefined,
): Promise<Branch> {
try {
return await createBranchApi(parentId, body, apiUrl);
} catch (err) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const existing = await listBranchesApi(parentId, apiUrl)
.then(branches => branches.find(branch => branch.name === body.name))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
.catch(() => undefined);
if (!existing) throw err;
return existing;
}
}

/**
* Poll the branch's own host until it serves, so 'ready' means usable.
*
* Returns false rather than throwing when the budget runs out: the branch DOES
* exist and is billing, so the command must still report its name and id and
* must not look like a failed creation.
*/
async function waitUntilServing(
branch: Branch,
spinner: ReturnType<typeof clack.spinner> | null,
): Promise<boolean> {
const baseUrl = buildOssHost(branch.appkey, branch.region);
const start = Date.now();
let announced = false;
while (Date.now() - start < HEALTH_TIMEOUT_MS) {
const health = await probeBackendHealth(baseUrl);
if (health.reachable) return true;
if (spinner && !announced) {
spinner.message(`Branch is provisioning its instance (${baseUrl} not answering yet)...`);
announced = true;
}
await new Promise(r => setTimeout(r, HEALTH_INTERVAL_MS));
}
return false;
}

async function pollUntilReady(
branchId: string,
apiUrl: string | undefined,
Expand Down
25 changes: 24 additions & 1 deletion src/lib/api/oss.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getProjectConfig } from '../config.js';
import { CLIError, ProjectNotLinkedError } from '../errors.js';
import { CLIError, formatFetchError, ProjectNotLinkedError } from '../errors.js';
import type {
ProjectConfig,
RotateKeyResponse,
Expand Down Expand Up @@ -227,3 +227,26 @@ export async function ossFetch(

return res;
}

/**
* Probe an InsForge backend's `/api/health` on an EXPLICIT base URL.
*
* Unlike `ossFetch`, this deliberately does not read the linked project: a
* freshly created branch is not linked yet, and the whole point is to ask
* whether ITS host is answering before we tell the user it is usable.
*
* Unauthenticated and non-throwing — callers poll it, so a connection reset
* while the instance boots is an expected answer ("not yet"), not an error.
*/
export async function probeBackendHealth(
baseUrl: string,
timeoutMs = 10_000,
): Promise<{ reachable: boolean; status: number | null; detail?: string }> {
const url = `${baseUrl.replace(/\/$/, '')}/api/health`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
return { reachable: res.ok, status: res.status };
} catch (err) {
return { reachable: false, status: null, detail: formatFetchError(err, url) };
}
}