Files
paseo/packages/app/e2e/import-session.opencode.real.spec.ts
T
Mohamed BoudraandGitHub 20e46e3e3f Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
2026-07-29 23:36:31 +02:00

241 lines
7.6 KiB
TypeScript

import { randomUUID } from "node:crypto";
import path from "node:path";
import { spawn } from "node:child_process";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const OPENCODE_SEED_TIMEOUT_MS = 45_000;
const PASEO_REPO_PATH = path.resolve(__dirname, "../../..");
interface OpenCodeSeedResult {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
}
interface ImportableOpenCodeSession {
providerHandleId: string;
}
interface OpenCodeImportScenario {
workspace: SeededWorkspace;
prompt: string;
promptPreview: string;
response: string;
}
let workspace: SeededWorkspace | null = null;
test.setTimeout(150_000);
test.afterEach(async () => {
await workspace?.cleanup().catch(() => undefined);
workspace = null;
});
test("imports a real OpenCode session from the workspace import sheet", async ({ page }) => {
const scenario = await seedPaseoWorkspaceWithOpenCodeSession();
workspace = scenario.workspace;
const importableSession = await waitForImportableOpenCodeSession(scenario);
await openWorkspace(page, scenario.workspace);
await importOpenCodeSession(page, importableSession);
await expectImportSheetClosed(page);
await expectImportedSessionOpen(page, scenario);
});
async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportScenario> {
const response = `PASEO_OPENCODE_IMPORT_E2E_OK_${randomUUID().slice(0, 8)}`;
const prompt = `Do not use tools. Reply with exactly: ${response}`;
const promptPreview = JSON.stringify(prompt);
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
const client = await connectSeedClient();
try {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: PASEO_REPO_PATH },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
response,
workspace: {
client,
repoPath: PASEO_REPO_PATH,
workspaceId: createdWorkspace.workspace.id,
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
},
},
};
} catch (error) {
await client.close().catch(() => undefined);
throw error;
}
}
async function launchOpenCodeSessionInWorkspace(repoPath: string, prompt: string): Promise<void> {
const result = await runOpenCodeSeed(repoPath, prompt);
if (result.code !== 0 || result.timedOut) {
throw new Error(formatOpenCodeLaunchError(result, prompt));
}
}
function openCodeSeedArgs(repoPath: string, prompt: string): string[] {
return [
"run",
"--print-logs",
"--log-level",
"INFO",
"--dir",
repoPath,
"--model",
OPENCODE_REAL_MODEL,
"--format",
"json",
prompt,
];
}
function runOpenCodeSeed(repoPath: string, prompt: string): Promise<OpenCodeSeedResult> {
return new Promise((resolve, reject) => {
const child = spawn("opencode", openCodeSeedArgs(repoPath, prompt), {
cwd: repoPath,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, OPENCODE_SEED_TIMEOUT_MS);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
resolve({ stdout, stderr, code, signal, timedOut });
});
});
}
function formatOpenCodeLaunchError(result: OpenCodeSeedResult, prompt: string): string {
return [
"OpenCode launch failed",
`command: ${["opencode", ...openCodeSeedArgs(PASEO_REPO_PATH, prompt)].join(" ")}`,
`exit: ${result.code ?? "null"}`,
result.signal ? `signal: ${result.signal}` : null,
result.timedOut ? `timed out after ${OPENCODE_SEED_TIMEOUT_MS}ms` : null,
result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null,
result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null,
]
.filter((part): part is string => part !== null)
.join("\n\n");
}
async function openWorkspace(page: Page, seed: SeededWorkspace): Promise<void> {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), seed.workspaceId));
await waitForWorkspaceTabsVisible(page);
}
async function waitForImportableOpenCodeSession(
scenario: OpenCodeImportScenario,
): Promise<ImportableOpenCodeSession> {
let importableSession: ImportableOpenCodeSession | null = null;
await expect
.poll(
async () => {
importableSession = await findImportableOpenCodeSession(scenario);
return importableSession?.providerHandleId ?? "";
},
{
timeout: 15_000,
intervals: [500, 1_000],
},
)
.not.toBe("");
return importableSession!;
}
async function findImportableOpenCodeSession(
scenario: OpenCodeImportScenario,
): Promise<ImportableOpenCodeSession | null> {
const sessions = await scenario.workspace.client.fetchRecentProviderSessions({
cwd: scenario.workspace.repoPath,
providers: ["opencode"],
limit: 5,
});
const entry = sessions.entries.find(
(session) =>
session.providerId === "opencode" && session.firstPromptPreview === scenario.promptPreview,
);
if (!entry) {
return null;
}
return { providerHandleId: entry.providerHandleId };
}
async function importOpenCodeSession(
page: Page,
session: ImportableOpenCodeSession,
): Promise<void> {
await page.getByRole("button", { name: "Workspace actions" }).click();
await page.getByTestId("workspace-header-import-agent").click();
await expect(page.getByTestId("import-session-sheet")).toBeVisible({ timeout: 15_000 });
const importSheet = page.getByTestId("import-session-sheet");
const sessionRow = importSheet.getByTestId(
`import-session-session-opencode-${session.providerHandleId}`,
);
await expect(sessionRow).toBeVisible({ timeout: 60_000 });
await sessionRow.click();
}
async function expectImportSheetClosed(page: Page): Promise<void> {
await expect(page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 15_000 });
}
async function expectImportedSessionOpen(
page: Page,
scenario: OpenCodeImportScenario,
): Promise<void> {
await expect(
page.locator('[data-testid="user-message"]', { hasText: scenario.promptPreview }),
).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-testid="assistant-message"]', { hasText: scenario.response }),
).toBeVisible({ timeout: 30_000 });
}