Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,15 @@ stored PAT order. Writes skip the read-only GitHub App. `github.getStatus`
reports the active read/write sources, per-credential failure/cooldown state,
fallback details, and any background-refresh pause without exposing tokens.

When GitHub itself is failing rather than rejecting the credential,
`authFailure.kind` is `service_unavailable` (GitHub returned 5xx) — scripts must
not treat that as a reason to re-auth or rotate a token. In that case, and for
an unclassifiable failure, `github.getStatus` also consults githubstatus.com and
attaches `serviceHealth` (`indicator`, `affected` components, `incidentUrl`)
when a GitHub surface ADE depends on is confirmed down. `serviceHealth` is
present only as positive corroboration: its absence never means the failure is
local, so do not branch on it being missing.

Pi sign-in has no typed command, the same way OpenCode's `ai.opencodeOAuth*`
actions do not. `ai.piLoginStart` blocks until a human finishes Pi's own browser
or device-code flow, and any prompt it raises is answered by a *second* call
Expand Down
50 changes: 50 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ vi.mock("../../desktop/src/main/services/automations/automationSecretService", (

import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices";
import { resetGitHubServiceHealthCache } from "../../desktop/src/main/services/github/githubStatusPage";
import {
clearGithubCredentialHealth,
githubCredentialCooldown,
Expand Down Expand Up @@ -185,6 +186,55 @@ describe("headlessLinearServices", () => {
}
});

// The renderer reaches GitHub through whichever service owns the project, so
// corroboration applied only to the desktop in-process service left the whole
// outage attribution dead in the shipping, runtime-backed (brain) build.
it("corroborates a GitHub server error against the status page", async () => {
const previousAdeHome = process.env.ADE_HOME;
const previousFetch = globalThis.fetch;
process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-outage-"));
const statusPagePayload = {
status: { indicator: "major", description: "Partial System Outage" },
components: [{ id: "brv1bkgrwx7q", name: "API Requests", status: "major_outage" }],
incidents: [{ name: "Incident with GitHub.com", shortlink: "https://stspg.io/live", resolved_at: null }],
};
const statusPageCalls: string[] = [];
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input instanceof Request ? input.url : input);
if (url.includes("githubstatus.com")) statusPageCalls.push(url);
if (url.includes("githubstatus.com")) {
return new Response(JSON.stringify(statusPagePayload), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// Every api.github.com call fails the way GitHub fails during an incident.
return new Response("No server is currently available to service your request.", { status: 503 });
}) as unknown as typeof fetch;
globalThis.fetch = fetchImpl;
const githubService = createHeadlessGitHubService(
"/tmp/ade-project",
{ debug() {}, info() {}, warn() {}, error() {} } as any,
{ fetchImpl },
);
try {
githubService.setToken("ghp_outage_token");
const status = await githubService.getStatus({ forceRefresh: true });

expect(status.authFailure?.kind).toBe("service_unavailable");
expect(status.serviceHealth?.affected.map((entry) => entry.surface)).toEqual(["api"]);
expect(status.serviceHealth?.incidentUrl).toBe("https://stspg.io/live");
expect(statusPageCalls).toHaveLength(1);
} finally {
// Module-level cache: leaving it warm would silently change what the
// sibling status-lookup tests observe.
resetGitHubServiceHealthCache();
globalThis.fetch = previousFetch;
if (previousAdeHome == null) delete process.env.ADE_HOME;
else process.env.ADE_HOME = previousAdeHome;
}
});

it("does not let an invalidated GitHub status lookup overwrite the newer cache", async () => {
const previousAdeHome = process.env.ADE_HOME;
const previousFetch = globalThis.fetch;
Expand Down
55 changes: 38 additions & 17 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { createLinearIssueTracker } from "../../desktop/src/main/services/c
import type { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService";
import type { ComputerUseArtifactBrokerService } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService";
import { resolveSmartLinkPreview } from "../../desktop/src/main/services/chat/smartLinkPreviewService";
import { attachGitHubServiceHealth } from "../../desktop/src/main/services/github/githubStatusPage";
import type { SmartLinkPreview } from "../../desktop/src/shared/smartLinks";
import {
getModelById,
Expand Down Expand Up @@ -1873,26 +1874,46 @@ export function createHeadlessGitHubService(
}
};

const resolveStatus = async (opts: { forceRefresh?: boolean }): Promise<GitHubStatus> => {
const forceRefresh = opts.forceRefresh === true;
if (forcedStatusLookupInFlight?.generation === statusLookupGeneration) {
return await forcedStatusLookupInFlight.promise;
}
if (!forceRefresh) {
return await performStatusLookup(false, statusLookupGeneration);
}

invalidateStatusCache();
const generation = statusLookupGeneration;
const lookup = performStatusLookup(true, generation);
forcedStatusLookupInFlight = { generation, promise: lookup };
try {
return await lookup;
} finally {
if (forcedStatusLookupInFlight?.promise === lookup) forcedStatusLookupInFlight = null;
}
};

service = {
verifyStoredPat,
async getStatus(opts: { forceRefresh?: boolean } = {}) {
const forceRefresh = opts.forceRefresh === true;
if (forcedStatusLookupInFlight?.generation === statusLookupGeneration) {
return await forcedStatusLookupInFlight.promise;
}
if (!forceRefresh) {
return await performStatusLookup(false, statusLookupGeneration);
}

invalidateStatusCache();
const generation = statusLookupGeneration;
const lookup = performStatusLookup(true, generation);
forcedStatusLookupInFlight = { generation, promise: lookup };
try {
return await lookup;
} finally {
if (forcedStatusLookupInFlight?.promise === lookup) forcedStatusLookupInFlight = null;
}
// Corroborate a failing status against githubstatus.com, exactly as the
// desktop in-process service does. The renderer reaches GitHub through
// whichever service owns the project, so applying this to only one of
// them would leave the outage attribution dead in the shipping,
// runtime-backed build. Wrapped at this single exit rather than inside
// `resolveStatus` so a cache hit still gets fresh corroboration — and so
// a future early return cannot skip it.
// `options.fetchImpl` is threaded through deliberately: it is the same
// seam every other GitHub call in this service uses, so a test that
// provokes a 5xx/unclassified failure stubs the status page instead of
// reaching the real githubstatus.com from the unit suite. Passed raw
// rather than via `requestGitHub`, which would replace the status-page
// lookup's own abort signal and defeat its timeout.
return await attachGitHubServiceHealth(await resolveStatus(opts), {
logger,
fetchImpl: options.fetchImpl,
});
},
async getBackgroundRequestPauseUntilMs() {
const inventory = await readCredentialInventoryAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {

const FALLBACK_COOLDOWN_MS = 5 * 60_000;
const SECONDARY_RATE_LIMIT_COOLDOWN_MS = 60_000;
// A GitHub 5xx deliberately gets NO cooldown. Cooldowns here are consulted by
// every operation path (githubRawRequest, githubRequest, readAuthToken), not
// just background probes, so parking a credential after one transient 502 would
// fail the user's next merge or PR read locally, without a request, for the
// whole window. The credential is not the problem, so it must stay usable the
// instant GitHub recovers.
const REPOSITORY_ACCESS_TTL_MS = 2 * 60_000;
export const GITHUB_BACKGROUND_RATE_LIMIT_RESERVE = 500;

Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/services/github/githubRateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GitHubAuthFailure, GitHubRateLimitState } from "../../../shared/types";
import { isGithubServiceUnavailable } from "../../../shared/githubServiceHealth";

export class GitHubRateLimitError extends Error {
constructor(
Expand Down Expand Up @@ -95,6 +96,19 @@ export function classifyGitHubAuthFailure(args: {
},
};
}
// Ordered before the transient-network check: GitHub's 503 body contains
// "temporarily unavailable"-adjacent wording that the transient regex also
// matches, and "GitHub is down" is the more precise, more actionable answer.
if (isGithubServiceUnavailable({ status: args.status, message })) {
return {
rateLimit,
authFailure: {
kind: "service_unavailable",
message,
retryAt: null,
},
};
}
if (isTransientGithubProbeFailure(message)) {
return {
rateLimit,
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/services/github/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
requestGithubRawWithCredentialFallback,
type GithubRawRequestArgs,
} from "./githubRawRequest";
import { attachGitHubServiceHealth } from "./githubStatusPage";
import {
classifyGitHubAuthFailure,
classifyGitHubGraphqlCredentialFailure,
Expand Down Expand Up @@ -1872,7 +1873,9 @@ export function createGithubService({
if (!opts.forceRefresh) return await statusInFlight;
await statusInFlight.catch(() => {});
}
const work = computeStatus(opts);
// Corroborate a failing status against githubstatus.com. Shared with the
// headless (brain) service so both getStatus owners behave identically.
const work = computeStatus(opts).then((status) => attachGitHubServiceHealth(status, { logger }));
statusInFlight = work;
try {
return await work;
Expand Down
169 changes: 169 additions & 0 deletions apps/desktop/src/main/services/github/githubStatusPage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
attachGitHubServiceHealth,
getGitHubServiceHealth,
resetGitHubServiceHealthCache,
} from "./githubStatusPage";
import type { GitHubStatus } from "../../../shared/types";

const OUTAGE_PAYLOAD = {
status: { indicator: "major", description: "Partial System Outage" },
components: [{ id: "brv1bkgrwx7q", name: "API Requests", status: "major_outage" }],
incidents: [{ name: "Incident with GitHub.com", shortlink: "https://stspg.io/x", resolved_at: null }],
};

function jsonResponse(payload: unknown, ok = true): Response {
return {
ok,
status: ok ? 200 : 503,
json: async () => payload,
} as unknown as Response;
}

describe("githubStatusPage", () => {
beforeEach(() => {
resetGitHubServiceHealthCache();
});

afterEach(() => {
vi.restoreAllMocks();
resetGitHubServiceHealthCache();
});

it("returns a corroborated incident", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD)));
const health = await getGitHubServiceHealth();
expect(health?.affected[0]?.surface).toBe("api");
expect(health?.incidentUrl).toBe("https://stspg.io/x");
});

it("caches so repeated failures don't re-hit the status page", async () => {
const fetchMock = vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD));
vi.stubGlobal("fetch", fetchMock);
await getGitHubServiceHealth();
await getGitHubServiceHealth();
await getGitHubServiceHealth();
expect(fetchMock).toHaveBeenCalledTimes(1);
});

// Without a negative cache, an outage that also breaks egress would retry the
// status page on every failed GitHub call — exactly when it helps least.
it("caches the null result too", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("getaddrinfo ENOTFOUND");
});
vi.stubGlobal("fetch", fetchMock);
expect(await getGitHubServiceHealth()).toBeNull();
expect(await getGitHubServiceHealth()).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups into one request", async () => {
const fetchMock = vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD));
vi.stubGlobal("fetch", fetchMock);
const [a, b] = await Promise.all([getGitHubServiceHealth(), getGitHubServiceHealth()]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(a).toEqual(b);
});

// An unreachable or broken status page must never itself produce UI: that
// would swap one wrong accusation for another.
it("stays silent on a non-ok response or unparseable body", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD, false)));
expect(await getGitHubServiceHealth()).toBeNull();

