Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] a81a770d6c fix(app): render mixed HTML headings without literal tags
Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag.
2026-10-14 11:11:18 +02:00
paseo-bot[bot] d57096a456 Start newly configured workspace services without restarting (#6542)
* fix(server): start newly configured workspace services without restarting

Extend the workspace port plan when paseo.json declares another service, preserving existing peer ports. Serialize extensions and roll back only their new reservations on failure.

* fix(server): serialize service restarts with port plan extensions

Keep concurrent restart and newly added service allocations in the same workspace update transaction so neither overwrites the other.

* fix(server): keep service port update failures scoped and release cancelled reservations

* test(hub): show creation errors in re-enrollment failures

Assert the error field along with success so failed CI reports include the reason agent creation was rejected.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 11:07:06 +02:00
paseo-bot[bot] ada7ba43f0 Keep long custom models removable in provider settings (#6507)
* fix(app): keep long custom models removable

Custom model labels and IDs could consume the row width and push Remove outside the sheet. Allow those text cells to shrink while preserving the button width and short-name alignment.

* test(app): wait for legacy agent creation before checking prompts

Agent-list membership includes initialization. Waiting for finish at that point can return on the initial idle transition before the initial prompt starts. Wait for the matching creation response before checking each completed turn, preserving the existing exact-once assertions.

* test(app): wait for directory search keyboard focus

The search page becomes visible before its deferred focus callback runs. Wait for the input to own keyboard focus so the journey types its query rather than dropping it during navigation.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 10:21:52 +02:00
paseo-bot[bot] fc8f085b9e Reject oversized messages without disconnecting the host (#6285)
* fix(client): reject a message over the daemon's size limit instead of reconnecting in a loop

The daemon closes the WebSocket with 1009 when a message exceeds its 100 MiB
limit. A workspace or agent creation that loses its connection mid-request
stays pending and, after reconnecting, finds no record of itself on the
daemon (the oversized frame was never read), so it resubmits the same frame
and the connection drops again every couple of seconds until the app reloads.

The client now checks each outbound frame against the same limit, which
protocol exports and the daemon sets explicitly, and fails that request with
a size error while the connection stays up.

Refs #5501

* test(client): cover the UTF-8 byte count at the message size limit

* fix(client): reject oversized relay messages without disconnecting

Check the encrypted wire size synchronously before submission, so a frame below the direct limit cannot close the relay and leave workspace creation resubmitting forever. Keep the relay limit shared with the existing checkout diff budget.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 08:20:40 +02:00
paseo-bot[bot]andGabriel Giordano 019e5279e9 Show Claude option previews in the question card, readable on Android (#6171)
* Show Claude option previews in the question card

* Wrap long lines in question option previews

* Check option previews under the option that owns them

* Show the whole option preview on native so every line is reachable

On Android, a slow drag inside a preview capped at 240px did not scroll
it, so lines past the cap could not be read. The preview's nested
scroll sits inside the option's Pressable; the same drag scrolls it
once the preview is outside the Pressable. Native now renders the
preview at full height so the chat scroll reaches every line, and a tap
on it still selects the option. Web keeps the 240px cap.

The mock agent's rollout preview is now taller than the cap so the
Android replay can check the last line.

---------

Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 07:13:06 +02:00
29 changed files with 882 additions and 78 deletions
@@ -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.`,
);
}
+26
View File
@@ -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");
});
+17
View File
@@ -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;
+1 -1
View File
@@ -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(
{
+3 -2
View File
@@ -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[];