diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f847914c4..e8a1df50e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -58,7 +58,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running. -Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to every active workspace with the same `cwd` as their owning workspace; tab and agent visibility still stays scoped to the agent's `workspaceId`. Running subagents contribute `running` to workspaces with the same `cwd` as their root parent's owning workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. +Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. ## The subagents track diff --git a/docs/data-model.md b/docs/data-model.md index c3fd69695..71e68c7bd 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -40,30 +40,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b Each agent is stored as a separate JSON file, grouped by project directory. -| Field | Type | Description | -| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | `string` | UUID, primary key | -| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | -| `cwd` | `string` | Working directory the agent operates in | -| `workspaceId` | `string?` | Owning workspace id. Legacy cwd-only records are migrated to a stable existing workspace owner; runtime workspace membership should not infer ownership from cwd when same-CWD workspaces exist. | -| `createdAt` | `string` (ISO 8601) | Creation timestamp | -| `updatedAt` | `string` (ISO 8601) | Last update timestamp | -| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | -| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | -| `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) | -| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | -| `lastModeId` | `string?` | Last active mode ID | -| `config` | `SerializableConfig?` | Agent session configuration (see below) | -| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | -| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | -| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | -| `lastError` | `string?` (nullable) | Last error message, if any | -| `requiresAttention` | `boolean?` | Whether the agent needs user attention | -| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | -| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | -| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | -| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | +| Field | Type | Description | +| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `lastError` | `string?` (nullable) | Last error message, if any | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | ### Nested: SerializableConfig @@ -129,9 +129,9 @@ Each agent is stored as a separate JSON file, grouped by project directory. ## Runtime-only Terminal Sessions -Terminals are live daemon state, not persisted JSON records. A terminal may carry a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but are not migrated or inferred into same-`cwd` workspace lists. +Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status. -Terminal activity still contributes to the aggregate workspace status bucket. When an owned terminal contributes activity, every active workspace with the owning workspace's `cwd` receives the same status bucket; terminal visibility itself stays `workspaceId`-scoped. +Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped. --- diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index 9f0d8cddc..28eca1350 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -32,7 +32,9 @@ Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification. -Terminal workspace membership is path-prefix based: a terminal opened in a workspace subdirectory rolls up to the deepest active parent workspace for status buckets and notification routing. +Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`. + +Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists. ## Hook reporting diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 111e2d10b..23f9eb3e0 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -23,7 +23,7 @@ async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); - server.listen(0, () => { + server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Failed to acquire port"))); @@ -34,6 +34,14 @@ async function getAvailablePort(): Promise { }); } +const RESERVED_LOCAL_PORTS = new Set([ + // Default developer daemon. + 6767, + // OpenCode's default local server port. Some provider probes can spawn it + // during daemon startup, so the E2E daemon must not choose the same port. + 61680, +]); + function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } { const lines: string[] = []; return { @@ -486,7 +494,7 @@ async function awaitRelayReady( async function getAvailablePortExcluding(excludedPorts: Set): Promise { for (;;) { const port = await getAvailablePort(); - if (!excludedPorts.has(port)) { + if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) { return port; } } @@ -677,8 +685,8 @@ export default async function globalSetup() { ensureRelayBuildArtifact(repoRoot); await loadEnvTestFile(repoRoot); - const port = await getAvailablePort(); - const metroPort = await getAvailablePort(); + const port = await getAvailablePortExcluding(new Set()); + const metroPort = await getAvailablePortExcluding(new Set([port])); const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 1428d6744..68ffac946 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -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( diff --git a/packages/app/e2e/helpers/daemon-client-loader.ts b/packages/app/e2e/helpers/daemon-client-loader.ts index 17e1a8327..3a2a1b7ce 100644 --- a/packages/app/e2e/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/helpers/daemon-client-loader.ts @@ -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; type OpenProjectPayload = Awaited>; @@ -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()}`); } diff --git a/packages/app/e2e/helpers/rename.ts b/packages/app/e2e/helpers/rename.ts index f2b18949f..41b2c125a 100644 --- a/packages/app/e2e/helpers/rename.ts +++ b/packages/app/e2e/helpers/rename.ts @@ -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>( - page: Page, - messageType: string, - extract: (inner: Record) => 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 }; - 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`); } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index c171200ef..1766c923f 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -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; diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts index b1d178371..f2516b22a 100644 --- a/packages/app/e2e/workspace-agent-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -1,10 +1,10 @@ import { randomUUID } from "node:crypto"; import { test, expect, type Page } from "./fixtures"; -import { seedWorkspace } from "./helpers/seed-client"; +import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; +import { renameModalInput, renameModalSubmit } from "./helpers/rename"; import { getServerId } from "./helpers/server-id"; async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) { @@ -17,8 +17,13 @@ async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId await expectWorkspaceTabVisible(page, agent.id); } +async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; +} + test.describe("Workspace agent tab rename", () => { - test("right-click rename sends update_agent_request and updates the tab label", async ({ + test("right-click rename persists the agent title and updates the tab label", async ({ page, }) => { test.setTimeout(120_000); @@ -32,12 +37,6 @@ test.describe("Workspace agent tab rename", () => { title: initialTitle, }); - const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({ - agentId: String(inner.agentId ?? ""), - name: String(inner.name ?? ""), - requestId: String(inner.requestId ?? ""), - })); - await openAgentInWorkspace(page, agent); const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); @@ -62,12 +61,7 @@ test.describe("Workspace agent tab rename", () => { await expect(input).toHaveCount(0, { timeout: 15_000 }); await expect(tab).toContainText(renamed, { timeout: 15_000 }); - - expect(updateFrames.length).toBeGreaterThan(0); - const lastFrame = updateFrames.at(-1)!; - expect(lastFrame.agentId).toBe(agent.id); - expect(lastFrame.name).toBe(renamed); - expect(lastFrame.requestId.length).toBeGreaterThan(0); + await expect.poll(() => fetchAgentTitle(workspace.client, agent.id)).toBe(renamed); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/workspace-agent-title-handoff.spec.ts index d75acc582..f4006b134 100644 --- a/packages/app/e2e/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/workspace-agent-title-handoff.spec.ts @@ -1,135 +1,35 @@ -import { test, expect, type Page } from "./fixtures"; +import { test, expect } from "./fixtures"; import { expectComposerVisible, submitMessage } from "./helpers/composer"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; -import { captureWsSessionFrames } from "./helpers/rename"; import { getServerId } from "./helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -interface WorkspaceTabProbeRecord { - at: number; - tabs: Array<{ - testId: string; - text: string; - ariaLabel: string; - }>; -} - -interface CapturedCreateAgentFrame { - initialPrompt: string | null; - configTitle: string | null; -} - -async function installWorkspaceTabProbe(page: Page): Promise { - await page.evaluate(() => { - type ProbeRecord = WorkspaceTabProbeRecord; - type ProbeWindow = Window & { - __workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void }; - }; - - const win = window as ProbeWindow; - win.__workspaceTabTitleProbe?.stop(); - - const records: ProbeRecord[] = []; - const isVisible = (element: Element): element is HTMLElement => { - if (!(element instanceof HTMLElement)) { - return false; - } - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" - ); - }; - const snapshot = () => { - records.push({ - at: performance.now(), - tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]')) - .filter(isVisible) - .map((element) => ({ - testId: element.getAttribute("data-testid") ?? "", - text: (element.textContent ?? "").replace(/\s+/g, " ").trim(), - ariaLabel: element.getAttribute("aria-label") ?? "", - })), - }); - }; - - snapshot(); - const observer = new MutationObserver(snapshot); - observer.observe(document.body, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ["aria-label", "class", "data-testid", "style"], - }); - const interval = window.setInterval(snapshot, 20); - win.__workspaceTabTitleProbe = { - records, - stop: () => { - observer.disconnect(); - window.clearInterval(interval); - }, - }; - }); -} - -async function readWorkspaceTabProbe(page: Page): Promise { - return page.evaluate(() => { - type ProbeWindow = Window & { - __workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void }; - }; - const probe = (window as ProbeWindow).__workspaceTabTitleProbe; - probe?.stop(); - return probe?.records ?? []; - }); -} - -function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean { - return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label)); -} - -function createFrameStartsWithPrompt( - frame: CapturedCreateAgentFrame, - promptTitle: string, -): boolean { - return frame.initialPrompt?.startsWith(promptTitle) ?? false; -} - -function countCreateFramesForPrompt( - frames: CapturedCreateAgentFrame[], - promptTitle: string, -): number { - return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length; -} - -function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean { - return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`); -} - -function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean { - return record.tabs.some(tabHasLoadingTitle); -} - -async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise { +async function waitForCreatedAgentId( + client: SeedDaemonClient, + input: { cwd: string; workspaceId: string }, +): Promise { await expect .poll( async () => { const result = await client.fetchAgents({ scope: "active" }); return result.entries - .filter((entry) => entry.agent.cwd === cwd) + .filter( + (entry) => + entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId, + ) .map((entry) => entry.agent.id); }, { timeout: 30_000 }, ) .toHaveLength(1); const result = await client.fetchAgents({ scope: "active" }); - const agent = result.entries.find((entry) => entry.agent.cwd === cwd); + const agent = result.entries.find( + (entry) => entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId, + ); if (!agent) { - throw new Error(`Expected one created agent in ${cwd}`); + throw new Error(`Expected one created agent in ${input.cwd}`); } return agent.agent.id; } @@ -142,43 +42,17 @@ async function fetchActiveAgentTitle( return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; } -async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise { - const promptTab = page.getByRole("button", { name: promptTitle }).first(); - await expect(promptTab).toBeVisible({ timeout: 15_000 }); - - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - await promptTab.click({ button: "right" }); - const renameAction = page.getByText("Rename", { exact: true }).first(); - if (await renameAction.isVisible().catch(() => false)) { - await page.keyboard.press("Escape"); - return; - } - await page.keyboard.press("Escape").catch(() => undefined); - await page.waitForTimeout(100); - } - - throw new Error("Prompt tab did not expose agent tab actions after create handoff"); -} - test.describe("Workspace agent title handoff", () => { - test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({ + test("shows the prompt tab title and replaces it when the daemon title updates", async ({ page, }) => { test.setTimeout(120_000); await page.setViewportSize({ width: 1440, height: 900 }); + const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" }); try { - const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => { - const config = (inner.config ?? {}) as Record; - return { - initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, - configTitle: typeof config.title === "string" ? config.title : null, - }; - }); - await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await waitForWorkspaceTabsVisible(page); await page.getByTestId("workspace-new-agent-tab-inline").click(); @@ -186,21 +60,39 @@ test.describe("Workspace agent title handoff", () => { const promptTitle = "Investigate optimistic tab title handoff"; const generatedTitle = "Generated Handoff Title"; - await installWorkspaceTabProbe(page); await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`); + await agentCreatedDelay.waitForCreateRequest(); + await agentCreatedDelay.waitForDelayedCreatedStatus(); - const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath); - await expect - .poll(() => countCreateFramesForPrompt(createFrames, promptTitle), { - timeout: 10_000, - }) - .toBe(1); - expect(createFrames.at(-1)).toEqual({ - initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`, - configTitle: null, + await expect(page.getByRole("button", { name: promptTitle }).first()).toBeVisible({ + timeout: 15_000, + }); + await expect( + page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }), + ).toHaveCount(0); + + const agentId = await waitForCreatedAgentId(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, }); - await waitForPromptTabAgentActions(page, promptTitle); + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0); + agentCreatedDelay.release(); + + const agentTab = page.getByTestId(`workspace-tab-agent_${agentId}`).first(); + await expect(agentTab).toBeVisible({ timeout: 15_000 }); + await expect + .poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 }) + .toBe(promptTitle); + await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 }); + await agentTab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({ + timeout: 10_000, + }); + await page.keyboard.press("Escape"); + await expect( + page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }), + ).toHaveCount(0); await workspace.client.updateAgent(agentId, { name: generatedTitle }); await expect @@ -209,12 +101,8 @@ test.describe("Workspace agent title handoff", () => { await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({ timeout: 15_000, }); - - const records = await readWorkspaceTabProbe(page); - expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true); - expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true); - expect(records.filter(recordHasLoadingTitle)).toEqual([]); } finally { + agentCreatedDelay.release(); await workspace.cleanup(); } }); diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index 831cad77d..01544b735 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "./fixtures"; -import { clickNewChat, clickNewTerminal } from "./helpers/launcher"; -import { captureWsSessionFrames } from "./helpers/rename"; +import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { expectTerminalSurfaceVisible, focusTerminalSurface, @@ -9,13 +9,25 @@ import { waitForTerminalContent, } from "./helpers/terminal-perf"; -interface CreateAgentFrame { - initialPrompt: string | null; +interface CreatedAgentCwdAssertion { + workspaceId: string; cwd: string | null; } -function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null { - return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null; +async function fetchSingleAgentForWorkspace( + workspace: SeededWorkspace, +): Promise { + const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries + .map((entry) => entry.agent) + .filter((agent) => agent.workspaceId === workspace.workspaceId); + if (agents.length !== 1) { + return null; + } + const [agent] = agents; + return { + workspaceId: workspace.workspaceId, + cwd: agent.cwd, + }; } test.describe("Workspace cwd correctness", () => { @@ -36,37 +48,36 @@ test.describe("Workspace cwd correctness", () => { await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); }); - test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => { + test("draft tab creates an agent in the workspace cwd", async ({ page }) => { test.setTimeout(60_000); - const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => { - const config = (inner.config ?? {}) as Record; - return { - initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, - cwd: typeof config.cwd === "string" ? config.cwd : null, - }; - }); + const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); - const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" }); - await workspace.navigateTo(); + await clickNewChat(page); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + const message = `cwd draft create ${Date.now()}`; + await expect(composer).toBeEditable({ timeout: 15_000 }); + await composer.fill(message); + await composer.press("Enter"); + await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); - await clickNewChat(page); - const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); - const message = `cwd draft create ${Date.now()}`; - await expect(composer).toBeEditable({ timeout: 15_000 }); - await composer.fill(message); - await composer.press("Enter"); - await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); + await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ + timeout: 30_000, + }); - await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ - timeout: 30_000, - }); - - await expect - .poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 }) - .toBe(workspace.repoPath); + await expect + .poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 }) + .toEqual({ + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + }); + } finally { + await workspace.cleanup(); + } }); test("worktree workspace opens terminals in the worktree directory", async ({ diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index 4269155ae..9546f7527 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -1,24 +1,140 @@ import { test, expect } from "./fixtures"; import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer"; import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; -import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; -import { captureWsSessionFrames } from "./helpers/rename"; +import { + assertNewWorkspaceSidebarAndHeader, + connectNewWorkspaceDaemonClient, + expectNewWorkspaceProjectSelected, + openGlobalNewWorkspaceComposer, + selectWorkspaceBacking, + submitNewWorkspaceEmpty, + submitNewWorkspacePrompt, +} from "./helpers/new-workspace"; +import { getServerId } from "./helpers/server-id"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; type NewWorkspaceDaemonClient = Awaited>; +type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running"; -interface CreateAgentFrame extends Record { - initialPrompt: string | null; - workspaceId: string | null; - provider: string | null; - cwd: string | null; +interface CreatedAgentAssertion { + workspaceId: string; + provider: string; + cwd: string; modeId: string | null; model: string | null; } -function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null { - return frames.find((frame) => frame.initialPrompt === prompt) ?? null; +async function fetchCreatedAgentForWorkspace( + seeded: SeededWorkspace, + workspaceId: string, +): Promise { + const result = await seeded.client.fetchAgents({ scope: "active" }); + const agents = result.entries + .map((entry) => entry.agent) + .filter((agent) => agent.cwd === seeded.repoPath && agent.workspaceId === workspaceId); + if (agents.length !== 1) { + return null; + } + const [agent] = agents; + return { + workspaceId, + provider: agent.provider, + cwd: agent.cwd, + modeId: agent.currentModeId, + model: agent.model, + }; +} + +async function fetchWorkspaceName( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const result = await client.fetchWorkspaces(); + return result.entries.find((entry) => entry.id === workspaceId)?.name ?? null; +} + +async function fetchWorkspaceStatuses( + client: NewWorkspaceDaemonClient, + workspaceIds: string[], +): Promise> { + const result = await client.fetchWorkspaces(); + const entriesById = new Map(result.entries.map((entry) => [entry.id, entry.status])); + return Object.fromEntries( + workspaceIds.map((workspaceId) => [workspaceId, entriesById.get(workspaceId) ?? null]), + ); +} + +async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promise { + const result = await seeded.client.fetchAgents({ scope: "active" }); + return result.entries.find((entry) => entry.agent.id === agentId)?.agent.status ?? null; +} + +async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) { + await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-grouping-status").click(); + await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({ + timeout: 30_000, + }); +} + +function statusGroupRows(page: import("@playwright/test").Page, bucket: string) { + return page.getByTestId(`sidebar-status-group-rows-${bucket}`); +} + +async function expectWorkspaceRowInStatusBucket( + page: import("@playwright/test").Page, + input: { rowTestId: string; bucket: string }, +) { + await expect(statusGroupRows(page, input.bucket).getByTestId(input.rowTestId)).toBeVisible({ + timeout: 30_000, + }); +} + +async function expectWorkspaceRowNotInStatusBuckets( + page: import("@playwright/test").Page, + input: { rowTestId: string; buckets: string[] }, +) { + for (const bucket of input.buckets) { + await expect(statusGroupRows(page, bucket).getByTestId(input.rowTestId)).toHaveCount(0, { + timeout: 5_000, + }); + } +} + +async function expectWorkspaceRowHasOnlyIndicator( + page: import("@playwright/test").Page, + input: { rowTestId: string; indicator: WorkspaceIndicator }, +) { + const row = page.getByTestId(input.rowTestId); + await expect(row).toBeVisible({ timeout: 30_000 }); + for (const indicator of [ + "attention", + "done", + "failed", + "loading", + "needs_input", + "running", + ] satisfies WorkspaceIndicator[]) { + const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`); + if (indicator === input.indicator) { + await expect(locator).toBeVisible({ timeout: 30_000 }); + } else { + await expect(locator).toHaveCount(0); + } + } +} + +async function expectWorkspaceRowDoesNotShowIndicator( + page: import("@playwright/test").Page, + input: { rowTestId: string; indicator: WorkspaceIndicator }, +) { + const row = page.getByTestId(input.rowTestId); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect( + row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`), + ).toHaveCount(0, { timeout: 5_000 }); } test.describe("Workspace model regressions", () => { @@ -34,14 +150,17 @@ test.describe("Workspace model regressions", () => { await client?.close().catch(() => undefined); }); - test("same-directory workspace does not inherit legacy cwd-only agent tabs", async ({ page }) => { + test("same-directory workspace does not show agents owned by another workspace", async ({ + page, + }) => { const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" }); try { - const legacyAgent = await seeded.client.createAgent({ + const ownedAgent = await seeded.client.createAgent({ provider: "mock", cwd: seeded.repoPath, - title: "Legacy cwd-only agent", + workspaceId: seeded.workspaceId, + title: "Agent owned by original workspace", modeId: "load-test", model: "ten-second-stream", }); @@ -62,7 +181,7 @@ test.describe("Workspace model regressions", () => { await gotoWorkspace(page, seeded.workspaceId); await expect .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) - .toContain(`workspace-tab-agent_${legacyAgent.id}`); + .toContain(`workspace-tab-agent_${ownedAgent.id}`); } finally { await seeded.cleanup(); } @@ -74,21 +193,6 @@ test.describe("Workspace model regressions", () => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "workspace-new-agent-model-", }); - const createFrames = captureWsSessionFrames( - page, - "create_agent_request", - (inner) => { - const config = (inner.config ?? {}) as Record; - return { - initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, - workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null, - provider: typeof config.provider === "string" ? config.provider : null, - cwd: typeof config.cwd === "string" ? config.cwd : null, - modeId: typeof config.modeId === "string" ? config.modeId : null, - model: typeof config.model === "string" ? config.model : null, - }; - }, - ); try { const secondWorkspace = await seeded.client.createWorkspace({ @@ -98,6 +202,7 @@ test.describe("Workspace model regressions", () => { if (!secondWorkspace.workspace) { throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace"); } + const workspace = secondWorkspace.workspace; await page.addInitScript(() => { localStorage.setItem( @@ -110,7 +215,7 @@ test.describe("Workspace model regressions", () => { }), ); }); - await gotoWorkspace(page, secondWorkspace.workspace.id); + await gotoWorkspace(page, workspace.id); await clickNewChat(page); await expectComposerVisible(page); @@ -121,12 +226,11 @@ test.describe("Workspace model regressions", () => { 0, ); await expect - .poll(() => createFrameForPrompt(createFrames, prompt), { + .poll(() => fetchCreatedAgentForWorkspace(seeded, workspace.id), { timeout: 10_000, }) .toEqual({ - initialPrompt: prompt, - workspaceId: secondWorkspace.workspace.id, + workspaceId: workspace.id, provider: "mock", cwd: seeded.repoPath, modeId: "load-test", @@ -136,4 +240,291 @@ test.describe("Workspace model regressions", () => { await seeded.cleanup(); } }); + + test("local same-directory workspace with an initial prompt shows a generated title", async ({ + page, + }) => { + const serverId = getServerId(); + const seeded: SeededWorkspace = await seedWorkspace({ + repoPrefix: "workspace-generated-local-title-", + }); + const previousConfig = await client.getDaemonConfig(); + + try { + await client.patchDaemonConfig({ + metadataGeneration: { + providers: [{ provider: "mock", model: "ten-second-stream" }], + }, + }); + + await gotoWorkspace(page, seeded.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); + await selectWorkspaceBacking(page, "local"); + await submitNewWorkspacePrompt(page, "Fix login bug"); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + client, + previousWorkspaceId: seeded.workspaceId, + projectDisplayName: seeded.projectDisplayName, + }); + const createdRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`, + ); + + await expect + .poll(() => fetchWorkspaceName(client, createdWorkspace.workspaceId), { + timeout: 30_000, + }) + .toBe("Fix login bug"); + await expectWorkspaceHeader(page, { + title: "Fix login bug", + subtitle: seeded.projectDisplayName, + }); + await expect(createdRow).toContainText("Fix login bug", { timeout: 30_000 }); + expect(createdWorkspace.workspaceDirectory).toBe(seeded.workspaceDirectory); + } finally { + await client + .patchDaemonConfig({ + metadataGeneration: { + providers: previousConfig.config.metadataGeneration.providers, + }, + }) + .catch(() => undefined); + await seeded.cleanup(); + } + }); + + test("running agent in one same-directory workspace only shows the loader on its owning row", async ({ + page, + }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ + repoPrefix: "workspace-same-cwd-running-", + }); + + try { + const secondWorkspace = await seeded.client.createWorkspace({ + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, + title: "Existing sibling workspace", + }); + if (!secondWorkspace.workspace) { + throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace"); + } + const secondWorkspaceId = secondWorkspace.workspace.id; + + const runningAgent = await seeded.client.createAgent({ + provider: "mock", + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Running agent", + modeId: "load-test", + model: "five-minute-stream", + initialPrompt: "stay running", + }); + await seeded.client.waitForAgentUpsert( + runningAgent.id, + (snapshot) => snapshot.status === "running", + 15_000, + ); + + await gotoWorkspace(page, seeded.workspaceId); + await waitForSidebarHydration(page); + + const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`; + const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`; + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: firstRowTestId, + indicator: "running", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: secondRowTestId, + indicator: "running", + }); + + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); + await selectWorkspaceBacking(page, "local"); + await submitNewWorkspaceEmpty(page); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + client, + previousWorkspaceId: seeded.workspaceId, + projectDisplayName: seeded.projectDisplayName, + }); + const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; + + await expect + .poll(() => fetchAgentStatus(seeded, runningAgent.id), { timeout: 10_000 }) + .toBe("running"); + + await expect + .poll( + () => + fetchWorkspaceStatuses(client, [ + seeded.workspaceId, + secondWorkspaceId, + createdWorkspace.workspaceId, + ]), + { timeout: 10_000 }, + ) + .toEqual({ + [seeded.workspaceId]: "running", + [secondWorkspaceId]: "done", + [createdWorkspace.workspaceId]: "done", + }); + + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: firstRowTestId, + indicator: "running", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: secondRowTestId, + indicator: "running", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: createdRowTestId, + indicator: "running", + }); + + await switchSidebarToStatusGrouping(page); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: firstRowTestId, + bucket: "running", + }); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: secondRowTestId, + bucket: "done", + }); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: createdRowTestId, + bucket: "done", + }); + await expectWorkspaceRowNotInStatusBuckets(page, { + rowTestId: secondRowTestId, + buckets: ["running", "needs_input", "attention"], + }); + await expectWorkspaceRowNotInStatusBuckets(page, { + rowTestId: createdRowTestId, + buckets: ["running", "needs_input", "attention"], + }); + } finally { + await seeded.cleanup(); + } + }); + + test("pending permission in one same-directory workspace marks only its own row needing input", async ({ + page, + }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ + repoPrefix: "workspace-same-cwd-permission-", + }); + + try { + const secondWorkspace = await seeded.client.createWorkspace({ + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, + title: "Permission sibling workspace", + }); + if (!secondWorkspace.workspace) { + throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace"); + } + const secondWorkspaceId = secondWorkspace.workspace.id; + + const agent = await seeded.client.createAgent({ + provider: "mock", + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Permission agent", + modeId: "load-test", + model: "ten-second-stream", + }); + await seeded.client.sendAgentMessage(agent.id, "Emit synthetic plan approval."); + const parked = await seeded.client.waitForFinish(agent.id, 15_000); + expect(parked.status).toBe("permission"); + + await gotoWorkspace(page, seeded.workspaceId); + await waitForSidebarHydration(page); + + const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`; + const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`; + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: firstRowTestId, + indicator: "needs_input", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: secondRowTestId, + indicator: "needs_input", + }); + + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); + await selectWorkspaceBacking(page, "local"); + await submitNewWorkspaceEmpty(page); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + client, + previousWorkspaceId: seeded.workspaceId, + projectDisplayName: seeded.projectDisplayName, + }); + const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; + + await expect + .poll( + () => + fetchWorkspaceStatuses(client, [ + seeded.workspaceId, + secondWorkspaceId, + createdWorkspace.workspaceId, + ]), + { timeout: 10_000 }, + ) + .toEqual({ + [seeded.workspaceId]: "needs_input", + [secondWorkspaceId]: "done", + [createdWorkspace.workspaceId]: "done", + }); + + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: firstRowTestId, + indicator: "needs_input", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: secondRowTestId, + indicator: "needs_input", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + rowTestId: createdRowTestId, + indicator: "needs_input", + }); + + await switchSidebarToStatusGrouping(page); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: firstRowTestId, + bucket: "needs_input", + }); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: secondRowTestId, + bucket: "done", + }); + await expectWorkspaceRowInStatusBucket(page, { + rowTestId: createdRowTestId, + bucket: "done", + }); + await expectWorkspaceRowNotInStatusBuckets(page, { + rowTestId: secondRowTestId, + buckets: ["running", "needs_input", "attention"], + }); + await expectWorkspaceRowNotInStatusBuckets(page, { + rowTestId: createdRowTestId, + buckets: ["running", "needs_input", "attention"], + }); + } finally { + await seeded.cleanup(); + } + }); }); diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts new file mode 100644 index 000000000..a46c9569d --- /dev/null +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -0,0 +1,537 @@ +import { randomUUID } from "node:crypto"; +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { test, expect, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import { buildSeededHost } from "./helpers/daemon-registry"; +import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader"; +import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory"; +import { + expectNewWorkspaceProjectSelected, + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + submitNewWorkspaceEmpty, +} from "./helpers/new-workspace"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; + +const LEGACY_AGENT_ID = "legacy-cwd-only-agent"; +const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`; + +interface RestartDaemonClient { + connect(): Promise; + close(): Promise; + fetchWorkspaces(): Promise<{ + entries: Array<{ + id: string; + name: string; + status: string; + workspaceDirectory: string; + }>; + }>; + fetchAgents(options?: { scope?: "active" }): Promise<{ + entries: Array<{ + agent: { + id: string; + workspaceId?: string; + status: string; + }; + }>; + }>; +} + +interface RestartDaemonClientConfig { + url: string; + clientId: string; + clientType: "cli"; + appVersion: string; + webSocketFactory: NodeWebSocketFactory; +} + +interface SeededRestartHome { + paseoHome: string; + cwd: string; + projectId: string; + projectDisplayName: string; + workspaceA: string; + workspaceB: string; + cleanup(): void; +} + +interface StartedDaemon { + port: number; + close(): Promise; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +async function seedRestartHome(): Promise { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-home-")); + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-cwd-")); + const projectsDir = path.join(paseoHome, "projects"); + const agentDir = path.join(paseoHome, "agents", projectDirNameFromCwd(cwd)); + mkdirSync(projectsDir, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + + const projectDisplayName = path.basename(cwd); + const project = { + projectId: `prj_restart_${randomUUID().slice(0, 8)}`, + rootPath: cwd, + kind: "non_git", + displayName: projectDisplayName, + customName: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }; + const workspaceA = { + workspaceId: "wks_restart_a", + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "Original restart workspace", + title: null, + branch: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }; + const workspaceB = { + workspaceId: "wks_restart_b", + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "Restart sibling workspace", + title: null, + branch: null, + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }; + + writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project])); + writeFileSync( + path.join(projectsDir, "workspaces.json"), + JSON.stringify([workspaceA, workspaceB]), + ); + writeFileSync( + path.join(agentDir, `${LEGACY_AGENT_ID}.json`), + JSON.stringify({ + id: LEGACY_AGENT_ID, + provider: "codex", + cwd, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + title: "Legacy cwd-only running agent", + labels: {}, + lastStatus: "running", + lastModeId: "default", + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }), + ); + + return { + paseoHome, + cwd, + projectId: project.projectId, + projectDisplayName, + workspaceA: workspaceA.workspaceId, + workspaceB: workspaceB.workspaceId, + cleanup: () => { + rmSync(paseoHome, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + }, + }; +} + +function projectDirNameFromCwd(cwd: string): string { + const { root } = path.win32.parse(cwd); + const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, ""); + const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, ""); + const prefix = sanitizedRoot ? `${sanitizedRoot}-` : ""; + if (!withoutRoot) { + return sanitizedRoot || "root"; + } + return prefix + withoutRoot.replace(/[\\/]+/g, "-"); +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForServer(port: number, child: ChildProcess): Promise { + const startedAt = Date.now(); + let lastConnectionError: unknown = null; + while (Date.now() - startedAt < 20_000) { + if (child.exitCode !== null) { + throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`); + } + try { + await new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.end(); + resolve(); + }); + socket.setTimeout(1000, () => { + socket.destroy(); + reject(new Error(`Connection timed out to daemon port ${port}`)); + }); + socket.on("error", reject); + }); + return; + } catch (error) { + lastConnectionError = error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw new Error( + `Restart test daemon did not listen on ${port}. Last error: ${ + lastConnectionError instanceof Error + ? lastConnectionError.message + : String(lastConnectionError) + }`, + ); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + const timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, 5000); + try { + await once(child, "exit"); + } finally { + clearTimeout(timeout); + } +} + +async function startRestartDaemon(input: { + paseoHome: string; + origin: string; +}): Promise { + const port = await getAvailablePort(); + if (port === 6767 || String(port) === process.env.E2E_DAEMON_PORT) { + return startRestartDaemon(input); + } + + const serverDir = path.resolve(__dirname, "../../server"); + const tsxBin = execSync("which tsx").toString().trim(); + const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { + cwd: serverDir, + env: { + ...process.env, + PASEO_HOME: input.paseoHome, + PASEO_SERVER_ID: SERVER_ID, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_CORS_ORIGINS: input.origin, + PASEO_RELAY_ENABLED: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + PASEO_NODE_ENV: "development", + NODE_ENV: "development", + }, + stdio: ["ignore", "ignore", "pipe"], + detached: false, + }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + stderr = stderr.split("\n").slice(-40).join("\n"); + }); + + try { + await waitForServer(port, child); + } catch (error) { + await stopProcess(child); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, + { cause: error }, + ); + } + + return { + port, + close: () => stopProcess(child), + }; +} + +async function connectRestartDaemonClient(port: number): Promise { + const DaemonClient = await loadDaemonClientConstructor< + RestartDaemonClientConfig, + RestartDaemonClient + >(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + clientId: `restart-playwright-${randomUUID()}`, + clientType: "cli", + appVersion: loadAppVersion(), + webSocketFactory: createNodeWebSocketFactory(), + }); + await client.connect(); + return client; +} + +function loadAppVersion(): string { + const packageJson = JSON.parse( + readFileSync(path.resolve(__dirname, "../package.json"), "utf8"), + ) as { version?: unknown }; + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error("Missing app package version"); + } + return packageJson.version; +} + +async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: number }) { + await page.route(/:(6767)\b/, (route) => route.abort()); + await page.routeWebSocket(/:(6767)\b/, async (ws) => { + await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); + }); + await page.route( + "**/*", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/html", + body: "storage seed", + }); + }, + { times: 1 }, + ); + await page.goto("/"); + + const host = buildSeededHost({ + serverId: input.serverId, + endpoint: `127.0.0.1:${input.port}`, + label: "restart daemon", + nowIso: nowIso(), + }); + await page.evaluate( + ({ daemon, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.removeItem("@paseo:settings"); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { + daemon: host, + preferences: { + serverId: input.serverId, + provider: "codex", + providerPreferences: { + codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, + }, + }, + }, + ); +} + +function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null { + const pathname = new URL(page.url()).pathname; + const match = pathname.match( + new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`), + ); + if (!match?.[1]) return null; + return decodeWorkspaceIdFromPathSegment(match[1]); +} + +async function expectWorkspaceRowHasOnlyIndicator( + page: Page, + input: { serverId: string; workspaceId: string; indicator: string }, +) { + const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) { + const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`); + if (indicator === input.indicator) { + await expect(locator).toBeVisible({ timeout: 30_000 }); + } else { + await expect(locator).toHaveCount(0); + } + } +} + +async function expectWorkspaceRowDoesNotShowIndicator( + page: Page, + input: { serverId: string; workspaceId: string; indicator: string }, +) { + const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect( + row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`), + ).toHaveCount(0, { timeout: 5_000 }); +} + +async function expectWorkspaceRowInStatusBucket( + page: Page, + input: { serverId: string; workspaceId: string; bucket: string }, +) { + await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-grouping-status").click(); + await expect( + page + .getByTestId(`sidebar-status-group-rows-${input.bucket}`) + .getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`), + ).toBeVisible({ timeout: 30_000 }); +} + +async function fetchLegacyAgent(client: RestartDaemonClient) { + const agents = await client.fetchAgents({ scope: "active" }); + return agents.entries.find(hasLegacyAgentId)?.agent ?? null; +} + +function hasLegacyAgentId(entry: { agent: { id: string } }): boolean { + return entry.agent.id === LEGACY_AGENT_ID; +} + +async function fetchWorkspaceStatuses( + client: RestartDaemonClient, + workspaceIds: string[], +): Promise> { + const workspaces = await client.fetchWorkspaces(); + const wantedWorkspaceIds = new Set(workspaceIds); + const statuses: Record = {}; + for (const workspace of workspaces.entries) { + if (wantedWorkspaceIds.has(workspace.id)) { + statuses[workspace.id] = workspace.status; + } + } + return statuses; +} + +test.describe("Workspace model restart regressions", () => { + test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({ + page, + baseURL, + }) => { + test.setTimeout(90_000); + const seeded = await seedRestartHome(); + const origin = new URL(baseURL ?? "http://localhost").origin; + const daemon = await startRestartDaemon({ paseoHome: seeded.paseoHome, origin }); + const serverId = SERVER_ID; + const client = await connectRestartDaemonClient(daemon.port); + + try { + await seedBrowserForDaemon(page, { serverId, port: daemon.port }); + + await expect + .poll(() => fetchLegacyAgent(client)) + .toMatchObject({ + id: LEGACY_AGENT_ID, + workspaceId: seeded.workspaceA, + status: "running", + }); + + await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA)); + await waitForSidebarHydration(page); + await expectWorkspaceRowHasOnlyIndicator(page, { + serverId, + workspaceId: seeded.workspaceA, + indicator: "running", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + serverId, + workspaceId: seeded.workspaceB, + indicator: "running", + }); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) + .toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`); + + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }); + await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); + await submitNewWorkspaceEmpty(page); + + await expect + .poll(() => { + const workspaceId = parseWorkspaceIdFromPageUrl(page, serverId); + return workspaceId && workspaceId !== seeded.workspaceA ? workspaceId : null; + }) + .not.toBeNull(); + const createdWorkspaceId = parseWorkspaceIdFromPageUrl(page, serverId); + if (!createdWorkspaceId) { + throw new Error(`Expected browser to navigate to created workspace, got ${page.url()}`); + } + + await expect + .poll(() => + fetchWorkspaceStatuses(client, [ + seeded.workspaceA, + seeded.workspaceB, + createdWorkspaceId, + ]), + ) + .toEqual({ + [seeded.workspaceA]: "running", + [seeded.workspaceB]: "done", + [createdWorkspaceId]: "done", + }); + + await expectWorkspaceRowDoesNotShowIndicator(page, { + serverId, + workspaceId: seeded.workspaceB, + indicator: "running", + }); + await expectWorkspaceRowDoesNotShowIndicator(page, { + serverId, + workspaceId: createdWorkspaceId, + indicator: "running", + }); + await expectWorkspaceRowInStatusBucket(page, { + serverId, + workspaceId: seeded.workspaceA, + bucket: "running", + }); + await expectWorkspaceRowInStatusBucket(page, { + serverId, + workspaceId: seeded.workspaceB, + bucket: "done", + }); + await expectWorkspaceRowInStatusBucket(page, { + serverId, + workspaceId: createdWorkspaceId, + bucket: "done", + }); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) + .toEqual([]); + + await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA)); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) + .toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts index 9d93f7f94..78d36e6cd 100644 --- a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -1,42 +1,67 @@ import { test, expect } from "./fixtures"; -import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl"; -import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; +import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; +import { renameModalInput, renameModalSubmit } from "./helpers/rename"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; + +async function fetchTerminalTitle( + workspace: SeededWorkspace, + terminalId: string, +): Promise { + const result = await workspace.client.listTerminals(workspace.repoPath, undefined, { + workspaceId: workspace.workspaceId, + }); + const terminal = result.terminals.find((entry) => entry.id === terminalId); + return terminal?.title ?? null; +} + +async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise { + await expect + .poll( + async () => { + const result = await workspace.client.listTerminals(workspace.repoPath, undefined, { + workspaceId: workspace.workspaceId, + }); + return result.terminals.map((entry) => entry.id); + }, + { timeout: 30_000 }, + ) + .toHaveLength(1); + const result = await workspace.client.listTerminals(workspace.repoPath, undefined, { + workspaceId: workspace.workspaceId, + }); + const terminal = result.terminals[0]; + if (!terminal) { + throw new Error("Expected one created terminal"); + } + return terminal.id; +} test.describe("Workspace terminal tab rename", () => { - let harness: TerminalE2EHarness; - - test.beforeAll(async () => { - harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" }); - }); - - test.afterAll(async () => { - await harness?.cleanup(); - }); - - test("right-click rename sends terminal.rename.request and updates the tab label", async ({ + test("right-click rename persists the terminal title and updates the tab label", async ({ page, }) => { test.setTimeout(60_000); - const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ - terminalId: String(inner.terminalId ?? ""), - title: String(inner.title ?? ""), - requestId: String(inner.requestId ?? ""), - })); + const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" }); + let terminalId: string | null = null; - await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => { - const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first(); + try { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewTerminal(page); + terminalId = await waitForCreatedTerminalId(workspace); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); await expect(tab).toBeVisible({ timeout: 15_000 }); await tab.click({ button: "right" }); - await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({ + await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ timeout: 10_000, }); - const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`); + const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); await expect(renameItem).toBeVisible({ timeout: 10_000 }); await renameItem.click(); - const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`; + const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`; const input = renameModalInput(page, modalPrefix); await expect(input).toBeVisible({ timeout: 10_000 }); @@ -45,12 +70,14 @@ test.describe("Workspace terminal tab rename", () => { await expect(input).toHaveCount(0, { timeout: 15_000 }); await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 }); - - expect(renameFrames.length).toBeGreaterThan(0); - const lastFrame = renameFrames.at(-1)!; - expect(lastFrame.terminalId).toBe(terminal.id); - expect(lastFrame.title).toBe("My Renamed Terminal"); - expect(lastFrame.requestId.length).toBeGreaterThan(0); - }); + await expect + .poll(() => fetchTerminalTitle(workspace, terminalId!)) + .toBe("My Renamed Terminal"); + } finally { + if (terminalId) { + await workspace.client.killTerminal(terminalId).catch(() => undefined); + } + await workspace.cleanup(); + } }); }); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index db042bbde..9b5f44f90 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -75,7 +75,6 @@ import { type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; -import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; @@ -276,13 +275,9 @@ const AgentStreamViewComponent = forwardRef { + const agentWorkspaceId = useSessionStore((state) => { if (!serverId || !agentId) { return null; } - return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null; + return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); const hasHydratedWorkspaces = useSessionStore((state) => serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, ); - const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd); + const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); useEffect(() => { if (redirectedRef.current) { @@ -67,14 +66,14 @@ function HostAgentReadyRouteContent() { if (!serverId || !agentId) { return; } - if (agentCwd?.trim() && !hasHydratedWorkspaces) { + if (agentWorkspaceId && !hasHydratedWorkspaces) { return; } if (!client || !isConnected) { redirectedRef.current = true; router.replace(buildHostRootRoute(serverId)); } - }, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]); + }, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]); useEffect(() => { if (redirectedRef.current) { @@ -91,15 +90,7 @@ function HostAgentReadyRouteContent() { if (cancelled || redirectedRef.current) { return; } - const cwd = result?.agent?.cwd?.trim(); - const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; - const workspaceId = resolveWorkspaceIdByDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: cwd, - }); - if (!workspaceId && !hasHydratedWorkspaces) { - return; - } + const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId); redirectedRef.current = true; if (workspaceId) { navigateToPreparedWorkspaceTab({ @@ -124,7 +115,7 @@ function HostAgentReadyRouteContent() { return () => { cancelled = true; }; - }, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]); + }, [agentId, client, isConnected, pathname, router, serverId]); return null; } diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index facd12af3..41454f973 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -202,7 +202,7 @@ function WorkspaceStatusIndicator({ if (bucket === "needs_input") { return ( - + ); @@ -222,7 +222,7 @@ function WorkspaceStatusIndicator({ ? EMPHASIZED_STATUS_DOT_OFFSET : DEFAULT_STATUS_DOT_OFFSET; return ( - + {dotColorStyle ? ( ): WorkspaceDescriptor { } describe("workspace archive pending suppression", () => { - it("tracks a locally pending workspace archive by id and directory", () => { + it("tracks a locally pending workspace archive by id", () => { markWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree", - workspaceDirectory: "/repo/worktree", }); expect( @@ -41,12 +40,6 @@ describe("workspace archive pending suppression", () => { workspaceId: "/repo/worktree", }), ).toBe(true); - expect( - isWorkspaceArchivePending({ - serverId: "server-1", - workspaceDirectory: "/repo/worktree", - }), - ).toBe(true); expect( shouldSuppressWorkspaceForLocalArchive({ serverId: "server-1", @@ -68,19 +61,34 @@ describe("workspace archive pending suppression", () => { markWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree", - workspaceDirectory: "/repo/worktree", }); expect( shouldSuppressWorkspaceForLocalArchive({ serverId: "server-1", - workspace: workspace({ workspaceDirectory: "/repo/worktree" }), + workspace: workspace({ archivingAt: null }), }), ).toBe(true); clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); }); + it("does not suppress a same-cwd sibling whose id is not the one archived", () => { + markWorkspaceArchivePending({ + serverId: "server-1", + workspaceId: "/repo/worktree", + }); + + expect( + shouldSuppressWorkspaceForLocalArchive({ + serverId: "server-1", + workspace: workspace({ id: "sibling", workspaceDirectory: "/repo/worktree" }), + }), + ).toBe(false); + + clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); + }); + it("allows upserts when this client did not start the archive", () => { expect( shouldSuppressWorkspaceForLocalArchive({ @@ -94,7 +102,6 @@ describe("workspace archive pending suppression", () => { markWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree", - workspaceDirectory: "/repo/worktree", }); expect( diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts index 9c77198c5..ae531be0e 100644 --- a/packages/app/src/contexts/session-workspace-upserts.ts +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -1,12 +1,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -interface PendingWorkspaceArchive { - workspaceId: string; - workspaceDirectory: string | null; -} - -const pendingWorkspaceArchivesByServer = new Map>(); +const pendingWorkspaceArchivesByServer = new Map>(); function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string { return `${input.serverId.trim()}::${input.workspaceId.trim()}`; @@ -15,7 +10,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st export function markWorkspaceArchivePending(input: { serverId: string; workspaceId: string; - workspaceDirectory?: string | null; }): void { const serverId = input.serverId.trim(); const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); @@ -23,11 +17,8 @@ export function markWorkspaceArchivePending(input: { return; } - const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map(); - archives.set(pendingArchiveKey({ serverId, workspaceId }), { - workspaceId, - workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory), - }); + const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set(); + archives.add(pendingArchiveKey({ serverId, workspaceId })); pendingWorkspaceArchivesByServer.set(serverId, archives); } @@ -54,34 +45,15 @@ export function clearWorkspaceArchivePending(input: { export function isWorkspaceArchivePending(input: { serverId: string; workspaceId?: string | null; - workspaceDirectory?: string | null; }): boolean { const serverId = input.serverId.trim(); - if (!serverId) { + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!serverId || !workspaceId) { return false; } const archives = pendingWorkspaceArchivesByServer.get(serverId); - if (!archives) { - return false; - } - - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) { - return true; - } - - const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory); - if (!workspaceDirectory) { - return false; - } - - for (const archive of archives.values()) { - if (archive.workspaceDirectory === workspaceDirectory) { - return true; - } - } - return false; + return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false; } export function shouldSuppressWorkspaceForLocalArchive(input: { @@ -91,6 +63,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: { return isWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspace.id, - workspaceDirectory: input.workspace.workspaceDirectory, }); } diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index b7e120ed9..e60913fcf 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -314,7 +314,7 @@ describe("checkout-git-actions-store", () => { const archive = useCheckoutGitActionsStore .getState() - .archiveWorktree({ serverId, cwd, worktreePath: cwd }); + .archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }); expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false); expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false); @@ -371,7 +371,9 @@ describe("checkout-git-actions-store", () => { appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot); await expect( - useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }), + useCheckoutGitActionsStore + .getState() + .archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }), ).rejects.toThrow("archive failed"); expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual( diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 0ffe075c8..3f0ff03a8 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -13,10 +13,7 @@ import { clearWorkspaceArchivePending, markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; -import { - resolveWorkspaceIdByDirectory, - resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-identity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { i18n } from "@/i18n/i18next"; @@ -155,15 +152,11 @@ function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): b function snapshotWorktreeArchiveState(input: { serverId: string; - worktreePath: string; + workspaceId: string | undefined; }): WorktreeArchiveSnapshot { const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces; - const workspaceId = resolveWorkspaceIdByDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: input.worktreePath, - }); - const workspaceKey = workspaceId - ? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId }) + const workspaceKey = input.workspaceId + ? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId }) : null; return { workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null, @@ -506,16 +499,16 @@ export const useCheckoutGitActionsStore = create() actionId: "archive-worktree", run: async () => { const client = resolveClient(serverId); - const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath }); + const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId }); // The server archive is keyed by worktreePath and must always run. The // optimistic client-side updates are keyed by workspace id, so they only - // apply when the workspace is resolved in the local store. + // apply when the caller passes the workspace id and it resolves in the + // local store. const workspace = snapshot.workspace; if (workspace) { markWorkspaceArchivePending({ serverId, workspaceId: workspace.id, - workspaceDirectory: workspace.workspaceDirectory, }); removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id }); } diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 6354c93fd..af66c94b4 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -1,5 +1,4 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; -import { router, type Href } from "expo-router"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useTranslation } from "react-i18next"; import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store"; @@ -15,9 +14,8 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; -import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; -import { buildHostRootRoute } from "@/utils/host-routes"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { confirmRiskyWorktreeArchive, type WorktreeArchiveWarningLabels, @@ -61,6 +59,9 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean { let references = 0; for (const candidate of workspaces) { + // Git-fact: counting how many workspaces still reference the worktree + // directory on disk, not attributing ownership. Same-cwd siblings keep the + // directory alive, so the disk-deletion offer must compare directories. if (candidate.workspaceDirectory === worktreePath) { references += 1; } @@ -227,6 +228,7 @@ interface UseGitActionsResult { export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { const { t } = useTranslation(); const toast = useToast(); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr"); @@ -533,27 +535,25 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const runArchiveWorktreeRecord = useCallback( (worktreePath: string, deleteWorktreeFromDisk: boolean) => { - const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; - const workspaceList = Array.from(workspaces?.values() ?? []); - const archivedWorkspaceId = resolveWorkspaceIdByDirectory({ - workspaces: workspaceList, - workspaceDirectory: worktreePath, - }); - const redirectRoute = archivedWorkspaceId - ? buildWorkspaceArchiveRedirectRoute({ - serverId, - archivedWorkspaceId, - workspaces: workspaceList, - }) - : buildHostRootRoute(serverId); - router.replace(redirectRoute as Href); + // These git actions only ever target the workspace the screen is showing, + // so the redirect is sourced from the active workspace selection (the + // screen's own route context), not from any cwd→workspace inference here. + if (activeWorkspaceSelection) { + redirectIfArchivingActiveWorkspace({ + serverId, + workspaceId: activeWorkspaceSelection.workspaceId, + activeWorkspaceSelection, + }); + } + // The server archive is keyed by worktreePath; the daemon owns the + // directory→workspace resolution for archive-by-path. void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch( (err) => { toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); }, ); }, - [cwd, runArchiveWorktree, serverId, t, toastActionError], + [activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError], ); const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord); @@ -567,9 +567,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; const workspaceList = Array.from(workspaces?.values() ?? []); - const workspace = workspaceList.find( - (candidate) => candidate.workspaceDirectory === worktreePath, - ); + // Git-fact lookup: find the workspace backing this worktree directory to show + // its name and diff stat in the confirmation. This reads display data by + // directory, the same as the disk-deletion reference counting, and never + // derives an id used to key the archive (the archive runs by path). + const workspace = + workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null; const confirmed = await confirmRiskyWorktreeArchive( { worktreeName: workspace?.name ?? branchLabel, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 9a6ea48e2..6a332b70c 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -6,6 +6,7 @@ export interface AgentScreenAgent { id: string; status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; + workspaceId?: string; capabilities?: AgentCapabilityFlags; lastError?: string | null; projectPlacement?: { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index e7f28ac87..23888fd16 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -5,7 +5,6 @@ import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-f import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { normalizeWorkspacePath } from "@/utils/workspace-identity"; import { selectPrHintFromStatus } from "@/git/use-pr-status-query"; import { useHostProjects } from "@/projects/host-projects"; import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; @@ -132,15 +131,10 @@ function getRootAgentWorkspaceActivity(input: { workspace: WorkspaceDescriptor; agents: Map | undefined; }): WorkspaceAgentActivity | null { - const workspaceDirectory = normalizeWorkspacePath(input.workspace.workspaceDirectory); - if (!workspaceDirectory) { - return null; - } - let latest: WorkspaceAgentActivity | null = null; for (const agent of input.agents?.values() ?? []) { if (agent.archivedAt || agent.parentAgentId) continue; - if (normalizeWorkspacePath(agent.cwd) !== workspaceDirectory) continue; + if (agent.workspaceId !== input.workspace.id) continue; const status = deriveSidebarStateBucket({ status: agent.status, pendingPermissionCount: agent.pendingPermissions.length, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index ac845a889..e07732cdc 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -254,6 +254,21 @@ describe("translation resources", () => { expect(en.openProject.tiles.addProject.title).toBe("Add a project"); }); + it("resolves the worktree delete prompt keys used by the sidebar prompt", () => { + const strings = flattenStrings(en); + + expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.title"]).toBe( + "Archive workspace", + ); + expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.message"]).toBe( + "Also remove the worktree from disk?", + ); + expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.keep"]).toBe( + "Keep on disk", + ); + expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.delete"]).toBe("Delete"); + }); + it("includes provider selector and pairing keys for the Batch 4D migration", () => { expect(en.modelSelector.title).toBe("Select provider"); expect(en.modelSelector.favorites).toBe("Favorites"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 53d000303..d317596d3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -811,12 +811,12 @@ export const ar: TranslationResources = { 'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.', hideConfirm: "يخفي", cancel: "يلغي", - }, - deleteWorktreePrompt: { - title: "أرشفة مساحة العمل", - message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟", - keep: "الاحتفاظ على القرص", - delete: "حذف", + deleteWorktreePrompt: { + title: "أرشفة مساحة العمل", + message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟", + keep: "الاحتفاظ على القرص", + delete: "حذف", + }, }, rename: { title: "إعادة تسمية مساحة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index db6dd3b88..4ea3bf0ff 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -817,12 +817,12 @@ export const en = { 'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.', hideConfirm: "Hide", cancel: "Cancel", - }, - deleteWorktreePrompt: { - title: "Archive workspace", - message: "Also remove the worktree from disk?", - keep: "Keep on disk", - delete: "Delete", + deleteWorktreePrompt: { + title: "Archive workspace", + message: "Also remove the worktree from disk?", + keep: "Keep on disk", + delete: "Delete", + }, }, rename: { title: "Rename workspace", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fbb054557..0a9864ddf 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -837,12 +837,12 @@ export const es: TranslationResources = { '¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.', hideConfirm: "Esconder", cancel: "Cancelar", - }, - deleteWorktreePrompt: { - title: "Archivar espacio de trabajo", - message: "¿También eliminar el worktree del disco?", - keep: "Conservar en disco", - delete: "Eliminar", + deleteWorktreePrompt: { + title: "Archivar espacio de trabajo", + message: "¿También eliminar el worktree del disco?", + keep: "Conservar en disco", + delete: "Eliminar", + }, }, rename: { title: "Cambiar nombre del espacio de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 622fedb73..9990a6835 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -836,12 +836,12 @@ export const fr: TranslationResources = { "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", hideConfirm: "Cacher", cancel: "Annuler", - }, - deleteWorktreePrompt: { - title: "Archiver l'espace de travail", - message: "Supprimer aussi le worktree du disque?", - keep: "Conserver sur le disque", - delete: "Supprimer", + deleteWorktreePrompt: { + title: "Archiver l'espace de travail", + message: "Supprimer aussi le worktree du disque?", + keep: "Conserver sur le disque", + delete: "Supprimer", + }, }, rename: { title: "Renommer l'espace de travail", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4adb2a6a1..ebf2319fd 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -829,12 +829,12 @@ export const ru: TranslationResources = { "Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.", hideConfirm: "Скрывать", cancel: "Отмена", - }, - deleteWorktreePrompt: { - title: "Архивировать рабочее пространство", - message: "Также удалить рабочее дерево с диска?", - keep: "Оставить на диске", - delete: "Удалить", + deleteWorktreePrompt: { + title: "Архивировать рабочее пространство", + message: "Также удалить рабочее дерево с диска?", + keep: "Оставить на диске", + delete: "Удалить", + }, }, rename: { title: "Переименовать рабочую область", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08d17ecd0..d9dab7b5c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -802,12 +802,12 @@ export const zhCN: TranslationResources = { hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。", hideConfirm: "隐藏", cancel: "取消", - }, - deleteWorktreePrompt: { - title: "归档 workspace", - message: "同时从磁盘删除 worktree?", - keep: "保留在磁盘上", - delete: "删除", + deleteWorktreePrompt: { + title: "归档 workspace", + message: "同时从磁盘删除 worktree?", + keep: "保留在磁盘上", + delete: "删除", + }, }, rename: { title: "重命名 workspace", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 3dd9afaf2..a582b92b9 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -93,6 +93,7 @@ interface ChatAgentStateShape { id: string | null; status: Agent["status"] | null; cwd: string | null; + workspaceId?: string; capabilities?: Agent["capabilities"]; lastError?: Agent["lastError"] | null; } @@ -136,6 +137,7 @@ function selectChatAgentState( id: agent.id, status: agent.status, cwd: agent.cwd, + workspaceId: agent.workspaceId, capabilities: agent.capabilities, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, @@ -156,6 +158,7 @@ function buildChatAgentFromState( id: state.id, status: state.status, cwd: state.cwd, + workspaceId: state.workspaceId, capabilities: state.capabilities, lastError: state.lastError ?? null, projectPlacement, @@ -545,15 +548,13 @@ function AgentPanelBody({ ); const agentState = useSessionStore( useShallow((state) => { - const session = state.sessions[serverId]; - const agent = agentId - ? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null) - : null; + const agent = resolveChatAgentFromSession(state, serverId, agentId); return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, status: agent?.status ?? null, cwd: agent?.cwd ?? null, + workspaceId: agent?.workspaceId, lastError: agent?.lastError ?? null, archivedAt: agent?.archivedAt ?? null, }; @@ -646,6 +647,7 @@ function AgentPanelBody({ id: agentState.id, status: agentState.status, cwd: agentState.cwd, + workspaceId: agentState.workspaceId, lastError: agentState.lastError ?? null, projectPlacement, } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index db103cc4b..cd8673784 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -75,6 +75,21 @@ function resolveCheckoutRequest( }; } +function buildFirstAgentContext(input: { + prompt: string; + attachments: AgentAttachment[]; +}): { prompt?: string; attachments?: AgentAttachment[] } | undefined { + const trimmedPrompt = input.prompt.trim(); + if (!trimmedPrompt && input.attachments.length === 0) { + return undefined; + } + + return { + ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), + attachments: input.attachments, + }; +} + interface NewWorkspaceScreenProps { serverId: string; sourceDirectory?: string; @@ -779,6 +794,8 @@ async function createMultiplicityWorkspace(input: { selectedItem: PickerItem | null; currentBranch: string | null; withInitialAgent: boolean; + prompt: string; + attachments: AgentAttachment[]; mergeWorkspaces: ( serverId: string, workspaces: ReturnType[], @@ -790,6 +807,10 @@ async function createMultiplicityWorkspace(input: { const baseBranch = isWorktree ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) : undefined; + const firstAgentContext = buildFirstAgentContext({ + prompt: input.prompt, + attachments: input.attachments, + }); const payload = await input.client.createWorkspace({ source: isWorktree ? { @@ -804,6 +825,7 @@ async function createMultiplicityWorkspace(input: { path: input.project.iconWorkingDir, projectId: input.project.projectKey, }, + ...(firstAgentContext ? { firstAgentContext } : {}), }); if (payload.error || !payload.workspace) { throw new Error(payload.error ?? input.createFailedMessage); @@ -1292,21 +1314,13 @@ export function NewWorkspaceScreen({ throw new Error("Choose a project"); } const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); - const trimmedPrompt = input.prompt.trim(); - const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0; + const firstAgentContext = buildFirstAgentContext(input); return { cwd: selectedProject.iconWorkingDir, projectId: selectedProject.projectKey, worktreeSlug: createNameId(), - ...(hasFirstAgentContext - ? { - firstAgentContext: { - ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), - ...(input.attachments.length > 0 ? { attachments: input.attachments } : {}), - }, - } - : {}), + ...(firstAgentContext ? { firstAgentContext } : {}), ...checkoutRequest, }; }, @@ -1334,6 +1348,8 @@ export function NewWorkspaceScreen({ selectedItem, currentBranch, withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, mergeWorkspaces, serverId, createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index 589eae678..676b74fd0 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -186,17 +186,18 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { const paneWorkspaceId = normalizedWorkspaceId || undefined; const unsubscribeChanged = client.on("terminals_changed", (message) => { + // The terminal subscription is keyed by cwd at the protocol level, so this + // gate only routes the event to the pane watching that cwd; it is not an + // ownership decision. if (message.payload.cwd !== workspaceDirectory) { return; } // Two workspaces can share a cwd, so the push can carry terminals from a - // sibling workspace. Keep only the ones whose workspaceId matches this - // pane; terminals without a workspaceId predate Model B and belong to - // whichever pane is watching the cwd. + // sibling workspace. A terminal belongs to a workspace only by its + // workspaceId, so keep only the ones whose workspaceId matches this pane. const matchingTerminals = message.payload.terminals.filter( - (terminal) => - terminal.workspaceId === undefined || terminal.workspaceId === paneWorkspaceId, + (terminal) => terminal.workspaceId === paneWorkspaceId, ); queryClient.setQueryData(queryKey, (current) => ({ diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5b7406923..2de57ad9c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1791,7 +1791,6 @@ function WorkspaceScreenContent({ sessionAgents: state.sessions[normalizedServerId]?.agents, agentDetails: state.sessions[normalizedServerId]?.agentDetails, workspaceId: normalizedWorkspaceId, - workspaceDirectory, }), workspaceAgentVisibilityEqual, ); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index a84379957..097189a7f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -77,6 +77,7 @@ describe("workspace navigation", () => { const agent = { id: "agent-1", cwd: "/repo/workspace-a", + workspaceId: "workspace-a", requiresAttention: true, attentionReason: "permission", } as unknown as Agent; diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index d1a4f9fc5..03be5571e 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -6,7 +6,7 @@ import { parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { - resolveWorkspaceIdByDirectory, + normalizeWorkspaceOpaqueId, resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-identity"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; @@ -76,11 +76,7 @@ export function navigateToWorkspace( }); const workspaceAgents = resolvedWorkspaceId ? Array.from(deps.getSessionAgents(serverId)).filter( - (agent) => - resolveWorkspaceIdByDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: agent.cwd, - }) === resolvedWorkspaceId, + (agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId, ) : []; const attentionAgentId = pickAttentionAgent(workspaceAgents); diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index c44042e8c..05c7c748f 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -7,7 +7,6 @@ import { selectHasWorkspaces, selectProjectOrder, selectRecommendedProjectPaths, - selectResolveWorkspaceIdByCwd, selectWorkspace, selectWorkspaceDirectory, selectWorkspaceExists, @@ -135,17 +134,6 @@ export function useHasWorkspaces(serverId: string | null): boolean { ); } -export function useResolveWorkspaceIdByCwd( - serverId: string | null, - cwd: string | null | undefined, -): string | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd), - workspaceEqualityFns.identity, - ); -} - export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] { return useStoreWithEqualityFn( useSessionStore, diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index d7388e785..a923bc291 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -6,7 +6,6 @@ import { selectHasWorkspaces, selectProjectOrder, selectRecommendedProjectPaths, - selectResolveWorkspaceIdByCwd, selectWorkspace, selectWorkspaceDirectory, selectWorkspaceFields, @@ -385,33 +384,6 @@ describe("selectHasWorkspaces", () => { }); }); -describe("selectResolveWorkspaceIdByCwd", () => { - it("resolves by cwd and stays stable under unrelated updates", () => { - const workspaceA = createWorkspace({ - id: "workspace-a", - workspaceDirectory: "/repo/a", - }); - const workspaceB = createWorkspace({ - id: "workspace-b", - workspaceDirectory: "/repo/b", - }); - initializeWorkspaces([workspaceA, workspaceB]); - - const tracked = trackSelector( - useSessionStore, - (state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"), - workspaceEqualityFns.identity, - ); - const before = tracked.current; - expect(before).toBe("workspace-a"); - - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - expect(tracked.current).toBe(before); - - tracked.stop(); - }); -}); - describe("selectWorkspaceStatusesForBadges", () => { it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => { const workspaceA = createWorkspace({ id: "workspace-a", status: "done" }); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 73fed2fb9..1c5ce851c 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -5,10 +5,7 @@ import { type WorkspaceStructureProject, } from "@/projects/workspace-structure"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; -import { - resolveWorkspaceIdByDirectory, - resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-identity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; @@ -250,21 +247,6 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0; } -export function selectResolveWorkspaceIdByCwd( - state: SessionsSnapshot, - serverId: string | null, - cwd: string | null | undefined, -): string | null { - if (!serverId || !cwd) { - return null; - } - const workspaces = state.sessions[serverId]?.workspaces; - return resolveWorkspaceIdByDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: cwd, - }); -} - export function selectWorkspaceStatusesForBadges( state: SessionsSnapshot, ): DesktopBadgeWorkspaceStatus[] { diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 4219b5203..dd5964797 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -56,6 +56,7 @@ const AGENT_DEFAULTS: Agent = { lastError: null, title: "Agent", cwd: WORKSPACE_DIRECTORY, + workspaceId: WORKSPACE_ID, model: null, features: undefined, thinkingOptionId: undefined, @@ -91,7 +92,7 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility { const sessionAgents = useSessionStore.getState().sessions[SERVER_ID]?.agents ?? new Map(); return deriveWorkspaceAgentVisibility({ sessionAgents, - workspaceDirectory: WORKSPACE_DIRECTORY, + workspaceId: WORKSPACE_ID, }); } diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 44d412ecb..f674dbf3e 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -11,8 +11,7 @@ export function navigateToAgent(input: NavigateToAgentInput): string { const session = useSessionStore.getState().sessions[serverId]; const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); return { - workspaces: session?.workspaces.values(), - agentCwd: agent?.cwd, + agentWorkspaceId: agent?.workspaceId, }; }, navigateToHostAgent: (route) => { diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index 3f0c43a0a..b3dd22123 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; import { resolveNavigateToAgent, type AgentNavTarget, @@ -11,24 +10,6 @@ const SERVER_ID = "server-1"; const WORKSPACE_ID = "workspace-1"; const AGENT_ID = "agent-1"; -function createWorkspace(): WorkspaceDescriptor { - return { - id: WORKSPACE_ID, - projectId: "project-1", - projectDisplayName: "Project", - projectRootPath: "/repo", - workspaceDirectory: "/repo/worktree", - projectKind: "git", - workspaceKind: "local_checkout", - name: "worktree", - status: "done", - archivingAt: null, - statusEnteredAt: null, - diffStat: null, - scripts: [], - }; -} - interface RecordedHostNav { route: string; } @@ -59,10 +40,9 @@ function createFakeNavigators(target: AgentNavTarget): { } describe("resolveNavigateToAgent", () => { - it("opens the resolved workspace tab when the agent's cwd matches a workspace", () => { + it("opens the workspace tab carried by the agent's workspaceId", () => { const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ - workspaces: [createWorkspace()], - agentCwd: "/repo/worktree", + agentWorkspaceId: WORKSPACE_ID, }); const route = resolveNavigateToAgent( @@ -83,10 +63,9 @@ describe("resolveNavigateToAgent", () => { ]); }); - it("falls back to the host agent route when the workspace is unknown", () => { + it("falls back to the host agent route when the agent has no workspaceId", () => { const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ - workspaces: [], - agentCwd: null, + agentWorkspaceId: null, }); const route = resolveNavigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }, deps); diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index bbe876d72..a472a9d46 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -1,6 +1,5 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; export interface NavigateToAgentInput { @@ -11,8 +10,7 @@ export interface NavigateToAgentInput { } export interface AgentNavTarget { - workspaces: Iterable | null | undefined; - agentCwd: string | null | undefined; + agentWorkspaceId: string | null | undefined; } export interface NavigateToAgentDeps { @@ -25,14 +23,11 @@ export function resolveNavigateToAgent( input: NavigateToAgentInput, deps: NavigateToAgentDeps, ): string { - const { workspaces, agentCwd } = deps.readAgentNavTarget({ + const { agentWorkspaceId } = deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId, }); - const workspaceId = resolveWorkspaceIdByDirectory({ - workspaces, - workspaceDirectory: agentCwd, - }); + const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); if (!workspaceId) { const route = buildHostAgentDetailRoute(input.serverId, input.agentId); diff --git a/packages/app/src/utils/workspace-identity.test.ts b/packages/app/src/utils/workspace-identity.test.ts index 34a9cada8..b3324b939 100644 --- a/packages/app/src/utils/workspace-identity.test.ts +++ b/packages/app/src/utils/workspace-identity.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { - resolveWorkspaceIdByDirectory, - resolveWorkspaceMapKeyByIdentity, - resolveWorkspaceRouteId, -} from "./workspace-identity"; +import { resolveWorkspaceMapKeyByIdentity, resolveWorkspaceRouteId } from "./workspace-identity"; function createWorkspace( input: Partial & Pick, @@ -38,42 +34,6 @@ describe("resolveWorkspaceRouteId", () => { }); }); -describe("resolveWorkspaceIdByDirectory", () => { - it("matches workspace directories", () => { - const workspaces = [ - createWorkspace({ - id: "workspace-1", - projectRootPath: "/repo", - workspaceDirectory: "/repo/.paseo/worktrees/feature", - }), - ]; - - expect( - resolveWorkspaceIdByDirectory({ - workspaces, - workspaceDirectory: "/repo/.paseo/worktrees/feature", - }), - ).toBe("workspace-1"); - }); - - it("does not match project root metadata", () => { - const workspaces = [ - createWorkspace({ - id: "workspace-1", - projectRootPath: "/repo", - workspaceDirectory: "/repo/.paseo/worktrees/feature", - }), - ]; - - expect( - resolveWorkspaceIdByDirectory({ - workspaces, - workspaceDirectory: "/repo", - }), - ).toBeNull(); - }); -}); - describe("resolveWorkspaceMapKeyByIdentity", () => { it("returns the existing map key when the identity already matches a key", () => { const workspaces = new Map([ diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 76c587a60..8c778372d 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -31,26 +31,6 @@ export function resolveWorkspaceRouteId(input: { return normalizeWorkspaceOpaqueId(input.routeWorkspaceId); } -// Single approved cwd→workspaceId inference site. -// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it. -export function resolveWorkspaceIdByDirectory(input: { - workspaces: Iterable | null | undefined; - workspaceDirectory: string | null | undefined; -}): string | null { - const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory); - if (!normalizedWorkspaceDirectory) { - return null; - } - - for (const workspace of input.workspaces ?? []) { - if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { - return workspace.id; - } - } - - return null; -} - export function resolveWorkspaceMapKeyByIdentity(input: { workspaces: Map | null | undefined; workspaceId: string | null | undefined; diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 72138ac70..dec15997f 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -57,16 +57,19 @@ function makeAgent(input: { }; } +const WORKSPACE_ID = "ws-1"; + describe("workspace agent visibility", () => { it("keeps subagents active and known while excluding them from auto-open", () => { - const workspaceDirectory = "/repo/worktree"; const parent = makeAgent({ id: "parent-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, }); const child = makeAgent({ id: "child-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, parentAgentId: "parent-agent", }); @@ -75,7 +78,7 @@ describe("workspace agent visibility", () => { [parent.id, parent], [child.id, child], ]), - workspaceDirectory, + workspaceId: WORKSPACE_ID, }); expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); @@ -84,17 +87,17 @@ describe("workspace agent visibility", () => { }); it("keeps archived subagents known but excludes them from active and auto-open", () => { - const workspaceDirectory = "/repo/worktree"; const archivedChild = makeAgent({ id: "archived-child", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, parentAgentId: "parent-agent", archivedAt: new Date("2026-03-04T00:01:00.000Z"), }); const result = deriveWorkspaceAgentVisibility({ sessionAgents: new Map([[archivedChild.id, archivedChild]]), - workspaceDirectory, + workspaceId: WORKSPACE_ID, }); expect(result.activeAgentIds).toEqual(new Set()); @@ -103,15 +106,16 @@ describe("workspace agent visibility", () => { }); it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { - const workspaceDirectory = "/repo/worktree"; const child = makeAgent({ id: "child-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, parentAgentId: "parent-agent", }); const parent = makeAgent({ id: "parent-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, }); const result = deriveWorkspaceAgentVisibility({ @@ -119,7 +123,7 @@ describe("workspace agent visibility", () => { [child.id, child], [parent.id, parent], ]), - workspaceDirectory, + workspaceId: WORKSPACE_ID, }); expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); @@ -128,21 +132,23 @@ describe("workspace agent visibility", () => { }); it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { - const workspaceDirectory = "/repo/worktree"; const visible = makeAgent({ id: "visible-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, createdAt: new Date("2026-03-04T00:00:00.000Z"), }); const archived = makeAgent({ id: "archived-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, archivedAt: new Date("2026-03-04T00:01:00.000Z"), createdAt: new Date("2026-03-04T00:01:00.000Z"), }); const otherWorkspace = makeAgent({ id: "other-workspace-agent", cwd: "/repo/other", + workspaceId: "ws-other", }); const sessionAgents = new Map([ @@ -153,7 +159,7 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, - workspaceDirectory, + workspaceId: WORKSPACE_ID, }); expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); @@ -164,18 +170,22 @@ describe("workspace agent visibility", () => { }); it("treats lazy historical details as known without making them active", () => { - const workspaceDirectory = "/repo/worktree"; - const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory }); + const active = makeAgent({ + id: "active-agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + }); const historicalDetail = makeAgent({ id: "historical-agent", - cwd: workspaceDirectory, + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, archivedAt: new Date("2026-03-04T00:01:00.000Z"), }); const result = deriveWorkspaceAgentVisibility({ sessionAgents: new Map([[active.id, active]]), agentDetails: new Map([[historicalDetail.id, historicalDetail]]), - workspaceDirectory, + workspaceId: WORKSPACE_ID, }); expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); @@ -226,49 +236,6 @@ describe("workspace agent visibility", () => { ).toBe(true); }); - it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => { - const sessionAgents = new Map([ - [ - "slash-agent", - makeAgent({ - id: "slash-agent", - cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/", - }), - ], - ]); - - const result = deriveWorkspaceAgentVisibility({ - sessionAgents, - workspaceDirectory: - "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid", - }); - - expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); - expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"])); - expect(result.knownAgentIds.has("slash-agent")).toBe(true); - }); - - it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => { - const sessionAgents = new Map([ - [ - "recent-agent", - makeAgent({ - id: "recent-agent", - cwd: "/tmp/workspace-lifecycle-main", - }), - ], - ]); - - const result = deriveWorkspaceAgentVisibility({ - sessionAgents, - workspaceDirectory: "/tmp/workspace-lifecycle-main", - }); - - expect(result.activeAgentIds).toEqual(new Set(["recent-agent"])); - expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["recent-agent"])); - }); - it("matches agents by workspaceId regardless of cwd", () => { const sessionAgents = new Map([ [ @@ -284,7 +251,6 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-1", - workspaceDirectory: "/repo/worktree", }); expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); @@ -306,26 +272,24 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-1", - workspaceDirectory: "/repo/worktree", }); expect(result.activeAgentIds).toEqual(new Set()); expect(result.knownAgentIds).toEqual(new Set()); }); - it("falls back to cwd matching for legacy agents without a workspaceId", () => { + it("excludes agents without a workspaceId", () => { const sessionAgents = new Map([ - ["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })], + ["ownerless-agent", makeAgent({ id: "ownerless-agent", cwd: "/repo/worktree" })], ]); const result = deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-1", - workspaceDirectory: "/repo/worktree", }); - expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"])); + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.knownAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index affdb5fc6..268034279 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -1,11 +1,7 @@ import type { Agent } from "@/stores/session-store"; import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions"; import { shouldAutoOpenAgentTab } from "@/subagents/policies"; -import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity"; - -function normalizeWorkspaceDirectory(value: string | null | undefined): string { - return normalizeWorkspacePath(value) ?? ""; -} +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; @@ -13,31 +9,18 @@ export interface WorkspaceAgentVisibility { knownAgentIds: Set; } -function agentBelongsToWorkspace(input: { - agent: Agent; - workspaceId: string | null; - normalizedWorkspaceDirectory: string; -}): boolean { - const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId); - if (agentWorkspaceId) { - return input.workspaceId !== null && agentWorkspaceId === input.workspaceId; - } - // COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall - // back to the single approved cwd→id inference by comparing the agent's cwd to - // this workspace's directory. Drop when the daemon floor always stamps workspaceId. - return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory; +function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { + return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId; } export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; agentDetails?: Map | undefined; - workspaceId?: string | null | undefined; - workspaceDirectory: string | null | undefined; + workspaceId: string | null | undefined; }): WorkspaceAgentVisibility { - const { sessionAgents, agentDetails, workspaceDirectory } = input; + const { sessionAgents, agentDetails } = input; const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory); - if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) { + if ((!sessionAgents && !agentDetails) || !workspaceId) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), @@ -49,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: { const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) { + if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } knownAgentIds.add(agent.id); @@ -61,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } for (const agent of agentDetails?.values() ?? []) { - if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) { + if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } knownAgentIds.add(agent.id); diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 1c94a0266..8d483ea4e 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -32,6 +32,9 @@ export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): bo if (candidate.id === workspace.workspaceId) { continue; } + // Git-fact: comparing directories to detect a sibling worktree reference on + // disk, not attributing ownership. The disk-deletion decision is about the + // backing directory, which same-cwd siblings genuinely share. if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) { return false; } diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index a1dfe7e30..1c0309179 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -51,7 +51,6 @@ function target(input?: Partial): WorkspaceArchiveTarget return { serverId: SERVER_ID, workspaceId: base.id, - workspaceDirectory: base.workspaceDirectory, ...input, }; } @@ -107,7 +106,6 @@ describe("archiveWorkspaceOptimistically", () => { isWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: archived.id, - workspaceDirectory: archived.workspaceDirectory, }), ).toBe(true); @@ -178,10 +176,7 @@ describe("archiveWorkspacesOptimistically", () => { const failures = await archiveWorkspacesOptimistically({ client, - workspaces: [ - target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }), - target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }), - ], + workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })], }); expect(failures).toHaveLength(1); diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index e3181f2eb..5199f371a 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -8,7 +8,6 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; export interface WorkspaceArchiveTarget { serverId: string; workspaceId: string; - workspaceDirectory?: string | null; } interface WorkspaceArchiveClient { @@ -46,7 +45,6 @@ function hideWorkspaceOptimistically( markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, }); useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6278688cd..e89210272 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1102,6 +1102,73 @@ test("sends structured first-agent context attachments with create_paseo_worktre }); }); +test("sends first-agent prompt context with workspace.create.request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createWorkspace( + { + source: { + kind: "directory", + path: "/tmp/project", + projectId: "local:/tmp/project", + }, + firstAgentContext: { + prompt: "Fix login bug", + attachments: [], + }, + }, + "req-local-title", + ); + + expect(mock.sent).toHaveLength(1); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.create.request", + requestId: "req-local-title", + source: { + kind: "directory", + path: "/tmp/project", + projectId: "local:/tmp/project", + }, + firstAgentContext: { + prompt: "Fix login bug", + attachments: [], + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.response", + payload: { + requestId: "req-local-title", + workspace: null, + error: "local title sentinel", + setupTerminalId: null, + }, + }), + ); + + await expect(createPromise).resolves.toEqual({ + requestId: "req-local-title", + workspace: null, + error: "local title sentinel", + setupTerminalId: null, + }); +}); + test("sends worktree base-ref fields in create_paseo_worktree_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 75500a8ec..85b79ab5d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1583,6 +1583,7 @@ test("importProviderSession imports the selected session without listing and pub provider: "codex", providerHandleId: "thread-selected", cwd: workdir, + workspaceId: "ws-imported", }); expect(client.listCalls).toBe(0); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f2cff102a..5955c8b92 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -733,35 +733,37 @@ export class AgentManager { } async listProviderAvailability(): Promise { - const checks = Array.from(this.clients.keys()).map(async (provider) => { - const client = this.clients.get(provider); - if (!client) { - return { - provider, - available: false, - error: `No client registered for provider '${provider}'`, - } satisfies ProviderAvailability; - } + return Promise.all( + Array.from(this.clients.keys()).map((provider) => this.getProviderAvailability(provider)), + ); + } - try { - const available = await client.isAvailable(); - return { - provider, - available, - error: null, - } satisfies ProviderAvailability; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn({ err: error, provider }, "Failed to check provider availability"); - return { - provider, - available: false, - error: message, - } satisfies ProviderAvailability; - } - }); + async getProviderAvailability(provider: AgentProvider): Promise { + const client = this.clients.get(provider); + if (!client) { + return { + provider, + available: false, + error: `No client registered for provider '${provider}'`, + }; + } - return Promise.all(checks); + try { + const available = await client.isAvailable(); + return { + provider, + available, + error: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn({ err: error, provider }, "Failed to check provider availability"); + return { + provider, + available: false, + error: message, + }; + } } async listDraftCommands(config: AgentSessionConfig): Promise { @@ -931,6 +933,7 @@ export class AgentManager { provider: AgentProvider; providerHandleId: string; cwd: string; + workspaceId: string; labels?: Record; }): Promise { const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession"); @@ -962,6 +965,7 @@ export class AgentManager { return this.registerSession(imported.session, importedConfig, resolvedAgentId, { labels: input.labels, + workspaceId: input.workspaceId, timelineRows, timelineNextSeq: timelineRows.length + 1, persistence: imported.persistence, diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts index 5e2b84d59..b15d97079 100644 --- a/packages/server/src/server/agent/agent-response-loop.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -135,10 +135,22 @@ describe("generateStructuredAgentResponseWithFallback", () => { function createManager( availability: Array<{ provider: string; available: boolean; error: string | null }>, - ) { + ): AgentManager & { checkedProviders: string[] } { + const checkedProviders: string[] = []; + const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry])); return { - listProviderAvailability: async () => availability, - } as unknown as AgentManager; + checkedProviders, + getProviderAvailability: async (provider: string) => { + checkedProviders.push(provider); + return ( + availabilityByProvider.get(provider) ?? { + provider, + available: false, + error: `No client registered for provider '${provider}'`, + } + ); + }, + } as unknown as AgentManager & { checkedProviders: string[] }; } it("uses the first available provider in the waterfall", async () => { @@ -171,6 +183,7 @@ describe("generateStructuredAgentResponseWithFallback", () => { expect(result).toEqual({ summary: "ok" }); expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]); + expect(manager.checkedProviders).toEqual(["claude"]); }); it("skips unavailable providers and uses the next available one", async () => { @@ -201,6 +214,7 @@ describe("generateStructuredAgentResponseWithFallback", () => { expect(result).toEqual({ summary: "ok" }); expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]); + expect(manager.checkedProviders).toEqual(["claude", "codex"]); }); it("falls back when an available provider fails", async () => { @@ -237,6 +251,7 @@ describe("generateStructuredAgentResponseWithFallback", () => { { provider: "claude", model: "haiku" }, { provider: "codex", model: "gpt-5.4-mini" }, ]); + expect(manager.checkedProviders).toEqual(["claude", "codex"]); }); it("throws a fallback error when all providers are unavailable or fail", async () => { diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index ac72af9c4..f6168623f 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -403,13 +403,11 @@ export async function generateStructuredAgentResponseWithFallback( const runStructured = runner ?? ((input: StructuredAgentGenerationOptions) => generateStructuredAgentResponse(input)); - const availability = await manager.listProviderAvailability(); - const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry])); const attempts: StructuredGenerationAttempt[] = []; for (const candidate of providers) { - const availabilityEntry = availabilityByProvider.get(candidate.provider); - if (availabilityEntry && !availabilityEntry.available) { + const availabilityEntry = await manager.getProviderAvailability(candidate.provider); + if (!availabilityEntry.available) { const reason = availabilityEntry.error ?? "unavailable"; attempts.push({ provider: candidate.provider, diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 4e246d24b..2a0fe1d84 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -29,7 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies { workspaceGitService: WorkspaceGitService; createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; archiveAgentForClose: (agentId: string) => Promise; - resolveWorkspaceIdForCwd: (cwd: string) => Promise; + findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; @@ -211,7 +211,7 @@ export class CreateAgentLifecycleDispatch { workspaceGitService: this.dependencies.workspaceGitService, agentManager: this.dependencies.agentManager, agentStorage: this.dependencies.agentStorage, - resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd, + findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 76b86c97c..a84c68488 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -54,6 +54,10 @@ interface CreateAgentCommandDependencies { providerSnapshotManager: ProviderSnapshotManager; daemonConfig?: StructuredGenerationDaemonConfig | null; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; + // Mints a fresh workspace for a cwd and returns its id. Used when an agent is + // created with no parent and no worktree: it owns a brand-new workspace rather + // than being attributed to an existing same-cwd workspace by path. + ensureWorkspaceForCreate?: (cwd: string) => Promise; } export interface CreateAgentFromSessionInput { @@ -258,8 +262,11 @@ async function resolveMcpCreateAgent( // workspace. When a new worktree is created the child lives in that fresh // workspace, so it is stamped with the new worktree's workspaceId instead // (mirrors the session path) — keeping the agent discoverable by - // workspaceId-scoped archive. - const workspaceId = setupContinuation ? createdWorkspaceId : parentAgent?.workspaceId; + // workspaceId-scoped archive. With neither a parent nor a worktree, the agent + // mints its own workspace; ownership is never resolved from cwd. + const workspaceId = setupContinuation + ? createdWorkspaceId + : (parentAgent?.workspaceId ?? (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd))); const { modeId: resolvedMode, featureValues: resolvedFeatures } = await dependencies.providerSnapshotManager.resolveCreateConfig({ @@ -305,6 +312,16 @@ async function resolveMcpCreateAgent( }; } +async function ensureWorkspaceForMcpCreate( + dependencies: CreateAgentCommandDependencies, + cwd: string, +): Promise { + if (!dependencies.ensureWorkspaceForCreate) { + return undefined; + } + return dependencies.ensureWorkspaceForCreate(cwd); +} + async function sendInitialPrompt( dependencies: CreateAgentCommandDependencies, resolved: ResolvedCreateAgent, diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 825eca3a2..d385e6786 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -388,6 +388,7 @@ test("importProviderSession imports a selected provider session without listing" providerHandleId: "provider-thread-imported", cwd, }, + workspaceId: "ws-imported", agentManager, agentStorage, logger: { warn: vi.fn(), error: vi.fn() } as never, @@ -398,6 +399,7 @@ test("importProviderSession imports a selected provider session without listing" provider: "custom-codex", providerHandleId: "provider-thread-imported", cwd, + workspaceId: "ws-imported", labels: undefined, }); expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith( @@ -437,6 +439,7 @@ test("importProviderSession passes labels through the manager import operation", cwd, labels: { source: "import" }, }, + workspaceId: "ws-imported", agentManager, agentStorage, logger: { warn: vi.fn(), error: vi.fn() } as never, @@ -446,6 +449,7 @@ test("importProviderSession passes labels through the manager import operation", provider: "codex", providerHandleId: "thread-imported", cwd, + workspaceId: "ws-imported", labels: { source: "import" }, }); }); @@ -460,6 +464,7 @@ test("importProviderSession requires cwd from the selected provider row", async provider: "opencode", providerHandleId: "thread-imported", }, + workspaceId: "ws-imported", agentManager, agentStorage: { list: vi.fn() } as unknown as AgentStorage, logger: { warn: vi.fn(), error: vi.fn() } as never, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 9f9775c43..ae482f6ea 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -62,6 +62,7 @@ export interface ListImportableProviderSessionsResult { export interface ImportProviderSessionInput { request: NormalizedImportAgentRequest; + workspaceId: string; agentManager: AgentManager; agentStorage: AgentStorage; workspaceGitService?: Pick; @@ -163,6 +164,7 @@ export async function importProviderSession( provider, providerHandleId, cwd, + workspaceId: input.workspaceId, labels, }); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 2ab2896fd..b60dbdb04 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1448,7 +1448,7 @@ describe("create_agent MCP tool", () => { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, - resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"), + findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"), listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds, @@ -1532,7 +1532,7 @@ describe("create_agent MCP tool", () => { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, - resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"), + findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"), listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord: vi.fn(async () => undefined), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 243eea817..90f293628 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -93,13 +93,16 @@ export interface AgentMcpServerOptions { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; - resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"]; + findWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; + // Mints a fresh workspace for a cwd and returns its id, used when an agent is + // created with no parent and no worktree. + ensureWorkspaceForCreate?: (cwd: string) => Promise; paseoHome?: string; worktreesRoot?: string; /** @@ -572,6 +575,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return expandUserPath(trimmedCwd); }; + async function resolveTerminalWorkspaceId(resolvedCwd: string): Promise { + // An MCP-spawned terminal belongs to the caller agent's workspace. Only if + // the caller has no workspace do we mint one for the cwd. + const callerAgent = callerAgentId ? agentManager.getAgent(callerAgentId) : null; + if (callerAgent?.workspaceId) { + return callerAgent.workspaceId; + } + + if (!options.ensureWorkspaceForCreate) { + throw new Error( + callerAgentId + ? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured` + : "workspaceId is required outside an agent-scoped session", + ); + } + + return options.ensureWorkspaceForCreate(resolvedCwd); + } + const buildCallerAgentScheduleConfigExtras = ( callerAgent: NonNullable>, ): Record => { @@ -926,6 +948,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom terminalManager, providerSnapshotManager, createPaseoWorktree: options.createPaseoWorktree, + ...(options.ensureWorkspaceForCreate + ? { ensureWorkspaceForCreate: options.ensureWorkspaceForCreate } + : {}), }, { kind: "mcp", @@ -1520,8 +1545,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom throw new Error("Terminal manager is not configured"); } + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const workspaceId = await resolveTerminalWorkspaceId(resolvedCwd); + const terminal = await terminalManager.createTerminal({ - cwd: resolveScopedCwd(cwd, { required: true }), + cwd: resolvedCwd, + workspaceId, ...(name?.trim() ? { name: name.trim() } : {}), }); @@ -2411,7 +2440,7 @@ function archiveWorktreeDependencies( if (!options.archiveWorkspaceRecord) { throw new Error("Workspace registry archiver is required to archive worktrees"); } - if (!options.resolveWorkspaceIdForCwd) { + if (!options.findWorkspaceIdForCwd) { throw new Error("Workspace resolver is required to archive worktrees"); } if (!options.listActiveWorkspaces) { @@ -2433,7 +2462,7 @@ function archiveWorktreeDependencies( workspaceGitService: options.workspaceGitService, agentManager: context.agentManager, agentStorage: context.agentStorage, - resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, + findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index d27e17666..1f314c921 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -45,6 +45,63 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("returns schema-shaped JSON for structured branch-name generation", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + + const resultPromise = session.run( + [ + "Generate a git branch name for a coding agent based on the user prompt and attachments.", + "Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters).", + "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", + "Return JSON only with fields 'title' and 'branch'.", + "", + "User context:", + "Fix login bug", + ].join("\n"), + ); + await vi.advanceTimersByTimeAsync(0); + + await expect(resultPromise).resolves.toMatchObject({ + sessionId: session.id, + finalText: JSON.stringify({ title: "Fix login bug", branch: "fix-login-bug" }), + canceled: false, + }); + }); + + test("returns schema-shaped JSON for structured agent-title generation", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + + const resultPromise = session.run( + [ + "Generate metadata for a coding agent based on the user prompt.", + "Title: short descriptive label (<= 80 chars).", + "Return JSON only with a single field 'title'.", + "", + "User prompt:", + "Fix login bug", + ].join("\n"), + ); + await vi.advanceTimersByTimeAsync(0); + + await expect(resultPromise).resolves.toMatchObject({ + sessionId: session.id, + finalText: JSON.stringify({ title: "Fix login bug" }), + canceled: false, + }); + }); + test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index e561213d8..1dfaf8cfd 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -270,6 +270,78 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre }; } +function parseStructuredBranchNamePrompt( + prompt: AgentPromptInput, +): { title: string; branch: string } | null { + const text = promptToText(prompt); + const hasBranchNamePrompt = + text.includes("Generate a git branch name for a coding agent") && + (text.includes("Return JSON only with fields 'title' and 'branch'.") || + text.includes('"title"') || + text.includes('"branch"')); + if ( + !hasBranchNamePrompt && + !( + text.includes("You must respond with JSON only that matches this JSON Schema") && + text.includes('"title"') && + text.includes('"branch"') + ) + ) { + return null; + } + + const seed = text.split("User context:\n").at(-1)?.trim() ?? ""; + const firstLine = + seed + .split("\n") + .find((line) => line.trim().length > 0) + ?.trim() ?? "Mock task"; + const title = firstLine + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\s+/g, " ") + .slice(0, 80) + .trim(); + const branch = + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 100) || "mock-task"; + + return { title: title || "Mock task", branch }; +} + +function parseStructuredAgentTitlePrompt(prompt: AgentPromptInput): { title: string } | null { + const text = promptToText(prompt); + const hasAgentTitlePrompt = + text.includes("Generate metadata for a coding agent based on the user prompt.") && + (text.includes("Return JSON only with a single field 'title'.") || text.includes('"title"')); + if ( + !hasAgentTitlePrompt && + !( + text.includes("You must respond with JSON only that matches this JSON Schema") && + text.includes('"title"') && + text.includes("User prompt:") + ) + ) { + return null; + } + + const seed = text.split("User prompt:\n").at(-1)?.trim() ?? ""; + const firstLine = + seed + .split("\n") + .find((line) => line.trim().length > 0) + ?.trim() ?? "Mock task"; + const title = firstLine + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\s+/g, " ") + .slice(0, 80) + .trim(); + + return { title: title || "Mock task" }; +} + function buildRepeatedPayload(bytes: number, prefix: string): string { const line = `${prefix} ${"x".repeat(96)}\n`; let output = ""; @@ -605,7 +677,13 @@ export class MockLoadTestAgentSession implements AgentSession { const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); const stress = parseAgentStreamStressPrompt(prompt); const questionPrompt = parseMockQuestionPrompt(prompt); - if (shouldEmitPlanApprovalPrompt(prompt)) { + const structuredBranchName = parseStructuredBranchNamePrompt(prompt); + const structuredAgentTitle = parseStructuredAgentTitlePrompt(prompt); + if (structuredBranchName) { + this.scheduleStructuredJsonTurn(turn, structuredBranchName); + } else if (structuredAgentTitle) { + this.scheduleStructuredJsonTurn(turn, structuredAgentTitle); + } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); } else if (questionPrompt) { this.scheduleQuestionPromptTurn(turn, questionPrompt); @@ -791,6 +869,49 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record): void { + turn.timer = setTimeout(() => { + this.emitStructuredJsonTurn(turn, result); + }, 0); + turn.timer.unref?.(); + } + + private emitStructuredJsonTurn(turn: ActiveTurn, result: Record): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + + const finalText = JSON.stringify(result); + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: finalText, + }); + this.activeTurn = null; + this.emit({ + type: "turn_completed", + provider: this.provider, + turnId: turn.turnId, + }); + turn.resolve({ + sessionId: this.id, + finalText, + timeline: [ + { + type: "assistant_message", + text: finalText, + }, + ], + canceled: false, + }); + } + private emitPlanApprovalTurn(turn: ActiveTurn): void { if (this.activeTurn !== turn) { return; diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts index 83ae26630..05759e89f 100644 --- a/packages/server/src/server/agent/structured-generation-providers.test.ts +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -1,13 +1,42 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js"; +import type { ProviderSnapshotEntry } from "./agent-sdk-types.js"; const READY = "ready" as const; const ERROR = "error" as const; +class ProviderSnapshots { + readonly calls: Array<{ cwd?: string; wait?: boolean }> = []; + + constructor(private readonly entries: ProviderSnapshotEntry[]) {} + + async listProviders(input: { cwd?: string; wait?: boolean } = {}) { + this.calls.push({ cwd: input.cwd, wait: input.wait }); + return this.entries; + } +} + describe("resolveStructuredGenerationProviders", () => { - test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => { - const listProviders = vi.fn(async () => [ + test("uses explicit configured provider models without refreshing provider snapshots", async () => { + const snapshots = new ProviderSnapshots([]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: snapshots, + daemonConfig: { + metadataGeneration: { + providers: [{ provider: "mock", model: "ten-second-stream" }], + }, + }, + }); + + expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]); + expect(snapshots.calls).toEqual([]); + }); + + test("falls back to dynamic defaults and current selection when no provider is configured", async () => { + const snapshots = new ProviderSnapshots([ { provider: "work-claude", status: READY, @@ -47,15 +76,7 @@ describe("resolveStructuredGenerationProviders", () => { const providers = await resolveStructuredGenerationProviders({ cwd: "/tmp/repo", - providerSnapshotManager: { listProviders }, - daemonConfig: { - metadataGeneration: { - providers: [ - { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, - { provider: "work-claude" }, - ], - }, - }, + providerSnapshotManager: snapshots, currentSelection: { provider: "focused-provider", model: "focused-model", @@ -64,36 +85,35 @@ describe("resolveStructuredGenerationProviders", () => { }); expect(providers).toEqual([ - { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, { provider: "work-claude", model: "claude-haiku-2026" }, { provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" }, { provider: "router", model: "minimax-m2.5-free" }, { provider: "router", model: "nemotron-3-super-free" }, { provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" }, ]); - expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true }); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); test("falls back to the current selection when defaults do not match", async () => { - const providers = await resolveStructuredGenerationProviders({ - cwd: "/tmp/repo", - providerSnapshotManager: { - listProviders: vi.fn(async () => [ + const snapshots = new ProviderSnapshots([ + { + provider: "current-provider", + status: READY, + enabled: true, + models: [ { provider: "current-provider", - status: READY, - enabled: true, - models: [ - { - provider: "current-provider", - id: "selected-model", - label: "Selected Model", - isDefault: true, - }, - ], + id: "selected-model", + label: "Selected Model", + isDefault: true, }, - ]), + ], }, + ]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: snapshots, currentSelection: { provider: "current-provider", model: "selected-model", @@ -107,26 +127,26 @@ describe("resolveStructuredGenerationProviders", () => { }); test("resolves a provider-only current selection to that provider's default model", async () => { - const providers = await resolveStructuredGenerationProviders({ - cwd: "/tmp/repo", - providerSnapshotManager: { - listProviders: vi.fn(async () => [ + const snapshots = new ProviderSnapshots([ + { + provider: "focused-provider", + status: READY, + enabled: true, + models: [ { provider: "focused-provider", - status: READY, - enabled: true, - models: [ - { - provider: "focused-provider", - id: "focused-default", - label: "Focused Default", - isDefault: true, - defaultThinkingOptionId: "balanced", - }, - ], + id: "focused-default", + label: "Focused Default", + isDefault: true, + defaultThinkingOptionId: "balanced", }, - ]), + ], }, + ]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: snapshots, currentSelection: { provider: "focused-provider" }, }); @@ -135,30 +155,30 @@ describe("resolveStructuredGenerationProviders", () => { ]); }); - test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => { - const providers = await resolveStructuredGenerationProviders({ - cwd: "/tmp/repo", - providerSnapshotManager: { - listProviders: vi.fn(async () => [ + test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => { + const snapshots = new ProviderSnapshots([ + { + provider: "opencode", + status: READY, + enabled: true, + models: [ { provider: "opencode", - status: READY, - enabled: true, - models: [ - { - provider: "opencode", - id: "plexus/small-fast", - label: "Small Fast", - isDefault: true, - metadata: { - providerId: "plexus", - modelId: "small-fast", - }, - }, - ], + id: "plexus/small-fast", + label: "Small Fast", + isDefault: true, + metadata: { + providerId: "plexus", + modelId: "small-fast", + }, }, - ]), + ], }, + ]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: snapshots, daemonConfig: { metadataGeneration: { providers: [{ provider: "plexus", model: "small-fast" }], @@ -166,22 +186,23 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]); + expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]); + expect(snapshots.calls).toEqual([]); }); test("keeps explicit candidates when provider snapshots are in error state", async () => { + const snapshots = new ProviderSnapshots([ + { + provider: "current-provider", + status: ERROR, + enabled: true, + error: "timed out", + }, + ]); + const providers = await resolveStructuredGenerationProviders({ cwd: "/tmp/repo", - providerSnapshotManager: { - listProviders: vi.fn(async () => [ - { - provider: "current-provider", - status: ERROR, - enabled: true, - error: "timed out", - }, - ]), - }, + providerSnapshotManager: snapshots, daemonConfig: { metadataGeneration: { providers: [{ provider: "current-provider", model: "configured-model" }], @@ -194,9 +215,7 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([ - { provider: "current-provider", model: "configured-model" }, - { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, - ]); + expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]); + expect(snapshots.calls).toEqual([]); }); }); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts index 9cf7ac461..b9ee9a44f 100644 --- a/packages/server/src/server/agent/structured-generation-providers.ts +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -43,6 +43,23 @@ export interface ResolveStructuredGenerationProvidersOptions { export async function resolveStructuredGenerationProviders( options: ResolveStructuredGenerationProvidersOptions, ): Promise { + const configuredProviders = readConfiguredProviders(options.daemonConfig); + if (configuredProviders.length > 0) { + const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders); + if (explicitProviders.length === configuredProviders.length) { + return dedupeProviders(explicitProviders); + } + + const providerEntries = await options.providerSnapshotManager.listProviders({ + cwd: options.cwd, + wait: false, + }); + const providers = resolveConfiguredProviders(configuredProviders, providerEntries); + if (providers.length > 0) { + return dedupeProviders(providers); + } + } + const providerEntries = await options.providerSnapshotManager.listProviders({ cwd: options.cwd, wait: true, @@ -52,7 +69,7 @@ export async function resolveStructuredGenerationProviders( const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); const providers: StructuredGenerationProvider[] = []; - for (const configured of readConfiguredProviders(options.daemonConfig)) { + for (const configured of configuredProviders) { const resolvedConfigured = resolveConfiguredCandidate( configured, modelEntries, @@ -83,6 +100,42 @@ export async function resolveStructuredGenerationProviders( return dedupeProviders(providers); } +function resolveConfiguredProviders( + configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], + providerEntries: readonly ProviderSnapshotEntry[], +): StructuredGenerationProvider[] { + const enabledEntries = providerEntries.filter((entry) => entry.enabled); + const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0); + const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); + const providers: StructuredGenerationProvider[] = []; + for (const configured of configuredProviders) { + const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider); + if (resolved) { + providers.push(resolved); + } + } + return providers; +} + +function resolveExplicitConfiguredProviders( + configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], +): StructuredGenerationProvider[] { + const providers: StructuredGenerationProvider[] = []; + for (const configured of configuredProviders) { + const provider = configured.provider.trim(); + const model = configured.model?.trim(); + if (!provider || !model) { + continue; + } + providers.push({ + provider, + model, + ...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}), + }); + } + return providers; +} + function resolveCurrentSelection( selection: ResolveStructuredGenerationProvidersOptions["currentSelection"], readyEntries: readonly ProviderSnapshotEntry[], diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 78fbf01a7..60dcc0f76 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -93,7 +93,7 @@ function createHarness(overrides?: { agentManager: {} as AutoArchiveArchiveOptions["agentManager"], agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"], terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], - resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"), + findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"), listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord: vi.fn(), markWorkspaceArchiving: vi.fn(), diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 762344063..5a762b81d 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -25,7 +25,7 @@ export interface AutoArchiveArchiveOptions { agentManager: AgentManager; agentStorage: AgentStorage; terminalManager: TerminalManager; - resolveWorkspaceIdForCwd: (cwd: string) => Promise; + findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; @@ -105,7 +105,7 @@ export async function archiveIfSafe(input: { workspaceGitService: options.workspaceGitService, agentManager: options.agentManager, agentStorage: options.agentStorage, - resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, + findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b82991e32..6648ad80b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -89,7 +89,10 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null { import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { createGitHubService } from "../services/github-service.js"; -import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js"; +import { + createPaseoWorktree as createRegisteredPaseoWorktree, + createLocalCheckoutWorkspace, +} from "./paseo-worktree-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; @@ -110,7 +113,7 @@ import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; -import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js"; +import { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js"; @@ -748,8 +751,17 @@ export async function createPaseoDaemon( workspaceRegistry, }); }; - const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise => { - return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list()); + // external path→workspace adapter, not ownership: archive-by-path requests that + // arrive with a worktree path and no workspaceId (old clients / CLI). + const findWorkspaceIdForCwdExternal = async (cwd: string): Promise => { + return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()); + }; + const ensureWorkspaceForCreateExternal = async (cwd: string): Promise => { + const workspace = await createLocalCheckoutWorkspace( + { cwd }, + { projectRegistry, workspaceRegistry, workspaceGitService }, + ); + return workspace.workspaceId; }; const listActiveWorkspacesExternal = async (): Promise => { const workspaces = await workspaceRegistry.list(); @@ -795,7 +807,7 @@ export async function createPaseoDaemon( agentStorage, terminalManager, logger, - resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, @@ -819,12 +831,13 @@ export async function createPaseoDaemon( providerSnapshotManager, github, workspaceGitService, - resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal, createPaseoWorktree: async (input, serviceOptions) => { return createPaseoWorktreeWorkflow( { @@ -850,13 +863,8 @@ export async function createPaseoDaemon( .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], ); }, - emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => { - await Promise.all( - wsServer - ?.listActiveSessions() - .map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [], - ); - void emitOptions; + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + await emitWorkspaceUpdatesExternal([workspaceId]); }, cacheWorkspaceSetupSnapshot: () => {}, emit: emitExternalSessionMessage, diff --git a/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts b/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts new file mode 100644 index 000000000..3229b8c4c --- /dev/null +++ b/packages/server/src/server/migrations/backfill-workspace-id.migration.test.ts @@ -0,0 +1,150 @@ +import { homedir } from "node:os"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { AgentStorage } from "../agent/agent-storage.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { + FileBackedWorkspaceRegistry, + createPersistedWorkspaceRecord, +} from "../workspace-registry.js"; +import { backfillWorkspaceIdForLegacyAgents } from "./backfill-workspace-id.migration.js"; + +function workspaceRecord( + cwd: string, + workspaceId: string, + overrides?: { createdAt?: string; archivedAt?: string }, +) { + return createPersistedWorkspaceRecord({ + workspaceId, + projectId: workspaceId, + cwd, + kind: "directory", + displayName: path.basename(cwd) || cwd, + createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + archivedAt: overrides?.archivedAt ?? null, + }); +} + +describe("backfillWorkspaceIdForLegacyAgents", () => { + let home: string; + let agentStorage: AgentStorage; + let workspaceRegistry: FileBackedWorkspaceRegistry; + + beforeEach(async () => { + home = mkdtempSync(path.join(tmpdir(), "paseo-backfill-")); + agentStorage = new AgentStorage(path.join(home, "agents"), createTestLogger()); + await agentStorage.initialize(); + workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(home, "workspaces.json"), + createTestLogger(), + ); + await workspaceRegistry.initialize(); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + async function seedLegacyAgent(cwd: string, id: string): Promise { + await agentStorage.upsert({ + id, + provider: "codex", + cwd, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "closed", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + } + + test("stamps the oldest exact-cwd workspace onto an unstamped legacy agent", async () => { + await workspaceRegistry.upsert( + workspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }), + ); + await workspaceRegistry.upsert( + workspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }), + ); + await seedLegacyAgent("/tmp/repo", "legacy-agent"); + + const migrated = await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect(migrated).toBe(1); + expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older"); + }); + + test("attributes to the deepest enclosing workspace when there is no exact match", async () => { + await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-root")); + await workspaceRegistry.upsert(workspaceRecord("/tmp/repo/packages/app", "ws-app")); + await seedLegacyAgent("/tmp/repo/packages/app/src", "legacy-agent"); + + await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-app"); + }); + + test("leaves already-stamped records untouched", async () => { + await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-cwd")); + await agentStorage.upsert({ + id: "stamped-agent", + provider: "codex", + cwd: "/tmp/repo", + workspaceId: "ws-explicit", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "closed", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + const migrated = await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect(migrated).toBe(0); + expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit"); + }); + + test("does not let the home directory own descendants", async () => { + const userHome = homedir(); + await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home")); + await seedLegacyAgent(path.join(userHome, "repo"), "legacy-agent"); + + const migrated = await backfillWorkspaceIdForLegacyAgents({ + agentStorage, + workspaceRegistry, + logger: createTestLogger(), + }); + + expect(migrated).toBe(0); + expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/migrations/backfill-workspace-id.migration.ts b/packages/server/src/server/migrations/backfill-workspace-id.migration.ts new file mode 100644 index 000000000..de9fc66d7 --- /dev/null +++ b/packages/server/src/server/migrations/backfill-workspace-id.migration.ts @@ -0,0 +1,88 @@ +// COMPAT(workspaceIdBackfill): one-time legacy backfill, delete after 2026-12-16 +// once floor >= the release that always stamps workspaceId at create time. +// +// This is the ONLY place in the codebase that maps a cwd to a workspaceId. +// Every other code path treats a record's `workspaceId` field as authoritative +// ownership. Legacy agent records persisted before workspaceId stamping have no +// owner, so this migration stamps each one with the workspace that owned its +// directory at the time it was written. It runs once at startup and writes the +// id back to storage, after which all runtime code can assume the field exists. +import { homedir } from "node:os"; +import { resolve, sep } from "node:path"; + +import type { Logger } from "pino"; + +import type { AgentStorage } from "../agent/agent-storage.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js"; + +// Picks the workspace that owned `cwd` for a legacy, unstamped agent record. +// Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the +// deepest enclosing workspace directory, never letting the home directory own +// descendants. Used only by the one-time backfill below. +function resolveLegacyWorkspaceOwner( + cwd: string, + workspaces: Iterable, +): string | null { + const normalizedCwd = resolve(cwd); + const userHome = resolve(homedir()); + const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt); + const exactMatches = activeWorkspaces.filter( + (workspace) => resolve(workspace.cwd) === normalizedCwd, + ); + if (exactMatches.length > 0) { + return oldestWorkspace(exactMatches).workspaceId; + } + + const prefixMatches = activeWorkspaces.filter((workspace) => { + const workspaceCwd = resolve(workspace.cwd); + if (workspaceCwd === userHome) { + return false; + } + return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${sep}`); + }); + if (prefixMatches.length === 0) { + return null; + } + + const deepestPrefixLength = Math.max( + ...prefixMatches.map((workspace) => resolve(workspace.cwd).length), + ); + return oldestWorkspace( + prefixMatches.filter((workspace) => resolve(workspace.cwd).length === deepestPrefixLength), + ).workspaceId; +} + +function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord { + return workspaces.reduce((oldest, candidate) => + candidate.createdAt < oldest.createdAt ? candidate : oldest, + ); +} + +export async function backfillWorkspaceIdForLegacyAgents(options: { + agentStorage: AgentStorage; + workspaceRegistry: WorkspaceRegistry; + logger: Logger; +}): Promise { + const workspaceRecords = await options.workspaceRegistry.list(); + const records = await options.agentStorage.list(); + let migrated = 0; + + for (const record of records) { + if (record.workspaceId) { + continue; + } + + const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords); + if (!workspaceId) { + continue; + } + + await options.agentStorage.upsert({ ...record, workspaceId }); + migrated += 1; + } + + if (migrated > 0) { + options.logger.info({ migrated }, "Backfilled workspaceId for legacy agent records"); + } + return migrated; +} diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index e889964ec..ed5f43cc8 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -27,7 +27,10 @@ export interface ArchivePaseoWorktreeDependencies { workspaceGitService: Pick; agentManager: Pick; agentStorage: Pick; - resolveWorkspaceIdForCwd: (cwd: string) => Promise; + // Resolves the worktree at a path to its workspaceId for archive-by-path. The + // path uniquely identifies a worktree workspace; this is a directory lookup for + // the archive target, not status/ownership. + findWorkspaceIdForCwd: (cwd: string) => Promise; // Active (non-archived) workspaces, used to decide whether the workspace being // archived is the last reference to its backing worktree directory, and to // break a same-cwd tie in favor of the worktree-kind record when archiving by @@ -150,7 +153,7 @@ export async function archivePaseoWorktree( async function resolveTargetWorkspaceId( dependencies: Pick< ArchivePaseoWorktreeDependencies, - "resolveWorkspaceIdForCwd" | "listActiveWorkspaces" + "findWorkspaceIdForCwd" | "listActiveWorkspaces" >, targetPath: string, ): Promise { @@ -162,7 +165,7 @@ async function resolveTargetWorkspaceId( if (worktreeMatch) { return worktreeMatch.workspaceId; } - return dependencies.resolveWorkspaceIdForCwd(targetPath); + return dependencies.findWorkspaceIdForCwd(targetPath); } export type ArchiveWorkspaceContentsDependencies = Pick< diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e2a6f7773..b49eebbc2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -162,8 +162,8 @@ import { deriveProjectGroupingName, deriveWorkspaceDisplayName, generateWorkspaceId, - resolveWorkspaceIdForRecord, } from "./workspace-registry-model.js"; +import { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -241,6 +241,7 @@ import { } from "../services/github-service.js"; import { summarizeFetchWorkspacesEntries, + workspaceIdsOnCheckout, WorkspaceDirectory, type WorkspaceUpdatesFilter, } from "./workspace-directory.js"; @@ -252,10 +253,6 @@ import { type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import { - migrateLegacyAgentWorkspaceOwnership, - resolveLegacyWorkspaceOwnerForCwd, -} from "./workspace-registry-bootstrap.js"; import { generateBranchNameFromFirstAgentContext, type GeneratedWorkspaceName, @@ -839,9 +836,6 @@ export class Session { private readonly github: GitHubService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; - // Resolves when the most recently scheduled background workspace-naming write - // completes. Tests await this (via the getter below) instead of sleeping. - private pendingWorkspaceNaming: Promise = Promise.resolve(); private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; @@ -1006,7 +1000,7 @@ export class Session { createPaseoWorktreeWorkflow: (input, workflowOptions) => this.createPaseoWorktreeWorkflow(input, workflowOptions), archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), - resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), + findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), @@ -1123,10 +1117,6 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds); } - async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable): Promise { - await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd))); - } - async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.syncWorkspaceGitObserverForWorkspace(workspace); await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); @@ -1664,7 +1654,7 @@ export class Session { ): Promise { const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); const workspaces = await this.workspaceRegistry.list(); - const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces); + const workspaceId = resolveWorkspaceIdForPath(normalizedCwd, workspaces); if (!workspaceId) { return null; } @@ -1778,7 +1768,11 @@ export class Session { } } - await this.emitWorkspaceUpdateForCwd(payload.cwd); + // A lifecycle change updates exactly the agent's owning workspace, never + // every workspace sharing its cwd. Ownership is the agent's workspaceId. + if (payload.workspaceId) { + await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); + } } catch (error) { this.sessionLogger.error({ err: error }, "Failed to emit agent update"); } @@ -2403,9 +2397,9 @@ export class Session { private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`); - const knownCwd = - this.agentManager.getAgent(agentId)?.cwd ?? - (await this.agentStorage.get(agentId))?.cwd ?? + const knownWorkspaceId = + this.agentManager.getAgent(agentId)?.workspaceId ?? + (await this.agentStorage.get(agentId))?.workspaceId ?? null; // File-backed storage still needs an early delete fence before closeAgent(). @@ -2446,8 +2440,8 @@ export class Session { }); } - if (knownCwd) { - await this.emitWorkspaceUpdateForCwd(knownCwd); + if (knownWorkspaceId) { + await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId); } } @@ -2506,7 +2500,9 @@ export class Session { agentId, }); } - await this.emitWorkspaceUpdateForCwd(payload.cwd); + if (payload.workspaceId) { + await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); + } } return { agentId, archivedAt }; @@ -3286,10 +3282,13 @@ export class Session { const createAgentConfig: AgentSessionConfig = createdWorktree ? { ...config, cwd: createdWorktree.worktree.worktreePath } : config; + // Ownership comes from an explicit id (worktree or request). An agent + // created with no explicit workspace mints a fresh one — we never resolve + // an existing workspace by cwd, because many workspaces may share a cwd. const workspaceId = createdWorktree?.workspace.workspaceId ?? msg.workspaceId ?? - (await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd)); + (await this.createWorkspaceForDirectory(createAgentConfig.cwd)).workspaceId; const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -3324,11 +3323,6 @@ export class Session { ); createdAgentId = snapshot.id; await this.forwardAgentUpdate(snapshot); - this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ - autoArchive, - agentId: snapshot.id, - createdWorktree, - }); if (!createdWorktree && trimmedPrompt) { await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({ workspaceId, @@ -3336,6 +3330,11 @@ export class Session { firstAgentContext, }); } + this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ + autoArchive, + agentId: snapshot.id, + createdWorktree, + }); if (requestId) { const agentPayload = await this.buildAgentPayload(liveSnapshot); this.emit({ @@ -3483,8 +3482,15 @@ export class Session { ); try { + if (!normalized.cwd) { + throw new Error("Import requires cwd from the selected provider session"); + } + // An imported agent mints its own workspace; ownership is its workspaceId, + // never an existing same-cwd workspace resolved by path. + const workspace = await this.createWorkspaceForDirectory(normalized.cwd); const { snapshot, timelineSize } = await importProviderSession({ request: normalized, + workspaceId: workspace.workspaceId, agentManager: this.agentManager, agentStorage: this.agentStorage, workspaceGitService: this.workspaceGitService, @@ -3493,7 +3499,7 @@ export class Session { paseoHome: this.paseoHome, logger: this.sessionLogger, }); - await this.registerWorkspaceForImportedAgent(snapshot.cwd); + await this.registerWorkspaceForImportedAgent(workspace); const agentPayload = await this.buildAgentPayload(snapshot); this.emit({ type: "status", @@ -3718,7 +3724,7 @@ export class Session { firstAgentContext: FirstAgentContext; }): Promise { // Capture the generated title from the generator callback so we can write - // displayName := title after the branch rename completes. + // title := generated title after the branch rename completes. let generatedTitle: string | null = null; const result = await attemptFirstAgentBranchAutoName({ cwd: input.workspace.cwd, @@ -3755,10 +3761,10 @@ export class Session { await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); } - // applyGeneratedWorkspaceTitle writes displayName := title (and, for a worktree - // rename, the new branch) for a workspace. It re-reads the current record from - // the registry so concurrent upserts that happened after workspace creation are - // not clobbered, and writes only its own fields (K4 fix). + // applyGeneratedWorkspaceTitle fills the generated title only while the + // workspace is still untitled. It re-reads the current record from the + // registry so concurrent upserts that happened after workspace creation are + // not clobbered, while still persisting branch metadata from the rename path. private async applyGeneratedWorkspaceTitle( workspaceId: string, input: { title: string; branch?: string | null }, @@ -3769,7 +3775,7 @@ export class Session { } await this.workspaceRegistry.upsert({ ...current, - displayName: input.title, + title: current.title || input.title, ...(input.branch ? { branch: input.branch } : {}), updatedAt: new Date().toISOString(), }); @@ -3812,18 +3818,15 @@ export class Session { // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing. // Directory workspaces have no branch — write only the title. await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title }); - await this.emitWorkspaceUpdateForCwd(input.cwd); + await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId); } private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: { - workspaceId?: string; + workspaceId: string; cwd: string; firstAgentContext: FirstAgentContext; }): Promise { - const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd)); - if (!workspaceId) { - return; - } + const workspaceId = input.workspaceId; this.scheduleWorkspaceNaming( () => this.maybeAutoNameDirectoryWorkspaceTitle({ @@ -5348,29 +5351,10 @@ export class Session { this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); - // K3: persist the new git branch for all workspaces at this cwd. We write - // only branch+updatedAt — displayName holds the human title and title is the - // user override; a branch rename must touch neither (name and branch are - // decoupled fields by construction). + // Branch is a git fact derived per-descriptor from each workspace's own + // live git snapshot (id → cwd); the reconciliation pass re-persists the + // `branch` field per workspace from its own cwd. No cwd → ids fan-out here. // TODO(K10): PR-binding on branch rename is deferred — see plan K10. - if (result.currentBranch) { - const allWorkspaces = await this.workspaceRegistry.list(); - const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd( - cwd, - allWorkspaces, - ); - await Promise.all( - workspaceIds.map(async (workspaceId) => { - const current = await this.workspaceRegistry.get(workspaceId); - if (!current) return; - await this.workspaceRegistry.upsert({ - ...current, - branch: result.currentBranch as string, - updatedAt: new Date().toISOString(), - }); - }), - ); - } // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -6074,7 +6058,7 @@ export class Session { workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, agentStorage: this.agentStorage, - resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), + findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), @@ -6822,16 +6806,13 @@ export class Session { return this.workspaceDirectory.buildDescriptorMap(options); } - private resolveRegisteredWorkspaceIdForCwd( - cwd: string, - workspaces: PersistedWorkspaceRecord[], - ): string | null { - return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); - } - - private async resolveWorkspaceIdForCwd(cwd: string): Promise { + // external path→workspace adapter, not ownership. Used by archive-by-path flows + // where the request carries a worktree path (unique to one workspace) rather + // than a workspaceId. This is a directory lookup for an archive target, not a + // status/ownership attribution. + private async findWorkspaceIdForCwd(cwd: string): Promise { const workspaces = await this.workspaceRegistry.list(); - return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); + return resolveWorkspaceIdForPath(cwd, workspaces); } private matchesWorkspaceFilter(input: { @@ -7324,32 +7305,21 @@ export class Session { private async emitWorkspaceUpdateForTerminalContribution( event: TerminalWorkspaceContributionChangedEvent, ): Promise { - if (event.workspaceId) { - const workspaces = await this.workspaceRegistry.list(); - const workspaceId = resolveWorkspaceIdForRecord( - { workspaceId: event.workspaceId, cwd: event.cwd }, - workspaces, - ); - const owningWorkspace = workspaceId - ? workspaces.find( - (workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt, - ) - : null; - if (!owningWorkspace) { - return; - } - const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd( - owningWorkspace.cwd, - workspaces, - ); - await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, { - skipReconcile: true, - }); + // A terminal's activity contributes only to the workspace it carries. A + // terminal with no workspaceId attributes to nothing — status is per-id. + if (!event.workspaceId) { return; } - await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true }); + await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], { + skipReconcile: true, + }); } + // A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose + // OWN cwd is this folder re-derives its git facts from that folder (id → cwd) + // and emits its own per-id descriptor. This is a deliberate same-folder fan, + // not a cwd → id ownership lookup: git never resolves which workspace owns a + // path. See `workspaceIdsOnCheckout`. private async emitWorkspaceUpdateForCwd( cwd: string, options?: { @@ -7357,11 +7327,7 @@ export class Session { dedupeGitState?: boolean; }, ): Promise { - const workspaces = await this.workspaceRegistry.list(); - const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd( - cwd, - workspaces, - ); + const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd); if (workspaceIds.length === 0) { return; } @@ -7594,15 +7560,16 @@ export class Session { return { snapshotByWorkspaceId }; } - private async registerWorkspaceForImportedAgent(cwd: string): Promise { + private async registerWorkspaceForImportedAgent( + workspace: PersistedWorkspaceRecord, + ): Promise { try { - const workspace = await this.findOrCreateWorkspaceForDirectory(cwd); await this.syncWorkspaceGitObserverForWorkspace(workspace); await this.describeWorkspaceRecord(workspace); - await this.emitWorkspaceUpdateForCwd(workspace.cwd); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } catch (error) { this.sessionLogger.warn( - { err: error, cwd }, + { err: error, workspaceId: workspace.workspaceId, cwd: workspace.cwd }, "Failed to register workspace for imported agent", ); } @@ -7658,13 +7625,6 @@ export class Session { return; } - await migrateLegacyAgentWorkspaceOwnership({ - agentStorage: this.agentStorage, - workspaceRegistry: this.workspaceRegistry, - logger: this.sessionLogger, - cwds: [cwd], - }); - const workspace = await createLocalCheckoutWorkspace( { cwd, title: request.title ?? null }, { @@ -7684,7 +7644,7 @@ export class Session { error: null, }, }); - await this.emitWorkspaceUpdateForCwd(workspace.cwd); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); void this.workspaceGitService .getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" }) .catch((error) => { @@ -7693,7 +7653,7 @@ export class Session { "Background snapshot refresh failed after workspace.create", ); }); - if (request.firstAgentContext?.prompt) { + if (request.firstAgentContext) { const firstAgentContext = request.firstAgentContext; this.scheduleWorkspaceNaming( () => @@ -7707,34 +7667,17 @@ export class Session { } } - private async resolveLegacyWorkspaceOwnerForAgentCreate( - cwd: string, - ): Promise { - return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined; - } - - // Schedules a background workspace-naming write off the request path and - // records the resulting promise so tests can await completion deterministically - // (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path. + // Schedules a background workspace-naming write off the request path. The + // setTimeout(0) keeps the LLM call off the hot path. private scheduleWorkspaceNaming( run: () => Promise, context: { cwd: string; message: string }, ): void { - this.pendingWorkspaceNaming = new Promise((resolveNaming) => { - setTimeout(() => { - void run() - .catch((error) => { - this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message); - }) - .finally(resolveNaming); - }, 0); - }); - } - - // Test-only handle: resolves when the most recent scheduled workspace-naming - // write finishes, so tests await real completion instead of a wall-clock sleep. - get pendingWorkspaceNamingForTests(): Promise { - return this.pendingWorkspaceNaming; + setTimeout(() => { + void run().catch((error) => { + this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message); + }); + }, 0); } private async handleWorkspaceCreateWorktree( @@ -7855,7 +7798,7 @@ export class Session { const project = await this.projectRegistry.get(workspace.projectId); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - await this.emitWorkspaceUpdateForCwd(workspace.cwd); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); this.sessionLogger.info( { requestedCwd, @@ -8078,8 +8021,8 @@ export class Session { warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput), - emitWorkspaceUpdateForCwd: (cwd, emitOptions) => - this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + emitWorkspaceUpdateForWorkspaceId: (workspaceId) => + this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { this.workspaceSetupSnapshots.set(workspaceId, snapshot); }, @@ -8145,7 +8088,7 @@ export class Session { } finally { this.clearWorkspaceArchiving([existing.workspaceId]); } - await this.emitWorkspaceUpdateForCwd(existing.cwd); + await this.emitWorkspaceUpdateForWorkspaceId(existing.workspaceId); this.emit({ type: "archive_workspace_response", payload: { @@ -8217,10 +8160,12 @@ export class Session { throw new Error(`Workspace not found: ${requestedWorkspaceId}`); } - const workspaceCwd = resolve(workspace.cwd); + // Clearing attention is scoped to the workspace that OWNS the agent, by + // workspaceId — never by comparing cwd strings. A sibling workspace + // sharing the same directory keeps its own agents' attention. const clearableAgentIds = agents .filter((agent) => !agent.archivedAt) - .filter((agent) => resolve(agent.cwd) === workspaceCwd) + .filter((agent) => agent.workspaceId === workspace.workspaceId) .filter((agent) => agent.requiresAttention === true) .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) .filter((agent) => agent.attentionReason !== "permission") diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1efec395b..689f3cf1e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,8 +1,8 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test, vi } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { z } from "zod"; import { Session } from "./session.js"; @@ -119,10 +119,6 @@ interface SessionTestAccess { getAgentPayloadById(agentId: string): Promise; buildProjectPlacementForCwd(cwd: string): Promise; buildProjectPlacement(cwd: string): Promise; - resolveRegisteredWorkspaceIdForCwd( - cwd: string, - workspaces: ReturnType[], - ): string; buildWorkspaceDescriptorMap(...args: unknown[]): Promise>; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; @@ -134,7 +130,6 @@ interface SessionTestAccess { workspaceId: string, input: { title: string; branch?: string | null }, ): Promise; - pendingWorkspaceNamingForTests: Promise; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -783,142 +778,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist } }); -test("create_agent_request with an initial prompt renames the local workspace title", async () => { - const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-local-title-")); - try { - const repoDir = path.join(workdir, "repo"); - mkdirSync(repoDir, { recursive: true }); - - const logger = { - child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); - const agentManager = new AgentManager({ - clients: { codex: new CreateAgentTestClient() }, - registry: agentStorage, - logger: asSessionLogger(logger), - idFactory: () => "00000000-0000-4000-8000-000000000552", - }); - const projectRegistry = new FileBackedProjectRegistry( - path.join(workdir, "projects.json"), - asSessionLogger(logger), - ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(workdir, "workspaces.json"), - asSessionLogger(logger), - ); - const workspaceGitService = createNoopWorkspaceGitService({ - getCheckout: async (cwd: string) => ({ - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: null, - worktreeRoot: repoDir, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), - }); - - await projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: "proj-local-title", - rootPath: repoDir, - kind: "git", - displayName: "repo", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }), - ); - await workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId: "ws-local-title", - projectId: "proj-local-title", - cwd: repoDir, - kind: "local_checkout", - displayName: "main", - branch: "main", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }), - ); - - const emitted: SessionOutboundMessage[] = []; - const session = asTestSession( - new Session({ - clientId: "test-client", - appVersion: null, - onMessage: (message) => emitted.push(message), - logger: asSessionLogger(logger), - downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), - paseoHome: path.join(workdir, "paseo-home"), - agentManager, - agentStorage, - projectRegistry, - workspaceRegistry, - chatService: asChatService(), - scheduleService: asScheduleService(), - loopService: asLoopService(), - checkoutDiffManager: asCheckoutDiffManager({ - subscribe: async () => ({ - initial: { cwd: repoDir, files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - onWorkspaceStateMayHaveChanged: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - }), - workspaceGitService, - generateWorkspaceName: async () => ({ - title: "Investigate Agent Health", - branch: null, - }), - daemonConfigStore: asDaemonConfigStore({ - get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), - onChange: () => () => {}, - }), - mcpBaseUrl: null, - stt: null, - tts: null, - providerSnapshotManager: createProviderSnapshotManagerStub().manager, - terminalManager: null, - }), - ); - - await session.handleMessage({ - type: "create_agent_request", - requestId: "req-create-local-title", - workspaceId: "ws-local-title", - config: { provider: "codex", cwd: repoDir }, - initialPrompt: "Investigate the agent health dashboard", - attachments: [], - }); - await session.pendingWorkspaceNamingForTests; - - expect(findByType(emitted, "status")?.payload).toMatchObject({ - status: "agent_created", - agent: { workspaceId: "ws-local-title" }, - }); - await expect(workspaceRegistry.get("ws-local-title")).resolves.toMatchObject({ - displayName: "Investigate Agent Health", - branch: "main", - }); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } -}); - test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = { @@ -1350,6 +1209,7 @@ test("workspace clear attention clears stored-only agents and responds", async ( makeAgent({ id: storedRecord.id, cwd: storedRecord.cwd, + workspaceId: workspace.workspaceId, status: "closed", updatedAt: storedRecord.updatedAt, requiresAttention: true, @@ -1458,16 +1318,18 @@ test("workspace clear attention can clear multiple workspaces in one request", a storedRecords.set(storedRecord.id, storedRecord); }; session.listAgentPayloads = async () => - Array.from(storedRecords.values()).map((record) => - makeAgent({ + Array.from(storedRecords.values()).map((record) => { + const owner = workspaces.find((workspace) => workspace.cwd === record.cwd); + return makeAgent({ id: record.id, cwd: record.cwd, + ...(owner ? { workspaceId: owner.workspaceId } : {}), status: "closed", updatedAt: record.updatedAt, requiresAttention: record.requiresAttention, attentionReason: record.attentionReason, - }), - ); + }); + }); await session.handleMessage({ type: "workspace.clear_attention.request", @@ -2714,18 +2576,21 @@ test("branch/detached policies and dominant status bucket are deterministic", as makeAgent({ id: "a1", cwd: REPO_CWD, + workspaceId: "ws-repo-status", status: "running", updatedAt: "2026-03-01T12:00:00.000Z", }), makeAgent({ id: "a2", cwd: REPO_CWD, + workspaceId: "ws-repo-status", status: "error", updatedAt: "2026-03-01T12:01:00.000Z", }), makeAgent({ id: "a3", cwd: REPO_CWD, + workspaceId: "ws-repo-status", status: "idle", updatedAt: "2026-03-01T12:02:00.000Z", pendingPermissions: 1, @@ -2741,7 +2606,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as expect(result.entries[0]?.status).toBe("needs_input"); }); -test("subdirectory agents map to an existing parent workspace descriptor", async () => { +test("subdirectory agents contribute to their owning workspace descriptor", async () => { const session = createSessionForWorkspaceTests(); session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ @@ -2754,10 +2619,13 @@ test("subdirectory agents map to an existing parent workspace descriptor", async updatedAt: "2026-03-01T12:00:00.000Z", }), ]; + // The agent runs in a subdirectory but carries its owning workspaceId; the + // subdir cwd is cosmetic and never drives attribution. session.listAgentPayloads = async () => [ makeAgent({ id: "a1", cwd: "/tmp/repo/packages/app", + workspaceId: "ws-repo-subdir", status: "running", updatedAt: "2026-03-01T12:03:00.000Z", }), @@ -2771,7 +2639,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ id: "ws-repo-subdir", - status: "done", + status: "running", activityAt: null, }); }); @@ -4543,13 +4411,17 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi expect(descriptor.statusEnteredAt).toBeNull(); } + // Agents own the workspace by id; cwd is incidental. + const owned = (input: Parameters[0]) => + makeAgent({ ...input, workspaceId: "ws-status-entered" }); + // 2. Single idle agent (derives to "done") — statusEnteredAt uses the // agent's updatedAt as a best-effort timestamp. { const { session, workspace } = setupSession(); const updatedAt = "2026-05-12T09:30:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done", cwd: workspace.cwd, status: "idle", @@ -4567,7 +4439,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi const { session, workspace } = setupSession(); const updatedAt = "2026-05-12T09:45:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-initializing", cwd: workspace.cwd, status: "initializing", @@ -4588,19 +4460,19 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi const runningUpdatedAt = "2026-05-12T10:00:00.000Z"; const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done", cwd: workspace.cwd, status: "idle", updatedAt: doneUpdatedAt, }), - makeAgent({ + owned({ id: "agent-running", cwd: workspace.cwd, status: "running", updatedAt: runningUpdatedAt, }), - makeAgent({ + owned({ id: "agent-needs-input", cwd: workspace.cwd, status: "idle", @@ -4620,7 +4492,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi const earlyUpdatedAt = "2026-05-12T08:00:00.000Z"; const lateUpdatedAt = "2026-05-12T08:30:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done-early", cwd: workspace.cwd, status: "idle", @@ -4634,13 +4506,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi // Second call: same winning bucket, newer agent updatedAt must not move // the workspace bucket entry time forward. session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done-early", cwd: workspace.cwd, status: "idle", updatedAt: earlyUpdatedAt, }), - makeAgent({ + owned({ id: "agent-done-late", cwd: workspace.cwd, status: "idle", @@ -4661,13 +4533,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi const doneUpdatedAt = "2026-05-12T08:00:00.000Z"; const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done", cwd: workspace.cwd, status: "idle", updatedAt: doneUpdatedAt, }), - makeAgent({ + owned({ id: "agent-needs-input", cwd: workspace.cwd, status: "idle", @@ -4680,7 +4552,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi // Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt. session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-done", cwd: workspace.cwd, status: "idle", @@ -4699,7 +4571,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi const attentionTs = "2026-05-12T11:00:00.000Z"; const updatedAt = "2026-05-12T10:00:00.000Z"; session.listAgentPayloads = async () => [ - makeAgent({ + owned({ id: "agent-attention", cwd: workspace.cwd, status: "idle", @@ -4716,7 +4588,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi } }); -test("same-cwd workspace descriptors share agent status by cwd", async () => { +test("same-cwd workspace descriptors compute agent status per workspaceId", async () => { const session = createSessionForWorkspaceTests(); const project = createPersistedProjectRecord({ projectId: "proj-same-cwd-status", @@ -4747,6 +4619,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => { session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspaceA, workspaceB]; + // A running agent owned by A leaves the sibling B done — status is per id. session.listAgentPayloads = async () => [ makeAgent({ id: "agent-running-a", @@ -4758,8 +4631,9 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => { ]; const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running"); - expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("running"); + expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done"); + // An attention agent owned by B leaves the sibling A done. session.listAgentPayloads = async () => [ makeAgent({ id: "agent-attention-b", @@ -4773,7 +4647,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => { }), ]; const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); - expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("attention"); + expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done"); expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention"); }); @@ -4805,6 +4679,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents makeAgent({ id: "agent-done", cwd: workspace.cwd, + workspaceId: workspace.workspaceId, status: "idle", updatedAt: doneEnteredAt, }), @@ -4819,6 +4694,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents ...makeAgent({ id: "agent-done", cwd: workspace.cwd, + workspaceId: workspace.workspaceId, status: "idle", updatedAt: doneEnteredAt, }), @@ -5591,24 +5467,6 @@ test("workspace.title.set.request returns accepted=false when workspace is not f expect(response?.payload.error).toBeTruthy(); }); -test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => { - const session = createSessionForWorkspaceTests(); - const home = homedir(); - const childCwd = path.join(home, "projects/new-app"); - const homeWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-home", - projectId: "proj-home", - cwd: home, - kind: "directory", - displayName: "home", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - - expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull(); - expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home"); -}); - function createSessionWithTerminalManager(options: { workspaces: PersistedWorkspaceRecord[]; projects: PersistedProjectRecord[]; @@ -5770,7 +5628,7 @@ test("terminal activity contribution change updates the correct workspace", asyn }); }); -test("same-cwd terminal activity updates every workspace status bucket for that cwd", async () => { +test("same-cwd terminal activity updates only the workspace that owns the terminal", async () => { const emitted: SessionOutboundMessage[] = []; const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-")); const workspaceA = createPersistedWorkspaceRecord({ @@ -5816,25 +5674,22 @@ test("same-cwd terminal activity updates every workspace status bucket for that message.payload.kind === "upsert" && message.payload.workspace.id === workspaceB.workspaceId && message.payload.workspace.status === "running", - "same-cwd terminal activity updates the stamped workspace", - ); - await waitForWorkspaceUpdate( - emitted, - (message) => - message.payload.kind === "upsert" && - message.payload.workspace.id === workspaceA.workspaceId && - message.payload.workspace.status === "running", - "same-cwd terminal activity updates the sibling workspace status bucket", + "same-cwd terminal activity updates the workspace that owns the terminal", ); + // The sibling A does not own the terminal, so its status is never driven to + // running by it. Status is per workspaceId, not per cwd. const updates = filterByType(emitted, "workspace_update"); - const upserts = updates.filter((update) => update.payload.kind === "upsert"); - const targetIds = new Set(upserts.map((update) => update.payload.workspace.id)); - expect(targetIds.has(workspaceB.workspaceId)).toBe(true); - expect(targetIds.has(workspaceA.workspaceId)).toBe(true); + const siblingRunning = updates.some( + (update) => + update.payload.kind === "upsert" && + update.payload.workspace.id === workspaceA.workspaceId && + update.payload.workspace.status === "running", + ); + expect(siblingRunning).toBe(false); }); -test("nested worktree attribution targets the deepest active workspace", async () => { +test("a worktree terminal updates only the workspace that owns it", async () => { const emitted: SessionOutboundMessage[] = []; const rootCwd = mkdtempSync(path.join(tmpdir(), "paseo-session-nested-")); const worktreeCwd = path.join(rootCwd, "worktree"); @@ -5872,7 +5727,13 @@ test("nested worktree attribution targets the deepest active workspace", async ( onMessage: (message) => emitted.push(message), }); - const terminal = await terminalManager.createTerminal({ cwd: terminalCwd }); + // The terminal is stamped with the worktree workspace at creation. Its cwd is + // a subdirectory, but ownership is the workspaceId, so only the worktree + // workspace (never the enclosing root) reflects the activity. + const terminal = await terminalManager.createTerminal({ + cwd: terminalCwd, + workspaceId: workspaceWorktree.workspaceId, + }); await terminalManager.setTerminalActivity(terminal.id, "working"); await waitForWorkspaceUpdate( emitted, @@ -5880,23 +5741,17 @@ test("nested worktree attribution targets the deepest active workspace", async ( message.payload.kind === "upsert" && message.payload.workspace.id === workspaceWorktree.workspaceId && message.payload.workspace.status === "running", - "nested terminal activity targets the deepest workspace", + "worktree terminal activity targets its owning workspace", ); const updates = filterByType(emitted, "workspace_update"); - const upserts = updates.filter((update) => update.payload.kind === "upsert"); - const targetIds = new Set(upserts.map((update) => update.payload.workspace.id)); - // The terminal has no workspaceId, so it falls back to cwd attribution. - // The deepest active workspace covering `terminalCwd` should win. - expect(targetIds.has(workspaceWorktree.workspaceId)).toBe(true); - expect(targetIds.has(workspaceRoot.workspaceId)).toBe(false); - expect(upserts[upserts.length - 1]?.payload).toMatchObject({ - kind: "upsert", - workspace: { - id: workspaceWorktree.workspaceId, - status: "running", - }, - }); + const rootRunning = updates.some( + (update) => + update.payload.kind === "upsert" && + update.payload.workspace.id === workspaceRoot.workspaceId && + update.payload.workspace.status === "running", + ); + expect(rootRunning).toBe(false); }); test("removing an idle terminal does not update workspace status", async () => { @@ -6004,10 +5859,43 @@ test("removing a contributing terminal clears workspace status", async () => { // observable branch proves the request fields reached createWorktreeCore. We do // not intercept the private workflow here. +test("failed local create_agent_request does not schedule workspace title generation", async () => { + vi.useFakeTimers(); + const emitted: SessionOutboundMessage[] = []; + let generateCalls = 0; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + generateWorkspaceName: async () => { + generateCalls += 1; + return { title: "Should Not Be Written", branch: null }; + }, + }); + + try { + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-failed-local-title", + workspaceId: "ws-repo-running", + config: { provider: "codex", cwd: REPO_CWD }, + initialPrompt: "This create will fail before an agent exists", + attachments: [], + }); + await vi.runAllTimersAsync(); + + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-failed-local-title", + }); + expect(generateCalls).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing so a // concurrent upsert that happened between workspace creation and the async name // write is not clobbered. -test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber concurrent title writes", async () => { +test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber concurrent title writes", async () => { const session = createSessionForWorkspaceTests(); // The record at create-time: no title override. @@ -6045,63 +5933,20 @@ test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber }); const saved = stored.get("ws-worktree-1"); - // The generated title is written as the displayName. - expect(saved?.displayName).toBe("Generated Task Title"); + // The branch-shaped display name stays branch-shaped. + expect(saved?.displayName).toBe("task-branch"); // The renamed branch is persisted into the dedicated branch field. expect(saved?.branch).toBe("task-branch-renamed"); // The concurrent user-set title is NOT clobbered. expect(saved?.title).toBe("User-set title"); }); -// U8: Directory workspaces get a generated title when firstAgentContext has a prompt. -// The generation is workspace-level (title only — no branch rename for directory workspaces). -test("workspace.create.request directory source with firstAgentContext schedules title generation and writes displayName", async () => { - const emitted: SessionOutboundMessage[] = []; - // Inject the workspace-name generator so no LLM is called and we control the - // returned title. Directory workspaces generate a title only (no branch). - const session = createSessionForWorkspaceTests({ - onMessage: (message) => emitted.push(message), - generateWorkspaceName: async () => ({ title: "Fix login bug", branch: null }), - }); - - // Track workspaceRegistry upserts so we can read the final stored displayName. - const stored = new Map>(); - session.workspaceRegistry.upsert = async (record: unknown) => { - const r = record as Record; - stored.set(r.workspaceId as string, r); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - (stored.get(workspaceId) ?? null) as unknown; - - // Silence workspace update side-effects. - session.emitWorkspaceUpdateForCwd = async () => {}; - session.emitWorkspaceUpdatesForWorkspaceIds = async () => {}; - - await session.handleMessage({ - type: "workspace.create.request", - requestId: "req-dir-title", - source: { kind: "directory", path: REPO_CWD }, - firstAgentContext: { prompt: "Fix the login bug so users can sign in", attachments: [] }, - }); - - // Await the background title-generation write deterministically (no sleep): - // scheduleWorkspaceNaming records the pending write the test awaits here. - await session.pendingWorkspaceNamingForTests; - - const response = emitted.find((m) => m.type === "workspace.create.response"); - expect(response?.payload).toMatchObject({ requestId: "req-dir-title", error: null }); - - // displayName must be the generated human title, NOT the directory basename. - const saved = [...stored.values()].find((r) => r.kind === "directory") as - | Record - | undefined; - expect(saved?.displayName).toBe("Fix login bug"); -}); - -// K3: handleCheckoutRenameBranchRequest must persist the new git branch into the -// dedicated branch field and must NOT touch displayName (the human title) or the -// user-set title. branch and name are decoupled fields by construction. -test("checkout.rename_branch.request persists the new branch and leaves name/title untouched", async () => { +// Phase 7: branch is a git fact derived per-descriptor from each workspace's own +// live git snapshot, and reconciliation re-persists `branch` per workspace from +// its own cwd. handleCheckoutRenameBranchRequest renames the git branch and +// re-emits, but performs NO denormalized cwd → ids branch write of its own — it +// never resolves which workspaces share the cwd to rewrite a cached branch. +test("checkout.rename_branch.request renames the branch without a denormalized branch write", async () => { const emitted: SessionOutboundMessage[] = []; const renameCalls: Array<{ cwd: string; newName: string }> = []; const session = createSessionForWorkspaceTests({ @@ -6125,10 +5970,12 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit }); const workspaces = new Map([[workspace.workspaceId, workspace]]); + const upsertedRecords: Array = []; session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; session.workspaceRegistry.upsert = async (record: unknown) => { const parsed = record as typeof workspace; + upsertedRecords.push(parsed); workspaces.set(parsed.workspaceId, parsed); }; @@ -6148,11 +5995,10 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit requestId: "req-rename-k3", }); - // K3: the new git branch must be persisted into the dedicated branch field. + // Phase 7: the handler performs no denormalized branch write of its own; the + // record is left for per-descriptor derivation and reconciliation to update. + expect(upsertedRecords).toEqual([]); const persisted = workspaces.get(workspace.workspaceId); - expect(persisted?.branch).toBe("feature/new-name"); - - // K3: rename must NOT touch displayName (the human name) or the user-set title. expect(persisted?.displayName).toBe("Refactor auth flow"); expect(persisted?.title).toBe("Refactor auth flow"); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 00a3aa4fb..be4bae891 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -9,7 +9,6 @@ import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; -import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js"; import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; @@ -570,6 +569,7 @@ export class VoiceAssistantWebSocketServer { void this.broadcastTerminalAttention({ terminalId: event.terminalId, cwd: event.cwd, + ...(event.workspaceId ? { workspaceId: event.workspaceId } : {}), terminalName: event.name, reason, }).catch((err) => { @@ -1827,14 +1827,10 @@ export class VoiceAssistantWebSocketServer { } } - private async resolveWorkspaceIdForCwd(cwd: string): Promise { - const workspaces = await this.workspaceRegistry.list(); - return resolveActiveWorkspaceRecordForCwd(cwd, workspaces)?.workspaceId; - } - private async broadcastTerminalAttention(params: { terminalId: string; cwd: string; + workspaceId?: string; terminalName: string; reason: TerminalAttentionReason; }): Promise { @@ -1852,7 +1848,7 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const nowMs = Date.now(); - const workspaceId = await this.resolveWorkspaceIdForCwd(params.cwd); + const workspaceId = params.workspaceId; const plan = computeNotificationPlan({ allStates, diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index e9b40c0ef..065264654 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../test-utils/test-logger.js"; import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js"; -import { WorkspaceDirectory, resolveRegisteredWorkspaceIdsForCwd } from "./workspace-directory.js"; +import { WorkspaceDirectory } from "./workspace-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; @@ -91,7 +91,13 @@ class WorkspaceStatus { }); hasRootAgent(input: AgentState): void { - this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd })); + this.agents.push( + createAgent({ + ...input, + cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, + }), + ); } hasSiblingWorkspaceSameCwd(): void { @@ -99,8 +105,8 @@ class WorkspaceStatus { } // A root agent owned by a specific workspace, even though both same-cwd - // workspaces share the directory. Ownership follows workspaceId; aggregate - // status intentionally fans out to every workspace for that cwd. + // workspaces share the directory. Ownership follows workspaceId, and status is + // computed per id: only the owning workspace reflects this agent's bucket. hasStampedRootAgent(input: AgentState & { workspaceId: string }): void { this.agents.push( createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }), @@ -112,6 +118,7 @@ class WorkspaceStatus { createAgent({ ...input, cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, }), ); @@ -126,6 +133,7 @@ class WorkspaceStatus { createAgent({ ...input, cwd: this.worktreeWorkspace.cwd, + workspaceId: this.worktreeWorkspace.workspaceId, labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, }), ); @@ -150,13 +158,14 @@ class WorkspaceStatus { hasWorkingTerminal(changedAt: number): void { this.terminals.push({ cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, activity: { state: "working", changedAt }, }); } // A working terminal owned by a specific same-cwd workspace. Ownership follows - // workspaceId; aggregate status intentionally fans out to every workspace for - // that cwd. + // workspaceId, and status is computed per id: only the owning workspace + // reflects this terminal's activity. hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void { this.terminals.push({ cwd: this.workspace.cwd, @@ -165,9 +174,12 @@ class WorkspaceStatus { }); } + // A terminal opened in a subdirectory still carries the owning workspace's id + // (stamped at creation); the subdir cwd is cosmetic, ownership is the id. hasWorkingTerminalInSubdirectory(changedAt: number): void { this.terminals.push({ cwd: `${this.workspace.cwd}/packages/app`, + workspaceId: this.workspace.workspaceId, activity: { state: "working", changedAt }, }); } @@ -175,6 +187,7 @@ class WorkspaceStatus { hasIdleTerminal(changedAt: number): void { this.terminals.push({ cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, activity: { state: "idle", changedAt }, }); } @@ -182,6 +195,7 @@ class WorkspaceStatus { hasFinishedTerminal(changedAt: number): void { this.terminals.push({ cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, activity: { state: "idle", attentionReason: "finished", changedAt }, }); } @@ -189,6 +203,7 @@ class WorkspaceStatus { hasUnknownTerminal(): void { this.terminals.push({ cwd: this.workspace.cwd, + workspaceId: this.workspace.workspaceId, activity: null, }); } @@ -280,7 +295,7 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); - test("same-cwd workspaces share agent status buckets", async () => { + test("same-cwd workspaces attribute agent status only to the owner", async () => { const workspace = new WorkspaceStatus(); workspace.hasSiblingWorkspaceSameCwd(); @@ -289,19 +304,16 @@ describe("WorkspaceDirectory", () => { status: "running", workspaceId: "workspace-1-sibling", }); - workspace.hasStampedRootAgent({ - id: "agent-b", - status: "idle", - workspaceId: "workspace-1", - }); + // The running agent belongs to the sibling; workspace-1 owns nothing active + // and stays done. Status never fans out across same-cwd workspaces. await expect(workspace.workspaceStatuses()).resolves.toEqual({ - "workspace-1": "running", + "workspace-1": "done", "workspace-1-sibling": "running", }); }); - test("same-cwd workspaces share agent attention buckets", async () => { + test("same-cwd workspaces attribute agent attention only to the owner", async () => { const workspace = new WorkspaceStatus(); workspace.hasSiblingWorkspaceSameCwd(); @@ -313,24 +325,34 @@ describe("WorkspaceDirectory", () => { }); await expect(workspace.workspaceStatuses()).resolves.toEqual({ - "workspace-1": "needs_input", + "workspace-1": "done", "workspace-1-sibling": "needs_input", }); }); - test("same-cwd workspaces share legacy unstamped agent status buckets", async () => { + test("each same-cwd workspace reflects only its own agent", async () => { const workspace = new WorkspaceStatus(); workspace.hasSiblingWorkspaceSameCwd(); - workspace.hasRootAgent({ id: "legacy-agent", status: "running" }); + workspace.hasStampedRootAgent({ + id: "agent-a", + status: "running", + workspaceId: "workspace-1-sibling", + }); + workspace.hasStampedRootAgent({ + id: "agent-b", + status: "idle", + pendingPermissionCount: 1, + workspaceId: "workspace-1", + }); await expect(workspace.workspaceStatuses()).resolves.toEqual({ - "workspace-1": "running", + "workspace-1": "needs_input", "workspace-1-sibling": "running", }); }); - test("same-cwd workspaces share terminal status buckets", async () => { + test("terminal status attributes only to the owning workspace", async () => { const workspace = new WorkspaceStatus(); const changedAt = new Date(NOW).getTime(); @@ -338,7 +360,7 @@ describe("WorkspaceDirectory", () => { workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt }); await expect(workspace.workspaceStatuses()).resolves.toEqual({ - "workspace-1": "running", + "workspace-1": "done", "workspace-1-sibling": "running", }); }); @@ -530,71 +552,3 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); }); - -describe("resolveRegisteredWorkspaceIdsForCwd", () => { - const sharedCwd = "/workspace/project"; - - const workspace1: PersistedWorkspaceRecord = { - workspaceId: "ws-1", - projectId: "proj-1", - cwd: sharedCwd, - kind: "local_checkout", - displayName: "main", - createdAt: NOW, - updatedAt: NOW, - archivedAt: null, - }; - - const workspace2: PersistedWorkspaceRecord = { - workspaceId: "ws-2", - projectId: "proj-1", - cwd: sharedCwd, - kind: "local_checkout", - displayName: "main-2", - createdAt: NOW, - updatedAt: NOW, - archivedAt: null, - }; - - const workspace3: PersistedWorkspaceRecord = { - workspaceId: "ws-3", - projectId: "proj-2", - cwd: "/workspace/other", - kind: "local_checkout", - displayName: "other", - createdAt: NOW, - updatedAt: NOW, - archivedAt: null, - }; - - test("returns both ids when two workspaces share the same cwd", () => { - const result = resolveRegisteredWorkspaceIdsForCwd(sharedCwd, [ - workspace1, - workspace2, - workspace3, - ]); - expect(result.sort()).toEqual(["ws-1", "ws-2"]); - }); - - test("returns a single id when only one workspace matches the cwd", () => { - const result = resolveRegisteredWorkspaceIdsForCwd("/workspace/other", [ - workspace1, - workspace2, - workspace3, - ]); - expect(result).toEqual(["ws-3"]); - }); - - test("returns prefix-matched id when no exact cwd match exists", () => { - const subdir = `${sharedCwd}/packages/app`; - // workspace1 and workspace2 both have sharedCwd as a prefix; the plural - // resolver must return both when multiple workspaces share the best prefix. - const result = resolveRegisteredWorkspaceIdsForCwd(subdir, [workspace1, workspace2]); - expect(result.sort()).toEqual(["ws-1", "ws-2"]); - }); - - test("returns empty array when no workspace matches", () => { - const result = resolveRegisteredWorkspaceIdsForCwd("/unrelated/path", [workspace1, workspace2]); - expect(result).toEqual([]); - }); -}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 4998b772b..f7cf898c9 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -1,5 +1,4 @@ -import { homedir } from "node:os"; -import { resolve, sep } from "node:path"; +import { resolve } from "node:path"; import type pino from "pino"; import type { AgentSnapshotPayload, @@ -16,17 +15,11 @@ import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import { resolveProjectDisplayName } from "./workspace-registry.js"; -import { - resolveActiveWorkspaceRecordForCwd, - resolveWorkspaceIdForRecord, -} from "./workspace-registry-model.js"; import { deriveTerminalActivityStatusBucket, type TerminalActivity, } from "@getpaseo/protocol/terminal-activity"; -type WorkspaceIdResolver = (cwd: string) => string | undefined; - const FETCH_WORKSPACES_SORT_KEYS = [ "status_priority", "activity_at", @@ -63,46 +56,6 @@ type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects" export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter; -export function resolveRegisteredWorkspaceIdForCwd( - cwd: string, - workspaces: PersistedWorkspaceRecord[], -): string | null { - const ids = resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces); - return ids[0] ?? null; -} - -export function resolveRegisteredWorkspaceIdsForCwd( - cwd: string, - workspaces: PersistedWorkspaceRecord[], -): string[] { - const resolvedCwd = resolve(cwd); - const exactMatches = workspaces.filter((workspace) => workspace.cwd === resolvedCwd); - if (exactMatches.length > 0) { - return exactMatches.map((workspace) => workspace.workspaceId); - } - - const userHome = homedir(); - let bestMatchLength = 0; - const prefixMatches: PersistedWorkspaceRecord[] = []; - for (const workspace of workspaces) { - if (workspace.cwd === userHome) continue; - if (workspace.archivedAt) continue; - const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`; - if (!resolvedCwd.startsWith(prefix)) { - continue; - } - if (workspace.cwd.length > bestMatchLength) { - bestMatchLength = workspace.cwd.length; - prefixMatches.length = 0; - prefixMatches.push(workspace); - } else if (workspace.cwd.length === bestMatchLength) { - prefixMatches.push(workspace); - } - } - - return prefixMatches.map((workspace) => workspace.workspaceId); -} - export interface WorkspaceDirectoryDeps { logger: pino.Logger; projectRegistry: { @@ -159,6 +112,23 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable, + cwd: string, +): string[] { + const resolvedCwd = resolve(cwd); + return Array.from(workspaces) + .filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd) + .map((workspace) => workspace.workspaceId); +} + export class WorkspaceDirectory { private readonly archivingByWorkspaceId = new Map(); /** @@ -232,9 +202,6 @@ export class WorkspaceDirectory { const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); - const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) => - resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId; - const includedWorkspaces = activeRecords.filter( (workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId), ); @@ -260,22 +227,17 @@ export class WorkspaceDirectory { ); this.applyAgentBucketContributions({ activeAgents, - activeRecords, - activeWorkspaceIds, descriptorsByWorkspaceId, }); // Terminal activity contributions: working terminal → running bucket. const terminalEntriesByWorkspaceId = this.applyTerminalContributions( terminalContributions, - activeRecords, - resolveActiveWorkspaceIdForCwd, descriptorsByWorkspaceId, ); const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId( activeAgents, - activeRecords, activeWorkspaceIds, ); @@ -305,15 +267,14 @@ export class WorkspaceDirectory { } // Aggregate each agent's state bucket into its owning workspace descriptor, - // keeping the highest-priority bucket. Delegated agents contribute to their - // delegation root's workspace; their own status is ignored unless running. + // keeping the highest-priority bucket. A record's owner IS its `workspaceId`; + // status never fans out to same-cwd siblings. Delegated agents contribute to + // their delegation root's workspace; their own status is ignored unless running. private applyAgentBucketContributions(params: { activeAgents: AgentSnapshotPayload[]; - activeRecords: PersistedWorkspaceRecord[]; - activeWorkspaceIds: ReadonlySet; descriptorsByWorkspaceId: Map; }): void { - const { activeAgents, activeRecords, activeWorkspaceIds, descriptorsByWorkspaceId } = params; + const { activeAgents, descriptorsByWorkspaceId } = params; const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const)); for (const agent of activeAgents) { @@ -338,70 +299,56 @@ export class WorkspaceDirectory { }); } - const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords); - for (const workspaceId of workspaceIds) { - if (!activeWorkspaceIds.has(workspaceId)) { - continue; - } - const existing = descriptorsByWorkspaceId.get(workspaceId); - if (!existing) { - continue; - } - - if ( - getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) - ) { - existing.status = bucket; - } + const workspaceId = workspaceAgent.workspaceId; + if (!workspaceId) { + continue; + } + const existing = descriptorsByWorkspaceId.get(workspaceId); + if (!existing) { + continue; + } + if ( + getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) + ) { + existing.status = bucket; } } } // Apply working terminal contributions to descriptor statuses and build a map // of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`. + // A terminal contributes only to the workspace it carries; same-cwd siblings + // are untouched. private applyTerminalContributions( terminalContributions: Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null; }>, - activeRecords: PersistedWorkspaceRecord[], - resolveWorkspaceIdForCwd: WorkspaceIdResolver, descriptorsByWorkspaceId: Map, ): Map> { const terminalEntriesByWorkspaceId = new Map< string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }> >(); - for (const { cwd, workspaceId: contributedWorkspaceId, activity } of terminalContributions) { - if (!activity) { + for (const { workspaceId, activity } of terminalContributions) { + if (!activity || !workspaceId) { continue; } const bucket = deriveTerminalActivityStatusBucket(activity); if (!bucket) continue; - const resolvedWorkspaceId = contributedWorkspaceId - ? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) - : resolveWorkspaceIdForCwd(cwd); - const workspaceIds = resolvedWorkspaceId - ? resolveWorkspaceIdsForStatusContribution( - { workspaceId: resolvedWorkspaceId, cwd }, - activeRecords, - ) - : resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords); - for (const workspaceId of workspaceIds) { - const existing = descriptorsByWorkspaceId.get(workspaceId); - if (!existing) { - continue; - } - if ( - getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) - ) { - existing.status = bucket; - } - const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; - entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() }); - terminalEntriesByWorkspaceId.set(workspaceId, entries); + const existing = descriptorsByWorkspaceId.get(workspaceId); + if (!existing) { + continue; } + if ( + getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) + ) { + existing.status = bucket; + } + const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; + entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() }); + terminalEntriesByWorkspaceId.set(workspaceId, entries); } return terminalEntriesByWorkspaceId; } @@ -508,20 +455,6 @@ export class WorkspaceDirectory { return candidates.at(-1) ?? null; } - resolveRegisteredWorkspaceIdForCwd( - cwd: string, - workspaces: PersistedWorkspaceRecord[], - ): string | null { - return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); - } - - resolveRegisteredWorkspaceIdsForCwd( - cwd: string, - workspaces: PersistedWorkspaceRecord[], - ): string[] { - return resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces); - } - // Project parents that have no active workspaces. These persist as first-class // empty projects so the sidebar can render an empty project row with a // "+ New workspace" affordance. @@ -651,49 +584,21 @@ export class WorkspaceDirectory { function groupAgentsByWorkspaceId( agents: AgentSnapshotPayload[], - activeRecords: PersistedWorkspaceRecord[], activeWorkspaceIds: ReadonlySet, ): Map { const byWorkspaceId = new Map(); for (const agent of agents) { - const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords); - for (const workspaceId of workspaceIds) { - if (!activeWorkspaceIds.has(workspaceId)) { - continue; - } - const entries = byWorkspaceId.get(workspaceId) ?? []; - entries.push(agent); - byWorkspaceId.set(workspaceId, entries); + const workspaceId = agent.workspaceId; + if (!workspaceId || !activeWorkspaceIds.has(workspaceId)) { + continue; } + const entries = byWorkspaceId.get(workspaceId) ?? []; + entries.push(agent); + byWorkspaceId.set(workspaceId, entries); } return byWorkspaceId; } -function resolveWorkspaceIdsForStatusContribution( - record: { workspaceId?: string; cwd: string }, - activeWorkspaces: PersistedWorkspaceRecord[], -): string[] { - const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces); - if (!owningWorkspaceId) { - const recordCwd = resolve(record.cwd); - return activeWorkspaces - .filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd) - .map((workspace) => workspace.workspaceId); - } - - const owningWorkspace = activeWorkspaces.find( - (workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt, - ); - if (!owningWorkspace) { - return []; - } - - const owningCwd = resolve(owningWorkspace.cwd); - return activeWorkspaces - .filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd) - .map((workspace) => workspace.workspaceId); -} - function resolveDelegationRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, diff --git a/packages/server/src/server/workspace-ownership.test.ts b/packages/server/src/server/workspace-ownership.test.ts new file mode 100644 index 000000000..5226825d0 --- /dev/null +++ b/packages/server/src/server/workspace-ownership.test.ts @@ -0,0 +1,69 @@ +import { homedir } from "node:os"; +import { basename } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; +import { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; + +function createWorkspaceRecord( + cwd: string, + workspaceId: string, + overrides?: { createdAt?: string; archivedAt?: string }, +) { + return createPersistedWorkspaceRecord({ + workspaceId, + projectId: workspaceId, + cwd, + kind: "directory", + displayName: basename(cwd) || cwd, + createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + archivedAt: overrides?.archivedAt ?? null, + }); +} + +// resolveWorkspaceIdForPath is the external path→workspace adapter for +// archive-by-path and agent project-placement display. It is NOT ownership: a +// record's owner is its workspaceId. The per-id status law is exercised in +// workspace-directory.test.ts. + +describe("resolveWorkspaceIdForPath", () => { + test("returns a single id when multiple workspaces share the exact cwd", () => { + const id = resolveWorkspaceIdForPath("/workspace/project", [ + createWorkspaceRecord("/workspace/project", "ws-1"), + createWorkspaceRecord("/workspace/project", "ws-2"), + createWorkspaceRecord("/workspace/other", "ws-3"), + ]); + expect(["ws-1", "ws-2"]).toContain(id); + }); + + test("resolves an exact archived workspace match for archive-by-path", () => { + expect( + resolveWorkspaceIdForPath("/workspace/project", [ + createWorkspaceRecord("/workspace/project", "ws-archived", { + archivedAt: "2026-03-05T00:00:00.000Z", + }), + ]), + ).toEqual("ws-archived"); + }); + + test("resolves the deepest enclosing workspace for a subdirectory path", () => { + expect( + resolveWorkspaceIdForPath("/workspace/project/packages/app", [ + createWorkspaceRecord("/workspace/project", "ws-1"), + createWorkspaceRecord("/workspace", "ws-root"), + ]), + ).toEqual("ws-1"); + }); + + test("does not match the home directory as a prefix", () => { + const home = homedir(); + + expect( + resolveWorkspaceIdForPath(`${home}/child`, [createWorkspaceRecord(home, "ws-home")]), + ).toBeNull(); + expect(resolveWorkspaceIdForPath(home, [createWorkspaceRecord(home, "ws-home")])).toEqual( + "ws-home", + ); + }); +}); diff --git a/packages/server/src/server/workspace-ownership.ts b/packages/server/src/server/workspace-ownership.ts new file mode 100644 index 000000000..382e1a465 --- /dev/null +++ b/packages/server/src/server/workspace-ownership.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os"; +import { resolve, sep } from "node:path"; + +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; + +// external path→workspace adapter, not ownership. +// +// Resolves a raw filesystem path to a single workspace id for the two flows that +// receive a path rather than an id: archive-by-path (an old client / CLI passes a +// worktree path) and agent project-placement display (an agent's cwd, which may +// be a subdirectory of its workspace). This is NEVER used to attribute a record's +// status to a workspace — status is per `workspaceId`, and git facts derive from a +// workspace's OWN cwd (id → cwd). +// +// Resolution: an exact directory match wins; otherwise the deepest enclosing +// workspace directory, never the home directory; null when nothing encloses it. +export function resolveWorkspaceIdForPath( + cwd: string, + workspaces: Iterable, +): string | null { + const workspaceRecords = Array.from(workspaces); + const resolvedCwd = resolve(cwd); + const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd); + if (exactMatch) { + return exactMatch.workspaceId; + } + + const userHome = resolve(homedir()); + let bestMatchLength = 0; + let bestMatch: PersistedWorkspaceRecord | null = null; + for (const workspace of workspaceRecords) { + if (workspace.archivedAt) continue; + const workspaceCwd = resolve(workspace.cwd); + if (workspaceCwd === userHome) continue; + const prefix = workspaceCwd.endsWith(sep) ? workspaceCwd : `${workspaceCwd}${sep}`; + if (!resolvedCwd.startsWith(prefix)) { + continue; + } + if (workspaceCwd.length > bestMatchLength) { + bestMatchLength = workspaceCwd.length; + bestMatch = workspace; + } + } + + return bestMatch?.workspaceId ?? null; +} diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 9777853d4..556b0ccad 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -101,6 +101,7 @@ function createWorkspaceGitServiceStub( projectDisplayName: string; workspaceDisplayName: string; gitRemote?: string | null; + currentBranch?: string | null; } >, ) { @@ -580,7 +581,7 @@ describe("WorkspaceReconciliationService", () => { expect(projects.get("p1")!.customName).toBe("My Fork"); }); - test("updates workspace display name when branch changes", async () => { + test("updates workspace branch metadata without clobbering the workspace name", async () => { const dir = createTempGitRepo("reconcile-branch-"); tempDirs.push(dir); @@ -606,7 +607,8 @@ describe("WorkspaceReconciliationService", () => { projectId: "p1", cwd: dir, kind: "local_checkout", - displayName: "main", + displayName: "Human workspace title", + branch: "main", createdAt: timestamp, updatedAt: timestamp, }), @@ -621,6 +623,7 @@ describe("WorkspaceReconciliationService", () => { projectKind: "git", projectDisplayName: path.basename(dir), workspaceDisplayName: "feature-branch", + currentBranch: "feature-branch", }, }), }); @@ -629,7 +632,12 @@ describe("WorkspaceReconciliationService", () => { const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated"); expect(wsUpdate).toBeDefined(); - expect(workspaces.get("w1")!.displayName).toBe("feature-branch"); + expect(wsUpdate).toMatchObject({ + kind: "workspace_updated", + fields: { branch: "feature-branch" }, + }); + expect(workspaces.get("w1")!.displayName).toBe("Human workspace title"); + expect(workspaces.get("w1")!.branch).toBe("feature-branch"); }); test("does not modify already-archived records", async () => { diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index dea5243fc..ae811622f 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -50,7 +50,7 @@ export type ReconciliationChange = kind: "workspace_updated"; workspaceId: string; directory: string; - fields: Partial>; + fields: Partial>; }; export interface ReconciliationResult { @@ -343,11 +343,10 @@ export class WorkspaceReconciliationService { const expectedKind = deriveWorkspaceKindFromMetadata(wsGit); - const workspaceUpdates: Partial> = - {}; + const workspaceUpdates: Partial> = {}; - if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { - workspaceUpdates.displayName = wsGit.workspaceDisplayName; + if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) { + workspaceUpdates.branch = wsGit.currentBranch; } if (workspace.kind !== expectedKind) { diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index d1b39bcc5..c673059e3 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -8,11 +8,11 @@ import { classifyDirectoryForProjectMembership, generateWorkspaceId, } from "./workspace-registry-model.js"; +import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, - type PersistedWorkspaceRecord, type ProjectRegistry, type WorkspaceRegistry, } from "./workspace-registry.js"; @@ -45,71 +45,6 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string { return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString(); } -export function resolveLegacyWorkspaceOwnerForCwd( - cwd: string, - workspaces: Iterable, -): string | null { - const normalizedCwd = path.resolve(cwd); - const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt); - const exactMatches = activeWorkspaces.filter( - (workspace) => path.resolve(workspace.cwd) === normalizedCwd, - ); - if (exactMatches.length > 0) { - return oldestWorkspace(exactMatches).workspaceId; - } - - const prefixMatches = activeWorkspaces.filter((workspace) => { - const workspaceCwd = path.resolve(workspace.cwd); - return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`); - }); - if (prefixMatches.length === 0) { - return null; - } - - const longestPrefixLength = Math.max( - ...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length), - ); - return oldestWorkspace( - prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength), - ).workspaceId; -} - -export async function migrateLegacyAgentWorkspaceOwnership(options: { - agentStorage: AgentStorage; - workspaceRegistry: WorkspaceRegistry; - logger: Logger; - cwds?: Iterable; -}): Promise { - const workspaceRecords = await options.workspaceRegistry.list(); - const cwdFilter = options.cwds - ? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd))) - : null; - const records = await options.agentStorage.list(); - let migrated = 0; - - for (const record of records) { - if (record.workspaceId) { - continue; - } - if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) { - continue; - } - - const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords); - if (!workspaceId) { - continue; - } - - await options.agentStorage.upsert({ ...record, workspaceId }); - migrated += 1; - } - - if (migrated > 0) { - options.logger.info({ migrated }, "Migrated legacy agent workspace ownership"); - } - return migrated; -} - export async function bootstrapWorkspaceRegistries(options: { paseoHome: string; agentStorage: AgentStorage; @@ -126,7 +61,7 @@ export async function bootstrapWorkspaceRegistries(options: { await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]); if (projectsExists && workspacesExists) { - await migrateLegacyAgentWorkspaceOwnership(options); + await backfillWorkspaceIdForLegacyAgents(options); return; } @@ -235,7 +170,7 @@ export async function bootstrapWorkspaceRegistries(options: { ), ); - await migrateLegacyAgentWorkspaceOwnership(options); + await backfillWorkspaceIdForLegacyAgents(options); options.logger.info( { @@ -247,9 +182,3 @@ export async function bootstrapWorkspaceRegistries(options: { "Workspace registries bootstrapped from existing agent storage", ); } - -function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord { - return workspaces.reduce((oldest, candidate) => - candidate.createdAt < oldest.createdAt ? candidate : oldest, - ); -} diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index e4f07f258..115d8048a 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { basename, isAbsolute, resolve } from "node:path"; import { @@ -9,7 +9,6 @@ import { deriveWorkspaceKind, detectStaleWorkspaces, generateWorkspaceId, - resolveWorkspaceIdForRecord, } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; @@ -62,18 +61,22 @@ describe("deriveProjectGroupingName", () => { describe("detectStaleWorkspaces", () => { test("returns workspace ids whose directories no longer exist", async () => { - const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing"); + const checkedDirectories: string[] = []; + const existingDirectories = new Set(["/tmp/existing"]); const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [ createWorkspaceRecord("/tmp/existing", "ws-existing"), createWorkspaceRecord("/tmp/missing", "ws-missing"), ], - checkDirectoryExists, + checkDirectoryExists: async (cwd) => { + checkedDirectories.push(cwd); + return existingDirectories.has(cwd); + }, }); expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]); - expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]); + expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]); }); test("keeps workspaces whose directories exist even when all agents are archived", async () => { @@ -232,65 +235,3 @@ describe("git worktree grouping", () => { ).toBe("worktree"); }); }); - -describe("resolveWorkspaceIdForRecord", () => { - test("resolves a stamped record to its workspaceId, ignoring cwd matches", () => { - const workspaces = [ - createWorkspaceRecord("/tmp/repo", "ws-a"), - createWorkspaceRecord("/tmp/repo", "ws-b"), - ]; - - const resolved = resolveWorkspaceIdForRecord( - { workspaceId: "ws-b", cwd: "/tmp/repo" }, - workspaces, - ); - - expect(resolved).toBe("ws-b"); - }); - - test("falls back to the single cwd match for a legacy record without workspaceId", () => { - const workspaces = [ - createWorkspaceRecord("/tmp/repo", "ws-only"), - createWorkspaceRecord("/tmp/other", "ws-other"), - ]; - - const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); - - expect(resolved).toBe("ws-only"); - }); - - test("does not resolve an unstamped legacy record with multiple cwd matches", () => { - const workspaces = [ - createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }), - createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }), - ]; - - const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); - - expect(resolved).toBeNull(); - }); - - test("returns null for a legacy record with no cwd match", () => { - const workspaces = [createWorkspaceRecord("/tmp/other", "ws-other")]; - - const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); - - expect(resolved).toBeNull(); - }); - - test("does not move a stamped record to another workspace when its owner is archived", () => { - const workspaces = [ - createWorkspaceRecord("/tmp/repo", "ws-archived", { - archivedAt: "2026-03-05T00:00:00.000Z", - }), - createWorkspaceRecord("/tmp/repo", "ws-live"), - ]; - - const resolved = resolveWorkspaceIdForRecord( - { workspaceId: "ws-archived", cwd: "/tmp/repo" }, - workspaces, - ); - - expect(resolved).toBeNull(); - }); -}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 46455a934..08af1ad27 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,5 +1,4 @@ import { randomBytes } from "node:crypto"; -import { homedir } from "node:os"; import { resolve } from "node:path"; import type { @@ -7,7 +6,6 @@ import type { ProjectPlacementPayload, } from "@getpaseo/protocol/messages"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { isSameOrDescendantPath } from "./path-utils.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; @@ -34,60 +32,6 @@ export function generateWorkspaceId(): string { return `wks_${randomBytes(8).toString("hex")}`; } -// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97. -// Resolves the owning workspace for a record (agent/terminal) that may predate -// workspaceId stamping. New records always carry workspaceId and hit the first -// branch; legacy records fall back only when cwd has a single active owner. -// Duplicate-cwd records must be stamped by migration before runtime projection; -// otherwise cwd-only membership leaks into every same-directory workspace. -export function resolveWorkspaceIdForRecord( - record: { workspaceId?: string; cwd: string }, - activeWorkspaces: Iterable, -): string | null { - const workspaces = Array.from(activeWorkspaces); - if (record.workspaceId) { - const exact = workspaces.find( - (workspace) => !workspace.archivedAt && workspace.workspaceId === record.workspaceId, - ); - if (exact) { - return exact.workspaceId; - } - return null; - } - - const resolvedCwd = resolve(record.cwd); - const cwdMatches = workspaces.filter( - (workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd, - ); - if (cwdMatches.length === 1) { - return cwdMatches[0].workspaceId; - } - - return null; -} - -export function resolveActiveWorkspaceRecordForCwd( - cwd: string, - workspaces: Iterable, -): PersistedWorkspaceRecord | null { - const resolvedCwd = resolve(cwd); - const userHome = resolve(homedir()); - let bestMatch: { workspace: PersistedWorkspaceRecord; cwd: string } | null = null; - - for (const workspace of workspaces) { - if (workspace.archivedAt) continue; - - const workspaceCwd = resolve(workspace.cwd); - if (workspaceCwd === userHome && resolvedCwd !== workspaceCwd) continue; - if (!isSameOrDescendantPath(workspaceCwd, resolvedCwd)) continue; - if (!bestMatch || workspaceCwd.length > bestMatch.cwd.length) { - bestMatch = { workspace, cwd: workspaceCwd }; - } - } - - return bestMatch?.workspace ?? null; -} - // Path-derived grouping key for a workspace directory. This is NOT the opaque // workspace identity (see generateWorkspaceId); never persist or compare it as one. export function deriveWorkspaceDirectoryKey( diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 9f405b9b0..320e5904c 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { DaemonClient } from "./test-utils/index.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; +import { AgentStorage } from "./agent/agent-storage.js"; import { getAskModeConfig } from "./daemon-e2e/agent-configs.js"; +import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -13,10 +16,12 @@ import { const WORKSPACE_A = "wks_same_cwd_a"; const WORKSPACE_B = "wks_same_cwd_b"; +const LEGACY_OWNER_WORKSPACE = "wks_legacy_owner"; +const PERMISSION_WAIT_MS = 15_000; -// Seed two active workspaces that share one cwd, so we can prove ownership -// stays workspaceId-scoped while aggregate status is cwd-scoped. Both -// registry files must exist on disk before the daemon starts: +// Seed two active workspaces that share one cwd, so we can prove agent +// ownership and status stay workspaceId-scoped — a sibling that owns nothing +// active stays done. Both registry files must exist on disk before the daemon starts: // bootstrapWorkspaceRegistries skips materialization when both files are // present, leaving these seeded records untouched. function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } { @@ -63,12 +68,291 @@ function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } { return { paseoHomeRoot, cwd }; } +async function seedWorkspaceWithLegacyAgent(): Promise<{ paseoHomeRoot: string; cwd: string }> { + const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-home-")); + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-dir-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const projectsDir = path.join(paseoHome, "projects"); + mkdirSync(projectsDir, { recursive: true }); + + const project = createPersistedProjectRecord({ + projectId: "prj_legacy_agent", + rootPath: cwd, + kind: "non_git", + displayName: path.basename(cwd), + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: LEGACY_OWNER_WORKSPACE, + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "original", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project])); + writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify([workspace])); + + const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), createTestLogger()); + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "legacy-cwd-only-agent", + provider: "codex", + cwd, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + title: "Legacy cwd-only agent", + labels: {}, + lastStatus: "running", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + return { paseoHomeRoot, cwd }; +} + async function statusByWorkspaceId(client: DaemonClient): Promise> { const workspaces = await client.fetchWorkspaces(); return new Map(workspaces.entries.map((entry) => [entry.id, entry.status])); } -test("two workspaces sharing one cwd share agent status without leaking ownership", async () => { +async function workspaceName(client: DaemonClient, workspaceId: string): Promise { + const workspaces = await client.fetchWorkspaces(); + return workspaces.entries.find((entry) => entry.id === workspaceId)?.name ?? null; +} + +async function legacyAgentWorkspaceId(client: DaemonClient): Promise { + const agents = await client.fetchAgents({ scope: "active" }); + return agents.entries.find((entry) => entry.agent.id === "legacy-cwd-only-agent")?.agent + .workspaceId; +} + +async function agentIdsOwnedByWorkspace( + client: DaemonClient, + workspaceId: string, +): Promise { + const agents = await client.fetchAgents({ scope: "active" }); + return agents.entries + .filter((entry) => entry.agent.workspaceId === workspaceId) + .map((entry) => entry.agent.id); +} + +async function waitForPermission(client: DaemonClient, agentId: string) { + const parked = await client.waitForFinish(agentId, PERMISSION_WAIT_MS); + expect(parked.status).toBe("permission"); + return parked; +} + +test("daemon bootstrap migrates cwd-only legacy agents before same-cwd workspaces are added", async () => { + const { paseoHomeRoot, cwd } = await seedWorkspaceWithLegacyAgent(); + const daemon = await createTestPaseoDaemon({ paseoHomeRoot }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE); + expect(await statusByWorkspaceId(client)).toEqual( + new Map([[LEGACY_OWNER_WORKSPACE, "running"]]), + ); + + const created = await client.createWorkspace({ + source: { kind: "directory", path: cwd, projectId: "prj_legacy_agent" }, + title: "Fresh same-cwd workspace", + }); + const createdWorkspaceId = created.workspace?.id; + if (!createdWorkspaceId) { + throw new Error(created.error ?? "Expected same-cwd workspace to be created"); + } + + // The migrated legacy agent stays owned by LEGACY_OWNER. The freshly created + // same-cwd workspace owns nothing, so its status is done — status is per id, + // never shared across same-cwd workspaces. + expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE); + expect(await agentIdsOwnedByWorkspace(client, LEGACY_OWNER_WORKSPACE)).toEqual([ + "legacy-cwd-only-agent", + ]); + expect(await agentIdsOwnedByWorkspace(client, createdWorkspaceId)).toEqual([]); + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [LEGACY_OWNER_WORKSPACE, "running"], + [createdWorkspaceId, "done"], + ]), + ); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("workspace.create directory source with firstAgentContext generates a daemon-visible workspace title", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-named-local-dir-")); + const daemon = await createTestPaseoDaemon({ + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.patchDaemonConfig({ + metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] }, + }); + + const created = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + firstAgentContext: { + prompt: "Fix login bug", + attachments: [], + }, + }); + const workspaceId = created.workspace?.id; + if (!workspaceId) { + throw new Error(created.error ?? "Expected workspace to be created"); + } + + await expect + .poll(() => workspaceName(client, workspaceId), { timeout: 10_000 }) + .toBe("Fix login bug"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 20_000); + +test("create_agent_request with initialPrompt generates a daemon-visible workspace title", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-submit-title-")); + const daemon = await createTestPaseoDaemon({ + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.patchDaemonConfig({ + metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] }, + }); + + const created = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + }); + const workspaceId = created.workspace?.id; + if (!workspaceId) { + throw new Error(created.error ?? "Expected workspace to be created"); + } + + const agent = await client.createAgent({ + provider: "mock", + cwd, + workspaceId, + model: "ten-second-stream", + initialPrompt: "Fix login bug", + }); + expect(agent.workspaceId).toBe(workspaceId); + + await expect + .poll(() => workspaceName(client, workspaceId), { timeout: 10_000 }) + .toBe("Fix login bug"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 20_000); + +test("creating another same-cwd local workspace keeps running status on the owning workspace only", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-running-same-cwd-create-")); + const daemon = await createTestPaseoDaemon({ + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const first = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "First same-cwd workspace", + }); + const firstWorkspaceId = first.workspace?.id; + if (!firstWorkspaceId) { + throw new Error(first.error ?? "Expected first workspace to be created"); + } + + const second = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "Second same-cwd workspace", + }); + const secondWorkspaceId = second.workspace?.id; + if (!secondWorkspaceId) { + throw new Error(second.error ?? "Expected second workspace to be created"); + } + + const agent = await client.createAgent({ + provider: "mock", + cwd, + workspaceId: firstWorkspaceId, + model: "five-minute-stream", + initialPrompt: "stay running", + }); + expect(agent.workspaceId).toBe(firstWorkspaceId); + + await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000); + + // Only the workspace that owns the agent is running. The same-cwd sibling + // owns nothing active and stays done — status never fans out across a cwd. + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [firstWorkspaceId, "running"], + [secondWorkspaceId, "done"], + ]), + ); + + const third = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "Third same-cwd workspace", + }); + const thirdWorkspaceId = third.workspace?.id; + if (!thirdWorkspaceId) { + throw new Error(third.error ?? "Expected third workspace to be created"); + } + + expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running"); + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [firstWorkspaceId, "running"], + [secondWorkspaceId, "done"], + [thirdWorkspaceId, "done"], + ]), + ); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("two workspaces sharing one cwd compute agent status per workspaceId", async () => { const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces(); const daemon = await createTestPaseoDaemon({ paseoHomeRoot }); const client = new DaemonClient({ @@ -88,9 +372,9 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi ); // 1. Agent created in workspace A carries workspaceId A. Ask mode + a - // write parks the agent on a pending permission, which contributes the - // deterministic "needs_input" aggregate signal to all same-cwd - // workspaces. + // write parks the agent on a pending permission, which drives the + // "needs_input" signal onto workspace A only — its same-cwd sibling B + // owns nothing and stays done. const agentA = await client.createAgent({ ...getAskModeConfig("codex"), cwd, @@ -103,8 +387,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi agentA.id, 'Use your shell tool to run: `printf "ok" > a.txt`. Request permission and wait.', ); - const parkedA = await client.waitForFinish(agentA.id, 60000); - expect(parkedA.status).toBe("permission"); + await waitForPermission(client, agentA.id); const fetchedA = await client.fetchAgent(agentA.id); expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A); @@ -112,27 +395,12 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi expect(await statusByWorkspaceId(client)).toEqual( new Map([ [WORKSPACE_A, "needs_input"], - [WORKSPACE_B, "needs_input"], + [WORKSPACE_B, "done"], ]), ); - // 2. Terminal created in A is delivered to A's directory subscription and to - // A's list, but never to B's — exercises the workspaceId terminal filter. - const terminalSnapshotsByWorkspace = new Map>(); - const unsubscribeTerminals = client.on("terminals_changed", (message) => { - if (message.type !== "terminals_changed") { - return; - } - for (const terminal of message.payload.terminals) { - const seen = terminalSnapshotsByWorkspace.get(terminal.workspaceId) ?? new Set(); - seen.add(terminal.id); - terminalSnapshotsByWorkspace.set(terminal.workspaceId, seen); - } - }); - - client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_A }); - client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_B }); - + // 2. Terminal created in A is visible in A's list, but never in B's — + // exercises the workspaceId terminal filter at the daemon boundary. const createdTerminal = await client.createTerminal(cwd, "A terminal", undefined, { workspaceId: WORKSPACE_A, }); @@ -142,26 +410,15 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi throw new Error("Expected a created terminal id"); } - // A's directory subscription receives a snapshot attributing the terminal - // to A. Poll the observed snapshot state instead of sleeping for a fixed - // window, so the assertion never races the daemon's snapshot push. - await expect - .poll(() => terminalSnapshotsByWorkspace.get(WORKSPACE_A)?.has(terminalId) ?? false) - .toBe(true); - unsubscribeTerminals(); - - // No snapshot ever attributed the terminal to B (the only other same-cwd - // workspace). - expect(terminalSnapshotsByWorkspace.get(WORKSPACE_B)?.has(terminalId) ?? false).toBe(false); - const listForA = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_A }); expect(listForA.terminals.some((terminal) => terminal.id === terminalId)).toBe(true); const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B }); expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false); - // 3. Agent created in workspace B carries workspaceId B while the aggregate - // needs_input status stays shared across both same-cwd workspace rows. + // 3. Agent created in workspace B carries workspaceId B and parks too. Now + // each workspace owns its own parked agent, so both read needs_input — + // by per-id ownership, not by sharing a cwd. const agentB = await client.createAgent({ ...getAskModeConfig("codex"), cwd, @@ -174,8 +431,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi agentB.id, 'Use your shell tool to run: `printf "ok" > b.txt`. Request permission and wait.', ); - const parkedB = await client.waitForFinish(agentB.id, 60000); - expect(parkedB.status).toBe("permission"); + await waitForPermission(client, agentB.id); const fetchedB = await client.fetchAgent(agentB.id); expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 8dd453dc4..0aad34c2b 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -804,11 +804,19 @@ async function acquireWorkspaceScriptTerminal(params: { existingRuntimeEntry: ReturnType; terminalManager: TerminalManager; repoRoot: string; + workspaceId: string; scriptName: string; env: Record | undefined; }): Promise<{ terminal: TerminalSession; reusableTerminal: TerminalSession | null }> { - const { serviceScript, existingRuntimeEntry, terminalManager, repoRoot, scriptName, env } = - params; + const { + serviceScript, + existingRuntimeEntry, + terminalManager, + repoRoot, + workspaceId, + scriptName, + env, + } = params; let reusableTerminal: TerminalSession | null = null; if (!serviceScript && existingRuntimeEntry?.terminalId) { reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; @@ -817,6 +825,7 @@ async function acquireWorkspaceScriptTerminal(params: { reusableTerminal ?? (await terminalManager.createTerminal({ cwd: repoRoot, + workspaceId, name: scriptName, title: scriptName, env, @@ -892,6 +901,7 @@ export async function spawnWorkspaceScript( existingRuntimeEntry, terminalManager, repoRoot, + workspaceId, scriptName, env, }); diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index cf6749b45..3dd0e4d43 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -104,7 +104,7 @@ function createWorkflowForRequestTest(options: { paseoHome: options.paseoHome, createPaseoWorktree, warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}), - emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async () => {}, cacheWorkspaceSetupSnapshot: () => {}, emit: () => {}, sessionLogger: createLogger(), @@ -428,7 +428,7 @@ describe("create-agent worktree setup boundary", () => { paseoHome, createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), warmWorkspaceGitData: async () => {}, - emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async () => {}, cacheWorkspaceSetupSnapshot: () => {}, emit: (message) => workspaceSetupEvents.push(message), sessionLogger: createLogger(), @@ -497,7 +497,7 @@ function createAgentStorageStub(): Pick { function createWorkspaceArchivingDeps() { return { - resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"), + findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"), listActiveWorkspaces: vi.fn(async () => []), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), markWorkspaceArchiving: vi.fn(), @@ -588,13 +588,13 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), @@ -659,7 +659,7 @@ describe("runWorktreeSetupInBackground", () => { expect(terminalManager.terminals).toHaveLength(0); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42"); }); test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => { @@ -686,14 +686,14 @@ describe("runWorktreeSetupInBackground", () => { const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); const logger = createLogger(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); const workspaceId = "ws-broken-feature"; await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (snapshotWorkspaceId, snapshot) => snapshots.set(snapshotWorkspaceId, snapshot), emit: (message) => emitted.push(message), @@ -732,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => { error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/), }); expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId); }); // POSIX-only: setup command is hardcoded to sh, printf, and sleep. @@ -761,13 +761,13 @@ describe("runWorktreeSetupInBackground", () => { const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); const logger = createLogger(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), @@ -885,13 +885,13 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), @@ -947,7 +947,7 @@ describe("runWorktreeSetupInBackground", () => { error: null, }); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); + expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44"); }); test("keeps setup completed without attempting script launch afterward", async () => { @@ -980,13 +980,13 @@ describe("runWorktreeSetupInBackground", () => { throw new Error("terminal spawn failed"); }, }); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), @@ -1033,7 +1033,7 @@ describe("runWorktreeSetupInBackground", () => { error: null, }); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45"); }); test("does not auto-start scripts in socket mode", async () => { @@ -1062,13 +1062,13 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { paseoHome, - emitWorkspaceUpdateForCwd, + emitWorkspaceUpdateForWorkspaceId, cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), @@ -1096,7 +1096,7 @@ describe("runWorktreeSetupInBackground", () => { error: null, }); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46"); }); test("returns the cached workspace setup snapshot for status requests", async () => { @@ -1816,7 +1816,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), killTerminalsForWorkspace, @@ -1924,7 +1924,7 @@ describe("archivePaseoWorktree", () => { archivedWorkspaceRecords.push(id); }, ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === sharedCwd ? workspaceA : null, ), killTerminalsForWorkspace: (workspaceId) => @@ -2030,7 +2030,7 @@ describe("archivePaseoWorktree", () => { archiveSnapshot, }, agentStorage: createAgentStorageStub(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), listActiveWorkspaces: vi.fn(async () => []), @@ -2135,7 +2135,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord, ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), killTerminalsForWorkspace: vi.fn(async () => {}), @@ -2184,7 +2184,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? "ws-archive-refresh" : null, ), killTerminalsForWorkspace: vi.fn(async () => {}), @@ -2233,7 +2233,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), // No other active workspace references the worktree dir. @@ -2285,7 +2285,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === sharedCwd ? workspaceA : null, ), // Sibling workspace B still references the same worktree directory. @@ -2336,7 +2336,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), listActiveWorkspaces, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index df59d4c85..d7b13ff38 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -97,7 +97,7 @@ interface BuildAgentSessionConfigDependencies { interface CreatePaseoWorktreeInBackgroundDependencies { paseoHome?: string; worktreesRoot?: string; - emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise; + emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void; emit: EmitSessionMessage; sessionLogger: Logger; @@ -764,6 +764,6 @@ export async function runWorktreeSetupInBackground( return; } } finally { - await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); + await dependencies.emitWorkspaceUpdateForWorkspaceId(options.workspaceId); } } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index fbee8a0f4..f08b1a4ff 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -64,7 +64,7 @@ it("returns empty list for new cwd", async () => { it("returns existing terminals on subsequent calls", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - const created = await manager.createTerminal({ cwd }); + const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); const first = await manager.getTerminals(cwd); const second = await manager.getTerminals(cwd); @@ -89,8 +89,8 @@ it("creates separate terminals for different cwds", async () => { const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-")); const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-")); temporaryDirs.push(firstCwd, secondCwd); - const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })]; - const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })]; + const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" })]; + const homeTerminals = [await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" })]; expect(tmpTerminals.length).toBe(1); expect(homeTerminals.length).toBe(1); @@ -104,7 +104,11 @@ it("lists subdirectory terminals when querying the workspace root", async () => mkdirSync(subdirCwd, { recursive: true }); temporaryDirs.push(rootCwd); - const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" }); + const created = await manager.createTerminal({ + cwd: subdirCwd, + name: "Mobile", + workspaceId: "ws-test", + }); const rootTerminals = await manager.getTerminals(rootCwd); expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]); @@ -113,7 +117,7 @@ it("lists subdirectory terminals when querying the workspace root", async () => it("includes only stamped terminals in workspace-scoped queries", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - const legacy = await manager.createTerminal({ cwd }); + const legacy = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" }); const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" }); @@ -127,8 +131,8 @@ it("includes only stamped terminals in workspace-scoped queries", async () => { it("creates additional terminal with auto-incrementing name", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - await manager.createTerminal({ cwd }); - const second = await manager.createTerminal({ cwd }); + await manager.createTerminal({ cwd, workspaceId: "ws-test" }); + const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); expect(second.name).toBe("Terminal 2"); @@ -138,7 +142,11 @@ it("creates additional terminal with auto-incrementing name", async () => { it("uses custom name when provided", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + name: "Dev Server", + workspaceId: "ws-test", + }); expect(session.name).toBe("Dev Server"); }); @@ -146,7 +154,7 @@ it("uses custom name when provided", async () => { it("creates first terminal if none exist", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); expect(session.name).toBe("Terminal 1"); @@ -157,7 +165,9 @@ it("creates first terminal if none exist", async () => { it("throws for relative paths", async () => { manager = createTerminalManager(); - await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path"); + await expect(manager.createTerminal({ cwd: "tmp", workspaceId: "ws-test" })).rejects.toThrow( + "cwd must be absolute path", + ); }); it("does not reject Windows absolute paths as relative", async () => { @@ -166,7 +176,7 @@ it("does not reject Windows absolute paths as relative", async () => { temporaryDirs.push(cwd); try { - await manager.createTerminal({ cwd }); + await manager.createTerminal({ cwd, workspaceId: "ws-test" }); } catch (error) { expect((error as Error).message).not.toBe("cwd must be absolute path"); } @@ -183,6 +193,7 @@ it("inherits registered env for the worktree root cwd", async () => { env: { PASEO_WORKTREE_PORT: "45678" }, }); await manager.createTerminal({ + workspaceId: "ws-test", cwd, command: process.execPath, args: [ @@ -208,6 +219,7 @@ it("inherits registered env for subdirectories within the worktree", async () => env: { PASEO_WORKTREE_PORT: "45679" }, }); await manager.createTerminal({ + workspaceId: "ws-test", cwd: subdirCwd, command: process.execPath, args: [ @@ -222,7 +234,10 @@ it("inherits registered env for subdirectories within the worktree", async () => it("returns terminal by id", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "ws-test", + }); const found = manager.getTerminal(session.id); expect(found).toBe(session); @@ -237,7 +252,10 @@ it("returns undefined for unknown id", () => { it("removes terminal from manager", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "ws-test", + }); const id = session.id; manager.killTerminal(id); @@ -248,7 +266,7 @@ it("removes terminal from manager", async () => { it("removes cwd entry when last terminal is killed", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - const created = await manager.createTerminal({ cwd }); + const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); manager.killTerminal(created.id); const remaining = await manager.getTerminals(cwd); @@ -259,8 +277,8 @@ it("removes cwd entry when last terminal is killed", async () => { it("keeps cwd entry when other terminals remain", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - await manager.createTerminal({ cwd }); - const second = await manager.createTerminal({ cwd }); + await manager.createTerminal({ cwd, workspaceId: "ws-test" }); + const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); const terminals = await manager.getTerminals(cwd); manager.killTerminal(terminals[0].id); @@ -279,7 +297,7 @@ it("is no-op for unknown id", () => { it("auto-removes terminal when shell exits", async () => { manager = createTerminalManager(); const cwd = realpathSync(tmpdir()); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); const exitedId = session.id; session.kill(); @@ -301,8 +319,8 @@ it("returns all cwds with active terminals", async () => { const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-")); const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-")); temporaryDirs.push(firstCwd, secondCwd); - await manager.createTerminal({ cwd: firstCwd }); - await manager.createTerminal({ cwd: secondCwd }); + await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" }); + await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" }); const dirs = manager.listDirectories(); expect(dirs).toContain(firstCwd); @@ -315,8 +333,8 @@ it("kills all terminals and clears state", async () => { const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-")); const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-")); temporaryDirs.push(firstCwd, secondCwd); - const tmpSession = await manager.createTerminal({ cwd: firstCwd }); - const homeSession = await manager.createTerminal({ cwd: secondCwd }); + const tmpSession = await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" }); + const homeSession = await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" }); const tmpId = tmpSession.id; const homeId = homeSession.id; @@ -340,8 +358,8 @@ it("emits cwd snapshots when terminals are created", async () => { }); }); - await manager.createTerminal({ cwd }); - await manager.createTerminal({ cwd, name: "Dev Server" }); + await manager.createTerminal({ cwd, workspaceId: "ws-test" }); + await manager.createTerminal({ cwd, name: "Dev Server", workspaceId: "ws-test" }); expect(snapshots).toContainEqual({ cwd, @@ -381,6 +399,7 @@ it("emits updated terminal titles after debounced title changes", async () => { }); const session = await manager.createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), command: process.execPath, args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"], @@ -402,7 +421,7 @@ it("emits empty snapshot when last terminal is removed", async () => { }); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); manager.killTerminal(session.id); expect(snapshots).toContainEqual({ @@ -416,6 +435,7 @@ it("emits empty snapshot when last terminal is removed", async () => { it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => { manager = createTerminalManager(); const session = await manager.createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), title: "Existing title", }); @@ -439,7 +459,10 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "ws-test", + }); expect(manager.setTerminalTitle(session.id, "x")).toBe(true); expect(session.getTitle()).toBe("x"); @@ -447,7 +470,10 @@ it("setTerminalTitle returns true and updates the terminal title for existing te it("new terminal starts with unknown activity", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "ws-test", + }); expect(session.getActivity()).toBeNull(); }); @@ -465,7 +491,7 @@ it("includes nullable activity in terminals-changed snapshot items", async () => ); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); expect(snapshots.length).toBeGreaterThan(0); const lastSnapshot = snapshots[snapshots.length - 1]; @@ -491,7 +517,7 @@ it("setTerminalActivity returns true and emits terminals-changed", async () => { ); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); await expect(manager.setTerminalActivity(session.id, "working")).resolves.toBe(true); const hasWorkingActivity = (entries: ActivityEntry[]) => @@ -519,7 +545,7 @@ it("clearTerminalAttention returns attention terminals to idle", async () => { ); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); await manager.setTerminalActivity(session.id, "attention"); await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(true); @@ -536,7 +562,10 @@ it("clearTerminalAttention returns attention terminals to idle", async () => { it("clearTerminalAttention leaves non-attention terminals unchanged", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "ws-test", + }); await manager.setTerminalActivity(session.id, "working"); await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(false); @@ -574,7 +603,7 @@ it("does not emit workspace contribution event for title-only changes", async () events.push(event); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); manager.setTerminalTitle(session.id, "New title"); expect(events).toEqual([]); @@ -614,7 +643,7 @@ it("does not emit workspace contribution event when an idle terminal is removed" events.push(event); }); - const session = await manager.createTerminal({ cwd }); + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); manager.killTerminal(session.id); expect(events).toEqual([]); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 4505a642b..8bd35ac5d 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -16,7 +16,7 @@ export interface TerminalListItem { id: string; name: string; cwd: string; - workspaceId?: string; + workspaceId: string; title?: string; activity: TerminalActivity | null; } @@ -32,6 +32,7 @@ export interface TerminalActivityTransitionEvent { terminalId: string; name: string; cwd: string; + workspaceId: string; activity: TerminalActivity | null; previous: TerminalActivity | null; } @@ -41,7 +42,7 @@ export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) export interface TerminalWorkspaceContributionChangedEvent { terminalId: string; cwd: string; - workspaceId?: string; + workspaceId: string; } export type TerminalWorkspaceContributionChangedListener = ( @@ -53,7 +54,7 @@ export interface TerminalManager { createTerminal(options: { id?: string; cwd: string; - workspaceId?: string; + workspaceId: string; name?: string; title?: string; env?: Record; @@ -159,7 +160,7 @@ export function createTerminalManager( emitTerminalWorkspaceContributionChanged({ terminalId: session.id, cwd: session.cwd, - ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + workspaceId: session.workspaceId, }); } @@ -200,7 +201,7 @@ export function createTerminalManager( emitTerminalWorkspaceContributionChanged({ terminalId: session.id, cwd: session.cwd, - ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + workspaceId: session.workspaceId, }); } }); @@ -215,7 +216,7 @@ export function createTerminalManager( id: input.session.id, name: input.session.name, cwd: input.session.cwd, - ...(input.session.workspaceId ? { workspaceId: input.session.workspaceId } : {}), + workspaceId: input.session.workspaceId, title: input.session.getTitle(), activity: input.session.getActivity(), }; @@ -254,6 +255,7 @@ export function createTerminalManager( terminalId: input.session.id, name: input.session.name, cwd: input.session.cwd, + workspaceId: input.session.workspaceId, activity: input.transition.activity, previous: input.transition.previous, }; @@ -307,7 +309,7 @@ export function createTerminalManager( async createTerminal(options: { id?: string; cwd: string; - workspaceId?: string; + workspaceId: string; name?: string; title?: string; env?: Record; @@ -341,7 +343,7 @@ export function createTerminalManager( await createTerminal({ id: terminalId, cwd: options.cwd, - ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}), + workspaceId: options.workspaceId, name: options.name ?? defaultName, ...(options.title ? { title: options.title } : {}), ...(options.command ? { command: options.command } : {}), diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index c870e4f99..a54421ad9 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -60,6 +60,7 @@ describe("terminal-session-controller restore", () => { id: "term-1", name: "Terminal", cwd: "/tmp", + workspaceId: "ws-test", send: vi.fn(), subscribe: (listener) => { terminalListener = listener; @@ -154,6 +155,7 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal id: input.id, name: input.name, cwd: input.cwd, + workspaceId: "ws-test", send: vi.fn(), subscribe: () => vi.fn(), onExit: () => vi.fn(), @@ -193,6 +195,7 @@ describe("terminal-session-controller wrap-flag gating", () => { id: "term-1", name: "Terminal", cwd: "/tmp", + workspaceId: "ws-test", send: vi.fn(), subscribe: (listener) => { queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); @@ -329,7 +332,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { changedListener?.({ cwd: subdirCwd, - terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }], + terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd, workspaceId: "ws-test" }], }); await flushMicrotasks(); @@ -339,8 +342,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", activity: null }, - { id: "subdir-term", name: "Mobile", activity: null }, + { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, + { id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null }, ], }, }, @@ -403,7 +406,9 @@ describe("terminal-session-controller subdirectory aggregation", () => { type: "list_terminals_response", payload: { cwd: rootCwd, - terminals: [{ id: "root-term", name: "Terminal 1", activity: null }], + terminals: [ + { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, + ], requestId: "req-root", }, }, @@ -411,7 +416,9 @@ describe("terminal-session-controller subdirectory aggregation", () => { type: "list_terminals_response", payload: { cwd: worktreeCwd, - terminals: [{ id: "worktree-term", name: "Feature", activity: null }], + terminals: [ + { id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null }, + ], requestId: "req-worktree", }, }, @@ -500,6 +507,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { id: "term-1", name: "Terminal", cwd: "/tmp", + workspaceId: "ws-test", send: vi.fn(), subscribe: (listener) => { terminalListener = listener; diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index ffb2df11a..409d32692 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -298,7 +298,7 @@ export class TerminalSessionController { terminals: Array<{ id: string; name: string; - workspaceId?: string; + workspaceId: string; title?: string; activity: TerminalActivity | null; }>; @@ -317,7 +317,7 @@ export class TerminalSessionController { ): { id: string; name: string; - workspaceId?: string; + workspaceId: string; title?: string; activity: TerminalActivity | null; } { @@ -326,7 +326,7 @@ export class TerminalSessionController { return { id: terminal.id, name: terminal.name, - ...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}), + workspaceId: terminal.workspaceId, ...(title ? { title } : {}), activity, }; @@ -520,9 +520,21 @@ export class TerminalSessionController { return; } + if (!msg.workspaceId) { + this.emit({ + type: "create_terminal_response", + payload: { + terminal: null, + error: "workspaceId is required", + requestId: msg.requestId, + }, + }); + return; + } + const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, - ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), + workspaceId: msg.workspaceId, name: msg.name, command: msg.command, args: msg.args, @@ -535,7 +547,7 @@ export class TerminalSessionController { id: session.id, name: session.name, cwd: session.cwd, - ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + workspaceId: session.workspaceId, ...(session.getTitle() ? { title: session.getTitle() } : {}), activity: session.getActivity(), }, diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 3e095b178..5a3f36ef9 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -66,7 +66,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { id: session.id, name: session.name, cwd: session.cwd, - ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + workspaceId: session.workspaceId, ...(session.getTitle() ? { title: session.getTitle() } : {}), activity: session.getActivity(), }; @@ -201,7 +201,11 @@ async function handleCreateTerminalRequest(message: TerminalCreateRequest): Prom }; inFlightTerminalCreateRequest = request; try { - const session = await manager.createTerminal(message.options); + const { workspaceId } = message.options; + if (!workspaceId) { + throw new Error("workspaceId is required"); + } + const session = await manager.createTerminal({ ...message.options, workspaceId }); if (request.errorReported) { session.kill(); return; diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 8e7eaf710..0ea433ebb 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -302,6 +302,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("executes a simple echo command", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -328,6 +329,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("executes multiple commands sequentially", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -353,6 +355,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures output from pwd in specified cwd", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -382,6 +385,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: homeDir, command: "/bin/zsh", args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], @@ -409,6 +413,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, command: process.execPath, args: [scriptPath, "run", "dev"], @@ -432,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("emits OSC title updates to title listeners", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -457,6 +463,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("keeps preset titles instead of applying OSC title updates", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -481,6 +488,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, shell: "/bin/sh", env: { PS1: "$ " }, @@ -514,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, shell: "/bin/sh", env: { PS1: "$ " }, @@ -538,6 +547,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("debounces rapid title changes and emits only the final title", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -581,6 +591,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: workingDir, shell: "/bin/zsh", env: { @@ -663,6 +674,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: workingDir, shell: "/bin/zsh", env: { @@ -692,6 +704,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures ANSI 16 color codes (mode 1)", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -721,6 +734,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures 256 color codes (mode 2)", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -746,6 +760,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures true color RGB (mode 3)", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -774,6 +789,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures background colors", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -800,6 +816,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("receives a snapshot on initial subscription", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), }), ); @@ -820,6 +837,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("receives output messages on updates without replay snapshots", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -849,6 +867,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("does not emit snapshot messages for resize-only updates", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -877,6 +896,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("emits output only after getState reflects the new data", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -902,6 +922,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("unsubscribe stops receiving messages", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -931,6 +952,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -950,6 +972,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -967,6 +990,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -986,6 +1010,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1005,6 +1030,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1024,6 +1050,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1043,6 +1070,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("streams raw output messages without replay metadata", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1070,6 +1098,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1112,6 +1141,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("returns current terminal state with grid", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1135,6 +1165,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("captures cursor presentation modes emitted by terminal apps", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1164,6 +1195,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("grid cells have char and color attributes", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1184,6 +1216,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("preserves scrollback buffer", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1222,6 +1255,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("terminates the shell process", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -1240,6 +1274,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { it("send after kill is a no-op", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index c23b0cd95..62e5fbb51 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -251,6 +251,7 @@ describe("createTerminal", () => { it("creates a terminal session with an id, name, and cwd", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), }), ); @@ -268,6 +269,7 @@ describe("createTerminal", () => { : "/bin/sh"; const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), shell, env: { PS1: "$ " }, @@ -281,6 +283,7 @@ describe("createTerminal", () => { it("uses default shell if not specified", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), }), ); @@ -294,6 +297,7 @@ describe("createTerminal", () => { : "/bin/sh"; const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), shell, env: { PS1: "$ " }, @@ -311,6 +315,7 @@ describe("createTerminal", () => { : "/bin/sh"; const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), shell, env: { PS1: "$ " }, @@ -327,6 +332,7 @@ describe("createTerminal", () => { it("reports per-row soft-wrap flags only when wrap flags are requested", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), cols: 40, rows: 10, @@ -358,6 +364,7 @@ describe("createTerminal", () => { it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), command: process.execPath, args: [ @@ -389,6 +396,7 @@ describe("createTerminal", () => { // summary races the async xterm parse and reads a stale buffer. const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), command: process.execPath, args: [ @@ -429,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { it("executes a simple echo command", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -455,6 +464,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { it("captures output from pwd in specified cwd", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -484,6 +494,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: homeDir, command: "/bin/zsh", args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], @@ -511,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, command: process.execPath, args: [scriptPath, "run", "dev"], @@ -534,6 +546,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("emits OSC title updates to title listeners", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -559,6 +572,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("keeps preset titles instead of applying OSC title updates", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -583,6 +597,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, shell: "/bin/sh", env: { PS1: "$ " }, @@ -616,6 +631,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: packageRoot, shell: "/bin/sh", env: { PS1: "$ " }, @@ -640,6 +656,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("debounces rapid title changes and emits only the final title", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -683,6 +700,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: workingDir, shell: "/bin/zsh", env: { @@ -765,6 +783,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: workingDir, shell: "/bin/zsh", env: { @@ -792,6 +811,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("clears already scheduled OSC title debounce timers when setting a user title", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -825,6 +845,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("ignores later OSC title updates after setting a user title", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -854,6 +875,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { it("trims user-set titles and treats empty titles as no-ops", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ " }, @@ -884,6 +906,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { it("captures ANSI 16 color codes (mode 1)", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -913,6 +936,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { it("captures true color RGB (mode 3)", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -941,6 +965,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { it("captures background colors", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: "/tmp", shell: "/bin/sh", env: { PS1: "$ ", TERM: "xterm-256color" }, @@ -967,6 +992,7 @@ describe("resize", () => { it("updates terminal dimensions on resize", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), rows: 24, cols: 80, @@ -985,6 +1011,7 @@ describe("resize", () => { it("grid reflects new dimensions after resize", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), rows: 24, cols: 80, @@ -1002,6 +1029,7 @@ describe("resize", () => { it("exposes the current size without extracting full state", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), rows: 24, cols: 80, @@ -1021,6 +1049,7 @@ describe("mouse events", () => { it("accepts mouse events without throwing", async () => { const session = trackSession( await createTerminal({ + workspaceId: "ws-test", cwd: realpathSync(tmpdir()), }), ); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 526687ce8..663d68d68 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -77,7 +77,7 @@ export interface TerminalSession { id: string; name: string; cwd: string; - workspaceId?: string; + workspaceId: string; send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void; @@ -118,7 +118,7 @@ function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | nu export interface CreateTerminalOptions { id?: string; cwd: string; - workspaceId?: string; + workspaceId: string; shell?: string; env?: Record; activityEnv?: Record; @@ -865,7 +865,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { manager = createWorkerTerminalManager(); const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` process.stdin.on("data", (chunk) => { @@ -212,6 +213,7 @@ it("delivers rapid small writes complete and in order through worker coalescing" // on platform-specific PTY input echo/canonical-mode behavior. const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, env: { PASEO_TERMINAL_BURST_GATE: burstGatePath }, ...nodeTerminalCommand(` @@ -272,6 +274,7 @@ it("pulls fresh terminal state from the worker authority", async () => { manager = createWorkerTerminalManager(); const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` process.stdout.write("worker-state-ready\\n"); @@ -304,6 +307,7 @@ it.skipIf(isPlatform("win32"))( // tracker records and reflects in its replay preamble (\x1b[=1;1u). const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` process.stdout.write("\\u001b[>1u"); @@ -327,6 +331,7 @@ it("refreshes cached terminal title after worker title changes", async () => { manager = createWorkerTerminalManager(); const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` process.stdout.write("\\u001b]0;Build Output\\u0007"); @@ -344,7 +349,7 @@ it("refreshes cached terminal size after worker resize", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-resize-")); temporaryDirs.push(cwd); manager = createWorkerTerminalManager(); - const session = trackTerminal(await manager.createTerminal({ cwd })); + const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "resize", rows: 10, cols: 40 }); @@ -361,7 +366,7 @@ it("captures terminal output from the worker authority", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-capture-")); temporaryDirs.push(cwd); manager = createWorkerTerminalManager(); - const session = trackTerminal(await manager.createTerminal({ cwd })); + const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "input", data: "echo hello world\r" }); @@ -388,6 +393,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy id: "terminal-1", name: "Terminal", cwd: "/tmp", + workspaceId: "ws-test", activity: { state: "idle", changedAt: 0 }, }, state: createTerminalState(), @@ -423,6 +429,7 @@ it("keeps registered cwd env inheritance behind the worker manager interface", a }); trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` require("node:fs").writeFileSync( @@ -450,6 +457,7 @@ it("injects parent-minted terminal activity env through the worker", async () => const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand(` require("node:fs").writeFileSync( @@ -495,7 +503,7 @@ it("starts the default shell through the worker and accepts quoted commands", as const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "shell quoted marker.txt"); - const session = trackTerminal(await manager.createTerminal({ cwd })); + const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); const command = [ "node", "-e", @@ -517,6 +525,7 @@ it("lists subdirectory terminals when querying the workspace root", async () => manager = createWorkerTerminalManager(); const created = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd: subdirCwd, ...nodeTerminalCommand("setInterval(() => {}, 1000);"), }), @@ -540,6 +549,7 @@ it("lists terminals locally without waiting on the worker", async () => { id: "terminal-root", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: { state: "idle", changedAt: 0 }, }, state: createTerminalState(), @@ -550,6 +560,7 @@ it("lists terminals locally without waiting on the worker", async () => { id: "terminal-subdir", name: "Shell", cwd: "/workspace/apps/mobile", + workspaceId: "ws-test", activity: { state: "idle", changedAt: 0 }, }, state: createTerminalState(), @@ -579,6 +590,7 @@ it("includes only stamped terminals in workspace-scoped local reads", async () = id: "terminal-legacy", name: "Legacy", cwd: "/workspace", + workspaceId: "ws-test", activity: null, }, state: createTerminalState(), @@ -640,6 +652,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term id: "terminal-a", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: { state: "idle", changedAt: 0 }, }, state: createTerminalState(), @@ -681,6 +694,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term terminalId: "terminal-a", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: workingActivity, previous: idleActivity, }, @@ -700,6 +714,7 @@ it("sets terminal activity through a worker request", async () => { id: "terminal-a", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: null, }, state: createTerminalState(), @@ -732,6 +747,7 @@ it("clears terminal attention through a worker request", async () => { id: "terminal-a", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: { state: "idle", attentionReason: "finished", changedAt: 1000 }, }, state: createTerminalState(), @@ -757,6 +773,7 @@ it("clears finished attention on a real terminal", async () => { manager = createWorkerTerminalManager(); const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand("setInterval(() => {}, 1000);"), }), @@ -792,6 +809,7 @@ it("removes worker terminals after killAndWait", async () => { manager = createWorkerTerminalManager(); const session = trackTerminal( await manager.createTerminal({ + workspaceId: "ws-test", cwd, ...nodeTerminalCommand("setInterval(() => {}, 1000);"), }), @@ -822,6 +840,7 @@ it("produces one terminals-changed snapshot per title change", async () => { id: "terminal-a", name: "Shell", cwd: "/workspace", + workspaceId: "ws-test", activity: null, }, state: createTerminalState(), diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 988118253..0f0243d7a 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -36,6 +36,20 @@ import type { const REQUEST_TIMEOUT_MS = 10000; +type RequiredWorkerTerminalInfo = WorkerTerminalInfo & { workspaceId: string }; + +function requiredWorkspaceId(workspaceId: string | undefined): string { + if (workspaceId === undefined) { + throw new Error("workspaceId is required"); + } + return workspaceId; +} + +function asRequiredWorkerTerminalInfo(info: WorkerTerminalInfo): RequiredWorkerTerminalInfo { + requiredWorkspaceId(info.workspaceId); + return info as RequiredWorkerTerminalInfo; +} + type TerminalWorkerRequestInput = TerminalWorkerRequest extends infer Request ? Request extends TerminalWorkerRequest ? Omit @@ -49,7 +63,7 @@ interface PendingRequest { } interface WorkerTerminalRecord { - info: WorkerTerminalInfo; + info: RequiredWorkerTerminalInfo; state: TerminalState; activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives @@ -114,12 +128,12 @@ function isResponse(message: TerminalWorkerToParentMessage): message is Terminal return message.type === "response"; } -function cloneTerminalInfo(info: WorkerTerminalInfo): WorkerTerminalInfo { +function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerminalInfo { return { id: info.id, name: info.name, cwd: info.cwd, - ...(info.workspaceId ? { workspaceId: info.workspaceId } : {}), + workspaceId: info.workspaceId, ...(info.title ? { title: info.title } : {}), activity: info.activity, }; @@ -196,7 +210,7 @@ export function createWorkerTerminalManager( id: record.info.id, name: record.info.name, cwd: record.info.cwd, - ...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}), + workspaceId: record.info.workspaceId, ...(record.info.title ? { title: record.info.title } : {}), activity: record.activity, }); @@ -205,7 +219,7 @@ export function createWorkerTerminalManager( } function registerRecord(input: { - info: WorkerTerminalInfo; + info: RequiredWorkerTerminalInfo; state: TerminalState; }): TerminalSession { const existing = recordsById.get(input.info.id); @@ -438,7 +452,7 @@ export function createWorkerTerminalManager( emitTerminalWorkspaceContributionChanged({ terminalId: removedRecord.info.id, cwd: removedRecord.info.cwd, - ...(removedRecord.info.workspaceId ? { workspaceId: removedRecord.info.workspaceId } : {}), + workspaceId: removedRecord.info.workspaceId, }); } emitTerminalsChanged({ @@ -506,6 +520,7 @@ export function createWorkerTerminalManager( terminalId: record.info.id, name: record.info.name, cwd: record.info.cwd, + workspaceId: record.info.workspaceId, activity: message.activity, previous: message.previous, }); @@ -515,7 +530,7 @@ export function createWorkerTerminalManager( emitTerminalWorkspaceContributionChanged({ terminalId: record.info.id, cwd: record.info.cwd, - ...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}), + workspaceId: record.info.workspaceId, }); } emitTerminalsChanged({ @@ -527,7 +542,10 @@ export function createWorkerTerminalManager( function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { - registerRecord({ info: message.terminal, state: message.state }); + registerRecord({ + info: asRequiredWorkerTerminalInfo(message.terminal), + state: message.state, + }); emitTerminalsChanged({ cwd: message.terminal.cwd, terminals: listTerminalItemsForCwd(message.terminal.cwd), @@ -661,13 +679,15 @@ export function createWorkerTerminalManager( return sessions; }, - async createTerminal(options: WorkerCreateTerminalOptions): Promise { + async createTerminal( + options: WorkerCreateTerminalOptions & { workspaceId: string }, + ): Promise { const terminalId = options.id ?? randomUUID(); const activityToken = createActivityToken(); const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; terminalActivityTokenById.set(terminalId, activityToken); let result: { - terminal: WorkerTerminalInfo; + terminal: RequiredWorkerTerminalInfo; state: TerminalState; }; try { @@ -680,7 +700,7 @@ export function createWorkerTerminalManager( activityUrl: terminalActivityUrl, }, })) as { - terminal: WorkerTerminalInfo; + terminal: RequiredWorkerTerminalInfo; state: TerminalState; }; } catch (error) { diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index d063fbda2..c6af449a3 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -15,6 +15,6 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"], + exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index df0655a00..326780e1a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,6 +57,6 @@ export default defineConfig({ ], }, test: { - exclude: [...configDefaults.exclude, "**/.claude/**"], + exclude: [...configDefaults.exclude, "**/.claude/**", "**/.dev/**"], }, });