fix(antigravity): attempt onboarding when projectId is empty (#5193 regression of #2541) (#7815)

* fix(antigravity): attempt onboarding when projectId is empty (#5193 regression of #2541)

PR #5193 changed onboarding from inline await to fire-and-forget gated by
if (projectId), which never fires when projectId is empty — the exact case
that needs onboarding. This re-introduced the #2541 catch-22.

Add an else-if branch that attempts onboarding inline (bounded by
AbortSignal.timeout) when projectId is empty, then retries loadCodeAssist
to discover the newly created project.

- Existing accounts with projectId: unchanged (fire-and-forget)
- New accounts without projectId: now onboarded within login flow
- Timeout bounded: +8s worst case (onboardUser + retry loadCodeAssist)
- Graceful degradation: if onboarding fails, lazy retry handles it

Tests:
- 3 new tests covering empty-projectId onboarding path (RED→GREEN)
- Existing 2 tests preserved and passing
- Adjusted timeout assertion for the stall test (now includes onboardUser stall)
- 5/5 passing on Node 24

Fixes #7814
Related: #5193, #2569, #2541, #2219

* docs(changelog): add fragment for antigravity onboarding empty-projectId fix (#7814)

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: Rafael Dias Zendron <rafaumeu@users.noreply.github.com>
This commit is contained in:
Rafael Dias Zendron
2026-07-20 10:08:37 -03:00
committed by GitHub
parent 7c63e99149
commit bf9202de0c
3 changed files with 153 additions and 10 deletions

View File

@@ -0,0 +1 @@
- fix(oauth): attempt Antigravity onboarding inline when `projectId` is empty, fixing a regression from #5193 that re-introduced the #2541 catch-22 for new accounts (#7814)

View File

@@ -116,13 +116,13 @@ export const antigravity = {
console.log("Failed to load code assist:", e);
}
// Fire-and-forget onboarding — it must NOT block the OAuth login response.
// The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the
// `/exchange` request hang forever when an upstream was slow/unreachable, so the
// dashboard "just spun". Onboarding is also performed lazily at request time by
// antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the
// 9router web flow. (#5180-followup / antigravity login hang)
if (projectId) {
// Fire-and-forget onboarding — must NOT block the OAuth login response.
// The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the
// `/exchange` request hang forever when an upstream was slow/unreachable, so the
// dashboard "just spun". Onboarding is also performed lazily at request time by
// antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the
// 9router web flow. (#5180-followup / antigravity login hang)
const onboardInBackground = async () => {
for (let i = 0; i < 10; i++) {
try {
@@ -140,6 +140,33 @@ export const antigravity = {
}
};
void onboardInBackground().catch(() => {});
} else if (ANTIGRAVITY_CONFIG.onboardUserEndpoints.length > 0) {
// No projectId — the account has no pre-existing Cloud Code project.
// Attempt onboarding inline (bounded by timeout) so the project gets created
// and discovered within the same login flow. If onboarding succeeds, retry
// loadCodeAssist to pick up the newly created projectId.
// (#5193 regression of #2541 — catch-22: onboarding was never attempted)
try {
await fetchFirstOk(
ANTIGRAVITY_CONFIG.onboardUserEndpoints,
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
// Onboarding succeeded (or at least the endpoint responded ok). Retry
// loadCodeAssist to discover the newly created project.
const retryRes = await fetchFirstOk(
ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints,
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
const retryData = await retryRes.json();
projectId =
retryData.cloudaicompanionProject?.id || retryData.cloudaicompanionProject || "";
} catch {
// Onboarding or retry failed/timed out — graceful degradation.
// projectId stays empty. Lazy retry at request-time by
// antigravityProjectBootstrap.ts will handle it later.
}
}
return { userInfo, projectId, tierId };

View File

@@ -89,7 +89,8 @@ test("postExchange returns before onboarding finishes (fire-and-forget — never
test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no infinite hang)", async () => {
globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => {
const u = String(url);
if (u.includes("userinfo") || u.includes("loadCodeAssist")) return stalledFetch(init);
if (u.includes("userinfo") || u.includes("loadCodeAssist") || u.includes("onboardUser"))
return stalledFetch(init);
return jsonRes({});
}) as typeof fetch;
@@ -97,8 +98,122 @@ test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no
const result = await antigravity.postExchange({ access_token: "tok" } as never);
const elapsed = Date.now() - start;
// userInfo + loadCodeAssist are AbortSignal.timeout(8s)-bounded (one shared
// deadline each), so the worst case is ~16s — never an infinite hang.
assert.ok(elapsed < 22000, `postExchange must be timeout-bounded; took ${elapsed}ms`);
// userInfo + loadCodeAssist + onboardUser(attempt) are each AbortSignal.timeout(8s)-bounded.
// When loadCodeAssist stalls → projectId empty → else-branch attempts onboardUser (also 8s).
// Worst case: 8 + 8 + 8 = 24s. With onboarding retry of loadCodeAssist also stalling: still
// within the 8s shared deadline of the retry. Never an infinite hang.
assert.ok(elapsed < 30000, `postExchange must be timeout-bounded; took ${elapsed}ms`);
assert.equal(result.projectId, "", "no project when loadCodeAssist times out");
});
// ---------------------------------------------------------------------------
// Tests for the empty-projectId onboarding path (fix for #5193 regression of #2541).
//
// When loadCodeAssist returns empty projectId (no pre-existing Cloud Code project),
// postExchange MUST attempt onboarding inline (not fire-and-forget) so the project
// gets created and discovered within the same login flow.
//
// Flip-proof: remove the `else` branch in postExchange and these tests fail —
// projectId stays empty because onboarding never runs.
test("postExchange attempts onboarding when projectId is empty and returns discovered projectId", async () => {
// loadCodeAssist returns no project → onboarding should be attempted inline
// → retry loadCodeAssist returns the newly created project.
let loadCodeAssistCallCount = 0;
let onboardUserCalled = false;
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "new-user@example.com" });
if (u.includes("loadCodeAssist")) {
loadCodeAssistCallCount++;
// First call: no project (triggers onboarding). Second call: project found.
if (loadCodeAssistCallCount === 1) {
return jsonRes({
cloudaicompanionProject: null,
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
return jsonRes({
cloudaicompanionProject: "new-project-456",
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) {
onboardUserCalled = true;
return jsonRes({ done: true });
}
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.ok(onboardUserCalled, "onboardUser must be called when projectId is empty");
assert.equal(
loadCodeAssistCallCount,
2,
"loadCodeAssist must be called twice: initial (empty) + retry after onboarding"
);
assert.equal(
result.projectId,
"new-project-456",
"projectId discovered after onboarding + retry"
);
});
test("postExchange falls back to empty projectId when onboardUser fails", async () => {
// loadCodeAssist returns no project → onboarding attempted but fails →
// projectId remains empty (graceful degradation, never throws).
let onboardUserCalled = false;
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" });
if (u.includes("loadCodeAssist")) {
return jsonRes({
cloudaicompanionProject: null,
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) {
onboardUserCalled = true;
return jsonRes({ error: "PERMISSION_DENIED" }, 403);
}
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.ok(onboardUserCalled, "onboardUser must still be attempted even if it may fail");
assert.equal(
result.projectId,
"",
"projectId stays empty when onboarding fails (graceful degradation)"
);
});
test("postExchange is timeout-bounded during onboarding attempt (empty projectId)", async () => {
// loadCodeAssist returns no project → onboarding stalls → timeout kicks in →
// postExchange returns empty projectId within ~8s (not infinite hang).
globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" });
if (u.includes("loadCodeAssist")) {
return jsonRes({
cloudaicompanionProject: null,
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) {
return stalledFetch(init);
}
return jsonRes({});
}) as typeof fetch;
const start = Date.now();
const result = await antigravity.postExchange({ access_token: "tok" } as never);
const elapsed = Date.now() - start;
assert.ok(elapsed < 22000, `onboarding attempt must be timeout-bounded; took ${elapsed}ms`);
assert.equal(result.projectId, "", "projectId stays empty when onboarding times out");
});