Skip to content

Commit 8977790

Browse files
committed
feat: add copilot token health alerting
1 parent 187ed99 commit 8977790

11 files changed

Lines changed: 325 additions & 18 deletions

logbook.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ Working tree progress:
209209
- Updated the dashboard top badges to show `Active Backend` separately from `Scope`, and adjusted related status/log copy to use `scope` wording where the dashboard is describing filtered history rather than the process runtime backend.
210210
- Added coverage that the overview API preserves the active backend even when the dashboard is filtered to a different backend scope.
211211

212+
## 2026-03-21
213+
214+
### Copilot token health alerting
215+
216+
Working tree progress:
217+
218+
- Added Copilot token expiry tracking and refresh-failure state in `src/lib/state.ts` and `src/lib/token.ts`.
219+
- Switched Copilot token refresh to record failures without crashing the process, so the existing token can stay in use until the next successful refresh.
220+
- Added a dashboard token-health endpoint and a persistent top banner that appears when the Copilot token has expired and refresh has failed.
221+
- Added coverage for token-health reporting, the refresh failure path, and the dashboard shell for the banner.
222+
- Updated the Responses SSE route test fixture so the mocked module still provides the route-level normalization export.
223+
212224
## Commit Trail
213225

214226
- `42214b8` 2026-03-08 `feat: port PR #205 — Responses API support and model-level routing`

src/lib/copilot-token-health.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type CopilotTokenRefreshError, state } from "./state"
2+
3+
export interface CopilotTokenHealthResponse {
4+
alert: boolean
5+
backend: typeof state.backend
6+
expired: boolean
7+
expiresAt?: number
8+
refreshError?: CopilotTokenRefreshError
9+
}
10+
11+
export interface CopilotTokenResponse {
12+
expires_at: number
13+
refresh_in: number
14+
token: string
15+
}
16+
17+
export function recordCopilotTokenSuccess(
18+
response: CopilotTokenResponse,
19+
): void {
20+
state.copilotToken = response.token
21+
state.copilotTokenExpiresAt = response.expires_at
22+
state.copilotTokenRefreshError = undefined
23+
}
24+
25+
export function recordCopilotTokenRefreshFailure(error: unknown): void {
26+
state.copilotTokenRefreshError = {
27+
at: Date.now(),
28+
message: error instanceof Error ? error.message : String(error),
29+
}
30+
}
31+
32+
export function getCopilotTokenHealth(
33+
now: number = Date.now(),
34+
): CopilotTokenHealthResponse {
35+
const expiresAt = state.copilotTokenExpiresAt
36+
const expired = typeof expiresAt === "number" && now >= expiresAt
37+
const refreshError = state.copilotTokenRefreshError
38+
39+
return {
40+
alert: state.backend === "copilot" && expired && Boolean(refreshError),
41+
backend: state.backend,
42+
expired,
43+
expiresAt,
44+
...(refreshError ? { refreshError } : {}),
45+
}
46+
}

src/lib/state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ export interface OpenAIOAuthState {
1515
accountId: string
1616
}
1717

18+
export interface CopilotTokenRefreshError {
19+
at: number
20+
message: string
21+
}
22+
1823
export interface State {
1924
backend: BackendId
2025
githubToken?: string
2126
copilotToken?: string
27+
copilotTokenExpiresAt?: number
28+
copilotTokenRefreshError?: CopilotTokenRefreshError
2229
openaiOAuth?: OpenAIOAuthState
2330

2431
accountType: string

src/lib/token.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { getDeviceCode } from "~/services/github/get-device-code"
77
import { getGitHubUser } from "~/services/github/get-user"
88
import { pollAccessToken } from "~/services/github/poll-access-token"
99

10+
import {
11+
recordCopilotTokenRefreshFailure,
12+
recordCopilotTokenSuccess,
13+
} from "./copilot-token-health"
1014
import { HTTPError } from "./error"
1115
import { state } from "./state"
1216

@@ -15,30 +19,34 @@ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
1519
const writeGithubToken = (token: string) =>
1620
fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)
1721