resetGitHubServiceHealthCache();
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: true,
status: 200,
json: async () => {
throw new Error("Unexpected token < in JSON");
},
} as unknown as Response)));
expect(await getGitHubServiceHealth()).toBeNull();
});

it("never reports an outage when nothing ADE uses is degraded", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({
status: { indicator: "major", description: "Partial System Outage" },
components: [{ id: "pjmpxvq2cmr2", name: "Copilot", status: "major_outage" }],
})));
expect(await getGitHubServiceHealth()).toBeNull();
});
});

function status(overrides: Partial<GitHubStatus> = {}): GitHubStatus {
return {
tokenStored: true,
patTokenStored: false,
tokenDecryptionFailed: false,
storageScope: "app",
authSource: "gh",
tokenType: "oauth",
repo: { owner: "arul28", name: "ADE" },
hasOrigin: true,
userLogin: null,
scopes: [],
ghCliPath: null,
ghAuthError: null,
checkedAt: null,
repoAccessOk: null,
repoAccessError: null,
connected: false,
...overrides,
};
}

describe("attachGitHubServiceHealth", () => {
beforeEach(() => resetGitHubServiceHealthCache());
afterEach(() => {
vi.restoreAllMocks();
resetGitHubServiceHealthCache();
});

it("attaches a corroborated incident to a GitHub-side failure", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD)));
const result = await attachGitHubServiceHealth(
status({
authFailure: { kind: "service_unavailable", message: "503", retryAt: null },
}),
);
expect(result.serviceHealth?.affected[0]?.surface).toBe("api");
});

