Merge branch 'main' into dev

Resolve 38 conflicts preserving both sides' work:
- Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main)
- App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main)
- Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main)
- Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
This commit is contained in:
Mohamed Boudra
2026-04-14 03:05:45 +07:00
333 changed files with 13490 additions and 6317 deletions
+3 -1
View File
@@ -19,13 +19,15 @@ test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
+16 -12
View File
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -83,33 +84,36 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance
new (
config: DaemonClientConfig,
) => DaemonClientInstance
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance;
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
@@ -127,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
+27 -18
View File
@@ -3,8 +3,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
@@ -18,7 +23,7 @@ type ArchiveTabDaemonClient = {
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
@@ -63,33 +68,36 @@ function buildSeededStoragePayload() {
};
}
type ArchiveTabDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient;
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
@@ -100,17 +108,18 @@ export async function createIdleAgent(
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
}
return {
id: created.id,
+1 -1
View File
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
@@ -0,0 +1,27 @@
import WebSocket from "ws";
type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
export type NodeWebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> },
) => WebSocketLike;
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
return (url: string, options?: { headers?: Record<string, string> }) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
}
+38 -29
View File
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
@@ -49,29 +50,36 @@ function getServerId(): string {
return serverId;
}
type TerminalPerfDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
@@ -83,6 +91,10 @@ export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): stri
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
@@ -124,33 +136,29 @@ export async function navigateToTerminal(
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.click();
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -161,6 +169,7 @@ export async function navigateToTerminal(
// overlay may never appear if attachment is instant
});
await terminalSurface.scrollIntoViewIfNeeded();
await terminalSurface.click();
}
@@ -57,7 +57,11 @@ test.describe("Terminal wire performance", () => {
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
await waitForTerminalContent(
page,
(text) => text.includes(sentinel),
THROUGHPUT_BUDGET_MS + 15_000,
);
const elapsedMs = Date.now() - startMs;
@@ -84,9 +88,10 @@ test.describe("Terminal wire performance", () => {
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
expect(
elapsedMs,
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}