22+
export async function refreshCopilotToken(): Promise<void> {
23+
consola.debug("Refreshing Copilot token")
24+
try {
25+
const response = await getCopilotToken()
26+
recordCopilotTokenSuccess(response)
27+
consola.debug("Copilot token refreshed")
28+
if (state.showToken) {
29+
consola.info("Refreshed Copilot token:", response.token)
30+
}
31+
} catch (error) {
32+
recordCopilotTokenRefreshFailure(error)
33+
consola.error("Failed to refresh Copilot token:", error)
34+
}
35+
}
36+
1837
export const setupCopilotToken = async () => {
19-
const { token, refresh_in } = await getCopilotToken()
20-
state.copilotToken = token
38+
const response = await getCopilotToken()
39+
recordCopilotTokenSuccess(response)
2140

2241
// Display the Copilot token to the screen
2342
consola.debug("GitHub Copilot Token fetched successfully!")
2443
if (state.showToken) {
25-
consola.info("Copilot token:", token)
44+
consola.info("Copilot token:", response.token)
2645
}
2746

28-
const refreshInterval = (refresh_in - 60) * 1000
29-
setInterval(async () => {
30-
consola.debug("Refreshing Copilot token")
31-
try {
32-
const { token } = await getCopilotToken()
33-
state.copilotToken = token
34-
consola.debug("Copilot token refreshed")
35-
if (state.showToken) {
36-
consola.info("Refreshed Copilot token:", token)
37-
}
38-
} catch (error) {
39-
consola.error("Failed to refresh Copilot token:", error)
40-
throw error
41-
}
47+
const refreshInterval = (response.refresh_in - 60) * 1000
48+
setInterval(() => {
49+
void refreshCopilotToken()
4250
}, refreshInterval)
4351
}
4452

src/routes/dashboard/page-body.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ export const dashboardBody = String.raw`
77
</div>
88
</section>
99
10+
<section
11+
class="panel panel-pad auth-banner"
12+
id="auth-banner"
13+
hidden
14+
role="alert"
15+
aria-live="polite"
16+
>
17+
<div class="auth-banner-title" id="auth-banner-title"></div>
18+
<div class="auth-banner-copy" id="auth-banner-copy"></div>
19+
</section>
20+
1021
<section class="summary-grid" id="summary-grid"></section>
1122
1223
<section class="upper-grid">

src/routes/dashboard/page-script.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const dashboardScript = String.raw`
3535
borderColor: "#ff9f5a",
3636
},
3737
];
38+
var TOKEN_HEALTH_REFRESH_MS = 30_000;
3839
var state = {
3940
backend: "all",
4041
bucket: "day",
@@ -47,6 +48,8 @@ export const dashboardScript = String.raw`
4748
overview: null,
4849
range: "total",
4950
refreshTimer: null,
51+
tokenHealth: null,
52+
tokenHealthTimer: null,
5053
series: [],
5154
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
5255
usage: null,
@@ -110,6 +113,18 @@ export const dashboardScript = String.raw`
110113
}
111114
}
112115
116+
function formatHealthTime(value) {
117+
if (!value) {
118+
return "unknown time";
119+
}
120+
121+
try {
122+
return new Date(value).toLocaleTimeString();
123+
} catch {
124+
return String(value);
125+
}
126+
}
127+
113128
function backendLabel(value) {
114129
switch (value) {
115130
case "copilot": {
@@ -1180,12 +1195,49 @@ export const dashboardScript = String.raw`
11801195
: "Waiting for first refresh";
11811196
}
11821197
1198+
function renderTokenHealthBanner() {
1199+
var banner = q("auth-banner");
1200+
var title = q("auth-banner-title");
1201+
var copy = q("auth-banner-copy");
1202+
var health = state.tokenHealth;
1203+
1204+
if (!banner || !title || !copy) {
1205+
return;
1206+
}
1207+
1208+
if (!health || health.backend !== "copilot" || !health.alert) {
1209+
banner.hidden = true;
1210+
title.textContent = "";
1211+
copy.textContent = "";
1212+
return;
1213+
}
1214+
1215+
var parts = [];
1216+
if (health.expiresAt) {
1217+
parts.push("Expired at " + formatHealthTime(health.expiresAt));
1218+
}
1219+
1220+
if (health.refreshError) {
1221+
parts.push(
1222+
"Refresh failed at " +
1223+
formatHealthTime(health.refreshError.at) +
1224+
": " +
1225+
health.refreshError.message,
1226+
);
1227+
}
1228+
1229+
banner.hidden = false;
1230+
title.textContent = "Copilot token expired";
1231+
copy.textContent = parts.join(" · ");
1232+
}
1233+
11831234
function render() {
11841235
renderControls();
11851236
renderSummary();
11861237
renderChart();
11871238
renderQuotaRegion();
11881239
renderLog();
1240+
renderTokenHealthBanner();
11891241
renderStatus();
11901242
}
11911243
@@ -1274,6 +1326,29 @@ export const dashboardScript = String.raw`
12741326
});
12751327
}
12761328
1329+
function refreshTokenHealth() {
1330+
return requestJson("/dashboard/api/token-health")
1331+
.then(function (result) {
1332+
state.tokenHealth = result;
1333+
renderTokenHealthBanner();
1334+
})
1335+
.catch(function () {
1336+
// Keep the last known token health if the dashboard status
1337+
// endpoint is temporarily unavailable.
1338+
});
1339+
}
1340+
1341+
function startTokenHealthRefresh() {
1342+
if (state.tokenHealthTimer) {
1343+
clearInterval(state.tokenHealthTimer);
1344+
}
1345+
1346+
void refreshTokenHealth();
1347+
state.tokenHealthTimer = setInterval(function () {
1348+
void refreshTokenHealth();
1349+
}, TOKEN_HEALTH_REFRESH_MS);
1350+
}
1351+
12771352
function scheduleRefresh() {
12781353
if (state.refreshTimer) {
12791354
clearTimeout(state.refreshTimer);
@@ -1381,10 +1456,14 @@ export const dashboardScript = String.raw`
13811456
if (state.eventSource) {
13821457
state.eventSource.close();
13831458
}
1459+
if (state.tokenHealthTimer) {
1460+
clearInterval(state.tokenHealthTimer);
1461+
}
13841462
});
13851463
13861464
render();
13871465
connectLive();
1466+
startTokenHealthRefresh();
13881467
void refreshAll();
13891468
})();
13901469
`

src/routes/dashboard/page-styles.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,33 @@ export const dashboardStyles = String.raw`
6262
margin-bottom: 20px;
6363
}
6464
65+
.auth-banner {
66+
display: grid;
67+
gap: 8px;
68+
margin-bottom: 20px;
69+
border-color: rgba(245, 111, 111, 0.42);
70+
background:
71+
linear-gradient(
72+
180deg,
73+
rgba(245, 111, 111, 0.18),
74+
rgba(255, 255, 255, 0.03)
75+
);
76+
}
77+
78+
.auth-banner-title {
79+
color: var(--red);
80+
font-size: 0.92rem;
81+
font-weight: 700;
82+
letter-spacing: 0.04em;
83+
text-transform: uppercase;
84+
}
85+
86+
.auth-banner-copy {
87+
color: var(--text);
88+
font-size: 0.95rem;
89+
line-height: 1.5;
90+
}
91+
6592
.topbar-row {
6693
display: flex;
6794
justify-content: space-between;

src/routes/dashboard/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Hono } from "hono"
22
import { createRequire } from "node:module"
33
import path from "node:path"
44

5+
import { getCopilotTokenHealth } from "~/lib/copilot-token-health"
56
import {
67
getTelemetryModelBreakdown,
78
getTelemetryOverview,
@@ -80,4 +81,8 @@ dashboardRoutes.get("/api/events", async (c) => {
8081
return c.json(await listTelemetryEvents({ backend, limit, model, outcome }))
8182
})
8283

84+
dashboardRoutes.get("/api/token-health", (c) => {
85+
return c.json(getCopilotTokenHealth())
86+
})
87+
8388
dashboardRoutes.get("/api/events/stream", streamDashboardEvents)

tests/copilot-token-health.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, expect, mock, test } from "bun:test"
2+
3+
import { recordCopilotTokenSuccess } from "../src/lib/copilot-token-health"
4+
import { state } from "../src/lib/state"
5+
6+
const getCopilotTokenMock = mock()
7+
8+
void mock.module("~/services/github/get-copilot-token", () => ({
9+
getCopilotToken: getCopilotTokenMock,
10+
}))
11+
12+
const { refreshCopilotToken } = await import("../src/lib/token")
13+
14+
afterEach(() => {
15+
getCopilotTokenMock.mockReset()
16+
state.backend = "copilot"
17+
state.copilotToken = undefined
18+
state.copilotTokenExpiresAt = undefined
19+
state.copilotTokenRefreshError = undefined
20+
})
21+
22+
test("recordCopilotTokenSuccess stores expiry and clears refresh errors", () => {
23+
const expiresAt = Date.now() + 120_000
24+
state.copilotTokenRefreshError = {
25+
at: Date.now(),
26+
message: "old error",
27+
}
28+
29+
recordCopilotTokenSuccess({
30+
token: "initial-token",
31+
expires_at: expiresAt,
32+
refresh_in: 120,
33+
})
34+
35+
expect(state.copilotToken).toBe("initial-token")
36+
expect(state.copilotTokenExpiresAt).toBe(expiresAt)
37+
expect(state.copilotTokenRefreshError).toBeUndefined()
38+
})
39+
40+
test("refreshCopilotToken records refresh failures without throwing", async () => {
41+
state.copilotToken = "initial-token"
42+
state.copilotTokenExpiresAt = Date.now() + 120_000
43+
getCopilotTokenMock.mockRejectedValueOnce(new Error("ConnectionRefused"))
44+
45+
await refreshCopilotToken()
46+
47+
expect(state.copilotToken).toBe("initial-token")
48+
expect(state.copilotTokenRefreshError?.message).toBe("ConnectionRefused")
49+
expect(typeof state.copilotTokenRefreshError?.at).toBe("number")
50+
})

0 commit comments

Comments
 (0)