Key workspace status and ownership by ID, not directory

Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
This commit is contained in:
Mohamed Boudra
2026-06-16 13:37:58 +07:00
parent ba9f7740a8
commit 9966e49329
102 changed files with 3222 additions and 1932 deletions
+2
View File
@@ -44,6 +44,7 @@ export interface IdleAgentSeedClient {
model: string;
modeId: string;
cwd: string;
workspaceId: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
@@ -66,6 +67,7 @@ export async function createIdleAgent(
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: opened.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion,
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
+1
View File
@@ -33,6 +33,7 @@ export async function seedMockAgentWorkspace(
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
+13 -11
View File
@@ -14,7 +14,9 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "openProject"
| "patchDaemonConfig"
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
@@ -314,18 +316,18 @@ export async function assertNewWorkspaceSidebarAndHeader(
assertHeader?: boolean;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
await expect
.poll(
() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
)
.not.toBeNull();
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
-27
View File
@@ -1,32 +1,5 @@
import { type Page } from "@playwright/test";
/**
* Listens for outbound WebSocket "session" frames of a given inner message type
* and accumulates them. The returned array is populated in-place as frames arrive.
*/
export function captureWsSessionFrames<T extends Record<string, unknown>>(
page: Page,
messageType: string,
extract: (inner: Record<string, unknown>) => T,
): T[] {
const captured: T[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
const raw = frame.payload;
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
if (outer.type === "session" && outer.message?.type === messageType) {
captured.push(extract(outer.message));
}
} catch {
// Ignore non-JSON and binary frames.
}
});
});
return captured;
}
export function renameModalInput(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-input`);
}
+17 -2
View File
@@ -58,7 +58,11 @@ export interface SeedDaemonClient {
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
error: string | null;
}>;
listTerminals(cwd?: string): Promise<{
listTerminals(
cwd?: string,
requestId?: string,
options?: { workspaceId?: string },
): Promise<{
terminals: Array<{
id: string;
name: string;
@@ -80,7 +84,18 @@ export interface SeedDaemonClient {
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
entries: Array<{
agent: {
id: string;
provider: string;
cwd: string;
workspaceId?: string;
model: string | null;
currentModeId: string | null;
status: string;
title?: string | null;
};
}>;
}>;
fetchRecentProviderSessions(options: {
cwd: string;