* 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.
241 lines
7.6 KiB
TypeScript
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 });
|
|
}
|