mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81a770d6c | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 |
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
it("renders heading text beside a linked picture without literal heading tags", () => {
|
||||
const source =
|
||||
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/l.svg",
|
||||
href: "https://example.com/r",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Confidence Score: 5/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
|
||||
"keeps a mixed %s heading together after preceding markdown",
|
||||
(heading) => {
|
||||
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "Intro\n\n" },
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Caption\n\nOutro" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves formatted text before and after images in a details heading", () => {
|
||||
const source =
|
||||
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "More",
|
||||
body: "**Before***After*",
|
||||
bodyParts: [
|
||||
{ kind: "markdown", text: "**Before**" },
|
||||
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
|
||||
{ kind: "markdown", text: "*After*" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mixed HTML headings literal in code examples", () => {
|
||||
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
|
||||
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("preserves unknown wrappers around text-only headings", () => {
|
||||
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
|
||||
{ kind: "markdown", text: "<section>Heading</section>" },
|
||||
]);
|
||||
});
|
||||
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
|
||||
@@ -125,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
if (isHeadingTag(token)) {
|
||||
const closeIndex = findMixedHeadingClose(tokens, cursor);
|
||||
if (closeIndex !== null) {
|
||||
// Headings render as their contents. Split those contents together so an
|
||||
// embedded image cannot strand the opening heading tag in a text slice.
|
||||
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
|
||||
appendDisplayPart(parts, part);
|
||||
}
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
if (
|
||||
isOpenTag(tokens[index], "details") ||
|
||||
findMixedHeadingClose(tokens, index) !== null ||
|
||||
parseInlineImageAt(tokens, index)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
|
||||
const token = tokens[start];
|
||||
if (!isHeadingTag(token)) {
|
||||
return null;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, start, token.name);
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
for (let index = start + 1; index < closeIndex; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return closeIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
|
||||
if (part.kind === "markdown") {
|
||||
appendMarkdownPart(parts, part.text);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,4 +147,29 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -133,6 +137,20 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
createClientChannel,
|
||||
type EncryptedChannel,
|
||||
@@ -8,7 +9,11 @@ import type {
|
||||
DaemonTransportFactory,
|
||||
TransportLogger,
|
||||
} from "./daemon-client-transport-types.js";
|
||||
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
|
||||
import {
|
||||
extractRelayMessage,
|
||||
normalizeTransportPayload,
|
||||
messageTooLargeError,
|
||||
} from "./daemon-client-transport-utils.js";
|
||||
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (event?: unknown) => void;
|
||||
@@ -137,7 +142,13 @@ export function createEncryptedTransport(
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
}
|
||||
void channel.send(normalizeTransportPayload(data)).catch((error) => {
|
||||
const payload = normalizeTransportPayload(data);
|
||||
// Reject synchronously so the requesting operation fails without closing the transport.
|
||||
const wireBytes = channel.outboundWireByteLength(payload);
|
||||
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
|
||||
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
void channel.send(payload).catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function messageTooLargeError(size: number, limit: number): Error {
|
||||
const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);
|
||||
return new Error(
|
||||
`Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7527,3 +7527,29 @@ test.each([false, true])(
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "message_limit_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
|
||||
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
|
||||
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
|
||||
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -13,6 +14,7 @@ import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
@@ -1132,6 +1134,19 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number
|
||||
return frame.byteLength;
|
||||
}
|
||||
|
||||
function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (typeof frame !== "string") {
|
||||
if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
return;
|
||||
}
|
||||
// UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured.
|
||||
if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return;
|
||||
const size = new TextEncoder().encode(frame).byteLength;
|
||||
if (size > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
|
||||
function describeInboundTransportFrame(
|
||||
frame: unknown,
|
||||
rawBytes: Uint8Array | null,
|
||||
@@ -1767,6 +1782,8 @@ export class DaemonClient {
|
||||
if (!this.transport) {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
}
|
||||
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
|
||||
assertTransportFrameFits(frame);
|
||||
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
|
||||
kind: typeof frame === "string" ? "text" : "binary",
|
||||
size: String(getTransportFrameSize(frame)),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */
|
||||
export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Largest encrypted frame the relay accepts. */
|
||||
export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -232,6 +232,7 @@ type SteeringReplayShape = "claude" | "codex";
|
||||
interface MockQuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
interface MockQuestionPromptQuestion {
|
||||
@@ -271,6 +272,16 @@ function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string |
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const ROLLOUT_FLAG_PREVIEW = [
|
||||
"Ship it dark behind the new-surface flag, then turn it on for internal users before everyone else.",
|
||||
"",
|
||||
'if (flags.enabled("new-surface")) {',
|
||||
" render(<NewSurface />);",
|
||||
"}",
|
||||
...Array.from({ length: 40 }, (_, i) => `// rollout step ${i + 1}`),
|
||||
"// LAST PREVIEW LINE",
|
||||
].join("\n");
|
||||
|
||||
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
|
||||
@@ -309,7 +320,10 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
|
||||
{
|
||||
question: "Which rollout should we use?",
|
||||
header: "rollout",
|
||||
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
|
||||
options: [
|
||||
{ label: "Immediately" },
|
||||
{ label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
cwd = mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
describe("a message larger than the daemon accepts", () => {
|
||||
const oversizedImage = { data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" };
|
||||
|
||||
test("fails the creation and keeps the connection up", async () => {
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = ctx.client.subscribeConnectionStatus((state) => {
|
||||
statuses.push(state.status);
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Oversized first message",
|
||||
initialPrompt: "Describe these screens.",
|
||||
images: [oversizedImage],
|
||||
}),
|
||||
).rejects.toThrow(/too large/);
|
||||
unsubscribe();
|
||||
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
expect((await ctx.client.fetchAgents()).entries).toEqual([]);
|
||||
}, 30000);
|
||||
|
||||
test("fails the send and keeps the connection up", async () => {
|
||||
const agent = await ctx.client.createAgent({ provider: "claude", cwd, title: "Agent" });
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = ctx.client.subscribeConnectionStatus((state) => {
|
||||
statuses.push(state.status);
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.client.sendMessage(agent.id, "Describe these screens.", { images: [oversizedImage] }),
|
||||
).rejects.toThrow(/too large/);
|
||||
unsubscribe();
|
||||
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { WebSocket } from "ws";
|
||||
import pino from "pino";
|
||||
import { Writable } from "node:stream";
|
||||
@@ -202,12 +204,20 @@ async function waitForCapturedLog(
|
||||
if (options.useLocalRelay) {
|
||||
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
|
||||
}
|
||||
relayProcess = spawn("npx", relayArgs, {
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
relayProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--experimental-vm-modules",
|
||||
path.resolve(relayDir, "../../node_modules/wrangler/wrangler-dist/cli.js"),
|
||||
...relayArgs.slice(1),
|
||||
],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
},
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
@@ -241,6 +251,73 @@ async function waitForCapturedLog(
|
||||
relayProcess = null;
|
||||
};
|
||||
|
||||
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
|
||||
await startRelay({ useLocalRelay: true });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
relayEnabled: true,
|
||||
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
});
|
||||
let client: DaemonClient | null = null;
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
});
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
client = new DaemonClient({
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64 },
|
||||
reconnect: { baseDelayMs: 100, maxDelayMs: 100 },
|
||||
});
|
||||
await client.connect();
|
||||
const statuses: string[] = [];
|
||||
client.subscribeConnectionStatus((state) => statuses.push(state.status));
|
||||
for (const imageMiB of [30, 61]) {
|
||||
// Attachments are base64 in the JSON, then E2EE base64 expands the frame again.
|
||||
const images = [
|
||||
{ data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" },
|
||||
];
|
||||
await expect(
|
||||
withTimeout(
|
||||
client.createWorkspace({
|
||||
source: { kind: "directory", path: daemon.staticDir },
|
||||
agent: {
|
||||
provider: "claude",
|
||||
cwd: daemon.staticDir,
|
||||
initialPrompt: "Describe these screens.",
|
||||
images,
|
||||
},
|
||||
}),
|
||||
10000,
|
||||
"Workspace creation did not settle",
|
||||
),
|
||||
).rejects.toThrow(/too large.*32\.0 MiB/);
|
||||
expect((await client.fetchAgents()).entries).toEqual([]);
|
||||
}
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir });
|
||||
await expect(
|
||||
client.sendMessage(agent.id, "Describe these screens.", {
|
||||
images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(/too large.*32\.0 MiB/);
|
||||
await client.sendMessage(agent.id, "A small follow-up still works.");
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(1);
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
} finally {
|
||||
await client?.close();
|
||||
await daemon.close();
|
||||
await stopRelay();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("daemon connects to relay and client ping/pong works through relay", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
|
||||
@@ -773,8 +773,12 @@ describe("Hub relationship", () => {
|
||||
const second = await relationship.ownedCreateResult("second-create");
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
|
||||
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
|
||||
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
|
||||
expect(first).toMatchObject({
|
||||
payload: { success: true, executionId: "first-execution", error: null },
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
payload: { success: true, executionId: "second-execution", error: null },
|
||||
});
|
||||
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import type { HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostnameAllowed } from "./hostnames.js";
|
||||
import {
|
||||
@@ -822,6 +823,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
maxPayload: MAX_WEBSOCKET_MESSAGE_BYTES,
|
||||
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
|
||||
verifyClient: ({ req }, callback) => {
|
||||
this.verifyWsUpgrade(
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocatedServices).toEqual(["api", "web", "worker"]);
|
||||
});
|
||||
|
||||
it("returns the existing plan without calling the allocator on later calls", async () => {
|
||||
it("extends the existing plan with newly declared services without reallocating peers", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||
@@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
|
||||
expect(secondPlan.has("new-service")).toBe(false);
|
||||
expect(allocationCount).toBe(2);
|
||||
expect(Array.from(secondPlan.entries())).toEqual([
|
||||
...firstPlan.entries(),
|
||||
["new-service", 4300],
|
||||
]);
|
||||
expect(allocationCount).toBe(3);
|
||||
});
|
||||
|
||||
it("shares one first-plan build across concurrent callers", async () => {
|
||||
@@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("extends a pending plan for a concurrent caller with another service", async () => {
|
||||
const allocation = createDeferredPort();
|
||||
const workspaceId = "registry-concurrent-extension-workspace";
|
||||
const firstPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
let allocations = 0;
|
||||
const secondPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
return 6101;
|
||||
},
|
||||
});
|
||||
allocation.resolve(6100);
|
||||
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
|
||||
expect(Array.from((await secondPlan).entries())).toEqual([
|
||||
["backend-dev", 6100],
|
||||
["web", 6101],
|
||||
]);
|
||||
expect(allocations).toBe(1);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("keeps the previous plan and reservations when an extension fails", async () => {
|
||||
const workspaceId = "registry-failed-extension-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6200,
|
||||
});
|
||||
let allocations = 0;
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
if (allocations === 1) return 6201;
|
||||
throw new Error("Allocation failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Allocation failed");
|
||||
const priorPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Existing port must survive");
|
||||
},
|
||||
});
|
||||
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
|
||||
const otherWorkspace = "registry-failed-extension-other-workspace";
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: otherWorkspace,
|
||||
services: [{ scriptName: "web" }],
|
||||
allocatePort: async ({ reservedPorts }) => {
|
||||
expect(reservedPorts.has(6200)).toBe(true);
|
||||
expect(reservedPorts.has(6201)).toBe(false);
|
||||
return 6201;
|
||||
},
|
||||
});
|
||||
expect(reusedPlan.get("web")).toBe(6201);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
releaseWorkspaceServicePortPlan(otherWorkspace);
|
||||
});
|
||||
|
||||
it("rejects a new explicit port that collides with an existing service", async () => {
|
||||
const workspaceId = "registry-extension-explicit-collision-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6300,
|
||||
});
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
|
||||
allocatePort: async () => 6301,
|
||||
}),
|
||||
).rejects.toThrow("Service 'web' has a duplicate port 6300");
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6300],
|
||||
["web", 6301],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses explicit configured ports without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
@@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
|
||||
const workspaceId = "registry-refresh-and-extend-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6400,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "backend-dev" },
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
const extending = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Explicit addition needs no allocation");
|
||||
},
|
||||
});
|
||||
allocation.resolve(6401);
|
||||
expect(await refreshing).toBe(6401);
|
||||
await extending;
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6401],
|
||||
["web", 6402],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it.each(["reused port", "allocation failure"])(
|
||||
"cancels a pending refresh and its waiter after workspace release (%s)",
|
||||
async (outcome) => {
|
||||
const workspaceId = "registry-cancelled-refresh-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
const port = await allocation.promise;
|
||||
if (outcome === "allocation failure") throw new Error("Allocation failed");
|
||||
return port;
|
||||
},
|
||||
});
|
||||
const waiting = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web", port: 6501 },
|
||||
allocatePort: async () => 6501,
|
||||
});
|
||||
const assertions = Promise.all([
|
||||
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
|
||||
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
allocation.resolve(6500);
|
||||
await assertions;
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-after-cancelled-refresh-workspace",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
expect(reusedPlan.get("api")).toBe(6500);
|
||||
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a waiting service restart allocate after another service's restart fails", async () => {
|
||||
const workspaceId = "registry-independent-refresh-failure-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: createSequentialPortAllocator(6600),
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const failedRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
await allocation.promise;
|
||||
throw new Error("API port script failed");
|
||||
},
|
||||
});
|
||||
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
|
||||
const waitingRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web" },
|
||||
allocatePort: async () => 6602,
|
||||
});
|
||||
allocation.resolve(0);
|
||||
await failedAssertion;
|
||||
expect(await waitingRefresh).toBe(6602);
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["api", 6600],
|
||||
["web", 6602],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses an explicit configured port without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
|
||||
@@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken {
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
class WorkspaceServicePortPlanReleasedError extends Error {
|
||||
constructor(workspaceId: string) {
|
||||
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkspaceServicePortPlanUpdate(
|
||||
pendingPlan: Promise<Map<string, number>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pendingPlan;
|
||||
} catch (error) {
|
||||
// Allocation failure belongs to the original request. A waiting request
|
||||
// retries against the rolled-back plan; workspace archival cancels both.
|
||||
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
||||
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
|
||||
const pendingWorkspaceServicePortPlanTokens = new Map<
|
||||
@@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
|
||||
export async function ensureWorkspaceServicePortPlan(
|
||||
options: EnsureWorkspaceServicePortPlanOptions,
|
||||
): Promise<ReadonlyMap<string, number>> {
|
||||
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
|
||||
if (existingPlan) {
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return ensureWorkspaceServicePortPlan(options);
|
||||
}
|
||||
|
||||
const existingPlan =
|
||||
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
|
||||
return new Map(existingPlan);
|
||||
}
|
||||
|
||||
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (!pendingPlan) {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
pendingPlan = createPendingWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
token,
|
||||
});
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
}
|
||||
|
||||
return new Map(await pendingPlan);
|
||||
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
|
||||
}
|
||||
|
||||
export function requirePlannedWorkspaceServicePort(
|
||||
@@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
|
||||
dynamicPortsByWorkspace.delete(workspaceId);
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(options: {
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
token: PendingWorkspaceServicePortPlanToken;
|
||||
}): Promise<Map<string, number>> {
|
||||
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
refreshScriptName?: string;
|
||||
}
|
||||
|
||||
function startWorkspaceServicePortPlanUpdate(
|
||||
options: WorkspaceServicePortPlanUpdateOptions,
|
||||
): Promise<Map<string, number>> {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(
|
||||
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
|
||||
): Promise<Map<string, number>> {
|
||||
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
|
||||
const retainedPlan = new Map(options.existingPlan);
|
||||
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
|
||||
try {
|
||||
const plan = await buildWorkspaceServicePortPlan({
|
||||
const updatedPlan = await buildWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
existingPlan: retainedPlan,
|
||||
});
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
|
||||
if (options.token.isReleased) {
|
||||
throw new Error(
|
||||
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
|
||||
);
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
}
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
if (plan.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return plan;
|
||||
} catch (error) {
|
||||
releaseWorkspaceServicePortPlan(options.workspaceId);
|
||||
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
|
||||
if (dynamicPorts) {
|
||||
for (const [scriptName, port] of dynamicPorts) {
|
||||
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!options.token.isReleased) {
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
if (options.token.isReleased)
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
throw error;
|
||||
} finally {
|
||||
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
||||
@@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: {
|
||||
}
|
||||
|
||||
async function buildWorkspaceServicePortPlan(options: {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
}): Promise<Map<string, number>> {
|
||||
const explicitPortOwners = new Map<number, string>();
|
||||
const explicitPortOwners = new Map<number, string>(
|
||||
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
|
||||
);
|
||||
for (const service of options.services) {
|
||||
if (options.existingPlan.has(service.scriptName)) continue;
|
||||
if (service.port === undefined) continue;
|
||||
if (explicitPortOwners.has(service.port)) {
|
||||
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
|
||||
@@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
explicitPortOwners.set(service.port, service.scriptName);
|
||||
}
|
||||
|
||||
const plan = new Map<string, number>();
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const service of options.services) {
|
||||
if (plan.has(service.scriptName)) continue;
|
||||
if (service.port !== undefined) {
|
||||
plan.set(service.scriptName, service.port);
|
||||
continue;
|
||||
@@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
export async function refreshWorkspaceServicePort(
|
||||
options: RefreshWorkspaceServicePortOptions,
|
||||
): Promise<number> {
|
||||
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return refreshWorkspaceServicePort(options);
|
||||
}
|
||||
|
||||
const reservedPorts = new Set(plan.values());
|
||||
const previousPort = plan.get(options.service.scriptName);
|
||||
if (previousPort !== undefined) {
|
||||
reservedPorts.delete(previousPort);
|
||||
}
|
||||
const oldDynamicPort = dynamicPortsByWorkspace
|
||||
.get(options.workspaceId)
|
||||
?.get(options.service.scriptName);
|
||||
const port = await resolveServicePort({
|
||||
service: options.service,
|
||||
const plan = await startWorkspaceServicePortPlanUpdate({
|
||||
workspaceId: options.workspaceId,
|
||||
services: [options.service],
|
||||
allocatePort: options.allocatePort,
|
||||
reservedPorts,
|
||||
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
|
||||
refreshScriptName: options.service.scriptName,
|
||||
});
|
||||
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
|
||||
releaseDynamicPort({
|
||||
workspaceId: options.workspaceId,
|
||||
scriptName: options.service.scriptName,
|
||||
port: oldDynamicPort,
|
||||
});
|
||||
}
|
||||
plan.set(options.service.scriptName, port);
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return port;
|
||||
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
|
||||
}
|
||||
|
||||
async function resolveServicePort(options: {
|
||||
|
||||
@@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a service added to paseo.json without restarting the daemon", async () => {
|
||||
const backend = {
|
||||
type: "service" as const,
|
||||
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
|
||||
};
|
||||
commitPaseoScripts({ "backend-dev": backend });
|
||||
|
||||
const serviceProxy = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
const options = {
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
branchName: "feature-added-service",
|
||||
daemonPort: 6767,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
};
|
||||
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
"backend-dev": backend,
|
||||
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
|
||||
|
||||
expect(webResult.port).toEqual(expect.any(Number));
|
||||
expect(webResult.port).not.toBe(backendResult.port);
|
||||
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
|
||||
String(backendResult.port),
|
||||
);
|
||||
expect(serviceProxy.listRoutes()).toHaveLength(2);
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
|
||||
lifecycle: "running",
|
||||
terminalId: backendResult.terminalId,
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with public aliases and public service URLs", async () => {
|
||||
commitPaseoScripts(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
@@ -2152,13 +2153,13 @@ export async function getCheckoutSnapshotFacts(
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
||||
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
||||
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
|
||||
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
|
||||
// the surrounding WebSocket JSON envelope after inverting that exact wire expansion.
|
||||
export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES =
|
||||
maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
|
||||
maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
|
||||
CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
|
||||
|
||||
interface StructuredDiffAccumulator {
|
||||
files: ParsedDiffFile[];
|
||||
|
||||
Reference in New Issue
Block a user