);
- if (error) return Unable to load team.;
+ if (error && (!team || !isRetryableTeamError(error)))
+ return Unable to load team.;
if (!team) return
Team not found.
;
return (
@@ -64,14 +83,35 @@ export function TeamPage({ slug }: { slug: string }) {
function TeamContent({
initialTeam,
+ slug,
canViewWork,
canReadAutomations,
}: {
initialTeam: TeamResponse;
+ slug: string;
canViewWork: boolean;
canReadAutomations: boolean;
}) {
+ const router = useRouter();
+ const { mutate } = useSWRConfig();
const { team: currentTeam, error } = useTeam(initialTeam.id);
+ // The ID-keyed detail cache holds the PATCH response even when directory reads lag.
+ const canonicalSlug = !error && currentTeam?.archivedAt === null ? currentTeam.slug : undefined;
+ useEffect(() => {
+ if (!currentTeam || !canonicalSlug || canonicalSlug === slug) return;
+ let cancelled = false;
+ void mutate(
+ TEAMS_KEY,
+ (current: { teams: TeamResponse[] } | undefined) =>
+ reconcileTeamDirectory(current, currentTeam),
+ { revalidate: false }
+ ).then(() => {
+ if (!cancelled) router.replace(`/teams/${encodeURIComponent(canonicalSlug)}`);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [router, mutate, slug, canonicalSlug, currentTeam]);
const team = currentTeam ?? initialTeam;
const capabilities = useTeamCapabilities(team);
const [tab, setTab] = useState("Overview");
diff --git a/packages/web/src/components/teams/teams-pages.test.tsx b/packages/web/src/components/teams/teams-pages.test.tsx
index 23e83d4a3b..7a5e662daf 100644
--- a/packages/web/src/components/teams/teams-pages.test.tsx
+++ b/packages/web/src/components/teams/teams-pages.test.tsx
@@ -22,8 +22,10 @@ const mocks = vi.hoisted(() => ({
join: vi.fn(),
repositories: vi.fn(),
secrets: vi.fn(),
+ replace: vi.fn(),
}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) }));
vi.mock("@/lib/auth-session", () => ({
useAuthSession: () => ({ data: { user: { id: "user_one" } }, status: "authenticated" }),
}));
@@ -190,6 +192,44 @@ describe("Teams index", () => {
});
describe("Team page tabs", () => {
+ it("follows navigation to a different active team", () => {
+ mocks.teams = [
+ team,
+ { ...team, id: "team_engineering", slug: "engineering", name: "Engineering" },
+ ];
+ const view = render();
+ expect(screen.getByRole("heading", { name: "Design" })).toBeInTheDocument();
+
+ view.rerender();
+
+ expect(screen.getByRole("heading", { name: "Engineering" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Design" })).not.toBeInTheDocument();
+ expect(mocks.replace).not.toHaveBeenCalled();
+ });
+
+ it.each(["missing", "archived"])(
+ "does not retain a team when navigating to the %s slug",
+ (slug) => {
+ mocks.teams = [team, { ...team, id: "team_archived", slug: "archived", archivedAt: 2 }];
+ const view = render();
+ expect(screen.getByRole("heading", { name: "Design" })).toBeInTheDocument();
+
+ view.rerender();
+
+ expect(screen.getByText("Team not found.")).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Design" })).not.toBeInTheDocument();
+ expect(mocks.replace).not.toHaveBeenCalled();
+
+ mocks.teams = [
+ { ...team, slug: "product-design" },
+ { ...team, id: "team_reused", name: "New Design Team" },
+ ];
+ view.rerender();
+ expect(screen.getByRole("heading", { name: "New Design Team" })).toBeInTheDocument();
+ expect(mocks.replace).not.toHaveBeenCalled();
+ }
+ );
+
it("shows the header and Members to a nonmember, even if mutation capabilities are present", () => {
mocks.teams = [
{
diff --git a/packages/web/src/hooks/use-teams.test.tsx b/packages/web/src/hooks/use-teams.test.tsx
index fb0d0d9dc7..7dba7ffea2 100644
--- a/packages/web/src/hooks/use-teams.test.tsx
+++ b/packages/web/src/hooks/use-teams.test.tsx
@@ -383,6 +383,89 @@ describe("team hooks", () => {
expect(result.current.detail.team?.capabilities?.canJoin).toBe(false);
});
+ it("does not seed a partial directory when updating from a detail-only route", async () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: "user_one", name: "Ada" } },
+ status: "authenticated",
+ });
+ const updated = { ...membership, slug: "product-design", updatedAt: 2 };
+ const otherTeam = { ...membership, id: "team_other", slug: "engineering" };
+ vi.mocked(browserApiFetch).mockImplementation(async (path, init) => {
+ if (init?.method === "PATCH") return Response.json(updated);
+ if (path === "/api/teams") return Response.json({ teams: [updated, otherTeam] });
+ return Response.json(membership);
+ });
+ const { result, rerender } = renderHook(
+ ({ directoryEnabled }) => ({
+ detail: useTeam(membership.id),
+ directory: useTeams(directoryEnabled),
+ cache: useSWRConfig().cache,
+ }),
+ { initialProps: { directoryEnabled: false }, wrapper }
+ );
+ await waitFor(() => expect(result.current.detail.team?.id).toBe(membership.id));
+
+ await act(() => result.current.detail.updateTeam({ slug: "product-design" }));
+
+ expect(result.current.cache.get("/api/teams")?.data).toBeUndefined();
+ expect(result.current.detail.team?.slug).toBe("product-design");
+ expect(
+ vi.mocked(browserApiFetch).mock.calls.filter(([path]) => path === "/api/teams")
+ ).toHaveLength(0);
+
+ rerender({ directoryEnabled: true });
+ await waitFor(() => expect(result.current.directory.teams).toHaveLength(2));
+ expect(result.current.directory.teams.map(({ id }) => id)).toEqual([
+ membership.id,
+ otherTeam.id,
+ ]);
+ });
+
+ it("revalidates a mounted directory that has no data when a PATCH commits", async () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: "user_one", name: "Ada" } },
+ status: "authenticated",
+ });
+ const updated = { ...membership, slug: "product-design", updatedAt: 2 };
+ const otherTeam = { ...membership, id: "team_other", slug: "engineering" };
+ let finishInitialDirectory!: (response: Response) => void;
+ const initialDirectory = new Promise((resolve) => {
+ finishInitialDirectory = resolve;
+ });
+ let directoryRequests = 0;
+ vi.mocked(browserApiFetch).mockImplementation(async (path, init) => {
+ if (init?.method === "PATCH") return Response.json(updated);
+ if (path === "/api/teams") {
+ directoryRequests += 1;
+ return directoryRequests === 1
+ ? initialDirectory
+ : Response.json({ teams: [updated, otherTeam] });
+ }
+ return Response.json(membership);
+ });
+ const { result } = renderHook(
+ () => ({ detail: useTeam(membership.id), directory: useTeams() }),
+ { wrapper }
+ );
+ await waitFor(() => expect(result.current.detail.team?.id).toBe(membership.id));
+ expect(result.current.directory.teams).toEqual([]);
+
+ await act(() => result.current.detail.updateTeam({ slug: "product-design" }));
+
+ expect(directoryRequests).toBe(2);
+ expect(result.current.directory.teams.map(({ id }) => id)).toEqual([
+ membership.id,
+ otherTeam.id,
+ ]);
+ expect(result.current.directory.teams[0]?.slug).toBe("product-design");
+ await act(async () => {
+ finishInitialDirectory(Response.json({ teams: [membership] }));
+ await initialDirectory;
+ });
+ expect(result.current.directory.teams).toHaveLength(2);
+ expect(result.current.directory.teams[0]?.slug).toBe("product-design");
+ });
+
it.each(["create", "update", "archive", "restore", "set-member", "remove-member"] as const)(
"refreshes the user-scoped membership cache after %s",
async (operation) => {
diff --git a/packages/web/src/hooks/use-teams.ts b/packages/web/src/hooks/use-teams.ts
index bc21bb6f33..395227c510 100644
--- a/packages/web/src/hooks/use-teams.ts
+++ b/packages/web/src/hooks/use-teams.ts
@@ -18,7 +18,7 @@ import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch";
import { useAuthSession } from "@/lib/auth-session";
import { ME_TEAMS_API_PATH, isMeTeamsCacheKey, meTeamsKey } from "@/lib/me-teams-cache";
-const TEAMS_KEY = "/api/teams";
+export const TEAMS_KEY = "/api/teams";
// Missing or incomplete capabilities leave the team visible while every team action stays disabled.
const teamSchema = teamResponseSchema.extend({
capabilities: teamResponseSchema.shape.capabilities.partial().optional(),
@@ -31,6 +31,15 @@ const meTeamsSchema = meTeamsResponseSchema.extend({
});
const membersSchema = z.object({ members: z.array(teamMemberSchema) });
+export function reconcileTeamDirectory(
+ current: z.infer | undefined,
+ team: TeamResponse
+) {
+ return current
+ ? { teams: [...current.teams.filter((existing) => existing.id !== team.id), team] }
+ : current;
+}
+
class TeamRequestError extends Error {
constructor(
message: string,
@@ -158,7 +167,11 @@ export function useTeam(id: string) {
const team = await write(key, "PATCH", input, teamSchema);
await Promise.allSettled([
mutate(key, team, { revalidate: false }),
- mutate(TEAMS_KEY),
+ mutate(
+ TEAMS_KEY,
+ (current: z.infer | undefined) => reconcileTeamDirectory(current, team),
+ { revalidate: (data) => data === undefined }
+ ),
mutate(isMeTeamsCacheKey),
]);
return team;
From 452b0b96033924849deb213ae969f23727129ebd Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Fri, 2 Oct 2026 12:05:49 -0700
Subject: [PATCH 03/68] fix: start archive preservation before awaiting status
projection (#2202)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Fixes COL-227.
- Commit the local archive status through `beginTransition()` before
invoking preservation. A local persistence failure throws synchronously
and skips the save; the existing async `transition()` API retains its
promise-rejection behavior.
- Start preservation before awaiting the D1 status projection. The
synchronous `archived` and `draining` writes remain in the same Durable
Object turn, so a bridge reconnect receives HTTP 503 while saving still
needs its sandbox.
- Keep archive eligibility checks and final index confirmation
unchanged. Missing/legacy shutdown records still receive HTTP 410. No
migrations or persisted fields are added.
- Cover the delayed projection and failed local write with integration
regressions. Restore the projection spy before awaiting rejection-safe
archive settlement, including when the gate is never reached.
## Verification
- The original reconnect regression failed with HTTP 410 before the
initial fix and passes with HTTP 503.
- The new SQLite-abort regression failed because a rejected archive
write still moved the sandbox to `draining`; it now leaves the shutdown
record and session status unchanged.
- `npm run test -w @open-inspect/control-plane -- --maxWorkers=1`
passed: 6,115 tests.
- `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 test/integration/sandbox-shutdown.test.ts
test/integration/session-lifecycle.test.ts
test/integration/session-batch-archive.test.ts
test/integration/websocket-sandbox.test.ts` passed: 75 tests.
- `npm run typecheck -w @open-inspect/control-plane` passed.
- Targeted ESLint, Prettier, and commit hooks passed.
---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/6d30e15bbef91458fcc84af0a844cc3f)*
## Summary by CodeRabbit
* **Bug Fixes**
* Session archiving now proceeds while sandbox preservation is underway,
while still confirming the archived status before completing.
* Sandbox reconnect attempts receive a temporary “Sandbox is being
saved” response while archiving is pending.
* Reconnect attempts for archived sessions with missing or legacy
shutdown records receive a “Session is terminal” response.
* If updating a session’s local status fails during archiving, sandbox
shutdown state remains unchanged.
---------
Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude
---
.../session-lifecycle.handler.test.ts | 36 ++++--
.../handlers/session-lifecycle.handler.ts | 8 +-
.../session/session-status-service.test.ts | 22 ++++
.../src/session/session-status-service.ts | 35 +++--
.../test/integration/sandbox-shutdown.test.ts | 120 ++++++++++++++++++
5 files changed, 197 insertions(+), 24 deletions(-)
diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
index 026df7ad7e..5ab14cb33a 100644
--- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
+++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
@@ -87,11 +87,13 @@ function createHandler() {
getSandbox,
} as unknown as SandboxRepository;
const transition = vi.fn<(status: SessionRow["status"]) => Promise>();
+ const beginTransition = vi.fn();
const confirmIndexStatus = vi.fn<() => Promise>();
const repairIndexStatus = vi.fn<() => Promise>();
const settleFromMessageState = vi.fn<() => Promise>();
const statusService = {
transition,
+ beginTransition,
repairIndexStatus,
confirmIndexStatus,
settleFromMessageState,
@@ -128,6 +130,7 @@ function createHandler() {
getSession,
getSandbox,
transition,
+ beginTransition,
repairIndexStatus,
confirmIndexStatus,
settleFromMessageState,
@@ -280,9 +283,9 @@ describe("SessionLifecycleHandler", () => {
});
it("archives successfully without participant authorization", async () => {
- const { handler, getSession, transition, preserveForArchive } = createHandler();
+ const { handler, getSession, beginTransition, preserveForArchive } = createHandler();
getSession.mockReturnValue(createSession());
- transition.mockResolvedValue(true);
+ beginTransition.mockResolvedValue(true);
const response = await handler.archive(
new Request("http://internal/internal/archive", {
@@ -294,14 +297,28 @@ describe("SessionLifecycleHandler", () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: "archived", outcome: "archived" });
- expect(transition).toHaveBeenCalledWith("archived");
+ expect(beginTransition).toHaveBeenCalledWith("archived");
// An archived session's reconnects are refused, so its sandbox is saved now.
expect(preserveForArchive).toHaveBeenCalledOnce();
- expect(transition.mock.invocationCallOrder[0]).toBeLessThan(
+ expect(beginTransition.mock.invocationCallOrder[0]).toBeLessThan(
preserveForArchive.mock.invocationCallOrder[0]
);
});
+ it("does not preserve when the synchronous local transition fails", async () => {
+ const { handler, getSession, beginTransition, preserveForArchive, confirmIndexStatus } =
+ createHandler();
+ getSession.mockReturnValue(createSession());
+ beginTransition.mockImplementation(() => {
+ throw new Error("local status write failed");
+ });
+
+ await expect(handler.archive()).rejects.toThrow("local status write failed");
+
+ expect(preserveForArchive).not.toHaveBeenCalled();
+ expect(confirmIndexStatus).not.toHaveBeenCalled();
+ });
+
it("archives a draft that was never prompted", async () => {
const { handler, getSession, transition } = createHandler();
getSession.mockReturnValue(createSession({ status: "created" }));
@@ -405,7 +422,8 @@ describe("SessionLifecycleHandler", () => {
});
it("returns 409 when archiving a session with queued work", async () => {
- const { handler, getSession, repository, transition, preserveForArchive } = createHandler();
+ const { handler, getSession, repository, beginTransition, preserveForArchive } =
+ createHandler();
getSession.mockReturnValue(createSession());
repository.getPendingOrProcessingCount.mockReturnValue(1);
@@ -417,12 +435,12 @@ describe("SessionLifecycleHandler", () => {
);
expect(response.status).toBe(409);
- expect(transition).not.toHaveBeenCalled();
+ expect(beginTransition).not.toHaveBeenCalled();
expect(preserveForArchive).not.toHaveBeenCalled();
});
it("returns 409 when archiving a cancelled session", async () => {
- const { handler, getSession, transition } = createHandler();
+ const { handler, getSession, beginTransition } = createHandler();
getSession.mockReturnValue(createSession({ status: "cancelled" }));
const response = await handler.archive(
@@ -433,7 +451,7 @@ describe("SessionLifecycleHandler", () => {
);
expect(response.status).toBe(409);
- expect(transition).not.toHaveBeenCalled();
+ expect(beginTransition).not.toHaveBeenCalled();
});
// Unarchive must not assert a status of its own. Forcing "active" left a
@@ -520,7 +538,7 @@ describe("canonical archive outcomes", () => {
expect(await response.json()).toMatchObject({
outcome: status === "cancelled" ? "skipped_cancelled" : "skipped_queued_work",
});
- expect(h.transition).not.toHaveBeenCalled();
+ expect(h.beginTransition).not.toHaveBeenCalled();
}
);
it("returns retryable failure when the projection cannot be confirmed", async () => {
diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
index 2f0c659140..5f4f70494b 100644
--- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
+++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
@@ -162,8 +162,12 @@ export class SessionLifecycleHandler {
});
}
- await this.statusService.transition("archived");
- await this.sandboxLifecycle.preserveForArchive();
+ // Commit archived before starting preservation, but do not await its index
+ // projection: reconnects must see draining in the same turn.
+ await Promise.all([
+ this.statusService.beginTransition("archived"),
+ this.sandboxLifecycle.preserveForArchive(),
+ ]);
try {
await this.statusService.confirmIndexStatus("archived");
} catch {
diff --git a/packages/control-plane/src/session/session-status-service.test.ts b/packages/control-plane/src/session/session-status-service.test.ts
index f80ab09ee4..05acdac416 100644
--- a/packages/control-plane/src/session/session-status-service.test.ts
+++ b/packages/control-plane/src/session/session-status-service.test.ts
@@ -200,6 +200,16 @@ describe("SessionStatusService.transition", () => {
expect(h.broadcast).toHaveBeenCalledWith({ type: "session_status", status: "active" });
});
+ it("keeps local write failures as promise rejections", async () => {
+ const h = harness();
+ h.repository.updateSessionStatus.mockImplementation(() => {
+ throw new Error("local status write failed");
+ });
+
+ await expect(h.service.transition("archived")).rejects.toThrow("local status write failed");
+ expect(h.statusProjection.project).not.toHaveBeenCalled();
+ });
+
it("short-circuits on same status: refreshes the index but neither persists nor broadcasts", async () => {
const h = harness({ session: createSession({ status: "active" }) });
@@ -415,6 +425,18 @@ describe("SessionStatusService.transition", () => {
});
});
+describe("SessionStatusService.beginTransition", () => {
+ it("throws a local write failure synchronously without starting projection", () => {
+ const h = harness();
+ h.repository.updateSessionStatus.mockImplementation(() => {
+ throw new Error("local status write failed");
+ });
+
+ expect(() => h.service.beginTransition("archived")).toThrow("local status write failed");
+ expect(h.statusProjection.project).not.toHaveBeenCalled();
+ });
+});
+
describe("SessionStatusService.cancel", () => {
it("closes local status and unfinished messages before publishing projections", async () => {
const h = harness({ session: createSession({ status: "active" }) });
diff --git a/packages/control-plane/src/session/session-status-service.ts b/packages/control-plane/src/session/session-status-service.ts
index 7544d7f4f9..525cf84eca 100644
--- a/packages/control-plane/src/session/session-status-service.ts
+++ b/packages/control-plane/src/session/session-status-service.ts
@@ -53,36 +53,45 @@ export class SessionStatusService {
* refreshed in the same-status case).
*/
async transition(status: SessionStatus): Promise {
+ return this.beginTransition(status);
+ }
+
+ /**
+ * Commit local status synchronously, then return its projection promise.
+ * A local write failure throws before callers can start dependent work.
+ */
+ beginTransition(status: SessionStatus): Promise {
const session = this.repository.getSession();
- if (!session) return false;
+ if (!session) return Promise.resolve(false);
const publicSessionId = this.getPublicSessionId(session);
if (session.status === status) {
- await this.syncSessionIndexStatusAndAdmission(
+ return this.syncSessionIndexStatusAndAdmission(
publicSessionId,
status,
session.updated_at,
session.status_revision
- ).catch((error) =>
- this.logSessionIndexStatusSyncError(publicSessionId, status, session.updated_at, error)
- );
- if (isTurnSettled(status)) {
- this.syncSessionMetrics(publicSessionId);
- }
- return false;
+ )
+ .catch((error) =>
+ this.logSessionIndexStatusSyncError(publicSessionId, status, session.updated_at, error)
+ )
+ .then(() => {
+ if (isTurnSettled(status)) {
+ this.syncSessionMetrics(publicSessionId);
+ }
+ return false;
+ });
}
const updatedAt = Math.max(Date.now(), session.updated_at + 1);
this.repository.updateSessionStatus(session.id, status, updatedAt);
- await this.projectTransition(
+ return this.projectTransition(
session,
publicSessionId,
status,
updatedAt,
session.status_revision + 1
- );
-
- return true;
+ ).then(() => true);
}
/**
diff --git a/packages/control-plane/test/integration/sandbox-shutdown.test.ts b/packages/control-plane/test/integration/sandbox-shutdown.test.ts
index 73ee04e0be..63b4f2cebc 100644
--- a/packages/control-plane/test/integration/sandbox-shutdown.test.ts
+++ b/packages/control-plane/test/integration/sandbox-shutdown.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { env } from "cloudflare:test";
import type { SessionDO } from "../../src/cloudflare/durable-object";
+import { SessionStatusProjectionStore } from "../../src/db/session-status-projection-store";
import {
DEFAULT_LIFECYCLE_CONFIG,
SandboxLifecycleManager,
@@ -304,6 +305,125 @@ describe("sandbox graceful shutdown wiring", () => {
expect(await response.text()).toBe("Sandbox is being saved");
});
+ it("does not start preservation when the local archive status write fails", async () => {
+ const { stub } = await initNamedSession(`archive-status-write-failure-${Date.now()}`);
+ await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" });
+ await seedShutdown(stub, {
+ generationReady: true,
+ runtimeReady: true,
+ protocolVersion: 1,
+ lifecyclePolicy: "confirmed",
+ });
+ await queryDO(stub, "UPDATE session SET status = 'completed'");
+ const shutdownBefore = await readShutdown(stub);
+
+ await runInSessionDO(stub, async (instance, state) => {
+ state.storage.sql.exec(
+ `CREATE TRIGGER fail_archive_status BEFORE UPDATE OF status ON session
+ WHEN NEW.status = 'archived'
+ BEGIN SELECT RAISE(ABORT, 'injected archive status write failure'); END`
+ );
+ try {
+ await expect(componentsOf(instance).sessionLifecycleHandler.archive()).rejects.toThrow(
+ "injected archive status write failure"
+ );
+ } finally {
+ state.storage.sql.exec("DROP TRIGGER fail_archive_status");
+ }
+ });
+
+ expect(await readShutdown(stub)).toEqual(shutdownBefore);
+ expect(await queryDO(stub, "SELECT status FROM session")).toEqual([{ status: "completed" }]);
+ });
+
+ it("keeps an archived sandbox alive while the status projection is pending", async () => {
+ const name = `archive-pending-projection-${Date.now()}`;
+ const { stub } = await initNamedSession(name);
+ await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" });
+ await runInSessionDO(stub, (_instance, state) => {
+ state.storage.sql.exec("UPDATE sandbox SET modal_object_id = 'sb-live'");
+ });
+ await seedShutdown(stub, {
+ providerObjectId: "sb-live",
+ generationReady: true,
+ runtimeReady: true,
+ protocolVersion: 1,
+ lifecyclePolicy: "confirmed",
+ });
+ await queryDO(stub, "UPDATE session SET status = 'completed'");
+
+ // Create and release the gate inside the DO to retain its I/O context.
+ let releaseProjection: (() => void) | undefined;
+ await runInSessionDO(stub, () => {
+ const project = SessionStatusProjectionStore.prototype.project;
+ vi.spyOn(SessionStatusProjectionStore.prototype, "project").mockImplementationOnce(
+ async function (this: SessionStatusProjectionStore, ...args) {
+ await new Promise((resolve) => {
+ releaseProjection = resolve;
+ });
+ return project.call(this, ...args);
+ }
+ );
+ });
+
+ const archiving = stub.fetch("http://internal/internal/archive", { method: "POST" });
+ const archiveSettled = archiving.catch(() => undefined);
+ try {
+ await vi.waitFor(() => expect(releaseProjection).toBeTypeOf("function"));
+ expect(await queryDO(stub, "SELECT status FROM session")).toEqual([{ status: "archived" }]);
+ const { ws, response } = await openSandboxWs(name, {
+ authToken: AUTH_TOKEN,
+ sandboxId: SANDBOX_ID,
+ });
+ expect(ws).toBeNull();
+ expect(response.status).toBe(503);
+ expect(await response.text()).toBe("Sandbox is being saved");
+ expect(await readShutdown(stub)).toMatchObject({
+ phase: "draining",
+ reason: "session_archived",
+ });
+ } finally {
+ await runInSessionDO(stub, () => {
+ releaseProjection?.();
+ vi.restoreAllMocks();
+ });
+ await archiveSettled;
+ }
+
+ expect((await archiving).status).toBe(200);
+ expect(await readShutdown(stub)).toMatchObject({
+ phase: "draining",
+ reason: "session_archived",
+ });
+ });
+
+ it.each(["missing", "legacy"])(
+ "tells an archived sandbox to exit when its shutdown record is %s",
+ async (policy) => {
+ const name = `archive-unmanaged-${policy}-${Date.now()}`;
+ const { stub } = await initNamedSession(name);
+ await seedSandboxAuth(stub, {
+ authToken: AUTH_TOKEN,
+ sandboxId: SANDBOX_ID,
+ status: "ready",
+ });
+ if (policy === "legacy") {
+ await seedShutdown(stub, { lifecyclePolicy: "legacy" });
+ }
+ await queryDO(stub, "UPDATE session SET status = 'completed'");
+
+ const archived = await stub.fetch("http://internal/internal/archive", { method: "POST" });
+ expect(archived.status).toBe(200);
+ const { ws, response } = await openSandboxWs(name, {
+ authToken: AUTH_TOKEN,
+ sandboxId: SANDBOX_ID,
+ });
+ expect(ws).toBeNull();
+ expect(response.status).toBe(410);
+ expect(await response.text()).toBe("Session is terminal");
+ }
+ );
+
it("preserves a completed session status when shutdown begins between prompts", async () => {
const name = `shutdown-completed-status-${Date.now()}`;
const { stub } = await initNamedSession(name);
From 1c691ec0b4dabb0a29e23942b318a02cc4863d8a Mon Sep 17 00:00:00 2001
From: Rahul Sethuram
Date: Fri, 2 Oct 2026 23:33:23 +0400
Subject: [PATCH 04/68] fix(control-plane): keep a reserved capture while its
own deadline holds (#2019)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`ImageBuildReaper.reconcileUnresolvedOperations` can clear the
provider-operation reference of a
capture that is still running.
## Mechanism
The `absent` branch settles on a `created_at`-anchored estimate:
```ts
if (outcome.type === "absent" && now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS) {
```
while the row carries the exact bound the comment directly above it
appeals to. The two are anchored
to **different clocks**:
- `DEFAULT_STALE_BUILD_MAX_AGE_MS` is 75 min measured from `created_at`
= `registerBuild`
(`src/image-builds/maintenance.ts:20-21`,
`src/image-builds/timeouts.ts:6-13`). Its own comment
(`maintenance.ts:14-18`) says the clock "starts at row registration, not
sandbox start, so dispatch
latency and provider queueing eat into the grace budget" — that budget
is
`IMAGE_BUILD_STALE_DISPATCH_GRACE_MS`, 5 min.
- `provider_operation_deadline_at` is stamped at **reservation** time
against the source's remaining
lifetime (`src/image-builds/daytona-adapter.ts:209-223,333-339`), and is
documented as a "Fixed
wall-clock deadline (ms) for that operation; never extended by a retry"
(`src/db/image-build-finalization.ts:73-74`). The capture attempt itself
treats exhaustion as
`now >= operation.deadlineAt` (`daytona-adapter.ts:305-313`).
A build dispatched later than the 5-minute registration grace allows
therefore holds a live deadline
that outlasts the estimate. The reaper could not even see it:
`UnresolvedProviderOperationRow` had no
such field and `UNRESOLVED_PROVIDER_OPERATIONS_SQL` did not select the
column
(`src/db/image-builds.ts:179-185,227-233`).
A row reaches that query while its deadline is still live via
`supersedeScopeBuilds`, which flips
`building` → `superseded` with no regard for a reserved operation
(`src/db/image-builds.ts:786-793`),
or via `markFailed`, which deliberately leaves the ref and deadline in
place — unlike `recordArtifact`
and `quarantineArtifact`, which NULL them
(`src/db/image-build-finalization.ts:399-414` vs
`:316-322,431-438`). Clearing the reference there drops the only handle
to a capture still running.
## Fix
Select the persisted deadline and retain the obligation while **either**
bound still holds. A row
that recorded no deadline (`?? 0`) is treated as already exhausted, so
the estimate alone decides
exactly as before — no behaviour change for rows predating the column.
## Verification
`npm test -w @open-inspect/control-plane --
src/image-builds/reaper.test.ts` → 19 passed.
The three new cases were falsified against the unfixed source: older
than the max age but within a
live deadline is **not** reaped (this one fails without the fix), an
expired deadline **is** reaped,
and a null deadline keeps today's behaviour.
## Summary by CodeRabbit
- **Bug Fixes**
- Prevented operations from being settled as absent before their
reserved capture deadline has elapsed.
- Operations without a recorded deadline continue to use the existing
stale-age handling.
- Improved reconciliation behavior and reporting for operations that may
still be running.
Co-authored-by: Cole Murray
---
packages/control-plane/src/db/image-builds.ts | 4 +-
.../src/image-builds/reaper.test.ts | 46 ++++++++++++++++++-
.../control-plane/src/image-builds/reaper.ts | 20 ++++++--
3 files changed, 64 insertions(+), 6 deletions(-)
diff --git a/packages/control-plane/src/db/image-builds.ts b/packages/control-plane/src/db/image-builds.ts
index 2b56b5d270..76fafded75 100644
--- a/packages/control-plane/src/db/image-builds.ts
+++ b/packages/control-plane/src/db/image-builds.ts
@@ -181,6 +181,8 @@ export interface UnresolvedProviderOperationRow {
provider: ImageBuildProvider;
provider_session_id: string | null;
provider_operation_ref: string;
+ /** Fixed wall-clock deadline (ms) the reservation recorded; null before it existed. */
+ provider_operation_deadline_at: number | null;
created_at: number;
}
@@ -225,7 +227,7 @@ export const UNBOUND_SOURCE_INTENTS_SQL = `SELECT id, provider, created_at
* still listed here is an obligation nothing else on the row records.
*/
export const UNRESOLVED_PROVIDER_OPERATIONS_SQL = `SELECT id, provider, provider_session_id,
- provider_operation_ref, created_at
+ provider_operation_ref, provider_operation_deadline_at, created_at
FROM image_builds
WHERE status IN ('failed', 'superseded')
AND provider_operation_ref IS NOT NULL
diff --git a/packages/control-plane/src/image-builds/reaper.test.ts b/packages/control-plane/src/image-builds/reaper.test.ts
index 417368d2c9..55da17b81d 100644
--- a/packages/control-plane/src/image-builds/reaper.test.ts
+++ b/packages/control-plane/src/image-builds/reaper.test.ts
@@ -272,11 +272,16 @@ describe("ImageBuildReaper unbound source recovery", () => {
});
describe("ImageBuildReaper orphan operation reconciliation", () => {
- const operation = (id: string, createdAt: number = conclusivelyAbsentAt) => ({
+ const operation = (
+ id: string,
+ createdAt: number = conclusivelyAbsentAt,
+ deadlineAt: number | null = null
+ ) => ({
id,
provider: "daytona" as const,
provider_session_id: "sandbox-7",
provider_operation_ref: `oi-image-${id}`,
+ provider_operation_deadline_at: deadlineAt,
created_at: createdAt,
});
@@ -297,7 +302,11 @@ describe("ImageBuildReaper orphan operation reconciliation", () => {
it("settles an absent operation only once a capture can no longer be running", async () => {
const store = createStore();
- store.listUnresolvedOperations.mockResolvedValue([operation("b-1")]);
+ // No deadline recorded — every row written before the reservation column
+ // existed — so the registration-anchored estimate is the only bound.
+ store.listUnresolvedOperations.mockResolvedValue([
+ operation("b-1", conclusivelyAbsentAt, null),
+ ]);
const adapter = createRecoverableAdapter();
adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" });
const { reaper } = createReaper({ store, adapter });
@@ -324,6 +333,39 @@ describe("ImageBuildReaper orphan operation reconciliation", () => {
expect(store.clearProviderOperation).not.toHaveBeenCalled();
});
+ it("keeps an absent operation whose recorded deadline has not passed", async () => {
+ const store = createStore();
+ // The registration-anchored estimate has lapsed, but the capture was
+ // reserved late enough that its own deadline is still live: the source it
+ // reads is still there to publish a snapshot nothing else would name.
+ store.listUnresolvedOperations.mockResolvedValue([
+ operation("b-1", conclusivelyAbsentAt, now + 1),
+ ]);
+ const adapter = createRecoverableAdapter();
+ adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" });
+ const { reaper } = createReaper({ store, adapter });
+
+ const result = await reaper.reconcileUnresolvedOperations(ctx, now);
+
+ expect(result).toEqual({ reconciled: 0, retained: 1 });
+ expect(store.clearProviderOperation).not.toHaveBeenCalled();
+ });
+
+ it("settles an absent operation once its recorded deadline has passed", async () => {
+ const store = createStore();
+ // Exhausted at the deadline, not after it, exactly as the attempt that
+ // reserved it gives up.
+ store.listUnresolvedOperations.mockResolvedValue([operation("b-1", conclusivelyAbsentAt, now)]);
+ const adapter = createRecoverableAdapter();
+ adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" });
+ const { reaper } = createReaper({ store, adapter });
+
+ const result = await reaper.reconcileUnresolvedOperations(ctx, now);
+
+ expect(result).toEqual({ reconciled: 1, retained: 0 });
+ expect(store.clearProviderOperation).toHaveBeenCalledWith("b-1", "oi-image-b-1");
+ });
+
it("keeps an operation that has not settled", async () => {
const store = createStore();
store.listUnresolvedOperations.mockResolvedValue([operation("b-1")]);
diff --git a/packages/control-plane/src/image-builds/reaper.ts b/packages/control-plane/src/image-builds/reaper.ts
index 680b8df9d5..a1c47973c4 100644
--- a/packages/control-plane/src/image-builds/reaper.ts
+++ b/packages/control-plane/src/image-builds/reaper.ts
@@ -198,9 +198,23 @@ export class ImageBuildReaper {
// Finding nothing under the reserved name is not yet evidence that
// nothing was produced: the record can appear well after the capture
// was accepted. Only once the source it reads has certainly outlived
- // its hard lifetime can a later artifact no longer arrive, which is
- // the same bound an unbound create intent settles on.
- if (outcome.type === "absent" && now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS) {
+ // its hard lifetime can a later artifact no longer arrive.
+ //
+ // The obligation therefore survives while either bound on that
+ // lifetime still holds. `created_at` is the estimate an unbound
+ // create intent settles on, anchored at registration; the row's
+ // deadline is the exact bound, fixed against the source's remaining
+ // lifetime when the capture was reserved. A build dispatched later
+ // than the age rule's registration grace allows holds a live deadline
+ // past it, and clearing the reference there would drop the only
+ // handle to a capture still running. A row that recorded no deadline
+ // is treated as already exhausted, so the estimate alone decides
+ // exactly as before.
+ if (
+ outcome.type === "absent" &&
+ (now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS ||
+ now < (row.provider_operation_deadline_at ?? 0))
+ ) {
this.retainOperation(row, result, ctx, now, "capture_may_still_be_running");
return;
}
From 864525d96e38aae186d3fe0dce3c57278e266ff1 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Fri, 2 Oct 2026 17:39:15 -0700
Subject: [PATCH 05/68] feat: add session source and user attribution analytics
(#2218)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Add a **Session origins** card directly below the Analytics session
summary, showing ranked source counts and shares for Slack, GitHub,
Linear, user/app, agent sub-sessions, and automations.
- Select a source to see its attributed users, session counts, and
within-source shares; reset to all sources or change the existing
date/scope controls.
- Extend the existing dashboard snapshot with source-by-user counts in
its single database batch. Reuse date-window, source-scope, and
visibility predicates; private sessions remain excluded.
- Preserve canonical user identities, separate same-name users, and
include legacy-login and unknown-user buckets. Explain attribution
limitations in the UI and correct outdated summary/scope copy.
- Include responsive layouts, accessible source controls, a
keyboard-focusable user list, loading/empty/cached states, and selection
resets.
## Scope and Data Semantics
This measures **session creation origins**, not subsequent message
activity. Existing attribution can identify integration actors or
automation owners rather than a direct human creator. Historical source
defaults remain under User / app; no speculative backfill or schema
migration is introduced. The existing Human default scope is retained;
All includes agent and automation sessions.
## Verification
- Shared package build passed.
- Control-plane and web typechecks passed.
- Repository ESLint and SQL portability checks passed.
- Targeted web analytics tests: **38 passed**.
- Targeted control-plane analytics tests: **54 passed**.
- Real-D1 analytics integration tests: **16 passed**, including
source/user grouping, all scopes, exact date boundaries, legacy/unknown
attribution, and visibility.
- Browser-tested the real `/analytics` page with mocked auth/API
responses at **1440×1100** and **390×844**: source filtering, All
sources reset, date/scope resets, and all six source categories. No
browser errors. Backend correctness was checked separately with real D1.
- Reviewed changes and fixed keyboard scrolling and
stale-source-selection issues. `git diff --check` passed.
## Visual Evidence
Viewport screenshots uploaded to the Open-Inspect session (mocked data,
`http://localhost:3000/analytics`):
- Desktop, all sources, 1440×1100: artifact
`a10c57ac73c91449d85436c35bad1030`
- Mobile, Slack selected, 390×844: artifact
`f82481b838e0b10c8e664783129d2d62`
---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/6b804adda7bf204945bdb55de6c5cc80)*
## Summary by CodeRabbit
* **New Features**
* Added a Session Origins card to analytics, showing session counts and
percentages by source and attributed user.
* Added source filtering, with the selection resetting when the date
range or scope changes and reverting to all sources if the selected
source is no longer available.
* Session origin data follows the selected date range and scope, and
includes unattributed sessions.
* Expanded active-user attribution to include legacy logins.
---------
Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude
---
.../src/db/analytics-dashboard-store.test.ts | 21 +-
.../src/db/analytics-dashboard-store.ts | 3 +
.../src/db/analytics-store.test.ts | 48 +++++
.../control-plane/src/db/analytics-store.ts | 57 ++++-
.../src/routes/analytics.test.ts | 8 +-
.../test/integration/analytics.test.ts | 114 ++++++++++
packages/shared/src/types/analytics.ts | 9 +
packages/shared/src/types/index.ts | 1 +
.../(app)/(sidebar)/analytics/page.test.tsx | 27 +++
.../app/(app)/(sidebar)/analytics/page.tsx | 11 +-
.../analytics/session-origins-card.test.tsx | 122 +++++++++++
.../analytics/session-origins-card.tsx | 196 ++++++++++++++++++
.../components/analytics/summary-cards.tsx | 2 +-
packages/web/src/hooks/use-analytics.test.tsx | 2 +
packages/web/src/hooks/use-analytics.ts | 1 +
15 files changed, 610 insertions(+), 12 deletions(-)
create mode 100644 packages/web/src/components/analytics/session-origins-card.test.tsx
create mode 100644 packages/web/src/components/analytics/session-origins-card.tsx
diff --git a/packages/control-plane/src/db/analytics-dashboard-store.test.ts b/packages/control-plane/src/db/analytics-dashboard-store.test.ts
index a5bf5c7f06..ee6c03cd3e 100644
--- a/packages/control-plane/src/db/analytics-dashboard-store.test.ts
+++ b/packages/control-plane/src/db/analytics-dashboard-store.test.ts
@@ -43,7 +43,12 @@ describe("AnalyticsDashboardStore", () => {
},
],
};
- if (index === 18)
+ if (index === 8)
+ return {
+ ...emptyResult(),
+ results: [{ source: "agent", user_key: "user-1", display_name: "Ada", sessions: 2 }],
+ };
+ if (index === 19)
return {
...emptyResult(),
results: [
@@ -101,16 +106,21 @@ describe("AnalyticsDashboardStore", () => {
});
expect(batch).toHaveBeenCalledTimes(1);
- expect(statements).toHaveLength(19);
- expect(batchedStatements).toHaveLength(19);
+ expect(statements).toHaveLength(20);
+ expect(batchedStatements).toHaveLength(20);
expect(batchedStatements.every((statement) => statements.includes(statement))).toBe(true);
- expect(queries[18]).toContain("root.spawn_source IN (?)");
- expect(statements[18].bind).toHaveBeenCalledWith(
+ expect(queries[19]).toContain("root.spawn_source IN (?)");
+ expect(statements[19].bind).toHaveBeenCalledWith(
1_699_395_200_000,
1_700_000_000_000,
"agent",
DASHBOARD_RUNS_LIMIT
);
+ expect(batchedStatements[8].bind).toHaveBeenCalledWith(
+ 1_699_395_200_000,
+ 1_700_000_000_000,
+ "agent"
+ );
expect(response).toMatchObject({
generatedAt: 1_700_000_000_000,
window: {
@@ -120,6 +130,7 @@ describe("AnalyticsDashboardStore", () => {
endAt: 1_700_000_000_000,
},
summary: { totalSessions: 0, totalPrs: 0 },
+ sessionOrigins: [{ source: "agent", userKey: "user-1", displayName: "Ada", sessions: 2 }],
breakdowns: {
repository: { entries: [] },
user: { entries: [] },
diff --git a/packages/control-plane/src/db/analytics-dashboard-store.ts b/packages/control-plane/src/db/analytics-dashboard-store.ts
index 7c387a7402..27166bf916 100644
--- a/packages/control-plane/src/db/analytics-dashboard-store.ts
+++ b/packages/control-plane/src/db/analytics-dashboard-store.ts
@@ -51,6 +51,7 @@ export class AnalyticsDashboardStore {
harness,
automation,
billing,
+ sessionOrigins,
...pullRequestAndRunResults
] = await this.db.batch([
analytics.prepareSummary(sessionFilters),
@@ -61,6 +62,7 @@ export class AnalyticsDashboardStore {
analytics.prepareBreakdown(sessionFilters, "harness"),
analytics.prepareBreakdown(sessionFilters, "automation"),
billingStatement,
+ analytics.prepareSessionOrigins(sessionFilters),
...pullRequestStatements,
runs.prepareList({ ...sessionFilters, limit: DASHBOARD_RUNS_LIMIT, orderBy: "cost" }),
]);
@@ -78,6 +80,7 @@ export class AnalyticsDashboardStore {
},
summary: analytics.decodeSummary(summary),
timeseries: analytics.decodeTimeseries(timeseries),
+ sessionOrigins: analytics.decodeSessionOrigins(sessionOrigins),
breakdowns: {
repository: analytics.decodeBreakdown(repository, "repo"),
user: analytics.decodeBreakdown(user, "user"),
diff --git a/packages/control-plane/src/db/analytics-store.test.ts b/packages/control-plane/src/db/analytics-store.test.ts
index aeb4bce5c3..7c4ef82a18 100644
--- a/packages/control-plane/src/db/analytics-store.test.ts
+++ b/packages/control-plane/src/db/analytics-store.test.ts
@@ -128,6 +128,54 @@ describe("AnalyticsStore row decoding", () => {
);
});
+ it("decodes session origins without merging users or sources", () => {
+ expect(
+ store.decodeSessionOrigins(
+ result([
+ { source: "user", user_key: "user-1", display_name: "Ada", sessions: 2 },
+ { source: "user", user_key: "user-2", display_name: "Ada", sessions: 1 },
+ { source: "slack-bot", user_key: "user-1", display_name: "Ada", sessions: 3 },
+ { source: "agent", user_key: "old-login", display_name: "old-login", sessions: 1 },
+ {
+ source: "automation",
+ user_key: "__unknown__",
+ display_name: "Unknown user",
+ sessions: 1,
+ },
+ ])
+ )
+ ).toEqual([
+ { source: "user", userKey: "user-1", displayName: "Ada", sessions: 2 },
+ { source: "user", userKey: "user-2", displayName: "Ada", sessions: 1 },
+ { source: "slack-bot", userKey: "user-1", displayName: "Ada", sessions: 3 },
+ { source: "agent", userKey: "old-login", displayName: "old-login", sessions: 1 },
+ { source: "automation", userKey: "__unknown__", displayName: "Unknown user", sessions: 1 },
+ ]);
+ expect(store.decodeSessionOrigins(result([]))).toEqual([]);
+ });
+
+ it.each([
+ { source: "invalid" },
+ { source: null },
+ { user_key: null },
+ { display_name: undefined },
+ { sessions: "2" },
+ ])("rejects malformed session origin fields: %j", (overrides) => {
+ expect(() =>
+ store.decodeSessionOrigins(
+ result([
+ {
+ source: "user",
+ user_key: "user-1",
+ display_name: "Ada",
+ sessions: 2,
+ ...overrides,
+ },
+ ])
+ )
+ ).toThrow("Invalid analytics session origin row");
+ });
+
it("decodes nullable breakdown fields", () => {
expect(
store.decodeBreakdown(
diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts
index f3b762773e..73b9d670d6 100644
--- a/packages/control-plane/src/db/analytics-store.ts
+++ b/packages/control-plane/src/db/analytics-store.ts
@@ -3,6 +3,7 @@ import type {
AnalyticsBreakdownBy,
AnalyticsBreakdownEntry,
AnalyticsBreakdownResponse,
+ AnalyticsSessionOriginEntry,
AnalyticsSummaryResponse,
AnalyticsTimeseriesResponse,
AnalyticsScope,
@@ -12,7 +13,7 @@ import {
ANALYTICS_SCOPE_SPAWN_SOURCES,
getCacheHitRatio,
} from "@open-inspect/shared/types/analytics";
-import type { SpawnSource } from "@open-inspect/shared/types/sessions";
+import { spawnSourceSchema, type SpawnSource } from "@open-inspect/shared/types/sessions";
import {
getModelDisplayName,
normalizeModelId,
@@ -83,6 +84,13 @@ const timeseriesRowSchema = z.object({
type TimeseriesRow = z.infer;
+const sessionOriginRowSchema = z.object({
+ source: spawnSourceSchema,
+ user_key: z.string(),
+ display_name: z.string(),
+ sessions: z.number(),
+});
+
const breakdownRowSchema = tokenRowSchema.extend({
key: z.string().nullable(),
display_name: z.string().nullable().optional(),
@@ -111,6 +119,9 @@ type BreakdownRow = z.infer;
type SqlBreakdownBy = Exclude;
const NO_REPOSITORY_ANALYTICS_KEY = "No repository";
+const USER_KEY_EXPRESSION = "COALESCE(s.user_id, NULLIF(s.scm_login, ''), '__unknown__')";
+const USER_DISPLAY_NAME_EXPRESSION =
+ "COALESCE(MAX(NULLIF(u.display_name, '')), MAX(NULLIF(s.scm_login, '')), 'Unknown user')";
export function mergeBreakdownEntries(
entries: AnalyticsBreakdownEntry[],
@@ -294,6 +305,46 @@ export class AnalyticsStore {
return { series };
}
+ prepareSessionOrigins(filters: AnalyticsFilters): SqlStatement {
+ const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source");
+ const visible = this.visible("s");
+
+ return this.db
+ .prepare(
+ `WITH filtered_sessions AS (
+ SELECT s.spawn_source, s.user_id, s.scm_login, ${USER_KEY_EXPRESSION} AS user_key
+ FROM sessions s
+ WHERE s.created_at >= ? AND s.created_at < ?
+ ${sql} ${visible.sql ? `AND ${visible.sql}` : ""}
+ ), user_labels AS (
+ SELECT s.user_key, ${USER_DISPLAY_NAME_EXPRESSION} AS display_name
+ FROM filtered_sessions s
+ LEFT JOIN users u ON s.user_id = u.id
+ GROUP BY s.user_key
+ )
+ SELECT s.spawn_source AS source,
+ s.user_key,
+ u.display_name,
+ COUNT(*) AS sessions
+ FROM filtered_sessions s
+ JOIN user_labels u ON s.user_key = u.user_key
+ GROUP BY s.spawn_source, s.user_key, u.display_name
+ ORDER BY sessions DESC, source ASC, u.display_name ASC, s.user_key ASC`
+ )
+ .bind(filters.startAt, filters.endAt, ...binds, ...visible.params);
+ }
+
+ decodeSessionOrigins(result: SqlResult): AnalyticsSessionOriginEntry[] {
+ return parseRows(result.results, sessionOriginRowSchema, "analytics session origin row").map(
+ (row) => ({
+ source: row.source,
+ userKey: row.user_key,
+ displayName: row.display_name,
+ sessions: row.sessions,
+ })
+ );
+ }
+
async getBreakdown(
filters: AnalyticsFilters,
by: AnalyticsBreakdownBy
@@ -331,7 +382,7 @@ export class AnalyticsStore {
"CASE WHEN s.repo_owner IS NULL OR s.repo_name IS NULL THEN NULL ELSE s.repo_owner || '/' || s.repo_name END";
const groupExpression = {
- user: "COALESCE(s.user_id, NULLIF(s.scm_login, ''), '__unknown__')",
+ user: USER_KEY_EXPRESSION,
repo: repoGroupExpression,
model: "s.model",
harness: "s.harness",
@@ -340,7 +391,7 @@ export class AnalyticsStore {
}[by];
const displayNameSelect = isUserBreakdown
- ? "COALESCE(MAX(NULLIF(u.display_name, '')), MAX(NULLIF(s.scm_login, '')), 'Unknown user') AS display_name,"
+ ? `${USER_DISPLAY_NAME_EXPRESSION} AS display_name,`
: by === "automation"
? "MAX(a.name) AS display_name,"
: "NULL AS display_name,";
diff --git a/packages/control-plane/src/routes/analytics.test.ts b/packages/control-plane/src/routes/analytics.test.ts
index f20503cc6e..37b7fa5269 100644
--- a/packages/control-plane/src/routes/analytics.test.ts
+++ b/packages/control-plane/src/routes/analytics.test.ts
@@ -84,12 +84,16 @@ describe("analytics route handlers", () => {
describe("dashboard", () => {
it("anchors one shared dashboard window", async () => {
- mockDashboardStore.get.mockResolvedValue({ generatedAt: FIXED_NOW });
+ const dashboard = {
+ generatedAt: FIXED_NOW,
+ sessionOrigins: [{ source: "user", userKey: "user-1", displayName: "Ada", sessions: 2 }],
+ };
+ mockDashboardStore.get.mockResolvedValue(dashboard);
const response = await callRoute("GET", "/analytics/dashboard?days=14");
expect(response.status).toBe(200);
- await expect(response.json()).resolves.toEqual({ generatedAt: FIXED_NOW });
+ await expect(response.json()).resolves.toEqual(dashboard);
expect(mockDashboardStore.get).toHaveBeenCalledWith({
days: 14,
scope: "human",
diff --git a/packages/control-plane/test/integration/analytics.test.ts b/packages/control-plane/test/integration/analytics.test.ts
index 62efcd4d42..fcbbef433f 100644
--- a/packages/control-plane/test/integration/analytics.test.ts
+++ b/packages/control-plane/test/integration/analytics.test.ts
@@ -3,6 +3,7 @@ import { createExecutionContext, env } from "cloudflare:test";
import type {
AnalyticsBreakdownResponse,
AnalyticsDashboardResponse,
+ AnalyticsSessionOriginEntry,
AnalyticsSummaryResponse,
AnalyticsTokenTotals,
AnalyticsTimeseriesResponse,
@@ -13,6 +14,7 @@ import { SessionIndexStore } from "../../src/db/session-index";
import { TeamMembershipStore } from "../../src/db/team-memberships";
import { SessionRunStore } from "../../src/db/session-run-store";
import { AnalyticsStore } from "../../src/db/analytics-store";
+import { AnalyticsDashboardStore } from "../../src/db/analytics-dashboard-store";
import { cleanD1Tables } from "./cleanup";
import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers";
@@ -143,6 +145,9 @@ describe("Analytics API", () => {
await serviceFetch("https://test.local/analytics/dashboard?scope=all", owner)
).json();
expect(dashboard.summary).toMatchObject({ totalCost: 3, privateSessionsCostUsd: 7 });
+ expect(dashboard.sessionOrigins).toEqual([
+ { source: "user", userKey: "alice", displayName: "alice", sessions: 1 },
+ ]);
const member = await (
await serviceFetch("https://test.local/analytics/summary?scope=all", {
as: { userId: "55555555555555555555555555555555", role: "member" },
@@ -222,6 +227,9 @@ describe("Analytics API", () => {
inputTokens: 3,
privateSessionsCostUsd: null,
});
+ expect(dashboard.sessionOrigins).toEqual([
+ expect.objectContaining({ source: "user", userKey: member, sessions: 2 }),
+ ]);
expect(
dashboard.timeseries.series
.flatMap((point) => Object.values(point.groups))
@@ -250,6 +258,111 @@ describe("Analytics API", () => {
expect(
await (await fetchAnalytics("summary?scope=all", "off")).json()
).toMatchObject({ totalSessions: 3, totalCost: 7, privateSessionsCostUsd: null });
+ const unenforced = await (
+ await fetchAnalytics("dashboard?scope=all", "off")
+ ).json();
+ expect(unenforced.sessionOrigins).toEqual([
+ expect.objectContaining({ source: "user", userKey: member, sessions: 3 }),
+ ]);
+ });
+
+ it("groups session origins by source and user identity within the exact scope and date window", async () => {
+ const endAt = Date.now();
+ const startAt = endAt - 7 * 24 * 60 * 60 * 1000;
+ const index = new SessionIndexStore(env.DB);
+ await seedUser(env.DB, { id: "origin-user-1", displayName: "Same name" });
+ await seedUser(env.DB, { id: "origin-user-2", displayName: "Same name" });
+ await seedUser(env.DB, { id: "origin-user-3", displayName: "" });
+
+ for (const [id, source, userId, scmLogin, createdAt] of [
+ ["start", "user", "origin-user-1", "old-login", startAt],
+ ["renamed", "user", "origin-user-1", "new-login", startAt + 1],
+ ["same-name", "user", "origin-user-2", "another-login", startAt + 1],
+ ["slack", "slack-bot", "origin-user-1", "new-login", startAt + 1],
+ ["linear", "linear-bot", "origin-user-1", "new-login", startAt + 1],
+ ["github", "github-bot", "origin-user-1", "new-login", startAt + 1],
+ ["agent", "agent", "origin-user-1", "new-login", startAt + 1],
+ ["automation", "automation", "origin-user-1", "new-login", endAt - 1],
+ ["historical", "user", null, "old-login", startAt + 1],
+ ["historical-repeat", "user", null, "old-login", startAt + 1],
+ ["historical-other", "user", null, "other-login", startAt + 1],
+ ["no-name", "user", "origin-user-3", "fallback-login", startAt + 1],
+ ["no-name-slack", "slack-bot", "origin-user-3", "other-fallback-login", startAt + 1],
+ ["no-name-before", "user", "origin-user-3", "zzz-outside-window", startAt - 1],
+ ["unknown-null", "user", null, null, startAt + 1],
+ ["unknown-empty", "user", null, "", startAt + 1],
+ ["before", "user", "origin-user-1", "new-login", startAt - 1],
+ ["end", "user", "origin-user-1", "new-login", endAt],
+ ["future", "user", "origin-user-1", "new-login", endAt + 1],
+ ] as const) {
+ await seedSession(index, {
+ id,
+ spawnSource: source,
+ userId,
+ scmLogin,
+ createdAt,
+ updatedAt: endAt,
+ repoOwner: null,
+ repoName: null,
+ status: "completed",
+ totalCost: 0,
+ activeDurationMs: 0,
+ messageCount: 0,
+ prCount: 0,
+ });
+ }
+
+ const origins: AnalyticsSessionOriginEntry[] = [
+ { source: "user", userKey: "origin-user-1", displayName: "Same name", sessions: 2 },
+ { source: "user", userKey: "origin-user-2", displayName: "Same name", sessions: 1 },
+ { source: "slack-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 },
+ { source: "linear-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 },
+ { source: "github-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 },
+ { source: "agent", userKey: "origin-user-1", displayName: "Same name", sessions: 1 },
+ { source: "automation", userKey: "origin-user-1", displayName: "Same name", sessions: 1 },
+ { source: "user", userKey: "old-login", displayName: "old-login", sessions: 2 },
+ { source: "user", userKey: "other-login", displayName: "other-login", sessions: 1 },
+ {
+ source: "user",
+ userKey: "origin-user-3",
+ displayName: "other-fallback-login",
+ sessions: 1,
+ },
+ {
+ source: "slack-bot",
+ userKey: "origin-user-3",
+ displayName: "other-fallback-login",
+ sessions: 1,
+ },
+ { source: "user", userKey: "__unknown__", displayName: "Unknown user", sessions: 2 },
+ ];
+ const dashboard = new AnalyticsDashboardStore(env.DB, { kind: "service", teamId: null }, "on");
+ for (const [scope, sources] of [
+ ["human", ["user", "slack-bot", "linear-bot", "github-bot"]],
+ ["agent", ["agent"]],
+ ["automation", ["automation"]],
+ ["all", ["user", "slack-bot", "linear-bot", "github-bot", "agent", "automation"]],
+ ] as const) {
+ const snapshot = await dashboard.get({ days: 7, startAt, endAt, scope });
+ const expected = origins.filter((entry) => sources.some((source) => source === entry.source));
+ expect(snapshot.sessionOrigins).toHaveLength(expected.length);
+ expect(snapshot.sessionOrigins).toEqual(expect.arrayContaining(expected));
+ expect(snapshot.summary.totalSessions).toBe(
+ expected.reduce((sum, entry) => sum + entry.sessions, 0)
+ );
+ for (const user of snapshot.breakdowns.user.entries) {
+ for (const origin of snapshot.sessionOrigins.filter(
+ (entry) => entry.userKey === user.key
+ )) {
+ expect(origin.displayName).toBe(user.displayName);
+ }
+ expect(user.sessions).toBe(
+ snapshot.sessionOrigins
+ .filter((entry) => entry.userKey === user.key)
+ .reduce((sum, entry) => sum + entry.sessions, 0)
+ );
+ }
+ }
});
it("sums token totals across sessions and provider merges without excluding zero-token history", async () => {
@@ -378,6 +491,7 @@ describe("Analytics API", () => {
expect(body).toMatchObject({
summary: { totalSessions: 0, totalPrs: 0 },
timeseries: { series: [] },
+ sessionOrigins: [],
breakdowns: {
repository: { entries: [] },
user: { entries: [] },
diff --git a/packages/shared/src/types/analytics.ts b/packages/shared/src/types/analytics.ts
index c8258fbb75..aec376bab8 100644
--- a/packages/shared/src/types/analytics.ts
+++ b/packages/shared/src/types/analytics.ts
@@ -107,6 +107,14 @@ export interface AnalyticsBreakdownResponse {
entries: AnalyticsBreakdownEntry[];
}
+export interface AnalyticsSessionOriginEntry {
+ source: SpawnSource;
+ /** Canonical user ID, legacy SCM login, or __unknown__; not necessarily a human creator. */
+ userKey: string;
+ displayName: string;
+ sessions: number;
+}
+
/** Sessions in one root_session_id family, attributed to its visible root. */
export interface SessionRun {
rootSessionId: string;
@@ -221,6 +229,7 @@ export interface AnalyticsDashboardResponse {
};
summary: AnalyticsSummaryResponse;
timeseries: AnalyticsTimeseriesResponse;
+ sessionOrigins: AnalyticsSessionOriginEntry[];
breakdowns: {
repository: AnalyticsBreakdownResponse;
user: AnalyticsBreakdownResponse;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index fb09614434..3431c4ce48 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -410,6 +410,7 @@ export type {
AnalyticsTimeseriesResponse,
AnalyticsBreakdownEntry,
AnalyticsBreakdownResponse,
+ AnalyticsSessionOriginEntry,
SessionRun,
AnalyticsRunsResponse,
AnalyticsPullRequestFunnel,
diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx
index c582caa8c8..281b8757b6 100644
--- a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx
+++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx
@@ -223,6 +223,33 @@ function getUserRows() {
}
describe("AnalyticsPage", () => {
+ it("renders session origins and resets the local source selection on range and scope changes", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ mockUseAnalyticsDashboard.mockReturnValue({
+ summary,
+ sessionOrigins: [{ source: "slack-bot", userKey: "zoe", displayName: "Zoe", sessions: 8 }],
+ loading: false,
+ });
+ await user.click(screen.getByRole("radio", { name: "7d" }));
+ let origins = within(screen.getByRole("region", { name: "Session origins" }));
+ await user.click(origins.getByRole("button", { name: /Slack/ }));
+ expect(origins.getByRole("button", { name: /Slack/ })).toHaveAttribute("aria-pressed", "true");
+ await user.click(screen.getByRole("radio", { name: "14d" }));
+ origins = within(screen.getByRole("region", { name: "Session origins" }));
+ expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ await user.click(origins.getByRole("button", { name: /Slack/ }));
+ await user.click(screen.getByRole("radio", { name: "All" }));
+ origins = within(screen.getByRole("region", { name: "Session origins" }));
+ expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ });
+
it("shows automation only for automation and all scopes and orders the new views", async () => {
const user = userEvent.setup();
renderPage();
diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx
index 7b4694ef49..4229eeaf86 100644
--- a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx
+++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx
@@ -17,6 +17,7 @@ import { AnalyticsPullRequestRepoTable } from "@/components/analytics/pull-reque
import { AnalyticsRepoBarChart } from "@/components/analytics/repo-bar-chart";
import { AnalyticsRunsTable } from "@/components/analytics/runs-table";
import { AnalyticsSummaryCards } from "@/components/analytics/summary-cards";
+import { AnalyticsSessionOriginsCard } from "@/components/analytics/session-origins-card";
import { AnalyticsTimeseriesChart } from "@/components/analytics/timeseries-chart";
import { AnalyticsTokenCards } from "@/components/analytics/token-cards";
import { AnalyticsUserTable } from "@/components/analytics/user-table";
@@ -44,6 +45,7 @@ export default function AnalyticsPage() {
const [sortDirection, setSortDirection] = useState("desc");
const {
summary,
+ sessionOrigins,
timeseries,
repoBreakdown,
userBreakdown,
@@ -64,6 +66,7 @@ export default function AnalyticsPage() {
);
const hasCachedData = Boolean(
summary ||
+ sessionOrigins?.length ||
timeseries?.series?.length ||
repoBreakdown?.entries?.length ||
sortedUserEntries?.length ||
@@ -189,7 +192,7 @@ export default function AnalyticsPage() {
- Human: sessions people started, including via Slack, Linear and GitHub. Agents:
+ Human: user/app and integration sessions (Slack, Linear and GitHub). Agents:
sessions spawned by other sessions. Automations: sessions started by
automations. All: every session.
+ Counts sessions created in the selected range and scope, including drafts, failures and
+ archived sessions. Private sessions are excluded. Sources describe creation, not later
+ messages or interactions. User / app includes user-authenticated creation and historical
+ sessions whose source defaulted to user; it does not mean browser-only usage.
+
+
+ Users reflect recorded attribution: the requesting actor, the attributed user for an agent
+ sub-session, or the automation owner or manual triggerer. Integration actors are not
+ always people. Older sessions may use a separate legacy login or have no recorded user.
+
urllib3
is raising ~$40,000 USD to release HTTP/2 support and ensure
long-term sustainable maintenance of the project. If your company or
organization uses Python and would benefit from HTTP/2 support in
Requests, pip, cloud SDKs, and thousands of other projects please consider contributing
financially to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.
Thank you for your support.
Security
Fixed the following security issues:
The TLS configuration for HTTPS proxies could be ignored or
overridden. (High severity, GHSA-8988-9cw3-xx77)
HTTPResponse.stream() and read_chunked()
could buffer a chunk-size line of unbounded length in memory. (High
severity, GHSA-vxq7-64xx-v4gw)
Chunked Deflate streaming could enter an infinite loop. (Medium
severity, GHSA-gh4c-6fx4-qh6g)
[!IMPORTANT]
urllib3 2.8.0 fixes HTTPS proxy TLS configuration being ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.
Configure proxy CA certificates and client certificates in
proxy_ssl_context, and proxy identity checks with
proxy_assert_hostname or
proxy_assert_fingerprint. Destination client certificates
and identity overrides no longer apply to HTTPS forwarding proxy
connections.
[!NOTE]
CVE IDs had not yet been assigned to these advisories at the time of
release due to a backlog at GitHub's CNA.
Deprecations & Removals
Deprecated using an empty collection as the Retry
option allowed_methods to retry any verb. (#5044)
Features
Added Url.auth_decoded and
Url.auth_decoded_joined convenience properties to the
result of parse_url(). (#4945)
Added basic_auth_encoding and
proxy_basic_auth_encoding parameters to
urllib3.util.make_headers(). (#5092)
Bugfixes
Fixed response header handling to replace obsolete folded header
lines (obs-fold) with spaces in accordance with RFC 9112,
preventing raw CRLF sequences from appearing in header values such as
Set-Cookie. (#1362)
Fixed usage of proxy_ssl_context with
ProxyManager when
use_forwarding_for_https=True. Passing
ssl_context instead of proxy_ssl_context for
HTTPS proxies in this configuration now emits a
FutureWarning and will raise an error in v3.0. (#2577)
Changed behavior of the default ConnectionPool.pool
initialization. LifoQueue is now resolved from the
queue module after the ConnectionPool is
instantiated instead of using the default cached QueueCls
class property. This is done because sometimes the
queue.LifoQueue is monkey-patched late in the program, such
as by gevent. (#3289)
Raised UnrewindableBodyError instead of
ValueError when retrying a request whose body had
tell() but not seek(). (#3779)
Decoded percent-encoded SOCKS proxy credentials before authenticating
with the proxy server. (#3785)
Fixed HTTPResponse.drain_conn() to discard unread
response data in 64 KiB chunks (same as the default amt
when doing HTTPResponse.stream(...)). (#5019)
Fixed is_ipaddress() to detect non-standard IPv4 forms
accepted by socket.connect, such as hex
(0x7f000001), octal (0177.0.0.1), and decimal
integers (2130706433), ensuring SSL certificate
verification uses the correct mode for these addresses. (#5029)
Fixed HTTPConnectionPool.urlopen raising a misleading
FullPoolError instead of ValueError when
called with an invalid timeout argument on a pool created
with block=True. (#5059)
Fixed port-zero handling to preserve explicit :0 values
instead of substituting the default ports 80 or 443 in URL parsing, pool
selection, proxy configuration, connection_from_url(), and
HTTP/2 request authority. (#5071,
#5101)
Fixed a bug where PoolManager passed the
assert_hostname and assert_fingerprint
parameters to HTTP connection pools. (#5077)
Fixed HTTPConnectionPool.urlopen() and HTTP proxy
forwarding to strip URL fragments from absolute request targets before
sending requests. (#5079)
Added safeguards to the proxy tunneling code to prevent potential
security issues when handling invalid characters in the proxy host and
HTTP headers. This change affects users of Python 3.10, Python 3.11, and
Python 3.12 when the standard library does not contain the fix; those on
newer Python versions should upgrade to 3.13.14+ or 3.14.5+ to get the
same security fixes. (#5091)
Fixed HTTPSConnection.connect() overriding
ProxyConfig.ssl_context's certificate policy and proxy
identity checks with the target connection's TLS settings when
forwarding through an HTTPS proxy.
HTTPSConnection no longer applies target SNI,
assertions, or client credentials to forwarding proxy handshakes and
continues to use its ssl_context as a fallback when an
HTTPS proxy forwards an HTTP target. (#5093)
Fixed URL parsing to more strictly enforce RFC 3986 host syntax,
rejecting invalid host input such as raw spaces and control characters,
malformed percent-encodings, and percent-encoded control characters in
HTTP(S) hosts and IPv6 zone identifiers, including proxy CONNECT tunnel
targets. Host normalization now also follows RFC 3986 normalization
rules for percent-encoded octets by decoding percent-encoded unreserved
characters and uppercasing the hexadecimal digits of retained
percent-encoded octets. (#5095)
The TLS configuration for HTTPS proxies could be ignored or
overridden.
(High severity, GHSA-8988-9cw3-xx77
<https://github.com/urllib3/urllib3/security/advisories/GHSA-8988-9cw3-xx77>__)
HTTPResponse.stream() and read_chunked()
could buffer a chunk-size
line of unbounded length in memory. (High severity,
GHSA-vxq7-64xx-v4gw
<https://github.com/urllib3/urllib3/security/advisories/GHSA-vxq7-64xx-v4gw>__)
Chunked Deflate streaming could enter an infinite loop. (Medium
severity,
GHSA-gh4c-6fx4-qh6g
<https://github.com/urllib3/urllib3/security/advisories/GHSA-gh4c-6fx4-qh6g>__)
.. caution::
urllib3 2.8.0 fixes HTTPS proxy TLS configuration being
ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.
Configure proxy CA certificates and client certificates in
proxy_ssl_context, and proxy identity checks with
proxy_assert_hostname or
proxy_assert_fingerprint.
Destination client certificates and identity overrides no longer
apply to HTTPS forwarding proxy connections.
Deprecations & Removals
Deprecated using an empty collection as the Retry
option
allowed_methods to retry any verb.
([#5044](https://github.com/urllib3/urllib3/issues/5044)
<https://github.com/urllib3/urllib3/issues/5044>__)
Features
Added Url.auth_decoded and
Url.auth_decoded_joined convenience
properties to the result of parse_url().
([#4945](https://github.com/urllib3/urllib3/issues/4945)
<https://github.com/urllib3/urllib3/issues/4945>__)
Added basic_auth_encoding and
proxy_basic_auth_encoding parameters to
urllib3.util.make_headers().
([#5092](https://github.com/urllib3/urllib3/issues/5092)
<https://github.com/urllib3/urllib3/issues/5092>__)