Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andluke 4f96142112 test(app): read expanded turns across the virtual window
Verify every command remains accessible while scrolling and after older history loads. Requiring all expanded rows in the DOM at once contradicts bounded desktop mounting.

Co-authored-by: luke <luopeihuan@gmail.com>
2026-11-03 10:04:10 +00:00
luke a5fa06b80b fix(app): virtualize chat turns that exceed the mounted window
- Desktop web aligns the mounted tail to the current turn, so one long tool run stayed fully mounted and stalled the chat open.
- Cap that rewind at 60 rows for the web virtualizer. A larger minimum still wins, and history paging plus the replica cache still keep a whole turn.
2026-11-03 16:16:32 +08:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
Mohamed Boudra e546497a21 fix(workspaces): keep merged pull requests visible
* fix(workspaces): keep merged pull requests visible

A remote update before merge can leave the checkout behind the PR head. Match terminal requests by branch for display and require an exact fresh head match before automatic archive.

* fix(forges): retain pull requests targeting a fork

A fork checkout can have its own pull request as well as one targeting its parent. Compare both candidates before choosing which request to display.

* fix(forges): rank closed drafts as terminal

GitHub retains the draft flag after closing a pull request. Selection must use the request state so a closed draft does not override an open or exact-head request.
2026-11-03 13:43:02 +07:00
24 changed files with 544 additions and 61 deletions
+8 -12
View File
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Current change-request lookup matches the branch and repository for both open and
terminal requests. Prefer an open request, then a terminal request at the local
HEAD, then the most recently updated terminal request. A reused branch can show
its previous request until a new one opens. Auto-archive separately requires an
observed open-to-merged transition and a fresh exact match between checkout HEAD
and the PR head, along with the existing workspace safety checks.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Keep checkout HEAD in adapter cache and poll identities so checkout changes
refresh the facts used by archive eligibility.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
+36 -3
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
waitForTimelineGeometryToSettle,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
@@ -407,6 +408,38 @@ async function seedWholeTurnHistory() {
return agent;
}
/** Read every command across the scroll window, without requiring offscreen rows to mount. */
async function expectExpandedCommandsReadable(page: Page, count: number) {
const timeline = page.getByTestId("agent-chat-scroll");
const commands = new Set<number>();
await timeline.hover();
for (let step = 0; step < count && commands.size < count; step += 1) {
const labels = await timeline
.getByRole("button", { name: /echo stress-\d+/ })
.allTextContents();
for (const label of labels) {
const command = /echo stress-(\d+)/.exec(label);
if (command) commands.add(Number(command[1]));
}
if (commands.size < count) {
await page.mouse.wheel(0, 300);
await waitForTimelineGeometryToSettle(page);
}
}
expect([...commands].sort((a, b) => a - b)).toEqual(
Array.from({ length: count }, (_, index) => index),
);
}
async function returnToExpandedSummary(page: Page, summary: Locator) {
await page.getByTestId("agent-chat-scroll").hover();
for (let step = 0; step < 120 && !(await summary.isVisible()); step += 1) {
await page.mouse.wheel(0, -300);
await waitForTimelineGeometryToSettle(page);
}
await summary.scrollIntoViewIfNeeded();
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
@@ -423,7 +456,7 @@ test("loads complete long turns and preserves expansion while revealing older hi
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
@@ -432,9 +465,9 @@ test("loads complete long turns and preserves expansion while revealing older hi
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await returnToExpandedSummary(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -26,14 +26,22 @@ export function getMountedRecentStreamItems(): number {
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
// Caps how far turn alignment may rewind. History paging and the replica
// cache omit it and still keep a whole turn.
maxMountedCount?: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
const mountedLimit =
input.maxMountedCount === undefined
? items.length
: Math.max(minMountedCount, input.maxMountedCount);
const earliestStart = Math.max(0, items.length - mountedLimit);
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > earliestStart && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1;
}
return startIndex;
@@ -3,6 +3,7 @@ import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
import { MAX_MOUNTED_WEB_WINDOW_ITEMS } from "./web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -125,6 +126,30 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.history).not.toContain(head[0]);
});
it("virtualizes a desktop web turn that runs past the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 80;
const tail = Array.from({ length }, (_, index) => ({
...userMessage(`row-${index}`, index % 60),
turnId: "long-run",
}));
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(model.segments.historyVirtualized).toHaveLength(80);
expect(model.segments.historyMounted[0]?.id).toBe(
`row-${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`,
);
expect(model.segments.historyVirtualized[0]?.id).toBe("row-0");
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
+5 -1
View File
@@ -2,7 +2,10 @@ import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import {
getWebPartialVirtualizationThreshold,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -134,6 +137,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getMountedRecentStreamItems(),
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
const split = {
history: orderedTail,
@@ -10,6 +10,7 @@ import {
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
@@ -108,6 +109,37 @@ describe("findMountedWindowStart", () => {
}),
).toBe(39);
});
it("keeps a whole turn when the caller does not cap the rewind", () => {
const items = Array.from({ length: MAX_MOUNTED_WEB_WINDOW_ITEMS + 40 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(findMountedWindowStart({ items, minMountedCount: 20 })).toBe(0);
});
it("stops a long turn at the cap and still honors a larger minimum", () => {
const items = Array.from({ length: 200 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(
findMountedWindowStart({
items,
minMountedCount: 20,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(200 - MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(
findMountedWindowStart({
items,
minMountedCount: 80,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(120);
});
});
describe("splitWebVirtualizedHistory", () => {
@@ -130,6 +162,39 @@ describe("splitWebVirtualizedHistory", () => {
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
it("virtualizes the older rows of a turn that is longer than the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 25;
const items = Array.from({ length }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.mountedEntries).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(window.virtualizedEntries).toHaveLength(25);
expect(window.mountedEntries[0]?.item.id).toBe(`t${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`);
expect(window.virtualizedEntries[0]?.item.id).toBe("t0");
});
it("keeps a turn that fits under the cap mounted together", () => {
const items = Array.from({ length: 40 }, (_, index) => ({
...toolCall(`t${index}`, index),
turnId: "short",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.virtualizedEntries).toHaveLength(0);
expect(window.mountedEntries).toHaveLength(40);
});
});
describe("estimateStreamItemHeight", () => {
@@ -8,6 +8,9 @@ import {
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
// Collapsed tool rows are about 40px, so this stays past a desktop viewport.
// Opening a long run mounts that tail and virtualizes the older rows.
export const MAX_MOUNTED_WEB_WINDOW_ITEMS = 60;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
@@ -86,6 +89,7 @@ export function splitWebVirtualizedHistory(input: {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
@@ -30,6 +30,7 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -46,6 +47,7 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -346,6 +348,25 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("denies archive when checkout facts do not establish safety", async () => {
for (const git of [{ headSha: undefined }, { behindOfOrigin: 1 }, { isDirty: null }]) {
const harness = createHarness({ snapshot: createSnapshot({ git }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
const harness = createHarness({
snapshot: createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("keeps the workspace active when the merged PR head differs from checkout", async () => {
const harness = createHarness({ snapshot: createSnapshot({ git: { headSha: "local-head" } }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,13 +59,21 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
if (
!pullRequest?.isMerged ||
!snapshot.git.headSha ||
snapshot.git.headSha !== pullRequest.headSha ||
snapshot.forge.error
) {
return;
}
if (snapshot.git.isDirty === true) {
if (snapshot.git.isDirty !== false) {
return;
}
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
if (
(snapshot.git.aheadOfOrigin !== null && snapshot.git.aheadOfOrigin !== 0) ||
(snapshot.git.behindOfOrigin !== null && snapshot.git.behindOfOrigin !== 0)
) {
return;
}
@@ -161,7 +161,12 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
async function createUnobservedWorkspace(journeyOptions?: {
archiveBarrier?: Promise<void>;
changeBeforeArchiveRead?: boolean;
}) {
const { archiveBarrier, changeBeforeArchiveRead: shouldChange = false } = journeyOptions ?? {};
let changeBeforeArchiveRead = shouldChange;
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
@@ -173,8 +178,10 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
runner: async () => ({ stdout: "", stderr: "" }),
});
let observation: PullRequestObservation | null = null;
let remoteHeadSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let archiveDecisions = 0;
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
@@ -186,6 +193,7 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
@@ -202,15 +210,29 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: service,
workspaceGitService: {
onSnapshotUpdated: service.onSnapshotUpdated.bind(service),
getSnapshot: async (
snapshotCwd: string,
readOptions: { force: true; afterInFlight?: boolean; reason: string },
) => {
if (changeBeforeArchiveRead && readOptions.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed before archive\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "new commit"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
resolvePath: path.resolve,
archiveIfSafe: async (input) =>
archiveIfSafe({
archiveIfSafe: async (input) => {
await archiveIfSafe({
...input,
deps: {
isPaseoOwnedWorktreeCwd,
@@ -227,13 +249,19 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
};
},
},
}),
});
archiveDecisions++;
},
});
return {
archives,
archiveDecisions: () => archiveDecisions,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -270,12 +298,33 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks checkout changes made after the merge notification", async () => {
const workspace = await createUnobservedWorkspace({ changeBeforeArchiveRead: true });
await workspace.observe("open");
await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged PR visible and the workspace active when its remote head advances", async () => {
const workspace = await createUnobservedWorkspace();
const open = await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(merged.forge.pullRequest?.url).toBe(open.forge.pullRequest?.url);
expect(merged.forge.pullRequest?.headSha).not.toBe(merged.git.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
releaseArchive = resolve;
});
const workspace = await createUnobservedWorkspace(archiveBarrier);
const workspace = await createUnobservedWorkspace({ archiveBarrier });
try {
await workspace.observe("open");
expect(workspace.archives).toEqual([]);
@@ -292,10 +341,10 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -90,6 +90,11 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => {
}
});
test("returns 404 for missing metadata while serving web UI deep links", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: true,
webUi: { enabled: true, distDir },
});
const origin = `http://127.0.0.1:${daemonHandle.port}`;
const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`);
expect(metadata.status).toBe(404);
expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await metadata.json()).toEqual({ error: "Not found" });
const deepLink = await fetch(`${origin}/h/example/agent/123`);
expect(deepLink.status).toBe(200);
expect(await deepLink.text()).toContain("<body>app</body>");
});
test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => {
daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false });
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
+27
View File
@@ -187,6 +187,33 @@ describe("daemon web UI route module", () => {
expect(res.body).toContain("app");
});
test.each([
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp/agents",
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", requestPath);
expect(res.status).toBe(404);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(JSON.parse(res.body)).toEqual({ error: "Not found" });
});
test("serves well-known files that exist in the dist directory", async () => {
await mkdir(path.join(distDir, ".well-known"), { recursive: true });
await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}');
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/.well-known/security.json");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(res.body).toBe('{"ok":true}');
});
test("serves static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
+24 -2
View File
@@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean {
return EXCLUDED_PATHS.has(requestPath);
}
// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth
// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not
// serve; the SPA's index.html with a 200 reads as malformed metadata.
function isWellKnownPath(requestPath: string): boolean {
return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/");
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
@@ -71,7 +78,14 @@ interface ResolvedTarget {
isIndexHtml: boolean;
}
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
interface ResolveTargetFileOptions {
distDir: string;
requestPath: string;
spaFallback: boolean;
}
function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null {
const { distDir, requestPath, spaFallback } = options;
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
let filePath = path.join(distDir, safePath);
@@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget
const finalStat = safeStat(filePath);
if (!finalStat?.isFile()) {
if (!spaFallback) {
return null;
}
filePath = path.join(distDir, "index.html");
const fallbackStat = safeStat(filePath);
if (!fallbackStat?.isFile()) {
@@ -190,8 +207,13 @@ interface ServeWebUiFileOptions {
function serveWebUiFile(options: ServeWebUiFileOptions): void {
const { distDir, requestPath, label, req, res } = options;
const target = resolveTargetFile(distDir, requestPath);
const wellKnown = isWellKnownPath(requestPath);
const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown });
if (!target) {
if (wellKnown) {
res.status(404).json({ error: "Not found" });
return;
}
res.status(404).end();
return;
}
@@ -155,6 +155,7 @@ export interface WorkspaceGitRuntimeSnapshot {
repoRoot: string | null;
mainRepoRoot: string | null;
currentBranch: string | null;
headSha?: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
isDirty: boolean | null;
@@ -185,6 +186,7 @@ export interface WorkspaceGitRuntimeSnapshot {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -320,6 +322,7 @@ export type WorkspaceGitSnapshotOptions =
| {
force: true;
includeForge?: boolean;
afterInFlight?: boolean;
reason: string;
};
@@ -3250,7 +3253,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: false,
queueIfBusy: force && options?.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
@@ -3550,6 +3553,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: refreshWorktree
@@ -128,6 +128,7 @@ export interface CurrentPullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
@@ -2064,7 +2064,7 @@ describe("createGiteaService", () => {
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,7 +2086,7 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ number: 8, state: "merged" });
});
it("returns null when no PR matches the current branch", async () => {
+10 -1
View File
@@ -162,6 +162,7 @@ const GiteaPrListItemSchema = z
updated: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
headSha: z.string().optional(),
})
.passthrough();
@@ -1010,6 +1011,7 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1082,6 +1084,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1653,6 +1656,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
candidates[0] ??
null;
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1718,7 +1722,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
return applyGiteaChecks(
{ ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
} catch {
return status;
}
@@ -23,7 +23,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
@@ -3219,7 +3219,7 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
@@ -3243,8 +3243,9 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestJson({
headRepositoryOwner: { login: "parentOwner" },
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
@@ -3265,12 +3266,105 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ state: "merged" });
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it("prefers an exact terminal head and otherwise the most recently updated terminal PR", async () => {
const owner = { login: "forkOwner" };
const terminal = ({
number,
headRefOid,
updatedAt,
state,
}: {
number: number;
headRefOid: string;
updatedAt: string;
state: string;
}) =>
JSON.parse(
currentPullRequestJson({
number,
headRefOid,
updatedAt,
state,
mergedAt: state === "MERGED" ? updatedAt : null,
headRepositoryOwner: owner,
}),
);
const older = terminal({
number: 41,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
state: "MERGED",
});
const newer = terminal({
number: 42,
headRefOid: "new",
updatedAt: "2026-07-02T00:00:00Z",
state: "CLOSED",
});
for (const [headSha, expected] of [
["old", 41],
["other", 42],
] as const) {
const runner = createScriptedRunner([JSON.stringify(older), JSON.stringify([older, newer])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha,
});
expect(status?.number).toBe(expected);
}
});
it("prefers an exact merged PR over a closed draft", async () => {
const owner = { login: "forkOwner" };
const closedDraft = JSON.parse(
currentPullRequestJson({
number: 1,
state: "CLOSED",
isDraft: true,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const merged = JSON.parse(
currentPullRequestJson({
number: 2,
state: "MERGED",
mergedAt: "2026-07-02T00:00:00Z",
headRefOid: "exact",
updatedAt: "2026-07-02T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([
JSON.stringify(closedDraft),
JSON.stringify([closedDraft, merged]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha: "exact",
});
expect(status).toMatchObject({ number: 2, state: "merged" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3313,6 +3407,84 @@ describe("ForgeService", () => {
expect(status).toMatchObject({ number: 43, state: "open" });
});
it("keeps a merged PR targeting the fork itself", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "checkout-head",
headRepositoryOwner: { login: "alice" },
}),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
"[]",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout-head",
});
expect(status).toMatchObject({ number: 7, repoOwner: "alice", state: "merged" });
});
it("compares the fork's terminal PR against parent PRs by state, head and update time", async () => {
const local = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
updatedAt: "2026-07-17T12:00:00Z",
headRefOid: "local-head",
headRepositoryOwner: { login: "alice" },
}),
);
for (const [state, headSha, updatedAt, expected] of [
["OPEN", "other", "2026-07-16T12:00:00Z", 8],
["CLOSED", "other", "2026-07-18T12:00:00Z", 7],
["CLOSED", "other", "2026-07-18T12:00:00Z", 8],
] as const) {
const parent = {
...local,
number: 8,
url: "https://github.com/acme/app/pull/8",
state,
mergedAt: null,
headRefOid: headSha,
updatedAt,
};
const runner = createScriptedRunner([
JSON.stringify(local),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
JSON.stringify([{ number: 8 }]),
JSON.stringify(parent),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: state === "OPEN" || expected === 7 ? "local-head" : "neither",
});
expect(status?.number).toBe(expected);
}
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
+26 -20
View File
@@ -346,6 +346,7 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -524,7 +525,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -640,6 +641,7 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -744,6 +746,7 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
@@ -1118,6 +1121,7 @@ interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string;
updatedAt?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -2968,7 +2972,7 @@ async function resolveCurrentPullRequestView(options: {
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
if (viewMatch?.status.state === "open") {
return viewMatch.status;
}
@@ -2980,12 +2984,13 @@ async function resolveCurrentPullRequestView(options: {
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return null;
return viewMatch?.status ?? null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
viewMatch,
repo: `${parentOwner}/${parentName}`,
});
}
@@ -2998,7 +3003,7 @@ async function resolveCurrentPullRequestView(options: {
run: options.run,
});
const match = pickPullRequestCandidate({
candidates,
candidates: viewMatch ? [...candidates, viewMatch] : candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
@@ -3019,6 +3024,7 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
viewMatch: ResolvedPullRequestCandidate | null;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3034,12 +3040,15 @@ async function resolveForkPullRequestView(options: {
),
);
const match = pickPullRequestCandidate({
candidates: candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
candidates: [
...candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate =>
candidate !== null && candidate.headRepositoryOwner === options.forkOwner,
),
...(options.viewMatch ? [options.viewMatch] : []),
],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
});
return match?.status ?? null;
}
@@ -3337,6 +3346,7 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
updatedAt: node.updatedAt,
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
@@ -3357,6 +3367,7 @@ function toCurrentPullRequestCandidate(
const headSha = item.headRefOid;
return {
status,
updatedAt: item.updatedAt,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
@@ -3380,12 +3391,6 @@ function pickPullRequestCandidate(options: {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
@@ -3411,17 +3416,17 @@ function comparePullRequestCandidatePreference(
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
return (
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
if (status.state === "open") {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
return 1;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3969,6 +3974,7 @@ function toCurrentPullRequestStatus(
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
@@ -357,14 +357,16 @@ describe("createGitLabService", () => {
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
it("keeps a merged MR when its remote head advances beyond the checkout", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [stale] : stale)),
);
await expect(
service.getCurrentPullRequestStatus({
@@ -372,8 +374,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
).resolves.toMatchObject({ state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
@@ -728,6 +728,7 @@ function toCurrentPullRequestStatus(
state: mapMergeRequestState(mr.state),
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
...(mr.sha ? { headSha: mr.sha } : {}),
isMerged: mr.state === "merged" || mr.merged_at != null,
isDraft: mr.draft ?? mr.work_in_progress ?? false,
mergeable: mapMergeable(mr),
@@ -987,6 +988,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
candidates[0] ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -4223,6 +4223,7 @@ export interface PullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;