mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224d3d7e17 | ||
|
|
896cd76d67 | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 |
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援するか、月 500 ドルで[スポンサー枠を購入](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)して、貴社のロゴをここと [paseo.sh のホームページ](https://paseo.sh/sponsor#spot)に掲載できます。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원하거나, 월 500달러에 [스폰서 자리를 구매](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)하여 회사 로고를 이곳과 [paseo.sh 홈페이지](https://paseo.sh/sponsor#spot)에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -193,6 +193,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra), or [sponsor a spot](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00) for $500 a month to put your company's logo here and on the [paseo.sh homepage](https://paseo.sh/sponsor#spot).
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作,或者以每月 500 美元[赞助一个展示位](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00),把贵公司的 logo 放在这里和 [paseo.sh 首页](https://paseo.sh/sponsor#spot)上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-3cqO8DpPVDjYaSh0q5EPJuMpCgl2OJ17uLGVGuk1Wwc=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
|
||||
interface SeededProject {
|
||||
projectKey: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project whose directory name ends in a space, which `seedWorkspace`
|
||||
* cannot produce because `mkdtemp` appends the random suffix. The project is
|
||||
* owned until `cleanup`, which removes the daemon record before the directory.
|
||||
*/
|
||||
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
|
||||
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
|
||||
await mkdir(directoryPath);
|
||||
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
|
||||
|
||||
const client = await connectSeedClient();
|
||||
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
|
||||
try {
|
||||
const added = await client.addProject(directoryPath);
|
||||
expect(added.error).toBeNull();
|
||||
expect(added.project?.projectRootPath).toBe(directoryPath);
|
||||
const project = added.project;
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`The daemon added ${directoryPath} without a project key`);
|
||||
}
|
||||
return {
|
||||
projectKey: project.projectKey,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function expectAppShellRendered(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
|
||||
}
|
||||
|
||||
// A project view key carries the project's path, so a directory whose name ends
|
||||
// in a space produces a key that ends in a space. The sidebar order store used
|
||||
// to trim it on write, which left the reconcile effect writing a new order on
|
||||
// every render until React tore the app down with "Maximum update depth
|
||||
// exceeded" on every load (#4880).
|
||||
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
|
||||
const project = await seedProjectNamedWithTrailingSpace();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectProjectRow(page, project.projectKey);
|
||||
await expectAppShellRendered(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
import type { BackPressSource } from "./visibility-tracker";
|
||||
|
||||
/**
|
||||
* Android's hardware Back press.
|
||||
*
|
||||
* iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as
|
||||
* anything subscribes, so nothing is registered off Android.
|
||||
*/
|
||||
export const systemBackPress: BackPressSource = {
|
||||
subscribe(onBackPress) {
|
||||
if (Platform.OS !== "android") return () => {};
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
||||
return () => subscription.remove();
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import { systemBackPress } from "./back-press";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -85,7 +86,11 @@ export function useIsolatedBottomSheetVisibility({
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const tracker = useMemo(
|
||||
() => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
|
||||
() =>
|
||||
createBottomSheetVisibilityTracker({
|
||||
onClose: () => onCloseRef.current(),
|
||||
backPress: systemBackPress,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BackPressSource,
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
@@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and
|
||||
* stops at the first one that claims it.
|
||||
*/
|
||||
class FakeBackPress implements BackPressSource {
|
||||
private listeners: (() => boolean)[] = [];
|
||||
|
||||
subscribe(onBackPress: () => boolean): () => void {
|
||||
this.listeners.push(onBackPress);
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(onBackPress);
|
||||
if (index >= 0) this.listeners.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a sheet claimed the press, false when it falls through to the navigator. */
|
||||
press(): boolean {
|
||||
for (let index = this.listeners.length - 1; index >= 0; index--) {
|
||||
if (this.listeners[index]?.()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const backPress = new FakeBackPress();
|
||||
let closeCount = 0;
|
||||
const tracker = createBottomSheetVisibilityTracker({
|
||||
onClose: () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
backPress,
|
||||
});
|
||||
return {
|
||||
sheet,
|
||||
backPress,
|
||||
tracker,
|
||||
closeCount: () => closeCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a sheet and runs it through the presentation the real sheet reports back. */
|
||||
function openSheet(context: ReturnType<typeof setup>) {
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: true });
|
||||
context.tracker.handleSheetIndexChange(0);
|
||||
}
|
||||
|
||||
describe("bottom sheet visibility tracker", () => {
|
||||
it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
@@ -160,4 +195,53 @@ describe("bottom sheet visibility tracker", () => {
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
|
||||
});
|
||||
|
||||
it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
|
||||
expect(context.backPress.press()).toBe(true);
|
||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||
const context = setup();
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: false });
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
|
||||
openSheet(context);
|
||||
context.tracker.handleSheetIndexChange(-1);
|
||||
context.tracker.handleSheetDismiss();
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
|
||||
it("gives the Back press to the sheet presented last", () => {
|
||||
const backPress = new FakeBackPress();
|
||||
const sheets = ["below", "above"].map((name) => {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress });
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
return { name, sheet };
|
||||
});
|
||||
|
||||
backPress.press();
|
||||
|
||||
expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([
|
||||
["below", "present"],
|
||||
["above", "dismiss"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops claiming Back once the sheet leaves the tree", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
context.tracker.attachController(null);
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The system Back press, as the tracker needs it.
|
||||
*
|
||||
* A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree
|
||||
* rather than a native modal window, so Android delivers Back to the navigator underneath it
|
||||
* unless something on top claims the press first.
|
||||
*
|
||||
* `subscribe` registers a listener and returns its removal. The listener returns `true` when it
|
||||
* claims the press. Listeners are offered the press newest-first, so a tracker holds its
|
||||
* subscription only while its sheet is on screen, and a stacked sheet answers before the one
|
||||
* below it.
|
||||
*/
|
||||
export interface BackPressSource {
|
||||
subscribe(onBackPress: () => boolean): () => void;
|
||||
}
|
||||
|
||||
export interface BottomSheetVisibilityTracker {
|
||||
attachController(controller: BottomSheetController | null): void;
|
||||
syncDesired(input: BottomSheetVisibilityInput): void;
|
||||
@@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
backPress?: BackPressSource;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
let isEnabled: boolean | undefined;
|
||||
let phase: BottomSheetPhase = "closed";
|
||||
let hasNotifiedClose = false;
|
||||
let releaseBackPress: (() => void) | null = null;
|
||||
|
||||
function setPhase(next: BottomSheetPhase): void {
|
||||
phase = next;
|
||||
syncBackPress();
|
||||
}
|
||||
|
||||
/** Holds the Back press for exactly the window the sheet is on screen, and no longer. */
|
||||
function syncBackPress(): void {
|
||||
const wantsBackPress = phase === "presented" && controller !== null;
|
||||
if (wantsBackPress === (releaseBackPress !== null)) return;
|
||||
if (!wantsBackPress) {
|
||||
releaseBackPress?.();
|
||||
releaseBackPress = null;
|
||||
return;
|
||||
}
|
||||
releaseBackPress =
|
||||
opts.backPress?.subscribe(() => {
|
||||
dismiss();
|
||||
return true;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
if (!controller || phase !== "closed") return;
|
||||
phase = "presenting";
|
||||
hasNotifiedClose = false;
|
||||
setPhase("presenting");
|
||||
controller.present();
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
if (!controller || phase === "closed" || phase === "dismissing") return;
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
controller.dismiss();
|
||||
}
|
||||
|
||||
@@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return {
|
||||
attachController(next) {
|
||||
controller = next;
|
||||
// A sheet that left the tree cannot answer, so it lets go of the press without changing
|
||||
// phase: the parent has not acknowledged the dismissal yet.
|
||||
syncBackPress();
|
||||
if (next && visible && isEnabled !== false) {
|
||||
present();
|
||||
}
|
||||
@@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return;
|
||||
}
|
||||
if (phase === "dismissing") {
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
return;
|
||||
}
|
||||
@@ -70,21 +112,21 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
handleSheetIndexChange(index) {
|
||||
if (index !== -1) {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
phase = "presented";
|
||||
setPhase("presented");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
}
|
||||
},
|
||||
handleSheetDismiss() {
|
||||
if (visible) {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
notifyClose();
|
||||
return;
|
||||
}
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type SqliteValue,
|
||||
} from "@/runtime/replica-cache/row-store-sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
DaemonClient,
|
||||
WorkspaceLabelListPayload,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeProjectDescriptor,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||
import { useWorkspaceLabels } from "@/workspace-labels";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import {
|
||||
DirectoryRefreshSupersededError,
|
||||
@@ -29,6 +33,8 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsWorkspaceLabels = false;
|
||||
listWorkspaceLabelsCalls = 0;
|
||||
fetchAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
@@ -144,19 +150,36 @@ class FakeDirectoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
getLastServerInfoMessage(): null {
|
||||
return null;
|
||||
observeWorkspaceLabels() {
|
||||
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
|
||||
}
|
||||
|
||||
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
|
||||
this.listWorkspaceLabelsCalls += 1;
|
||||
return {
|
||||
requestId: "workspace-labels",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const serverIds = new Set<string>();
|
||||
|
||||
function createDirectory(serverId: string): {
|
||||
function createDirectory(
|
||||
serverId: string,
|
||||
options?: { workspaceLabels?: boolean },
|
||||
): {
|
||||
client: FakeDirectoryClient;
|
||||
directory: DirectorySync;
|
||||
} {
|
||||
serverIds.add(serverId);
|
||||
const client = new FakeDirectoryClient();
|
||||
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
|
||||
const directory = new DirectorySync(serverId, {
|
||||
onAgentStoppedRunning: () => undefined,
|
||||
markAgentLoading: () => undefined,
|
||||
@@ -207,6 +230,7 @@ function createAgent(serverId: string, id: string) {
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
useWorkspaceLabels.setState({ hosts: {} });
|
||||
});
|
||||
|
||||
describe("DirectorySync session readiness", () => {
|
||||
@@ -1349,3 +1373,103 @@ it("fills every cached workspace beneath live updates received during the SQLite
|
||||
await cache.flush();
|
||||
database.close();
|
||||
});
|
||||
|
||||
function createWorkspaceLabelDirectory(serverId: string) {
|
||||
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: {
|
||||
directorySync: true,
|
||||
projectList: true,
|
||||
workspaceLabels: true,
|
||||
workspaceMultiplicity: true,
|
||||
},
|
||||
});
|
||||
return { client, directory };
|
||||
}
|
||||
|
||||
async function flushWorkspaceLabels(): Promise<void> {
|
||||
for (let index = 0; index < 4; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("DirectorySync workspace labels", () => {
|
||||
it("loads the catalog when an agent route is open before the sidebar", async () => {
|
||||
const serverId = "workspace-labels-route-then-full";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setAgentRouteDemand(["agent-1"]);
|
||||
await directory.refreshDemand();
|
||||
directory.setDemand({}, true);
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("loads the catalog when the sidebar demands the directory first", async () => {
|
||||
const serverId = "workspace-labels-full-first";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the picker online during a full refresh", async () => {
|
||||
const serverId = "workspace-labels-refresh-no-flicker";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
|
||||
const status = state.hosts[serverId]?.status;
|
||||
if (status) statuses.push(status);
|
||||
});
|
||||
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||
expect(statuses).not.toContain("offline");
|
||||
unsubscribe();
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("restores the catalog after reconnecting", async () => {
|
||||
const serverId = "workspace-labels-reconnect";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
directory.connectionChanged({
|
||||
client: null,
|
||||
status: "offline",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
});
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +189,8 @@ export class DirectorySync {
|
||||
this.connection = connection;
|
||||
this.abortPendingSessionWaits();
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
|
||||
void this.connectWorkspaceLabels().catch(() => undefined);
|
||||
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateSidebarOrderState } from "./sidebar-order-store";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeSidebarOrderUpdates,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/sidebar-workspaces-view-model";
|
||||
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
|
||||
|
||||
describe("migrateSidebarOrderState", () => {
|
||||
it("prefixes legacy per-server workspace order with the source server id", () => {
|
||||
@@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => {
|
||||
expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]);
|
||||
});
|
||||
});
|
||||
|
||||
// A directory whose name ends in a space produces a view key that ends in a
|
||||
// space. Trimming it on write used to make the sidebar's reconcile effect loop
|
||||
// forever, crashing the app with React error #185 (see #4880).
|
||||
describe("sidebar order keys that end in whitespace", () => {
|
||||
const PROJECT_KEY = "host:srv-1:/home/u/Reklamation ";
|
||||
|
||||
beforeEach(() => {
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a project key exactly as given", () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]);
|
||||
});
|
||||
|
||||
it("scopes a workspace order under the project key the sidebar indexes by", () => {
|
||||
useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]);
|
||||
|
||||
// use-sidebar-workspaces-list reads workspaceOrderByProject by view key
|
||||
// directly, so the scope has to be stored under that same key.
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([
|
||||
"srv-1:main",
|
||||
]);
|
||||
expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]);
|
||||
});
|
||||
|
||||
it("settles the sidebar order reconcile instead of rewriting it forever", () => {
|
||||
const projects: SidebarProjectEntry[] = [
|
||||
{
|
||||
viewKey: PROJECT_KEY,
|
||||
projectName: "Reklamation ",
|
||||
projectKind: "non_git",
|
||||
iconWorkingDir: "/home/u/Reklamation ",
|
||||
hosts: [],
|
||||
workspaces: [],
|
||||
},
|
||||
];
|
||||
const runPass = () =>
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: useSidebarOrderStore.getState().projectOrder,
|
||||
getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key),
|
||||
});
|
||||
|
||||
// First pass adds the newly visible project.
|
||||
const first = runPass();
|
||||
expect(first.projectOrder).toEqual([PROJECT_KEY]);
|
||||
useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []);
|
||||
|
||||
// Second pass must find nothing left to do; otherwise the effect that
|
||||
// applies these updates re-runs on every render.
|
||||
expect(runPass().projectOrder).toBeNull();
|
||||
});
|
||||
|
||||
it("still trims keys when migrating persisted state", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrder: [" host-a:one ", "host-a:one"],
|
||||
});
|
||||
|
||||
expect(migrated.projectOrder).toEqual(["host-a:one"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope {
|
||||
projectViewKey: string;
|
||||
}
|
||||
|
||||
// Trims each key. Only for persisted state read at migration time, where a
|
||||
// stray space is an artifact of an older format rather than part of the key.
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops blank keys and duplicates but keeps each key exactly as given. View
|
||||
* keys embed a project's path, so a directory whose name ends in a space
|
||||
* produces a key that ends in a space. Trimming it stores a key that can never
|
||||
* match the one the sidebar looks up, so the caller sees its key as missing,
|
||||
* writes it again, and the effect that reconciles the order never settles.
|
||||
*/
|
||||
function dedupeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key.trim() || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(key);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceOrderByProject(
|
||||
workspaceOrderByProject: Record<string, string[]> | undefined,
|
||||
): Record<string, string[]> {
|
||||
@@ -134,27 +158,22 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
workspaceOrderByProject: {},
|
||||
getProjectOrder: () => get().projectOrder,
|
||||
setProjectOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ projectOrder: normalized });
|
||||
set({ projectOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder,
|
||||
setPinnedWorkspaceOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ pinnedWorkspaceOrder: normalized });
|
||||
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getWorkspaceOrder: (projectViewKey) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return [];
|
||||
return get().workspaceOrderByProject[scope] ?? [];
|
||||
if (!projectViewKey.trim()) return [];
|
||||
return get().workspaceOrderByProject[projectViewKey] ?? [];
|
||||
},
|
||||
setWorkspaceOrder: (projectViewKey, keys) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return;
|
||||
const normalized = normalizeKeys(keys);
|
||||
if (!projectViewKey.trim()) return;
|
||||
set((state) => ({
|
||||
workspaceOrderByProject: {
|
||||
...state.workspaceOrderByProject,
|
||||
[scope]: normalized,
|
||||
[projectViewKey]: dedupeKeys(keys),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import {
|
||||
createSidebarViewStorage,
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
useSidebarViewStore,
|
||||
} from "./sidebar-view-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MemoryStorage extends StateStorage<Promise<void>> {
|
||||
reads: string[];
|
||||
}
|
||||
|
||||
@@ -147,6 +147,11 @@ class WorkspaceLabelsController {
|
||||
client: DaemonClient;
|
||||
supportsWorkspaceLabels: boolean;
|
||||
}): Promise<void> {
|
||||
const existing = this.connections.get(input.serverId);
|
||||
if (existing?.client === input.client && input.supportsWorkspaceLabels) {
|
||||
await this.refresh(input.serverId);
|
||||
return;
|
||||
}
|
||||
this.disconnect(input.serverId);
|
||||
const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica();
|
||||
this.replicas.set(input.serverId, replica);
|
||||
|
||||
@@ -131,6 +131,7 @@ function WorkspaceLabelPickerPage({
|
||||
}, [assignedLabels, host?.status, labels, model]);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot);
|
||||
const offline = !snapshot.online;
|
||||
const error = snapshot.error ?? (host?.error ? t("workspaceLabels.errors.load") : null);
|
||||
const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]);
|
||||
const toggle = useCallback(
|
||||
(label: WorkspaceLabelDefinition, assigned: boolean) => {
|
||||
@@ -159,9 +160,7 @@ function WorkspaceLabelPickerPage({
|
||||
>
|
||||
{t("workspaceLabels.create")}
|
||||
</MenuSubTrigger>
|
||||
{snapshot.error ? (
|
||||
<MenuHint testID="workspace-label-picker-error">{snapshot.error}</MenuHint>
|
||||
) : null}
|
||||
{error ? <MenuHint testID="workspace-label-picker-error">{error}</MenuHint> : null}
|
||||
{host?.status === "unsupported" ? (
|
||||
<MenuHint>{t("workspaceLabels.updateHostUse")}</MenuHint>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AsyncStorage has no implementation under Node: its default export reaches for
|
||||
* the native module and every call rejects, so any test that touches a
|
||||
* persisted store emits unhandled rejections from the persist middleware.
|
||||
*
|
||||
* This is a real in-memory key-value store with the same contract, not a
|
||||
* recording double, so a test can read back what a store wrote.
|
||||
*/
|
||||
const entries = new Map<string, string>();
|
||||
|
||||
export function clearAsyncStorageStub(): void {
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
const AsyncStorageStub = {
|
||||
getItem: (key: string): Promise<string | null> => Promise.resolve(entries.get(key) ?? null),
|
||||
setItem: (key: string, value: string): Promise<void> => {
|
||||
entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeItem: (key: string): Promise<void> => {
|
||||
entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
getAllKeys: (): Promise<string[]> => Promise.resolve([...entries.keys()]),
|
||||
// `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
|
||||
// hands it the real AsyncStorage, so a stub that stops at the single-key calls
|
||||
// fails any test that reaches the provider cache with `multiGet is not a function`.
|
||||
multiGet: (keys: readonly string[]): Promise<Array<[string, string | null]>> =>
|
||||
Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])),
|
||||
multiSet: (pairs: ReadonlyArray<readonly [string, string]>): Promise<void> => {
|
||||
for (const [key, value] of pairs) entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
multiRemove: (keys: readonly string[]): Promise<void> => {
|
||||
for (const key of keys) entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clear: (): Promise<void> => {
|
||||
entries.clear();
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
export default AsyncStorageStub;
|
||||
@@ -185,6 +185,12 @@ export default defineConfig({
|
||||
find: /^expo-linking$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
|
||||
},
|
||||
// No Node implementation: every call on the real module rejects, so a test
|
||||
// touching a persisted store drowns in unhandled rejections.
|
||||
{
|
||||
find: /^@react-native-async-storage\/async-storage$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"),
|
||||
},
|
||||
{
|
||||
find: /^lucide-react-native$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"),
|
||||
|
||||
@@ -25,4 +25,11 @@ describe("timelineItemIdentity", () => {
|
||||
).toBe("review/row-1");
|
||||
expect(timelineItemIdentity({ type: "reasoning", text: "hi" })).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies an assistant message by its provider message id", () => {
|
||||
expect(
|
||||
timelineItemIdentity({ type: "assistant_message", text: "hi", messageId: "msg-1" }),
|
||||
).toBe("message:msg-1");
|
||||
expect(timelineItemIdentity({ type: "assistant_message", text: "hi" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,5 +3,8 @@ import type { AgentTimelineItem } from "./agent-types.js";
|
||||
export function timelineItemIdentity(item: AgentTimelineItem): string | null {
|
||||
if (item.type === "tool_call") return item.callId;
|
||||
if (item.type === "plugin") return `${item.pluginId}/${item.id}`;
|
||||
// Streamed chunks of one provider assistant message share its id, so they
|
||||
// stay one item even when another row is recorded between two chunks.
|
||||
if (item.type === "assistant_message" && item.messageId) return `message:${item.messageId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -140,3 +141,61 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads an archived agent's history after its working directory is removed", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
|
||||
const worktree = path.join(root, "managed-worktree");
|
||||
await mkdir(worktree, { recursive: true });
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const agentId = "00000000-0000-4000-8000-000000000501";
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
|
||||
workspaceId: "workspace-worktree",
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "what did you change", logger, {});
|
||||
// Dispatching a run does not finish it: the provider appends the reply to its
|
||||
// history afterwards, and the turn is finalized only once that append lands.
|
||||
// Archive after the turn is finalized so the transcript this test reads back is
|
||||
// already on disk when the worktree goes away.
|
||||
const finished = await manager.waitForAgentEvent(agent.id);
|
||||
expect(finished.status).toBe("idle");
|
||||
await manager.archiveAgent(agent.id);
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
// Archiving the workspace removes the worktree it owned. The agent's history is
|
||||
// persisted and reading it must not depend on that directory still being there.
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
|
||||
const loaded = await ensureAgentLoaded(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(loaded.id).toBe(agentId);
|
||||
// The transcript is replayed from the provider's persisted history, so the reply the
|
||||
// agent gave before the worktree went away is still readable.
|
||||
const replies = manager
|
||||
.getTimeline(agentId)
|
||||
.filter((item) => item.type === "assistant_message");
|
||||
expect(replies.length).toBeGreaterThan(0);
|
||||
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -897,6 +897,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one assistant message whole when a steer lands between its chunks", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-split-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
// Zero window: every streamed chunk flushes on the coalescer's leading edge,
|
||||
// so the timeline reaches its next state without waiting on a timer.
|
||||
agentStreamCoalesceWindowMs: 0,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const run = manager.streamAgent(agent.id, "initial");
|
||||
void (async () => {
|
||||
for await (const _event of run) {
|
||||
}
|
||||
})();
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
|
||||
const conversation = () =>
|
||||
manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.filter(
|
||||
(row) => row.item.type === "assistant_message" || row.item.type === "user_message",
|
||||
)
|
||||
.map((row) => row.item);
|
||||
|
||||
session.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "active-turn-1",
|
||||
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
});
|
||||
await expect
|
||||
.poll(conversation)
|
||||
.toMatchObject([
|
||||
{ type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
]);
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, "keep going", { clientMessageId: "keep-going-client" }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
session.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "active-turn-1",
|
||||
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
|
||||
});
|
||||
|
||||
await expect.poll(conversation).toMatchObject([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
@@ -139,6 +140,28 @@ export type AgentRunCancellationResult =
|
||||
| { status: "settled" }
|
||||
| { status: "refused" };
|
||||
|
||||
/** A session that will run in a directory needs that directory to be there. */
|
||||
async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
|
||||
try {
|
||||
const stats = await stat(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: ${cwd}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}`, { cause: error });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to access working directory: ${cwd}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedSessionConfig {
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchConfig: AgentSessionConfig;
|
||||
@@ -148,6 +171,8 @@ interface PreparedSessionConfig {
|
||||
interface NormalizeConfigOptions {
|
||||
resolveDefaultModel?: boolean;
|
||||
env?: Record<string, string>;
|
||||
/** Defaults to interactive. A history load reads persisted state and runs nothing. */
|
||||
purpose?: AgentResumePurpose;
|
||||
}
|
||||
|
||||
interface TimeoutOptions {
|
||||
@@ -1229,7 +1254,7 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
config,
|
||||
resolvedAgentId,
|
||||
options?.env,
|
||||
{ env: options?.env },
|
||||
);
|
||||
this.requireEnabledProvider(storedConfig.provider);
|
||||
const client = await this.requireAvailableClient({
|
||||
@@ -1331,17 +1356,21 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider: handle.provider,
|
||||
} as AgentSessionConfig;
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
);
|
||||
|
||||
// Decide residency from durable state inside the lifecycle lane. A loader may
|
||||
// have read the record before a queued archive or restore completed.
|
||||
// have read the record before a queued archive or restore completed. Residency is
|
||||
// settled before the config is prepared, because a history load reads an archived
|
||||
// agent whose working directory may be gone.
|
||||
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
|
||||
const currentResumeOptions = record
|
||||
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
|
||||
: resumeOptions;
|
||||
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
||||
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
{ purpose },
|
||||
);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
@@ -1358,7 +1387,7 @@ export class AgentManager {
|
||||
undefined,
|
||||
{
|
||||
reason: "resume",
|
||||
purpose: currentResumeOptions?.purpose ?? "interactive",
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
},
|
||||
);
|
||||
@@ -4999,23 +5028,11 @@ export class AgentManager {
|
||||
// Always resolve cwd to absolute path for consistent history file lookup
|
||||
if (normalized.cwd) {
|
||||
normalized.cwd = resolve(normalized.cwd);
|
||||
try {
|
||||
const cwdStats = await stat(normalized.cwd);
|
||||
if (!cwdStats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error(`Working directory does not exist: ${normalized.cwd}`, { cause: error });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to access working directory: ${normalized.cwd}`, { cause: error });
|
||||
// Only a session that will run in the directory needs it to still be there. Reading
|
||||
// an archived agent's history runs nothing, and must survive the worktree it ran in
|
||||
// being removed when its workspace was archived.
|
||||
if (options.purpose !== "history") {
|
||||
await assertUsableWorkingDirectory(normalized.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5088,9 +5105,12 @@ export class AgentManager {
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
env?: Record<string, string>,
|
||||
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
|
||||
env: options.env,
|
||||
purpose: options.purpose,
|
||||
});
|
||||
const paseoToolPolicy = this.paseoToolsEnabled
|
||||
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
||||
: { enabled: false };
|
||||
|
||||
@@ -643,10 +643,13 @@ export interface AgentCreateSessionOptions {
|
||||
persistSession?: boolean;
|
||||
}
|
||||
|
||||
/** What a resumed session is for: driving the agent, or reading what it already did. */
|
||||
export type AgentResumePurpose = "interactive" | "history";
|
||||
|
||||
/** Runtime-only intent for a persisted-session resume. Never persist this option. */
|
||||
export interface AgentResumeSessionOptions {
|
||||
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
|
||||
purpose?: "interactive" | "history";
|
||||
purpose?: AgentResumePurpose;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
it("keeps a streaming assistant message whole when a steering prompt is recorded mid-stream", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1");
|
||||
store.append("agent-1", {
|
||||
type: "assistant_message",
|
||||
text: "with the uncommit",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
store.append("agent-1", { type: "user_message", text: "keep going" });
|
||||
store.append("agent-1", {
|
||||
type: "assistant_message",
|
||||
text: "ted changes.",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(store.getItems("agent-1")).toEqual([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps an overshooting before cursor into the bounded tail window", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1", {
|
||||
|
||||
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
} from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentSession,
|
||||
AgentTimelineItem,
|
||||
AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
@@ -866,6 +873,97 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
async function captureSdkUserMessage(prompt: AgentPromptInput): Promise<SDKUserMessage> {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
let resolveSent: ((message: SDKUserMessage) => void) | null = null;
|
||||
const sent = new Promise<SDKUserMessage>((resolve) => {
|
||||
resolveSent = resolve;
|
||||
});
|
||||
queryFactory.mockImplementation((input: { prompt: AsyncIterable<SDKUserMessage> }) => {
|
||||
void (async () => {
|
||||
for await (const message of input.prompt) {
|
||||
resolveSent?.(message);
|
||||
break;
|
||||
}
|
||||
})();
|
||||
return queryMock;
|
||||
});
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
try {
|
||||
await session.startTurn(prompt);
|
||||
return await sent;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
const issueAttachment: AgentAttachment = {
|
||||
type: "forge_issue",
|
||||
mimeType: "application/paseo-forge-issue",
|
||||
forge: "github",
|
||||
number: 12,
|
||||
title: "Fake issue for QA",
|
||||
url: "https://example.invalid/acme/app/issues/12",
|
||||
body: "This is an attached issue body.",
|
||||
};
|
||||
|
||||
// Claude Code expands a slash command only when it is the last content block of the user
|
||||
// message, so an auto-attached issue or a pasted screenshot must not be appended after it.
|
||||
test("sends a typed slash command last when an attachment follows it", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", undefined, [issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends a typed slash command last when an image follows it", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", [{ data: "aGk=", mimeType: "image/png" }], undefined),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "aGk=" } },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps typed text before attachments when it is not a slash command", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("please look at this issue", undefined, [issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: "please look at this issue" },
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves the typed slash command, not an attachment that reads like one", async () => {
|
||||
const chatHistory: AgentAttachment = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "/earlier command quoted from another chat",
|
||||
};
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", undefined, [chatHistory, issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: chatHistory.text },
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -3374,9 +3374,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
> = [];
|
||||
// Claude Code expands a slash command only when it is the last content block of the user
|
||||
// message. buildAgentPrompt places the typed text ahead of images and rendered attachments,
|
||||
// so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as
|
||||
// literal text instead of the command it names.
|
||||
let typedSlashCommandIndex = -1;
|
||||
if (Array.isArray(prompt)) {
|
||||
for (const chunk of prompt) {
|
||||
if (chunk.type === "text") {
|
||||
// Attachments rendered as text always carry a mimeType; the composer's typed text does
|
||||
// not. Only the typed block can be a slash command the user meant to invoke.
|
||||
if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) {
|
||||
typedSlashCommandIndex = content.length;
|
||||
}
|
||||
content.push({ type: "text", text: chunk.text });
|
||||
} else if (chunk.type === "image") {
|
||||
if (isImageMimeType(chunk.mimeType)) {
|
||||
@@ -3396,6 +3406,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
} else {
|
||||
content.push({ type: "text", text: prompt });
|
||||
}
|
||||
if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) {
|
||||
const [slashCommand] = content.splice(typedSlashCommandIndex, 1);
|
||||
content.push(slashCommand);
|
||||
}
|
||||
|
||||
const messageId = randomUUID();
|
||||
this.rememberUserMessageId(messageId);
|
||||
|
||||
@@ -744,6 +744,94 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Default Permissions pins the user as approvals reviewer on thread/start", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession(
|
||||
{ modeId: "auto", thinkingOptionId: "medium" },
|
||||
{ autoReviewEnabled: true },
|
||||
);
|
||||
session.currentThreadId = null;
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = {
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "default-mode-thread" } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await session.startTurn("trigger thread creation");
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall?.params).toMatchObject({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("switching from auto-review back to Default returns approvals to the user", async () => {
|
||||
const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true });
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
|
||||
await session.setMode("auto");
|
||||
await session.startTurn("needs approval");
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Read-only pins the user as approvals reviewer on thread/start", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession(
|
||||
{ modeId: "read-only", thinkingOptionId: "medium" },
|
||||
{ autoReviewEnabled: true },
|
||||
);
|
||||
session.currentThreadId = null;
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = {
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "read-only-thread" } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await session.startTurn("trigger thread creation");
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall?.params).toMatchObject({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
|
||||
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
|
||||
|
||||
@@ -834,6 +922,7 @@ describe("Codex app-server provider", () => {
|
||||
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
|
||||
expect(turnStart).not.toHaveProperty("approvalPolicy");
|
||||
expect(turnStart).not.toHaveProperty("sandboxPolicy");
|
||||
expect(turnStart).not.toHaveProperty("approvalsReviewer");
|
||||
});
|
||||
|
||||
test("carries the complete native workspace-write policy including writable roots", async () => {
|
||||
|
||||
@@ -273,17 +273,19 @@ interface CodexAppServerAgentDeps {
|
||||
interface CodexModePreset {
|
||||
approvalPolicy: string;
|
||||
sandbox: string;
|
||||
approvalsReviewer?: "auto_review";
|
||||
approvalsReviewer: "user" | "auto_review";
|
||||
}
|
||||
|
||||
const MODE_PRESETS: Record<string, CodexModePreset> = {
|
||||
"read-only": {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
auto: {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
"auto-review": {
|
||||
approvalPolicy: "on-request",
|
||||
@@ -293,6 +295,7 @@ const MODE_PRESETS: Record<string, CodexModePreset> = {
|
||||
"full-access": {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,15 +303,6 @@ function isAutoReviewReviewer(value: string | undefined): boolean {
|
||||
return value === "auto_review" || value === "guardian_subagent";
|
||||
}
|
||||
|
||||
function applyApprovalsReviewerParam(
|
||||
params: Record<string, unknown>,
|
||||
preset: CodexModePreset,
|
||||
): void {
|
||||
if (preset.approvalsReviewer) {
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldPromoteThreadResponseToAutoReview(params: {
|
||||
approvalsReviewer: string | undefined;
|
||||
approvalPolicy: string;
|
||||
@@ -4151,7 +4145,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
: toSandboxPolicy(sandboxPolicyType, workspaceWrite);
|
||||
}
|
||||
if (this.hasWorkflowModeOverride) {
|
||||
applyApprovalsReviewerParam(params, preset);
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
return { approvalPolicy, sandboxPolicyType };
|
||||
}
|
||||
@@ -5205,7 +5199,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
...(this.ephemeral ? { ephemeral: true } : {}),
|
||||
};
|
||||
if (this.hasWorkflowModeOverride) {
|
||||
applyApprovalsReviewerParam(params, preset);
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
return { params, approvalPolicy, sandbox };
|
||||
}
|
||||
|
||||
@@ -268,6 +268,41 @@ describe("JsonlRpcProcess", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("requestStopWork resolves once the child has exited", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(transport.requestStopWork({ type: "abort" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// A dying child's stdin pipe breaks a fraction before its exit event, so deciding at the
|
||||
// moment of the write failure would refuse a stop the runtime did honor.
|
||||
test("requestStopWork resolves when stdin fails just before the child exits", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
const stopped = transport.requestStopWork({ type: "abort" });
|
||||
child.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
|
||||
await expect(stopped).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// `close()` and a spawn error dispose the transport before the child is known to be
|
||||
// gone, and a child can outlive SIGKILL, so a disposed transport is not proof that the
|
||||
// requested work stopped.
|
||||
test("requestStopWork keeps failing while the transport is disposed but the child has not exited", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
child.emit("error", new Error("spawn failed"));
|
||||
|
||||
await expect(transport.requestStopWork({ type: "abort" })).rejects.toThrow(
|
||||
"JSONL RPC process is closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
@@ -81,6 +81,8 @@ export class JsonlRpcProcess {
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private exited = false;
|
||||
private closing: Promise<void> | null = null;
|
||||
private readonly frameDecoder: JsonlFrameDecoder;
|
||||
|
||||
constructor(private readonly options: JsonlRpcProcessOptions) {
|
||||
@@ -111,6 +113,7 @@ export class JsonlRpcProcess {
|
||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
this.child.on("exit", (code, signal) => {
|
||||
this.exited = true;
|
||||
const error = new Error(
|
||||
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
@@ -175,6 +178,36 @@ export class JsonlRpcProcess {
|
||||
return this.startRequest(command, timeoutMs).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command whose goal an exited process has already met — stopping a turn,
|
||||
* clearing a queue. Resolves once the child has exited instead of rejecting,
|
||||
* because nothing is queued and nothing is running, which is what the caller
|
||||
* asked for. Use `request` when the caller needs an answer from a live process.
|
||||
*
|
||||
* Keyed on an observed exit rather than on `disposed`: `close()` disposes the
|
||||
* transport before termination completes, and a child that outlives SIGKILL can
|
||||
* still be working, so a disposed transport is not proof the work stopped.
|
||||
*/
|
||||
async requestStopWork(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs?: number | null,
|
||||
): Promise<void> {
|
||||
if (this.exited) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.request(command, timeoutMs);
|
||||
} catch (error) {
|
||||
// A dying child breaks its stdin pipe a fraction before it emits `exit`, which
|
||||
// fails this request and starts a termination. Wait for that termination to reach
|
||||
// an answer rather than refusing a stop the runtime went on to honor.
|
||||
await this.closing?.catch(() => undefined);
|
||||
if (!this.exited) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(message: Record<string, unknown>): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
@@ -193,6 +226,11 @@ export class JsonlRpcProcess {
|
||||
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.failAll(error);
|
||||
this.closing = this.terminate();
|
||||
await this.closing;
|
||||
}
|
||||
|
||||
private async terminate(): Promise<void> {
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "../../tools/types.js";
|
||||
import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
|
||||
"turn_started",
|
||||
"turn_completed",
|
||||
"turn_failed",
|
||||
"turn_canceled",
|
||||
]);
|
||||
|
||||
function isTurnLifecycle(type: AgentStreamEvent["type"]): boolean {
|
||||
return TURN_LIFECYCLE_EVENTS.has(type);
|
||||
}
|
||||
|
||||
// What OMP reports for a turn the user stopped: an error message on a terminal
|
||||
// response whose stop reason says the request was aborted.
|
||||
const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
provider: "ai-harness-omp",
|
||||
model: "glm-5.3-flash-high",
|
||||
responseId: "chatcmpl-aborted",
|
||||
stopReason: "aborted",
|
||||
errorMessage: "Interrupted by user",
|
||||
};
|
||||
|
||||
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
|
||||
expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({
|
||||
readyTimeoutMs: 20_000,
|
||||
@@ -624,6 +649,74 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an interrupt that OMP reports as an aborted turn cancels instead of failing", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurn("do something long");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "sleep 30" },
|
||||
});
|
||||
runtime.streamAssistantText("working on it");
|
||||
// OMP ends the turn with an aborted terminal response before answering the abort.
|
||||
runtime.onAbort = () => {
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
};
|
||||
|
||||
await omp.interrupt();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an aborted turn that settles after the interrupt cancels exactly once", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurn("do something long");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText("working on it");
|
||||
// The provider-idle check is still in flight when the abort is acknowledged.
|
||||
const releaseState = runtime.holdStateRequests();
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
|
||||
await omp.interrupt();
|
||||
releaseState();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
});
|
||||
|
||||
test("an autonomous OMP turn aborted with no client turn id cancels", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText("autonomous work");
|
||||
runtime.onAbort = () => {
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
};
|
||||
|
||||
await omp.interrupt();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
});
|
||||
|
||||
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.resume({
|
||||
|
||||
@@ -526,6 +526,11 @@ function latestOmpErrorMessage(messages: OmpAgentMessage[]): string | null {
|
||||
return formatOmpErrorMessage(latestAssistant);
|
||||
}
|
||||
|
||||
function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean {
|
||||
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
||||
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -2133,6 +2138,19 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.clearNoTurnBuffers();
|
||||
// OMP reports a stopped turn as a terminal response carrying its interrupt
|
||||
// text as an error. That is the user's own Stop, not a failed turn.
|
||||
if (isOmpAbortedTerminalResponse(messages)) {
|
||||
this.usagePoller.stopTurn();
|
||||
this.terminalizeActiveWork();
|
||||
this.emit({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
reason: "interrupted",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const errorMessage = latestOmpErrorMessage(messages);
|
||||
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
||||
this.usagePoller.stopTurn();
|
||||
@@ -2161,6 +2179,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
try {
|
||||
const state = await this.runtimeSession.getState();
|
||||
this.state = state;
|
||||
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
|
||||
// An interrupt settled this turn while the state check was in flight.
|
||||
return;
|
||||
}
|
||||
if (!state.isStreaming && !state.isCompacting) {
|
||||
this.completeTurn(turnId, messages);
|
||||
return;
|
||||
|
||||
@@ -66,10 +66,7 @@ function createRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: OmpChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
function onOmpCommand(child: OmpChild, handler: (command: Record<string, unknown>) => void): void {
|
||||
let buffer = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
@@ -78,17 +75,34 @@ function replyToCommands(
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const command = JSON.parse(line) as Record<string, unknown>;
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
handler(JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: OmpChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
onOmpCommand(child, (command) => {
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
|
||||
function exitOnCommand(child: OmpChild, type: string): void {
|
||||
onOmpCommand(child, (command) => {
|
||||
if (command.type === type) {
|
||||
child.emit("exit", 1, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -418,4 +432,24 @@ describe("OMP CLI runtime", () => {
|
||||
expect(result).toHaveLength(2_000);
|
||||
expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" }));
|
||||
});
|
||||
|
||||
// A dead runtime owns no turn, so aborting it is already satisfied. Rejecting here
|
||||
// makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which
|
||||
// pins the agent at `running` until the daemon restarts. See issue #3749.
|
||||
test("abort resolves once the OMP process has exited", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("abort resolves when the OMP process exits while the abort is in flight", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
exitOnCommand(child, "abort");
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
await this.requestStopWork({ type: "abort" });
|
||||
}
|
||||
|
||||
async getState(): Promise<OmpSessionState> {
|
||||
@@ -292,6 +292,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
|
||||
}
|
||||
|
||||
private requestStopWork(command: OmpRpcCommand): Promise<void> {
|
||||
return this.process.requestStopWork(OmpRpcCommandSchema.parse(command));
|
||||
}
|
||||
|
||||
private emit(event: OmpRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
|
||||
@@ -113,6 +113,8 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
getStateRequestCount = 0;
|
||||
abortRequested = false;
|
||||
abortError: Error | null = null;
|
||||
onAbort: (() => void) | null = null;
|
||||
private heldStateRequests: Array<() => void> | null = null;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
id: string;
|
||||
@@ -243,6 +245,8 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
throw this.abortError;
|
||||
}
|
||||
this.abortRequested = true;
|
||||
// OMP can stream a turn's teardown before it answers the abort request.
|
||||
this.onAbort?.();
|
||||
}
|
||||
|
||||
async getState(): Promise<OmpSessionState> {
|
||||
@@ -251,6 +255,10 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
if (this.getStateRequestCount >= waiter.count) waiter.resolve();
|
||||
else this.stateRequestWaiters.push(waiter);
|
||||
}
|
||||
if (this.heldStateRequests) {
|
||||
const held = this.heldStateRequests;
|
||||
await new Promise<void>((resolve) => held.push(resolve));
|
||||
}
|
||||
if (this.getStateError) {
|
||||
throw this.getStateError;
|
||||
}
|
||||
@@ -261,6 +269,16 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Holds every state request until the returned function releases them. */
|
||||
holdStateRequests(): () => void {
|
||||
const held: Array<() => void> = [];
|
||||
this.heldStateRequests = held;
|
||||
return () => {
|
||||
this.heldStateRequests = null;
|
||||
for (const resolve of held.splice(0)) resolve();
|
||||
};
|
||||
}
|
||||
|
||||
queueStateReports(states: OmpSessionState[]): void {
|
||||
this.stateReports.push(...states);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,15 @@ function onPiCommand(child: PiChild, handler: (command: Record<string, unknown>)
|
||||
});
|
||||
}
|
||||
|
||||
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
|
||||
function exitOnCommand(child: PiChild, type: string): void {
|
||||
onPiCommand(child, (command) => {
|
||||
if (command.type === type) {
|
||||
child.emit("exit", 1, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: PiChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
@@ -506,4 +515,25 @@ describe("PiCliRuntime", () => {
|
||||
// Neither RPC returned usable data — should resolve with empty object
|
||||
expect(stats).toEqual({});
|
||||
});
|
||||
|
||||
// A dead runtime owns no turn, so interrupting it is already satisfied. Rejecting here
|
||||
// makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which
|
||||
// pins the agent at `running` until the daemon restarts. See issue #3749.
|
||||
test("abort and clearQueue resolve once the Pi process has exited", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(session.clearQueue()).resolves.toBeUndefined();
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("abort resolves when the Pi process exits while the abort is in flight", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
exitOnCommand(child, "abort");
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async clearQueue(): Promise<void> {
|
||||
await this.request({ type: "clear_queue" });
|
||||
await this.requestStopWork({ type: "clear_queue" });
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
@@ -148,7 +148,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
await this.requestStopWork({ type: "abort" });
|
||||
}
|
||||
|
||||
async getState(): Promise<PiSessionState> {
|
||||
@@ -237,6 +237,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
return this.process.request(command, timeoutMs);
|
||||
}
|
||||
|
||||
private requestStopWork(command: PiRpcCommand): Promise<void> {
|
||||
return this.process.requestStopWork(command);
|
||||
}
|
||||
|
||||
private async waitForCompletion(command: PiRpcCommand): Promise<void> {
|
||||
// Pi only replies after its compaction work is durable. Its child process and
|
||||
// session close paths already reject pending RPCs, so no elapsed-time failure
|
||||
|
||||
@@ -64,6 +64,33 @@ describe("projectTimelineRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps one assistant message whole when a steering user message lands mid-stream", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-02-13T00:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-02-13T00:00:00.100Z",
|
||||
item: { type: "user_message", text: "keep going" },
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-02-13T00:00:00.200Z",
|
||||
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected.map((entry) => entry.item)).toEqual([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps adjacent assistant chunks with different message ids separate in projected mode", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
|
||||
@@ -155,6 +155,17 @@ function mergeIdentityEntries(existing: WorkingEntry, entry: WorkingEntry): Work
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
...mergeIdentityMetadata(existing, entry, "tool_lifecycle"),
|
||||
};
|
||||
case "assistant_message":
|
||||
if (existing.item.type !== "assistant_message" || existing.turnId !== entry.turnId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
item: { ...existing.item, text: `${existing.item.text}${entry.item.text}` },
|
||||
timestamp: entry.timestamp,
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
...mergeIdentityMetadata(existing, entry, "assistant_merge"),
|
||||
};
|
||||
case "plugin":
|
||||
if (existing.item.type !== "plugin") return null;
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
|
||||
import express from "express";
|
||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||
import { constants, existsSync, unlinkSync } from "fs";
|
||||
import { open, rm } from "fs/promises";
|
||||
import { open, rm, stat } from "fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -891,6 +891,7 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false,
|
||||
logger,
|
||||
});
|
||||
const agentProviderRuntime = await createAgentProviderRuntime({
|
||||
|
||||
@@ -1117,6 +1117,7 @@ function createDeps(options?: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
|
||||
@@ -154,12 +154,15 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
// last-reference worktree directory is gone.
|
||||
await expectAgentAbsentFromActiveList(created.id);
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
// Archived tabs can continue asking for history. These reads must not recreate
|
||||
// the removed workspace observation or compromise the next agent lifecycle.
|
||||
// Archived tabs can continue asking for history, and those reads are served from
|
||||
// persisted state. They must not recreate the removed worktree or its workspace
|
||||
// observation, and must not compromise the next agent lifecycle.
|
||||
const staleTimelineReads = await Promise.allSettled(
|
||||
Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })),
|
||||
);
|
||||
expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(staleTimelineReads.every((result) => result.status === "fulfilled")).toBe(true);
|
||||
expect(existsSync(created.cwd)).toBe(false);
|
||||
expect((await ctx.client.fetchWorkspaces()).entries).toHaveLength(0);
|
||||
const subsequent = await ctx.client.createAgent({
|
||||
config: { ...getFullAccessConfig("codex"), cwd: repoDir },
|
||||
initialPrompt: "Say done.",
|
||||
|
||||
@@ -902,6 +902,7 @@ export class Session {
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
isDirectory: (path) => this.filesystem.isDirectory(path),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
|
||||
+154
-1
@@ -1,9 +1,10 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getCheckoutStatus } from "../../../utils/checkout-git.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import {
|
||||
createNoGitWorkspaceRuntimeSnapshot,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
createPersistedWorkspaceRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
@@ -31,6 +33,15 @@ const logger = createTestLogger();
|
||||
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
// The real filesystem, so "this directory is gone" is observed rather than modelled.
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return statSync(target).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let gitRoots: Set<string>;
|
||||
let gitBranches: Map<string, string | null>;
|
||||
@@ -106,6 +117,7 @@ beforeEach(async () => {
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
isDirectory,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
@@ -161,6 +173,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
|
||||
|
||||
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
|
||||
gitRoots.add(repo);
|
||||
@@ -187,6 +200,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
|
||||
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
@@ -224,6 +238,7 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
|
||||
|
||||
test("reopening archived exact-root records restores the fresh Git project", async () => {
|
||||
const cwd = path.join(tmpDir, "repo");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
@@ -249,6 +264,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
const archivedProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
@@ -279,6 +295,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
|
||||
test("uses one workspace snapshot when reopening an archived workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
@@ -300,6 +317,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
isDirectory,
|
||||
});
|
||||
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -310,6 +328,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
|
||||
test("reopening an archived workspace refreshes placement without renaming it", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
|
||||
@@ -430,6 +449,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged
|
||||
|
||||
test("does not unarchive either record when checkout refresh fails", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
|
||||
@@ -767,3 +787,136 @@ test.each(["missing", "archived"] as const)(
|
||||
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
|
||||
},
|
||||
);
|
||||
|
||||
test("a failed import keeps an archived worktree's placement when its directory is gone", async () => {
|
||||
// The git port here is the production read: getCheckoutStatus against the real
|
||||
// filesystem, so "the worktree directory is gone" is observed, not modelled.
|
||||
const mainRepoRoot = path.join(tmpDir, "main-repo");
|
||||
mkdirSync(mainRepoRoot, { recursive: true });
|
||||
const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example");
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: mainRepoRoot,
|
||||
kind: "git",
|
||||
displayName: "main-repo",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
const archived = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-archived-worktree",
|
||||
projectId: project.projectId,
|
||||
cwd: worktreeCwd,
|
||||
kind: "worktree",
|
||||
displayName: "feature/example",
|
||||
branch: "feature/example",
|
||||
worktreeRoot: worktreeCwd,
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot,
|
||||
createdAt: ARCHIVED_AT,
|
||||
updatedAt: ARCHIVED_AT,
|
||||
archivedAt: ARCHIVED_AT,
|
||||
});
|
||||
await workspaceRegistry.upsert(archived);
|
||||
const realCheckoutProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
logger,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
const status = await getCheckoutStatus(cwd, { logger });
|
||||
return checkoutLiteFromGitSnapshot(
|
||||
cwd,
|
||||
status.isGit
|
||||
? {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
repoRoot: status.repoRoot,
|
||||
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
}
|
||||
: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => {
|
||||
throw new Error("provider resume failed");
|
||||
}),
|
||||
).rejects.toThrow("provider resume failed");
|
||||
|
||||
expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({
|
||||
kind: "worktree",
|
||||
branch: "feature/example",
|
||||
worktreeRoot: worktreeCwd,
|
||||
mainRepoRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/away");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
gitRoots.delete(repo);
|
||||
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/away",
|
||||
});
|
||||
});
|
||||
|
||||
test("a directory that goes away while the git read is in flight keeps its placement", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/vanishing");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
|
||||
const vanishingProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
logger,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/vanishing",
|
||||
});
|
||||
});
|
||||
|
||||
+26
-8
@@ -1,6 +1,7 @@
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
generateWorkspaceId,
|
||||
@@ -94,11 +95,27 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
logger: Logger;
|
||||
lifecycle?: PluginLifecycle;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
||||
|
||||
/**
|
||||
* Placement facts at a workspace directory, or null when there is nothing
|
||||
* there to read. A git read answers "not a checkout" for a plain directory
|
||||
* and for a directory that is gone, and only the first is evidence that a
|
||||
* worktree stopped being one. That answer therefore counts only while the
|
||||
* directory is there on both sides of the read, so a worktree removed or
|
||||
* unmounted while the read is in flight stays an absence.
|
||||
*/
|
||||
async function observeWorkspaceCheckout(cwd: string): Promise<ProjectCheckoutLitePayload | null> {
|
||||
if (!(await deps.isDirectory(cwd))) return null;
|
||||
const checkout = await workspaceGitService.getCheckout(cwd);
|
||||
if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null;
|
||||
return checkout;
|
||||
}
|
||||
|
||||
async function runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
|
||||
@@ -366,12 +383,12 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
const timestamp = new Date().toISOString();
|
||||
const checkout =
|
||||
workspace.archivedAt || project.archivedAt
|
||||
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||
? await observeWorkspaceCheckout(workspace.cwd)
|
||||
: null;
|
||||
const autoArchivedChangeRequestUrl =
|
||||
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
|
||||
let next: PersistedWorkspaceRecord | null = null;
|
||||
if (workspace.archivedAt && checkout) {
|
||||
if (workspace.archivedAt) {
|
||||
const placementUpdate = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
@@ -384,10 +401,11 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
if (checkout && (project.archivedAt || workspace.archivedAt)) {
|
||||
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
if (project.archivedAt || workspace.archivedAt) {
|
||||
const projectCheckout =
|
||||
checkout && areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind = projectCheckout.isGit ? "git" : "non_git";
|
||||
const projectKey = deriveProjectKey({
|
||||
rootPath: project.rootPath,
|
||||
@@ -414,10 +432,10 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
async function refreshWorkspaceRecord(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
|
||||
const checkout = await observeWorkspaceCheckout(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (project && !project.archivedAt) {
|
||||
await refreshProjectKind(project, workspace.cwd, checkout);
|
||||
await refreshProjectKind(project, workspace.cwd, checkout ?? undefined);
|
||||
}
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import { deriveProjectKey } from "./project-key.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -336,6 +337,14 @@ class ObservedPlacements {
|
||||
projectId: spec.id,
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
// Seeded records already carry the key reconciliation derives, so a fixture's
|
||||
// first pass reports only what its test is about.
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
displayName: spec.id,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
@@ -473,7 +482,11 @@ describe("observed workspace placement", () => {
|
||||
|
||||
test("archives missing workspace directories on the periodic pass", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
{
|
||||
id: "project-one",
|
||||
root: "repo",
|
||||
workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }],
|
||||
},
|
||||
]);
|
||||
await observed.start();
|
||||
await observed.deleteWorkspaceDirectory("workspace-one");
|
||||
@@ -487,7 +500,11 @@ describe("observed workspace placement", () => {
|
||||
|
||||
test("preserves a periodic full pass queued behind metadata reconciliation", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
{
|
||||
id: "project-one",
|
||||
root: "repo",
|
||||
workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }],
|
||||
},
|
||||
]);
|
||||
await observed.start();
|
||||
const metadataRead = observed.holdNextReconciliation();
|
||||
|
||||
@@ -598,6 +598,11 @@ describe("WorkspaceReconciliationService", () => {
|
||||
});
|
||||
|
||||
test("archives workspaces whose directories no longer exist", async () => {
|
||||
const projectRoot = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-missing-workspace-")),
|
||||
);
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const archivedWorkspaceIds: string[] = [];
|
||||
|
||||
@@ -605,7 +610,8 @@ describe("WorkspaceReconciliationService", () => {
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-test",
|
||||
rootPath: projectRoot,
|
||||
projectKey: canonicalLocalProjectKey(projectRoot),
|
||||
kind: "non_git",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -617,7 +623,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-reconcile-test",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -640,7 +646,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-reconcile-test",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
@@ -648,12 +654,59 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("keeps workspaces whose project root is missing with them", async () => {
|
||||
const mountParent = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-unmounted-")));
|
||||
tempDirs.push(mountParent);
|
||||
// The external volume is not mounted, so nothing under it resolves.
|
||||
const projectRoot = path.join(mountParent, "ExternalSSD", "repo");
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: projectRoot,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a project active after all its workspaces are archived", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-orphan-project-")));
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||
rootPath: projectRoot,
|
||||
projectKey: canonicalLocalProjectKey(projectRoot),
|
||||
kind: "non_git",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
@@ -665,7 +718,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-reconcile-orphan",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
@@ -685,14 +738,14 @@ describe("WorkspaceReconciliationService", () => {
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")).toEqual({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-reconcile-orphan",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
title: null,
|
||||
@@ -1382,13 +1435,17 @@ describe("WorkspaceReconciliationService", () => {
|
||||
});
|
||||
|
||||
test("calls onChanges callback when changes are applied", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-callback-")));
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-callback-test",
|
||||
rootPath: projectRoot,
|
||||
projectKey: canonicalLocalProjectKey(projectRoot),
|
||||
kind: "non_git",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -1400,7 +1457,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-callback-test",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -1422,13 +1479,16 @@ describe("WorkspaceReconciliationService", () => {
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("logs reconciliation changes with affected paths and reasons", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-log-")));
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const { logger, infoRecords } = createCapturingLogger();
|
||||
|
||||
@@ -1436,7 +1496,8 @@ describe("WorkspaceReconciliationService", () => {
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-log-test",
|
||||
rootPath: projectRoot,
|
||||
projectKey: canonicalLocalProjectKey(projectRoot),
|
||||
kind: "non_git",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -1448,7 +1509,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-log-test",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
@@ -1473,7 +1534,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-log-test",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -227,6 +227,14 @@ export class WorkspaceReconciliationService {
|
||||
workspace,
|
||||
state: this.inspectDirectory(workspace.cwd),
|
||||
}));
|
||||
// Project roots are read after the workspace directories, so a volume that
|
||||
// goes away mid-pass leaves its project unreachable rather than its workspaces
|
||||
// alone. The skew can only withhold an archive, never produce one.
|
||||
const reachableProjectIds = new Set(
|
||||
activeProjects
|
||||
.filter((project) => this.inspectDirectory(project.rootPath) === "directory")
|
||||
.map((project) => project.projectId),
|
||||
);
|
||||
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
for (const { workspace, state } of workspaceDirectoryStates) {
|
||||
@@ -236,9 +244,16 @@ export class WorkspaceReconciliationService {
|
||||
workspacesByProject.set(workspace.projectId, list);
|
||||
}
|
||||
|
||||
// 1. Archive workspaces whose directories no longer exist
|
||||
// 1. Archive workspaces whose directories no longer exist, but only when the
|
||||
// project they belong to is still reachable. A missing project root means the
|
||||
// whole location is unavailable - an unmounted volume, an offline share, a disk
|
||||
// that has not appeared yet - and absence there proves nothing about the
|
||||
// workspace. Projects already persist through that; their workspaces do too.
|
||||
const missingWorkspaces = workspaceDirectoryStates
|
||||
.filter(({ state }) => state === "missing")
|
||||
.filter(
|
||||
({ workspace, state }) =>
|
||||
state === "missing" && reachableProjectIds.has(workspace.projectId),
|
||||
)
|
||||
.map(({ workspace }) => workspace);
|
||||
await Promise.all(
|
||||
missingWorkspaces.map(async (workspace) => {
|
||||
@@ -265,7 +280,7 @@ export class WorkspaceReconciliationService {
|
||||
// Projects persist until explicitly removed, even when they currently have
|
||||
// zero active workspaces, so they still reconcile their own metadata.
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"),
|
||||
activeProjects.filter((project) => reachableProjectIds.has(project.projectId)),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
|
||||
@@ -87,6 +87,31 @@ describe("workspace placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the recorded placement when nothing was read at the workspace directory", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/repo-feature",
|
||||
kind: "worktree",
|
||||
displayName: "feature/placement",
|
||||
branch: "feature/placement",
|
||||
worktreeRoot: "/repo-feature",
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout: null,
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(update).toBeNull();
|
||||
});
|
||||
|
||||
test("updates live placement while preserving its durable name and base branch", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
|
||||
@@ -109,12 +109,17 @@ export function initialWorkspacePlacement(
|
||||
/**
|
||||
* Applies live placement facts without rewriting the workspace's durable name
|
||||
* or its creation-time base branch.
|
||||
*
|
||||
* A null checkout is an absence, not an observation: nothing was read at the
|
||||
* workspace directory, so the recorded placement stands. Only a directory that
|
||||
* is there can show that a worktree stopped being one.
|
||||
*/
|
||||
export function reconcileWorkspacePlacement(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
checkout: ProjectCheckoutLitePayload | null;
|
||||
updatedAt: string;
|
||||
}): WorkspacePlacementUpdate | null {
|
||||
if (!input.checkout) return null;
|
||||
const observed = initialWorkspacePlacement({
|
||||
source: "checkout",
|
||||
cwd: input.workspace.cwd,
|
||||
|
||||
@@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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";
|
||||
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
||||
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
|
||||
|
||||
interface RunnerCall {
|
||||
args: string[];
|
||||
@@ -3324,34 +3325,33 @@ describe("ForgeService", () => {
|
||||
name: "parentRepo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||
}),
|
||||
JSON.stringify([
|
||||
{
|
||||
number: 41,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/41",
|
||||
title: "Wrong fork owner",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "otherFork" },
|
||||
},
|
||||
{
|
||||
number: 42,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
||||
title: "Real fork PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
},
|
||||
]),
|
||||
JSON.stringify([{ number: 41 }, { number: 42 }]),
|
||||
JSON.stringify({
|
||||
number: 41,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/41",
|
||||
title: "Wrong fork owner",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "otherFork" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
number: 42,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
||||
title: "Real fork PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
@@ -3371,27 +3371,182 @@ describe("ForgeService", () => {
|
||||
title: "Real fork PR",
|
||||
headRefName: "feature/fork",
|
||||
});
|
||||
expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([
|
||||
expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([
|
||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
["repo", "view", "--json", "owner,name,parent"],
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
"parentOwner/parentRepo",
|
||||
"--state",
|
||||
"all",
|
||||
"--head",
|
||||
"forkOwner:feature/fork",
|
||||
"--limit",
|
||||
"10",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_FIELDS,
|
||||
],
|
||||
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
|
||||
["pr", "view", "41", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries scoped PR list without statusCheckRollup when token permissions are insufficient", async () => {
|
||||
it("reads the fork PR carrying the checked-out commit past newer attempts on the branch", async () => {
|
||||
const checkoutSha = "3333333333333333333333333333333333333333";
|
||||
const viewed: number[] = [];
|
||||
const runner: GitHubCommandRunner = async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "forkOwner" },
|
||||
name: "parentRepo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "api") {
|
||||
// Newest first, and the checkout is sitting on the oldest attempt.
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{ number: 50, head: { sha: "5".repeat(40) } },
|
||||
{ number: 49, head: { sha: "4".repeat(40) } },
|
||||
{ number: 48, head: { sha: "8".repeat(40) } },
|
||||
{ number: 47, head: { sha: checkoutSha } },
|
||||
]),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "view" && /^\d+$/.test(args[2] ?? "")) {
|
||||
const number = Number(args[2]);
|
||||
viewed.push(number);
|
||||
return {
|
||||
stdout: currentPullRequestJson({
|
||||
number,
|
||||
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
|
||||
state: "CLOSED",
|
||||
headRefOid: number === 47 ? checkoutSha : `${number}`.repeat(20),
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
throw noPullRequestError(args);
|
||||
};
|
||||
const service = createGitHubService({
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const status = await service.getCurrentPullRequestStatus({
|
||||
cwd: "/repo",
|
||||
headRef: "feature/fork",
|
||||
headSha: checkoutSha,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
number: 47,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
});
|
||||
expect(viewed).toContain(47);
|
||||
expect(viewed).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
|
||||
const rateLimitError = new GitHubCommandError({
|
||||
args: ["api", "repos/parentOwner/parentRepo/pulls"],
|
||||
cwd: "/repo",
|
||||
exitCode: 1,
|
||||
stderr: "API rate limit exceeded",
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
{ error: noPullRequestError() },
|
||||
JSON.stringify({
|
||||
owner: { login: "forkOwner" },
|
||||
name: "parentRepo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||
}),
|
||||
{ error: rateLimitError },
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
||||
).rejects.toThrow(GitHubCommandError);
|
||||
});
|
||||
|
||||
it("resolves a fork PR whose branch name is crowded in the parent repository", async () => {
|
||||
// Mirrors what `gh` really does: `pr list --head` matches the string
|
||||
// against headRefName, so an "owner:branch" head never matches, and a
|
||||
// popular branch name fills the candidate page with other forks' PRs.
|
||||
const calls: RunnerCall[] = [];
|
||||
function parentRepoPullRequest(
|
||||
number: number,
|
||||
headRepositoryOwner: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
number,
|
||||
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
|
||||
title: `PR ${number}`,
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "main",
|
||||
headRefOid: `${number}`.padStart(40, "0"),
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: headRepositoryOwner },
|
||||
};
|
||||
}
|
||||
const crowd = Array.from({ length: 10 }, (_, index) =>
|
||||
parentRepoPullRequest(100 + index, `otherFork${index}`),
|
||||
);
|
||||
const runner: GitHubCommandRunner = async (args, options) => {
|
||||
calls.push({ args, cwd: options.cwd });
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "forkOwner" },
|
||||
name: "parentRepo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "4984") {
|
||||
return { stdout: JSON.stringify(parentRepoPullRequest(4984, "forkOwner")), stderr: "" };
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "view") {
|
||||
throw noPullRequestError(args);
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
const headRef = args[args.indexOf("--head") + 1];
|
||||
const limit = Number(args[args.indexOf("--limit") + 1]);
|
||||
const matching = crowd.filter((candidate) => candidate.headRefName === headRef);
|
||||
return { stdout: JSON.stringify(matching.slice(0, limit)), stderr: "" };
|
||||
}
|
||||
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
|
||||
expect(args[1]).toContain("head=forkOwner%3Amain");
|
||||
return { stdout: JSON.stringify([{ number: 4984 }]), stderr: "" };
|
||||
}
|
||||
return { stdout: "{}", stderr: "" };
|
||||
};
|
||||
const service = createGitHubService({
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const status = await service.getCurrentPullRequestStatus({
|
||||
cwd: "/repo",
|
||||
headRef: "main",
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
number: 4984,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
headRefName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries the fork PR view without statusCheckRollup when token permissions are insufficient", async () => {
|
||||
const runner = createScriptedRunner([
|
||||
currentPullRequestJson({
|
||||
number: 7,
|
||||
@@ -3404,36 +3559,30 @@ describe("ForgeService", () => {
|
||||
name: "parentRepo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||
}),
|
||||
JSON.stringify([{ number: 42 }]),
|
||||
{
|
||||
error: statusCheckRollupPermissionError([
|
||||
"pr",
|
||||
"list",
|
||||
"view",
|
||||
"42",
|
||||
"--repo",
|
||||
"parentOwner/parentRepo",
|
||||
"--state",
|
||||
"all",
|
||||
"--head",
|
||||
"forkOwner:feature/fork",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_FIELDS,
|
||||
"--limit",
|
||||
"10",
|
||||
]),
|
||||
},
|
||||
JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
||||
title: "Real fork PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
number: 42,
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
||||
title: "Real fork PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
@@ -3454,34 +3603,17 @@ describe("ForgeService", () => {
|
||||
checks: [],
|
||||
checksStatus: "none",
|
||||
});
|
||||
expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([
|
||||
expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([
|
||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
["repo", "view", "--json", "owner,name,parent"],
|
||||
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
|
||||
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"view",
|
||||
"42",
|
||||
"--repo",
|
||||
"parentOwner/parentRepo",
|
||||
"--state",
|
||||
"all",
|
||||
"--head",
|
||||
"forkOwner:feature/fork",
|
||||
"--limit",
|
||||
"10",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_FIELDS,
|
||||
],
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
"parentOwner/parentRepo",
|
||||
"--state",
|
||||
"all",
|
||||
"--head",
|
||||
"forkOwner:feature/fork",
|
||||
"--limit",
|
||||
"10",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_BASE_FIELDS,
|
||||
],
|
||||
@@ -3629,21 +3761,20 @@ describe("ForgeService", () => {
|
||||
name: "repo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "repo" },
|
||||
}),
|
||||
JSON.stringify([
|
||||
{
|
||||
number: 88,
|
||||
url: "https://github.com/parentOwner/repo/pull/88",
|
||||
title: "Fork-only PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: null,
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
},
|
||||
]),
|
||||
JSON.stringify([{ number: 88 }]),
|
||||
JSON.stringify({
|
||||
number: 88,
|
||||
url: "https://github.com/parentOwner/repo/pull/88",
|
||||
title: "Fork-only PR",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/fork",
|
||||
mergedAt: null,
|
||||
statusCheckRollup: [],
|
||||
reviewDecision: null,
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
@@ -3662,7 +3793,7 @@ describe("ForgeService", () => {
|
||||
repoName: "repo",
|
||||
headRefName: "feature/fork",
|
||||
});
|
||||
expect(runner.calls[2]?.args).toContain("forkOwner:feature/fork");
|
||||
expect(runner.calls[2]?.args).toEqual(["api", `repos/parentOwner/repo/pulls?${FORK_PR_QUERY}`]);
|
||||
});
|
||||
|
||||
it("propagates DNS errors while resolving the current PR view", async () => {
|
||||
|
||||
@@ -117,6 +117,11 @@ export const GITHUB_POLL_ALIGNMENT_MS = 5_000;
|
||||
const GITHUB_GRAPHQL_RESERVE_RATIO = 0.4;
|
||||
const GITHUB_GRAPHQL_RESET_GRACE_MS = 1_000;
|
||||
const BATCH_PR_CANDIDATE_LIMIT = 10;
|
||||
// How many of a fork branch's own pull requests are read in full. One is the
|
||||
// normal case; the rest are earlier attempts on the same branch, and the one
|
||||
// carrying the checkout's head commit is read whichever attempt it belongs to.
|
||||
const FORK_PR_VIEW_LIMIT = 3;
|
||||
const FORK_PR_PAGE_SIZE = 100;
|
||||
const FROZEN_PR_CHECKS_CACHE_MAX_ENTRIES = 512;
|
||||
const GITHUB_ENV = {
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
@@ -457,6 +462,16 @@ const GitHubRepoViewSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ForkPullRequestRefsSchema = z.array(
|
||||
z.object({
|
||||
number: z.number(),
|
||||
head: z
|
||||
.object({ sha: z.string().catch("") })
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const PullRequestCheckoutTargetSchema = z.object({
|
||||
data: z.object({
|
||||
repository: z.object({
|
||||
@@ -2964,8 +2979,6 @@ async function resolveCurrentPullRequestView(options: {
|
||||
return viewMatch.status;
|
||||
}
|
||||
|
||||
let listHeadRef = options.headRef;
|
||||
let listRepo: string | undefined;
|
||||
let headRepositoryOwner = options.headRepositoryOwner;
|
||||
|
||||
if (!headRepositoryOwner) {
|
||||
@@ -2977,17 +2990,19 @@ async function resolveCurrentPullRequestView(options: {
|
||||
return null;
|
||||
}
|
||||
if (parentOwner && parentName) {
|
||||
listHeadRef = `${forkOwner}:${options.headRef}`;
|
||||
listRepo = `${parentOwner}/${parentName}`;
|
||||
return resolveForkPullRequestView({
|
||||
...options,
|
||||
forkOwner,
|
||||
repo: `${parentOwner}/${parentName}`,
|
||||
});
|
||||
}
|
||||
headRepositoryOwner = forkOwner;
|
||||
}
|
||||
|
||||
const candidates = await listCurrentPullRequestCandidates({
|
||||
cwd: options.cwd,
|
||||
headRef: listHeadRef,
|
||||
headRef: options.headRef,
|
||||
run: options.run,
|
||||
repo: listRepo,
|
||||
});
|
||||
const match = pickPullRequestCandidate({
|
||||
candidates,
|
||||
@@ -2998,6 +3013,77 @@ async function resolveCurrentPullRequestView(options: {
|
||||
return match?.status ?? null;
|
||||
}
|
||||
|
||||
// A fork checkout's pull request lives in the parent repository, where only
|
||||
// its head repository tells it apart from every other fork's pull request on
|
||||
// the same branch name. `gh pr list --head` cannot ask that question: it
|
||||
// forwards the whole value as a head branch name, so "owner:branch" matches
|
||||
// nothing (cli/cli#10945), and the bare branch name loses a common name like
|
||||
// `main` among the other forks crowding the candidate page. The REST pulls
|
||||
// endpoint is the only lookup that takes a head repository, so it names the
|
||||
// pull requests and the ordinary view path reads each one's status.
|
||||
async function resolveForkPullRequestView(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headSha?: string;
|
||||
forkOwner: string;
|
||||
repo: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<CurrentPullRequestStatus | null> {
|
||||
const numbers = await listForkPullRequestNumbers(options);
|
||||
const candidates = await Promise.all(
|
||||
numbers.map((number) =>
|
||||
tryCurrentPullRequestView({
|
||||
cwd: options.cwd,
|
||||
headRef: options.headRef,
|
||||
run: options.run,
|
||||
args: ["pr", "view", String(number), "--repo", options.repo],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const match = pickPullRequestCandidate({
|
||||
candidates: candidates.filter(
|
||||
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
|
||||
),
|
||||
headRef: options.headRef,
|
||||
headSha: options.headSha,
|
||||
headRepositoryOwner: options.forkOwner,
|
||||
});
|
||||
return match?.status ?? null;
|
||||
}
|
||||
|
||||
// The endpoint answers with the fork branch's pull requests, newest first, so
|
||||
// a branch reused for several attempts can carry the checkout's head commit on
|
||||
// an older one. Those are read first, and the rest of the window is filled
|
||||
// newest-first, so the full read stays bounded without dropping the attempt the
|
||||
// checkout is actually sitting on.
|
||||
async function listForkPullRequestNumbers(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headSha?: string;
|
||||
forkOwner: string;
|
||||
repo: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<number[]> {
|
||||
const query = new URLSearchParams({
|
||||
state: "all",
|
||||
per_page: String(FORK_PR_PAGE_SIZE),
|
||||
head: `${options.forkOwner}:${options.headRef}`,
|
||||
});
|
||||
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
|
||||
const stdout = await options.run(args, { cwd: options.cwd });
|
||||
const refs = parseGitHubJsonOutput(stdout, ForkPullRequestRefsSchema, {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
emptyFallback: "[]",
|
||||
});
|
||||
function carriesHeadSha(ref: (typeof refs)[number]): boolean {
|
||||
return options.headSha !== undefined && ref.head?.sha === options.headSha;
|
||||
}
|
||||
return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))]
|
||||
.slice(0, FORK_PR_VIEW_LIMIT)
|
||||
.map((ref) => ref.number);
|
||||
}
|
||||
|
||||
async function addCurrentPullRequestGithubFacts(options: {
|
||||
cwd: string;
|
||||
status: CurrentPullRequestStatus | null;
|
||||
@@ -3057,16 +3143,18 @@ async function loadPullRequestGithubFacts(options: {
|
||||
async function tryCurrentPullRequestView(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
args?: string[];
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<ResolvedPullRequestCandidate | null> {
|
||||
const args = options.args ?? ["pr", "view"];
|
||||
try {
|
||||
const stdout = await runCurrentPullRequestStatusCommand({
|
||||
cwd: options.cwd,
|
||||
run: options.run,
|
||||
args: ["pr", "view"],
|
||||
args,
|
||||
});
|
||||
return parseCurrentPullRequestCandidate(stdout, options.headRef, {
|
||||
args: ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
args: [...args, "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
cwd: options.cwd,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -3081,13 +3169,8 @@ async function listCurrentPullRequestCandidates(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
repo?: string;
|
||||
}): Promise<ResolvedPullRequestCandidate[]> {
|
||||
const args = ["pr", "list"];
|
||||
if (options.repo) {
|
||||
args.push("--repo", options.repo);
|
||||
}
|
||||
args.push("--state", "all", "--head", options.headRef, "--limit", "10");
|
||||
const args = ["pr", "list", "--state", "all", "--head", options.headRef, "--limit", "10"];
|
||||
try {
|
||||
const stdout = await runCurrentPullRequestStatusCommand({
|
||||
cwd: options.cwd,
|
||||
|
||||
@@ -44,3 +44,25 @@ export function SlackIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCollectiveIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 39.98 40.05"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M32.76 19.97c0 2.53-.73 4.94-2 6.94l5.14 5.16c2.5-3.36 4.08-7.57 4.08-12.1 0-4.5-1.57-8.7-4.08-12.08l-5.13 5.16c1.26 2 2 4.3 2 6.93z" />
|
||||
<path d="M20 32.8c-7.02 0-12.78-5.78-12.78-12.83 0-7.04 5.76-12.82 12.77-12.82 2.6 0 4.9.73 6.9 2.1l5.13-5.15C28.68 1.58 24.5 0 20 0 9 0 0 8.94 0 20.08s9 19.97 20 19.97c4.6 0 8.8-1.57 12.14-4.1L27 30.8c-1.98 1.26-4.4 2-7 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuyMeACoffeeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
|
||||
import { ClaudeIcon, MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
|
||||
import { FAQItem } from "~/components/faq-item";
|
||||
import { SiteFooter } from "~/components/site-footer";
|
||||
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||
import { SiteHeader } from "~/components/site-header";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -123,7 +124,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<AutomationSection />
|
||||
<ExtensibleSection />
|
||||
<FAQ />
|
||||
<SponsorCTA />
|
||||
<SponsorSection />
|
||||
<SponsorsSection />
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
@@ -1196,45 +1198,3 @@ function FAQ() {
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorCTA() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={FADE_IN_UP}
|
||||
whileInView={FADE_IN}
|
||||
viewport={VIEWPORT_60}
|
||||
transition={EASE_OUT_05}
|
||||
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-left space-y-4 max-w-xl mx-auto"
|
||||
>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
|
||||
<p>Paseo is an independent open source project for running coding agents.</p>
|
||||
<p>Its guiding principle is optionality and freedom of choice.</p>
|
||||
<p>
|
||||
I wanted to use any provider without being locked into any ecosystem, run it on my own
|
||||
infrastructure, access it from anywhere, and have it be fully automatable.
|
||||
</p>
|
||||
<p>I am hoping that you will enjoy Paseo as much as I do.</p>
|
||||
<p>If you like Paseo, sponsorship is the best way to support continued development.</p>
|
||||
<p>- Mo</p>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<a
|
||||
href="/sponsor"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white/10 border border-white/20 px-5 py-2.5 text-sm font-medium text-white hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-pink-400"
|
||||
>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
Sponsor Paseo
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { ArrowRight, Check, ExternalLink } from "lucide-react";
|
||||
import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons";
|
||||
import {
|
||||
BUY_ME_A_COFFEE_URL,
|
||||
GITHUB_SPONSORS_URL,
|
||||
HOMEPAGE_SPONSORS,
|
||||
HOMEPAGE_SPOT_PRICE,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
SPONSOR_CONTACT_EMAIL,
|
||||
SPONSOR_SPOT_CHECKOUT_URL,
|
||||
openSpotCount,
|
||||
} from "~/data/sponsors";
|
||||
|
||||
function SectionHeading({
|
||||
as: Heading,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
title: string;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-12 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading className="text-3xl font-medium tracking-tight">{title}</Heading>
|
||||
{badge ? (
|
||||
<span className="rounded-full bg-emerald-400/10 px-2 py-1 text-xs text-emerald-300">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="max-w-lg text-base text-pretty text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAINTAINER_LINK = (
|
||||
<a
|
||||
href="https://github.com/boudra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-white/80"
|
||||
>
|
||||
Mo
|
||||
</a>
|
||||
);
|
||||
|
||||
export function FounderNote() {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
|
||||
<p>
|
||||
I build Paseo on my own. There are no investors, no board and no company behind it, and I
|
||||
have turned down funding offers to keep it that way.
|
||||
</p>
|
||||
<p>
|
||||
A tool that sits between you and your code, your keys and your machines has to stay neutral,
|
||||
and that gets hard once someone else owns a return on it. Funding creates pressure to
|
||||
monetize, and that pressure changes what gets built.
|
||||
</p>
|
||||
<p>
|
||||
Paseo is self-funded: the work is paid for by sponsorship and by{" "}
|
||||
<a href="/hub" className="underline hover:text-white/90">
|
||||
Paseo Hub
|
||||
</a>
|
||||
, an optional hosted service. Your support is what lets me work on Paseo full time.
|
||||
</p>
|
||||
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BackingOption {
|
||||
href: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
external: boolean;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
|
||||
{
|
||||
href: GITHUB_SPONSORS_URL,
|
||||
name: "GitHub Sponsors",
|
||||
detail: "From $5 a month, or one time",
|
||||
icon: GitHubIcon,
|
||||
external: true,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
href: OPEN_COLLECTIVE_URL,
|
||||
name: "Open Collective",
|
||||
detail: "Monthly or one time, with an invoice",
|
||||
icon: OpenCollectiveIcon,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: BUY_ME_A_COFFEE_URL,
|
||||
name: "Buy Me a Coffee",
|
||||
detail: "One time, no account needed",
|
||||
icon: BuyMeACoffeeIcon,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: "/sponsor#spot",
|
||||
name: "Sponsor a spot",
|
||||
detail: "For companies",
|
||||
icon: SpotIcon,
|
||||
external: false,
|
||||
},
|
||||
];
|
||||
|
||||
function SpotIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block rounded-md border border-dashed border-current ${className ?? ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackingOptions() {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{BACKING_OPTIONS.map((option) => (
|
||||
<a
|
||||
key={option.href}
|
||||
href={option.href}
|
||||
{...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={`flex items-start gap-4 rounded-xl border p-4 transition-colors ${
|
||||
option.primary
|
||||
? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
<option.icon className="mt-0.5 h-6 w-6 shrink-0 text-white/60" />
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 font-medium text-white">
|
||||
{option.name}
|
||||
{option.external ? (
|
||||
<ExternalLink className="h-3.5 w-3.5 text-white/40" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-white/40" />
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-white/50">{option.detail}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The four homepage spots: sponsor logos first, then a placeholder for each open spot. */
|
||||
function SponsorLogoRow() {
|
||||
const placeholders = Array.from({ length: openSpotCount() }, (_, index) => index);
|
||||
return (
|
||||
<ul className="grid grid-cols-2 gap-4">
|
||||
{HOMEPAGE_SPONSORS.map((sponsor) => (
|
||||
<li key={sponsor.href}>
|
||||
<a
|
||||
href={sponsor.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
|
||||
>
|
||||
<img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{placeholders.map((index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href="/sponsor#spot"
|
||||
className="flex h-32 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/20 transition-colors hover:border-white/40 hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="text-sm font-medium text-white/70">Sponsor</span>
|
||||
<span className="text-xs text-white/40">{HOMEPAGE_SPOT_PRICE} a month</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const SPOT_FEATURES: ReadonlyArray<string> = [
|
||||
"Your logo on the homepage of paseo.sh, around 70,000 visitors a month",
|
||||
"Your logo in the GitHub README, around 30,000 visitors a month",
|
||||
"An announcement in the Discord, around 2,000 members",
|
||||
"A mention in the changelog",
|
||||
];
|
||||
|
||||
export function SponsorSpotSection() {
|
||||
const open = openSpotCount();
|
||||
return (
|
||||
<section id="spot" className="scroll-mt-8">
|
||||
<SectionHeading
|
||||
as="h2"
|
||||
title="Sponsor a spot"
|
||||
description="Support an independent project and put your company in front of a fast-growing audience of developers."
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span>
|
||||
<span className="mb-1 text-sm text-white/45">a month</span>
|
||||
</div>
|
||||
<ul className="grid max-w-2xl gap-3 text-white/70">
|
||||
{SPOT_FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<Check aria-hidden="true" className="mt-1 size-4 shrink-0 text-emerald-300" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
{open > 0 ? (
|
||||
<a
|
||||
href={SPONSOR_SPOT_CHECKOUT_URL}
|
||||
className="inline-flex items-center rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition-colors hover:bg-white/90"
|
||||
>
|
||||
Sponsor a spot
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
|
||||
className="text-sm text-white/60 underline transition-colors hover:text-white/90"
|
||||
>
|
||||
{open > 0 ? "Or email me first" : "Email me to hear when a spot opens"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The /sponsor page's first section: the note and the ways to back the work. */
|
||||
export function SponsorPaseoSection() {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeading as="h1" title="Sponsor Paseo" />
|
||||
<div className="space-y-10">
|
||||
<FounderNote />
|
||||
<BackingOptions />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Homepage: a short version of the note with the same links as /sponsor. */
|
||||
export function SponsorSection() {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeading as="h2" title="Sponsor Paseo" />
|
||||
<div className="space-y-10">
|
||||
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
|
||||
<p>
|
||||
I build Paseo on my own, with no investors and no company behind it, and I have turned
|
||||
down funding offers to keep it that way. A tool that sits between you and your code,
|
||||
your keys and your machines has to stay neutral, and funding creates pressure to
|
||||
monetize.
|
||||
</p>
|
||||
<p>
|
||||
Paseo is self-funded through sponsorship and{" "}
|
||||
<a href="/hub" className="underline hover:text-white/90">
|
||||
Paseo Hub
|
||||
</a>
|
||||
, and your support is what lets me work on it full time.
|
||||
</p>
|
||||
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
|
||||
</div>
|
||||
<BackingOptions />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Homepage: the four sponsor spots, as they look once filled. */
|
||||
export function SponsorsSection() {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeading as="h2" title="Sponsors" badge="New" />
|
||||
<SponsorLogoRow />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Homepage sponsor spots. This file is the inventory: the site shows one open
|
||||
* placeholder for every spot not listed in `HOMEPAGE_SPONSORS`.
|
||||
*
|
||||
* Approving a sponsor means three edits, or the site promises something it does
|
||||
* not deliver:
|
||||
*
|
||||
* 1. Add the entry below and drop its logo in `public/sponsors/`.
|
||||
* 2. Add the logo under the `## Sponsors` marker in `README.md`,
|
||||
* `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order.
|
||||
* 3. Remove all of it when the subscription ends.
|
||||
*
|
||||
* The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed
|
||||
* checkouts, and a checkout you decline and refund still counts against that
|
||||
* cap. When you refund one, raise the link's completed-session limit by one, or
|
||||
* the page will offer a spot the link refuses to sell.
|
||||
*/
|
||||
export interface HomepageSponsor {
|
||||
name: string;
|
||||
href: string;
|
||||
/** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */
|
||||
logo: string;
|
||||
}
|
||||
|
||||
export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = [];
|
||||
|
||||
export const HOMEPAGE_SPOT_COUNT = 4;
|
||||
export const HOMEPAGE_SPOT_PRICE = "$500";
|
||||
|
||||
/** Stripe Payment Link for the monthly spot subscription. */
|
||||
export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00";
|
||||
|
||||
export const SPONSOR_CONTACT_EMAIL = "hello@paseo.sh";
|
||||
|
||||
export const GITHUB_SPONSORS_URL = "https://github.com/sponsors/boudra";
|
||||
export const OPEN_COLLECTIVE_URL =
|
||||
"https://opencollective.com/paseo-ai/donate?interval=month&amount=10&contributeAs=me";
|
||||
export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo";
|
||||
|
||||
export function openSpotCount(): number {
|
||||
return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { SponsorPaseoSection, SponsorSpotSection } from "~/components/sponsorship";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export const Route = createFileRoute("/sponsor")({
|
||||
head: () =>
|
||||
pageMeta(
|
||||
"Sponsor Paseo",
|
||||
"Support the independent open-source project behind Paseo. Built by Mo, funded by the community.",
|
||||
"Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or put your company's logo on the homepage.",
|
||||
"/sponsor",
|
||||
),
|
||||
component: Sponsor,
|
||||
@@ -15,120 +16,10 @@ export const Route = createFileRoute("/sponsor")({
|
||||
function Sponsor() {
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-8">Sponsor</h1>
|
||||
|
||||
{/* Founder note */}
|
||||
<div className="space-y-6 text-white/70 leading-relaxed max-w-2xl">
|
||||
<p className="font-medium">
|
||||
Hey, I'm{" "}
|
||||
<a
|
||||
href="https://github.com/boudra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-white/80"
|
||||
>
|
||||
Mo
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Paseo started as a personal project: I wanted to run coding agents from anywhere without
|
||||
giving up control of my code, my keys, or my workflow.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I work on Paseo full-time. It's an independent, self-funded open-source project. I
|
||||
don't have investors, a board, or a big team. I'm just shipping software the
|
||||
best I know how and hope it's useful to other people.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sponsorship is what makes that sustainable. It lets me stay focused on the product instead
|
||||
of chasing monetization, and it sends a clear signal that independent, open-source tools
|
||||
have a place in developer workflows.
|
||||
</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
<div className="space-y-24">
|
||||
<SponsorPaseoSection />
|
||||
<SponsorSpotSection />
|
||||
</div>
|
||||
|
||||
{/* Sponsor links */}
|
||||
<section className="mt-12 space-y-4">
|
||||
<h2 className="text-xl font-medium">Support Paseo</h2>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a
|
||||
href="https://github.com/sponsors/boudra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">GitHub Sponsors</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://opencollective.com/paseo-ai/donate?interval=month&amount=10&contributeAs=me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 39.98 40.05"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path
|
||||
d="M32.76 19.97c0 2.53-.73 4.94-2 6.94l5.14 5.16c2.5-3.36 4.08-7.57 4.08-12.1 0-4.5-1.57-8.7-4.08-12.08l-5.13 5.16c1.26 2 2 4.3 2 6.93z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20 32.8c-7.02 0-12.78-5.78-12.78-12.83 0-7.04 5.76-12.82 12.77-12.82 2.6 0 4.9.73 6.9 2.1l5.13-5.15C28.68 1.58 24.5 0 20 0 9 0 0 8.94 0 20.08s9 19.97 20 19.97c4.6 0 8.8-1.57 12.14-4.1L27 30.8c-1.98 1.26-4.4 2-7 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">Open Collective</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://buymeacoffee.com/paseo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path
|
||||
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">Buy Me a Coffee</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user