// Definitive answers FROM GitHub about this credential. Letting a mild,
// unrelated degradation overwrite them would tell a rate-limited or
// under-scoped user there is nothing to fix, hiding their real remedy.
it("never overrides a definitive GitHub answer, and never asks the status page about one", async () => {
const fetchMock = vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD));
vi.stubGlobal("fetch", fetchMock);
// `network` is ADE's own connectivity failing — the status page is just as
// unreachable, so asking only adds latency to every offline blip.
for (const kind of ["invalid_token", "permission_denied", "rate_limited", "network"] as const) {
const result = await attachGitHubServiceHealth(
status({ authFailure: { kind, message: "x", retryAt: null } }),
);
expect(result.serviceHealth).toBeFalsy();
}
expect(fetchMock).not.toHaveBeenCalled();
});

// A fine-grained PAT that simply lacks this repo is a LOCAL misconfiguration:
// connected === false, but GitHub never failed. Treating that as a trigger
// would make this module a once-a-minute beacon for the whole session.
it("does not consult the status page when GitHub reported no failure", async () => {
const fetchMock = vi.fn(async () => jsonResponse(OUTAGE_PAYLOAD));
vi.stubGlobal("fetch", fetchMock);
const result = await attachGitHubServiceHealth(status({ connected: false, authFailure: null }));
expect(result.serviceHealth).toBeFalsy();
expect(fetchMock).not.toHaveBeenCalled();
});

it("stays silent when the status page is unreachable", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ENOTFOUND"); }));
const result = await attachGitHubServiceHealth(
status({ authFailure: { kind: "service_unavailable", message: "503", retryAt: null } }),
);
expect(result.serviceHealth).toBeFalsy();
});
});
Loading
Loading