mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Make workspace IDs opaque, independent of the filesystem path (#1503)
* Make workspace IDs opaque, independent of the filesystem path Workspace IDs were the resolved checkout/worktree path, so code could treat an ID as a path: prefix matching, deriving directories from it, falling back to a path when a lookup missed. IDs are now opaque - compared only by exact equality and never used as a path. Anything path-shaped resolves the workspace record first and reads its cwd. New workspaces get a generated `wks_` ID; existing path-shaped IDs are read from disk and never regenerated, so there is no migration and no change to the wire or persisted schemas. Groundwork for running multiple workspaces in a single directory. * Keep attachment scope key stable and align refetch test The attachment scope key keeps `workspace=` instead of `wsid=` so existing persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch test no longer asserts a filter, matching refetch fetching one page and selecting by id client-side. * Fix live agent updates in directories without a registered workspace The opaque-ID work removed buildProjectPlacementForCwd's directory fallback, so an agent running in a folder with no registered workspace (e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate then threw "Workspace not found", the error was swallowed by its catch, and no agent_update was emitted — live model/thinking switches and status updates silently stopped. Caught by the live-preferences e2e suite. The fallback builds a directory-scoped project placement keyed by the path. That key is a project grouping key (non-git projects group by path), not a workspace id, so it stays within the opaque-id rule. * Always run server worktree archive, even without a resolved workspace Archiving bailed out entirely when the workspace was not found in the client store, so a race or stale state could make "archive" do nothing server-side with only a console.warn. The server archive is keyed by worktreePath, which is always available, so it now runs regardless; only the optimistic client-side updates (keyed by workspace id) are gated on the workspace being resolved. * Expect a directory-scoped placement for unregistered agent dirs This unit test asserted the no-placement behavior reverted in the live agent-update fix, which had broken live model/thinking switching. Update it to expect the directory-scoped placement now emitted for an agent in a directory with no registered workspace. * Fix opaque workspace routing in app E2E * Preserve workspace IDs during partial bootstrap * Fix archive flows for opaque workspace IDs * Stop treating opaque workspace IDs as filesystem paths Workspace IDs are opaque (wks_<hex>), but several call sites still passed the id where a directory was expected, which broke those flows for opaque IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git operations, and server archive/reconcile cleanup keyed git-watch and subscription teardown by id, leaking that state. Git and filesystem operations now take the workspace directory; the opaque id is used only for identity and cache keys. Archive/reconcile cleanup routes through a single teardownArchivedWorkspace helper that keeps the key split explicit: runtime store by id, git watch and subscription by cwd. Path-derived grouping keys are renamed to directory keys, and the helpers are split into workspace-identity and workspace-directory so the id-vs-path boundary is obvious.
This commit is contained in:
@@ -97,6 +97,8 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|
||||
@@ -87,7 +87,7 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
@@ -232,7 +232,7 @@ initializing → idle ⇄ running
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
|
||||
|
||||
## Agent providers
|
||||
|
||||
|
||||
@@ -386,16 +386,18 @@ emptied duplicate.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
|
||||
@@ -68,7 +68,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
@@ -94,7 +94,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await reloadWorkspace(page, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(page, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
|
||||
44
packages/app/e2e/branch-switcher.spec.ts
Normal file
44
packages/app/e2e/branch-switcher.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectWorkspaceBranch, switchBranchFromHeader } from "./helpers/branch-switcher";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Branch switcher", () => {
|
||||
// The first test after a spec-file switch can fail while the shared daemon
|
||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("switches the workspace branch from the header for an opaque workspace id", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "branch-switch-",
|
||||
repo: { branches: ["main", "dev"] },
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await switchBranchFromHeader(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("dev");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -244,7 +244,7 @@ test.describe("Composer attachments", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
@@ -34,6 +35,10 @@ function buildSeededStoragePayload() {
|
||||
* idle agent from the same client it uses for everything else.
|
||||
*/
|
||||
export interface IdleAgentSeedClient {
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: { id: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -52,6 +57,10 @@ export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
@@ -71,6 +80,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +143,7 @@ export async function openWorkspaceWithAgents(
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
|
||||
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
|
||||
40
packages/app/e2e/helpers/branch-switcher.ts
Normal file
40
packages/app/e2e/helpers/branch-switcher.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
// The header branch switcher renders as a button whose accessible name carries the
|
||||
// current branch ("Current branch: <name>. Press to switch branch."). Matching on the
|
||||
// accessible name keeps these helpers tied to what a screen reader user hears, and it
|
||||
// proves the header resolved a real checkout directory from the opaque workspace id.
|
||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
return page
|
||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||
await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function switchBranchFromHeader(
|
||||
page: Page,
|
||||
input: { from: string; to: string },
|
||||
): Promise<void> {
|
||||
await branchSwitcherTrigger(page, input.from).click();
|
||||
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
await expect(picker).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The branch switcher combobox renders its options as plain text rows with no ARIA
|
||||
// role, so filter by the visible branch name and click the matching row. Filtering
|
||||
// first guarantees a single, unambiguous match.
|
||||
const search = page.getByPlaceholder("Filter branches...");
|
||||
await expect(search).toBeVisible({ timeout: 30_000 });
|
||||
await search.fill(input.to);
|
||||
|
||||
const option = picker.getByText(input.to, { exact: true });
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
|
||||
await expect(picker).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export async function startRunningMockAgent(
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getServerId } from "./server-id";
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getServerId } from "./server-id";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
client: SeedDaemonClient;
|
||||
cleanup(): Promise<void>;
|
||||
@@ -40,6 +41,7 @@ export async function seedMockAgentWorkspace(
|
||||
});
|
||||
return {
|
||||
agentId: agent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
@@ -50,8 +52,8 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
@@ -59,9 +61,9 @@ export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
export async function openAgentRoute(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(buildAgentRoute(input.cwd, input.agentId));
|
||||
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { expectWorkspaceHeader } from "./workspace-ui";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
@@ -12,17 +12,20 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "fetchWorkspaces"
|
||||
| "openProject"
|
||||
>;
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
|
||||
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: WorkspacePayload) {
|
||||
@@ -35,6 +38,39 @@ function requireWorkspace(payload: WorkspacePayload) {
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorkspaceById(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceDescriptor | null> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
return payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||
}
|
||||
|
||||
async function waitForWorkspaceDescriptor(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceDescriptor> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const workspace = await fetchWorkspaceById(client, workspaceId);
|
||||
if (workspace) {
|
||||
return workspace;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Workspace descriptor not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const match = pathname.match(
|
||||
@@ -57,24 +93,19 @@ export async function openProjectViaDaemon(
|
||||
repoPath: string,
|
||||
): Promise<OpenedProject> {
|
||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
if (!payload.success) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
throw new Error(`Failed to archive workspace: ${workspaceDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +131,7 @@ export async function createWorktreeViaDaemon(
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
@@ -241,12 +267,13 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
client: NewWorkspaceDaemonClient;
|
||||
previousWorkspaceId: string;
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
): Promise<{ workspaceId: string }> {
|
||||
): 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;
|
||||
@@ -263,21 +290,27 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId);
|
||||
|
||||
if (input.assertSidebarRow !== false) {
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
`sidebar-workspace-row-${input.serverId}:${workspace.id}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
if (input.assertHeader !== false) {
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
title: workspace.name,
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
return { workspaceId };
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AgentHandle {
|
||||
page: Page;
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
@@ -61,14 +62,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
function agentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
async function openAgent(
|
||||
page: Page,
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(agentRoute(input.workspaceId, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
@@ -180,10 +184,11 @@ export async function launchAgent(input: {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
workspaceId: opened.workspace.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,38 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
|
||||
// suite addresses them by the stable test ids the app assigns per workspace — the same
|
||||
// convention the rename flow uses. The kebab only reveals on hover.
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceAbsentFromSidebar(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline(
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
@@ -261,7 +261,7 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
targetWorkspacePath: workspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,29 +18,8 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
|
||||
.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
export function workspaceLabelFromPath(value: string): string {
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith("/var/")) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
if (trimmed.startsWith("/private/var/")) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||
);
|
||||
return page.locator(ids.join(",")).first();
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
}
|
||||
|
||||
export async function expectSidebarWorkspaceSelected(input: {
|
||||
@@ -69,13 +48,13 @@ export async function expectSidebarWorkspaceSelected(input: {
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
targetWorkspacePath: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -89,11 +68,10 @@ export async function waitForWorkspaceInSidebar(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const candidates = candidateWorkspaceIds(input.workspaceId);
|
||||
const selector = candidates
|
||||
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
|
||||
.join(",");
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
|
||||
await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({
|
||||
state: "visible",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface WorkspaceStatusGroupEvent {
|
||||
@@ -183,7 +182,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -193,14 +192,14 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceId of createdWorktreeIds) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeIds.clear();
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
@@ -223,7 +222,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
@@ -233,7 +232,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId,
|
||||
@@ -247,7 +246,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
@@ -271,7 +270,7 @@ test.describe("New workspace flow", () => {
|
||||
slug: `nav-${Date.now()}`,
|
||||
});
|
||||
localWorkspaceIds.add(rootWorkspace.workspaceId);
|
||||
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -279,7 +278,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
@@ -294,7 +293,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: worktreeWorkspace.workspaceId,
|
||||
workspaceId: worktreeWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: worktreeWorkspace.workspaceName,
|
||||
@@ -315,7 +314,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
@@ -354,7 +353,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -367,12 +366,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||
await expect(page).toHaveURL(
|
||||
@@ -388,7 +388,7 @@ test.describe("New workspace flow", () => {
|
||||
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||
title: createdWorkspace.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -428,7 +428,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -455,12 +455,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||
@@ -507,7 +508,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -523,12 +524,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
@@ -560,7 +562,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -576,10 +578,11 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
@@ -618,7 +621,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -640,17 +643,18 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||
expect(existsSync(createdWorkspace.workspaceDirectory)).toBe(true);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceId,
|
||||
worktreePath: createdWorkspace.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-sheet-stack-",
|
||||
title: "Provider sheet stack e2e",
|
||||
initialPrompt: "Prepare provider sheet stack test agent.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
@@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function closeSheetByHeaderButton(page: Page, testId: string) {
|
||||
const sheet = page.getByTestId(testId);
|
||||
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
@@ -69,14 +74,15 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Add model" }).click();
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "add-custom-model-sheet");
|
||||
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-diagnostic-sheet");
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
@@ -87,18 +93,20 @@ test.describe("provider settings bottom-sheet stack", () => {
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-settings-sheet");
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-settings-sheet");
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
@@ -74,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
@@ -105,13 +105,13 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
@@ -92,7 +92,7 @@ async function openWorkspaceThroughApp(
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: input.serverId,
|
||||
targetWorkspacePath: input.workspace.workspaceId,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceLocation(page, input);
|
||||
@@ -209,7 +209,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
@@ -365,7 +365,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
@@ -397,7 +397,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
@@ -461,7 +461,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe("Workspace pane mounting", () => {
|
||||
title: `pane-remount-${Date.now()}`,
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
59
packages/app/e2e/worktree-archive.spec.ts
Normal file
59
packages/app/e2e/worktree-archive.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree archive", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ retries: 1, timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("wt-archive-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
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,7 +276,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||
workspaceDirectory: workspaceRoot,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -93,7 +93,7 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ function normalizeCwd(cwd: string): string {
|
||||
|
||||
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeScopePart(workspaceId)}`
|
||||
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
@@ -35,11 +36,13 @@ export function BranchSwitcher({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
|
||||
@@ -120,10 +120,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
@@ -1538,7 +1535,7 @@ function WorkspaceRowWithMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -1580,7 +1577,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1660,7 +1657,7 @@ function WorkspaceRowWithMenu({
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceExecutionDirectory({
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1687,7 +1684,7 @@ function WorkspaceRowWithMenu({
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
@@ -40,10 +40,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
@@ -355,7 +352,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -391,7 +388,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
if (!confirmed) return;
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -441,7 +438,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceExecutionDirectory({
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -462,7 +459,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "@/composer/types";
|
||||
@@ -300,9 +300,10 @@ export function WorkspaceSetupDialog() {
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
||||
workspace: ensuredWorkspace,
|
||||
}).workspaceDirectory;
|
||||
const workspaceDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
});
|
||||
const agent = await connectedClient.createAgent(
|
||||
buildCreateAgentOptions({
|
||||
composerState,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -121,7 +121,7 @@ async function submitDraftCreateRequest(input: {
|
||||
attachments?: unknown;
|
||||
client: DaemonClient | null;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceExecutionAuthority: { workspaceId: string } | null;
|
||||
workspaceId: string | null;
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
@@ -141,13 +141,13 @@ async function submitDraftCreateRequest(input: {
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
workspaceExecutionAuthority,
|
||||
workspaceId,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
} = input;
|
||||
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
invariant(workspaceExecutionAuthority, "Workspace authority is required");
|
||||
invariant(workspaceId, "Workspace id is required");
|
||||
if (!client) {
|
||||
throw new Error(input.hostDisconnectedMessage);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function submitDraftCreateRequest(input: {
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await client.createAgent({
|
||||
config,
|
||||
workspaceId: workspaceExecutionAuthority.workspaceId,
|
||||
workspaceId,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
@@ -317,9 +317,8 @@ export function WorkspaceDraftAgentTab({
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const draftSetup = initialSetup ?? null;
|
||||
const draftWorkingDirectory = resolveDraftWorkingDirectory({
|
||||
workspaceDirectory,
|
||||
@@ -471,7 +470,7 @@ export function WorkspaceDraftAgentTab({
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceExecutionAuthority,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export function patchWorkspaceScripts(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
|
||||
@@ -37,7 +37,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.id,
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
@@ -51,13 +51,13 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
const workspaceId = "ws-feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -65,8 +65,7 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -301,9 +300,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(
|
||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
||||
@@ -313,6 +316,7 @@ describe("checkout-git-actions-store", () => {
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
@@ -325,49 +329,52 @@ describe("checkout-git-actions-store", () => {
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: "ws-feature",
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
// The server archive is keyed by worktreePath and must run regardless.
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
// The optimistic client-side mark is never keyed by the path.
|
||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual(
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
featureWorkspace,
|
||||
);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
||||
@@ -380,9 +387,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -158,12 +158,13 @@ function snapshotWorktreeArchiveState(input: {
|
||||
worktreePath: string;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
}) ?? input.worktreePath;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
});
|
||||
const workspaceKey = workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
@@ -173,13 +174,13 @@ function snapshotWorktreeArchiveState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void {
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().removeWorkspace(serverId, worktreePath);
|
||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
||||
}
|
||||
|
||||
function restoreWorktreeArchiveState(input: {
|
||||
@@ -195,9 +196,9 @@ function restoreWorktreeArchiveState(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.worktreePath.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
@@ -504,31 +505,35 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
|
||||
});
|
||||
// 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.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({
|
||||
serverId,
|
||||
worktreePath: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
if (workspace) {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
||||
if (workspace) {
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
54
packages/app/src/git/branch-switcher-operations.test.ts
Normal file
54
packages/app/src/git/branch-switcher-operations.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createBranchSwitcherOperations } from "./branch-switcher-operations";
|
||||
|
||||
function createRecordingClient() {
|
||||
const cwds: string[] = [];
|
||||
const client = {
|
||||
getBranchSuggestions: async (options: { cwd: string; limit?: number }) => {
|
||||
cwds.push(options.cwd);
|
||||
return { branches: [], error: null };
|
||||
},
|
||||
stashList: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { entries: [] };
|
||||
},
|
||||
stashSave: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
stashPop: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
checkoutSwitchBranch: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
} as unknown as DaemonClient;
|
||||
return { client, cwds };
|
||||
}
|
||||
|
||||
describe("createBranchSwitcherOperations", () => {
|
||||
it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => {
|
||||
const workspaceDirectory = "/Users/dev/project";
|
||||
const workspaceId = "wks_3f9a2b1c";
|
||||
const { client, cwds } = createRecordingClient();
|
||||
|
||||
const operations = createBranchSwitcherOperations(client, workspaceDirectory);
|
||||
await operations.getBranchSuggestions(200);
|
||||
await operations.listPaseoStashes();
|
||||
await operations.saveStash("main");
|
||||
await operations.popStash(0);
|
||||
await operations.switchBranch("feature");
|
||||
|
||||
expect(cwds).toEqual([
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
]);
|
||||
expect(cwds).not.toContain(workspaceId);
|
||||
});
|
||||
});
|
||||
16
packages/app/src/git/branch-switcher-operations.ts
Normal file
16
packages/app/src/git/branch-switcher-operations.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
// Binds the branch switcher's git operations to a single workspace directory, so a
|
||||
// workspace id can never be passed where a cwd is expected. `cwd` is set once here;
|
||||
// callers choose the operation, never the directory.
|
||||
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) {
|
||||
return {
|
||||
getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }),
|
||||
listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }),
|
||||
saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }),
|
||||
popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex),
|
||||
switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch),
|
||||
};
|
||||
}
|
||||
|
||||
export type BranchSwitcherOperations = ReturnType<typeof createBranchSwitcherOperations>;
|
||||
@@ -15,8 +15,9 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
@@ -495,18 +496,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedWorkspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
}) ?? worktreePath;
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
}) as Href,
|
||||
);
|
||||
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
});
|
||||
const redirectRoute = archivedWorkspaceId
|
||||
? buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
})
|
||||
: buildHostRootRoute(serverId);
|
||||
router.replace(redirectRoute as Href);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
|
||||
function workspaceNameFromDirectory(directory: string): string {
|
||||
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
|
||||
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
||||
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
|
||||
}
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
export interface SidebarWorkspaceEntry {
|
||||
@@ -54,7 +60,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
client: DaemonClient | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
currentBranchName: string | null;
|
||||
isGitCheckout: boolean;
|
||||
isConnected: boolean;
|
||||
@@ -30,6 +32,7 @@ export function useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
@@ -39,22 +42,29 @@ export function useBranchSwitcher({
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Git operations are bound to the workspace directory; the opaque workspace id is
|
||||
// used only for query cache identity below, never as a cwd.
|
||||
const operations = useMemo(
|
||||
() =>
|
||||
client && workspaceDirectory
|
||||
? createBranchSwitcherOperations(client, workspaceDirectory)
|
||||
: null,
|
||||
[client, workspaceDirectory],
|
||||
);
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!operations) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
const payload = await client.getBranchSuggestions({
|
||||
cwd: normalizedWorkspaceId,
|
||||
limit: 200,
|
||||
});
|
||||
const payload = await operations.getBranchSuggestions(200);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.branches ?? [];
|
||||
},
|
||||
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
|
||||
enabled: isOpen && isGitCheckout && Boolean(operations) && isConnected,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
@@ -70,20 +80,21 @@ export function useBranchSwitcher({
|
||||
);
|
||||
|
||||
const invalidateStashAndCheckout = useCallback(async () => {
|
||||
if (!workspaceDirectory) return;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
||||
invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: normalizedServerId,
|
||||
cwd: normalizedWorkspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
}),
|
||||
]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
const maybeRestoreStashForBranch = useCallback(
|
||||
async (branchId: string) => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
try {
|
||||
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
|
||||
const stashPayload = await operations.listPaseoStashes();
|
||||
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
|
||||
if (!targetStash) return;
|
||||
const shouldRestore = await confirmDialog({
|
||||
@@ -93,7 +104,7 @@ export function useBranchSwitcher({
|
||||
cancelLabel: t("branchSwitcher.later"),
|
||||
});
|
||||
if (!shouldRestore) return;
|
||||
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
|
||||
const popPayload = await operations.popStash(targetStash.index);
|
||||
if (popPayload.error) {
|
||||
toast.error(popPayload.error.message);
|
||||
} else {
|
||||
@@ -104,12 +115,12 @@ export function useBranchSwitcher({
|
||||
// Non-critical — user can still restore on next branch switch
|
||||
}
|
||||
},
|
||||
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
|
||||
[operations, invalidateStashAndCheckout, toast, t],
|
||||
);
|
||||
|
||||
const stashAndSwitch = useCallback(
|
||||
async (branchId: string) => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
const shouldStash = await confirmDialog({
|
||||
title: t("branchSwitcher.uncommittedTitle"),
|
||||
message: t("branchSwitcher.uncommittedMessage"),
|
||||
@@ -119,15 +130,13 @@ export function useBranchSwitcher({
|
||||
if (!shouldStash) return;
|
||||
|
||||
try {
|
||||
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
|
||||
branch: currentBranchName ?? undefined,
|
||||
});
|
||||
const stashPayload = await operations.saveStash(currentBranchName ?? undefined);
|
||||
if (stashPayload.error) {
|
||||
toast.error(stashPayload.error.message);
|
||||
return;
|
||||
}
|
||||
await invalidateStashAndCheckout();
|
||||
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
const switchPayload = await operations.switchBranch(branchId);
|
||||
if (switchPayload.error) {
|
||||
toast.error(switchPayload.error.message);
|
||||
return;
|
||||
@@ -137,7 +146,7 @@ export function useBranchSwitcher({
|
||||
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash"));
|
||||
}
|
||||
},
|
||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
|
||||
[operations, currentBranchName, invalidateStashAndCheckout, toast, t],
|
||||
);
|
||||
|
||||
const handleBranchSelect = useCallback(
|
||||
@@ -145,9 +154,9 @@ export function useBranchSwitcher({
|
||||
if (branchId === currentBranchName) return;
|
||||
|
||||
void (async () => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
try {
|
||||
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
const payload = await operations.switchBranch(branchId);
|
||||
if (payload.error) {
|
||||
// If the error is about uncommitted changes, offer the stash dialog
|
||||
if (payload.error.message.toLowerCase().includes("uncommitted")) {
|
||||
@@ -166,11 +175,10 @@ export function useBranchSwitcher({
|
||||
})();
|
||||
},
|
||||
[
|
||||
client,
|
||||
operations,
|
||||
currentBranchName,
|
||||
invalidateStashAndCheckout,
|
||||
maybeRestoreStashForBranch,
|
||||
normalizedWorkspaceId,
|
||||
stashAndSwitch,
|
||||
t,
|
||||
toast,
|
||||
|
||||
@@ -230,9 +230,7 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only");
|
||||
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
|
||||
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
|
||||
expect(en.panels.file.executionDirectoryMissing).toBe(
|
||||
"Workspace execution directory not found.",
|
||||
);
|
||||
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
|
||||
});
|
||||
|
||||
it("includes workspace Git and review keys for the Batch 4B migration", () => {
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export const ar: TranslationResources = {
|
||||
creatingAgent: "وكيل الخلق",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.",
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
loading: "جارٍ تحميل الملف...",
|
||||
noPreview: "لا تتوفر معاينة",
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
|
||||
@@ -1298,7 +1298,7 @@ export const en = {
|
||||
creatingAgent: "Creating agent",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Workspace execution directory not found.",
|
||||
directoryMissing: "Workspace directory not found.",
|
||||
loading: "Loading file...",
|
||||
noPreview: "No preview available",
|
||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||
|
||||
@@ -1327,7 +1327,7 @@ export const es: TranslationResources = {
|
||||
creatingAgent: "Agente creador",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.",
|
||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||
loading: "Cargando archivo...",
|
||||
noPreview: "No hay vista previa disponible",
|
||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||
|
||||
@@ -1330,7 +1330,7 @@ export const fr: TranslationResources = {
|
||||
creatingAgent: "Agent créateur",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.",
|
||||
directoryMissing: "Répertoire Workspace introuvable.",
|
||||
loading: "Chargement du fichier...",
|
||||
noPreview: "Aucun aperçu disponible",
|
||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||
|
||||
@@ -1319,7 +1319,7 @@ export const ru: TranslationResources = {
|
||||
creatingAgent: "Создание агента",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Каталог выполнения Workspace не найден.",
|
||||
directoryMissing: "Каталог Workspace не найден.",
|
||||
loading: "Загрузка файла...",
|
||||
noPreview: "Предварительный просмотр недоступен",
|
||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||
|
||||
@@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = {
|
||||
creatingAgent: "正在创建 Agent",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "未找到 workspace 执行目录。",
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
loading: "正在加载文件...",
|
||||
noPreview: "没有可用预览",
|
||||
binaryPreviewUnavailable: "二进制预览不可用",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BrowserPane } from "@/components/browser-pane";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
function getBrowserLabel(input: { title: string; url: string }): string {
|
||||
const title = input.title.trim();
|
||||
@@ -55,8 +55,7 @@ function useBrowserPanelDescriptor(target: {
|
||||
function BrowserPanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const { focusPane, isInteractive } = usePaneFocus();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
invariant(target.kind === "browser", "BrowserPanel requires browser target");
|
||||
return (
|
||||
<BrowserPane
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
@@ -28,15 +28,12 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
function FilePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceDirectory = workspaceAuthority?.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.executionDirectoryMissing")}</Text>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
@@ -37,21 +37,15 @@ function useTerminalPanelDescriptor(
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const workspace = useWorkspace(context.serverId, context.workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(
|
||||
workspaceAuthority.ok
|
||||
? "Workspace execution directory not found"
|
||||
: workspaceAuthority.message,
|
||||
);
|
||||
throw new Error("Workspace directory not found");
|
||||
}
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
@@ -76,13 +70,9 @@ function useTerminalPanelDescriptor(
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const isGitCheckout = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspace.projectKind === "git"
|
||||
: false;
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspace?.projectKind === "git";
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const handleOpenFileExplorer = useCallback(() => {
|
||||
if (!workspaceDirectory) {
|
||||
@@ -102,11 +92,7 @@ function TerminalPanel() {
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>
|
||||
{workspaceAuthority.ok
|
||||
? "Workspace execution directory not found."
|
||||
: workspaceAuthority.message}
|
||||
</Text>
|
||||
<Text>Workspace directory not found.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ function normalizeBaseRef(baseRef: string | null | undefined): string {
|
||||
|
||||
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeKeyPart(workspaceId)}`
|
||||
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
@@ -38,7 +38,7 @@ interface UseWorkspaceTerminalsInput {
|
||||
workspaceDirectory: string | null;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
hasHydratedWorkspaces: boolean;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
isMissingWorkspaceDirectory: boolean;
|
||||
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
onScriptTerminalSelected: (terminalId: string) => void;
|
||||
onWorkspacePathUnavailable: () => void;
|
||||
@@ -56,7 +56,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreated,
|
||||
onScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable,
|
||||
@@ -209,7 +209,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
|
||||
setPendingCreateInput(null);
|
||||
onWorkspacePathUnavailable();
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
]);
|
||||
@@ -233,7 +233,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
|
||||
onWorkspacePathUnavailable();
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +245,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreateQueued,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
|
||||
@@ -110,11 +110,7 @@ import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store"
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceRouteId,
|
||||
type WorkspaceExecutionAuthorityResult,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
@@ -170,7 +166,6 @@ import {
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { resolveCloseAgentTabPolicy } from "@/subagents";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
@@ -966,13 +961,13 @@ function useCloseTabs(): UseCloseTabsResult {
|
||||
|
||||
interface WorkspaceHeaderMenuProps {
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
currentBranchName: string | null;
|
||||
showWorkspaceSetup: boolean;
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
copyPathDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
@@ -1047,13 +1042,13 @@ function WorkspaceHeaderMenuTriggerIcon({
|
||||
|
||||
function WorkspaceHeaderMenu({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
currentBranchName,
|
||||
showWorkspaceSetup,
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
copyPathDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
@@ -1126,7 +1121,7 @@ function WorkspaceHeaderMenu({
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={menuCopyIcon}
|
||||
disabled={!isAbsolutePath(normalizedWorkspaceId)}
|
||||
disabled={copyPathDisabled}
|
||||
onSelect={onCopyWorkspacePath}
|
||||
>
|
||||
{t("workspace.header.actions.copyPath")}
|
||||
@@ -1198,6 +1193,7 @@ interface WorkspaceHeaderTitleBarProps {
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
copyPathDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
@@ -1233,6 +1229,7 @@ function WorkspaceHeaderTitleBar({
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
copyPathDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
@@ -1280,13 +1277,13 @@ function WorkspaceHeaderTitleBar({
|
||||
<View style={styles.compactHeaderMenuCluster}>
|
||||
<WorkspaceHeaderMenu
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={importAgentDisabled}
|
||||
copyPathDisabled={copyPathDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={menuNewBrowserIcon}
|
||||
@@ -1331,7 +1328,7 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||
}
|
||||
|
||||
interface RenderWorkspaceContentInput {
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
isMissingWorkspaceDirectory: boolean;
|
||||
activeTabDescriptor: WorkspaceTabDescriptor | null;
|
||||
hasHydratedAgents: boolean;
|
||||
mountedFocusedPaneTabIds: string[];
|
||||
@@ -1346,7 +1343,7 @@ interface RenderWorkspaceContentInput {
|
||||
|
||||
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
||||
const {
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
activeTabDescriptor,
|
||||
hasHydratedAgents,
|
||||
mountedFocusedPaneTabIds,
|
||||
@@ -1356,11 +1353,11 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
buildMobilePaneContentModel,
|
||||
} = input;
|
||||
|
||||
if (isMissingWorkspaceExecutionAuthority) {
|
||||
if (isMissingWorkspaceDirectory) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>
|
||||
Workspace execution directory is missing. Reload workspace data before opening tabs.
|
||||
Workspace directory is missing. Reload workspace data before opening tabs.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -1454,22 +1451,6 @@ function deriveWorkspaceHeaderFields(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceAuthorityState {
|
||||
workspaceDirectory: string | null;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
}
|
||||
|
||||
function resolveWorkspaceAuthorityState(
|
||||
workspaceAuthority: WorkspaceExecutionAuthorityResult,
|
||||
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
|
||||
): WorkspaceAuthorityState {
|
||||
const authority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
||||
return {
|
||||
workspaceDirectory: authority?.workspaceDirectory ?? null,
|
||||
isMissingWorkspaceExecutionAuthority: Boolean(workspaceDescriptor && !authority),
|
||||
};
|
||||
}
|
||||
|
||||
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
||||
const trimmed = host?.label?.trim();
|
||||
return trimmed ? trimmed : fallback;
|
||||
@@ -1777,15 +1758,8 @@ function WorkspaceScreenContent({
|
||||
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceAuthority = useMemo(
|
||||
() =>
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspace: workspaceDescriptor,
|
||||
}),
|
||||
[workspaceDescriptor],
|
||||
);
|
||||
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
|
||||
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
|
||||
const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null;
|
||||
const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory;
|
||||
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
|
||||
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
|
||||
const openImportSheet = useCallback(() => {
|
||||
@@ -1870,7 +1844,7 @@ function WorkspaceScreenContent({
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreated: handleTerminalCreated,
|
||||
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||
@@ -3260,7 +3234,7 @@ function WorkspaceScreenContent({
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const content = renderWorkspaceContent({
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
activeTabDescriptor,
|
||||
hasHydratedAgents,
|
||||
mountedFocusedPaneTabIds,
|
||||
@@ -3411,11 +3385,13 @@ function WorkspaceScreenContent({
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
{workspaceDirectory ? (
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
@@ -3654,6 +3630,7 @@ function WorkspaceScreenContent({
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
copyPathDisabled={!workspaceDirectory}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import { encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment } from "@/utils/host-routes";
|
||||
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
@@ -136,7 +136,7 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
return `workspace-browser-close-${tab.target.browserId}`;
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
|
||||
selection = normalized;
|
||||
revision += 1;
|
||||
notifyListeners();
|
||||
// workspaceId is opaque; do not parse this persisted selection back into a path.
|
||||
void storage.write(JSON.stringify(normalized)).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
@@ -77,7 +77,7 @@ export function navigateToWorkspace(
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(serverId)).filter(
|
||||
(agent) =>
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
}) === resolvedWorkspaceId,
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceExists,
|
||||
selectWorkspaceExecutionAuthority,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScopeForServer,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type WorkspaceStructure,
|
||||
} from "./selectors";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
|
||||
import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
|
||||
// These are the ONLY supported ways to read workspaces from the session store.
|
||||
@@ -72,14 +71,14 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceExecutionAuthority(
|
||||
export function useWorkspaceDirectory(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceExecutionAuthorityResult | null {
|
||||
): string | null {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId),
|
||||
workspaceEqualityFns.deep,
|
||||
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceExecutionAuthority,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScopeForServer,
|
||||
@@ -142,34 +142,30 @@ describe("selectWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectWorkspaceExecutionAuthority", () => {
|
||||
it("preserves the old missing-workspace message with the requested id", () => {
|
||||
describe("selectWorkspaceDirectory", () => {
|
||||
it("returns the workspace directory, never the opaque workspace id", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "wks_3f9a2b1c",
|
||||
workspaceDirectory: "/Users/dev/project",
|
||||
});
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const directory = selectWorkspaceDirectory(
|
||||
useSessionStore.getState(),
|
||||
SERVER_ID,
|
||||
"wks_3f9a2b1c",
|
||||
);
|
||||
|
||||
expect(directory).toBe("/Users/dev/project");
|
||||
expect(directory).not.toBe("wks_3f9a2b1c");
|
||||
});
|
||||
|
||||
it("returns null when the workspace is missing", () => {
|
||||
initializeWorkspaces([]);
|
||||
|
||||
expect(
|
||||
selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
message: "Workspace not found: missing-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps deep-equal authority references under unrelated workspace updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const tracked = trackSelector(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const before = tracked.current;
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
tracked.stop();
|
||||
selectWorkspaceDirectory(useSessionStore.getState(), SERVER_ID, "missing-id"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
type WorkspaceExecutionAuthorityResult,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
@@ -113,6 +111,14 @@ export function selectWorkspaceFields<T>(
|
||||
return workspace ? project(workspace) : null;
|
||||
}
|
||||
|
||||
export function selectWorkspaceDirectory(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): string | null {
|
||||
return selectWorkspace(state, serverId, workspaceId)?.workspaceDirectory || null;
|
||||
}
|
||||
|
||||
export function selectWorkspaceExists(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
@@ -128,20 +134,6 @@ export function selectHasHydratedWorkspaces(
|
||||
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
|
||||
}
|
||||
|
||||
export function selectWorkspaceExecutionAuthority(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceExecutionAuthorityResult | null {
|
||||
if (serverId === null || workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
return getWorkspaceExecutionAuthority({
|
||||
workspaces: state.sessions[serverId]?.workspaces,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function selectWorkspaceStructureProjects(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
@@ -257,7 +249,7 @@ export function selectResolveWorkspaceIdByCwd(
|
||||
return null;
|
||||
}
|
||||
const workspaces = state.sessions[serverId]?.workspaces;
|
||||
return resolveWorkspaceIdByExecutionDirectory({
|
||||
return resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
|
||||
@@ -99,6 +99,44 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
expect(workspace.scripts).not.toBe(scripts);
|
||||
});
|
||||
|
||||
it("canonicalizes the workspace directory and treats a blank one as empty", () => {
|
||||
const canonical = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/app/",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
});
|
||||
expect(canonical.workspaceDirectory).toBe("/repo/app");
|
||||
|
||||
const blank = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: " ",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
});
|
||||
expect(blank.workspaceDirectory).toBe("");
|
||||
});
|
||||
|
||||
it("defaults missing scripts to an empty array", () => {
|
||||
const payload = {
|
||||
id: "1",
|
||||
|
||||
@@ -27,8 +27,11 @@ import type {
|
||||
ServerCapabilities,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import {
|
||||
normalizeWorkspaceOpaqueId,
|
||||
normalizeWorkspacePath,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import {
|
||||
createAgentLastActivityCoalescer,
|
||||
type AgentLastActivityCommitter,
|
||||
@@ -149,7 +152,10 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
// Canonicalize the workspace directory once, at the store boundary, so every
|
||||
// consumer can read workspace.workspaceDirectory directly. Empty means "no
|
||||
// usable directory" (older daemons may omit it; the wire field is optional).
|
||||
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
name: payload.name,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("workspace route parsing", () => {
|
||||
it("keeps URL-safe workspace IDs unencoded", () => {
|
||||
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
|
||||
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
|
||||
expect(decodeWorkspaceIdFromPathSegment("wks_10b3479c955fcc4c")).toBe("wks_10b3479c955fcc4c");
|
||||
});
|
||||
|
||||
it("encodes non-URL-safe workspace IDs as base64url", () => {
|
||||
|
||||
@@ -100,6 +100,19 @@ function isLegacyPathLikeWorkspaceValue(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function hasLegacyDecodeNoise(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint == null) continue;
|
||||
if (codePoint < 0x20 || codePoint === 0x7f || codePoint === 0xfffd) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCleanLegacyPathDecode(value: string): boolean {
|
||||
return isLegacyPathLikeWorkspaceValue(value) && !hasLegacyDecodeNoise(value);
|
||||
}
|
||||
|
||||
export type WorkspaceOpenIntent =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
@@ -193,13 +206,14 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
|
||||
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
|
||||
}
|
||||
|
||||
// COMPAT(legacyPathWorkspaceId): IDs were path-shaped before v0.1.95. Remove when deep-link floor >= v0.2.0.
|
||||
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
|
||||
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
|
||||
if (base64Decoded && isCleanLegacyPathDecode(base64Decoded)) {
|
||||
return normalizeWorkspaceId(base64Decoded);
|
||||
}
|
||||
|
||||
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
|
||||
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
|
||||
if (relaxedBase64Decoded && isCleanLegacyPathDecode(relaxedBase64Decoded)) {
|
||||
return normalizeWorkspaceId(relaxedBase64Decoded);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
export interface NavigateToAgentInput {
|
||||
@@ -29,7 +29,7 @@ export function resolveNavigateToAgent(
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: agentCwd,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Href } from "expo-router";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||
|
||||
export function buildWorkspaceArchiveRedirectRoute(input: {
|
||||
serverId: string;
|
||||
|
||||
23
packages/app/src/utils/workspace-directory.test.ts
Normal file
23
packages/app/src/utils/workspace-directory.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
|
||||
|
||||
describe("resolveWorkspaceDirectory", () => {
|
||||
it("canonicalizes a workspace directory and returns null when blank", () => {
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
|
||||
"C:/repo/app",
|
||||
);
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: " " })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireWorkspaceDirectory", () => {
|
||||
it("returns the canonical directory when present", () => {
|
||||
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo/app/" })).toBe("/repo/app");
|
||||
});
|
||||
|
||||
it("throws naming the workspace when the directory is missing", () => {
|
||||
expect(() =>
|
||||
requireWorkspaceDirectory({ workspaceId: "wks_1", workspaceDirectory: " " }),
|
||||
).toThrow("Workspace directory is missing for workspace wks_1");
|
||||
});
|
||||
});
|
||||
24
packages/app/src/utils/workspace-directory.ts
Normal file
24
packages/app/src/utils/workspace-directory.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export function resolveWorkspaceDirectory(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspacePath(input.workspaceDirectory);
|
||||
}
|
||||
|
||||
export function requireWorkspaceDirectory(input: {
|
||||
workspaceId?: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string {
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
});
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error(
|
||||
input.workspaceId
|
||||
? `Workspace directory is missing for workspace ${input.workspaceId}`
|
||||
: "Workspace directory is missing.",
|
||||
);
|
||||
}
|
||||
return workspaceDirectory;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceAuthorityResult {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
}
|
||||
|
||||
export type WorkspaceExecutionAuthorityFailureReason =
|
||||
| "workspace_id_missing"
|
||||
| "workspace_missing"
|
||||
| "workspace_directory_missing";
|
||||
|
||||
export type WorkspaceExecutionAuthorityResult =
|
||||
| { ok: true; authority: WorkspaceAuthorityResult }
|
||||
| {
|
||||
ok: false;
|
||||
reason: WorkspaceExecutionAuthorityFailureReason;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function resolveWorkspaceRouteId(input: {
|
||||
routeWorkspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceIdByExecutionDirectory(input: {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaces = input.workspaces;
|
||||
if (!workspaces) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspaces.has(normalizedWorkspaceId)) {
|
||||
return normalizedWorkspaceId;
|
||||
}
|
||||
|
||||
for (const [workspaceKey, workspace] of workspaces) {
|
||||
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
|
||||
return workspaceKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceExecutionAuthorityResult {
|
||||
const workspace =
|
||||
"workspace" in input
|
||||
? input.workspace
|
||||
: (() => {
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: input.workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaces?.get(workspaceKey) ?? null;
|
||||
})();
|
||||
|
||||
if ("workspaces" in input) {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_id_missing",
|
||||
message: "Workspace id is required.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_missing",
|
||||
message:
|
||||
"workspaces" in input
|
||||
? `Workspace not found: ${input.workspaceId ?? ""}`
|
||||
: "Workspace not found.",
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_directory_missing",
|
||||
message: `Workspace directory is missing for workspace ${workspace.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authority: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceDirectory,
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requireWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceAuthorityResult {
|
||||
const result = getWorkspaceExecutionAuthority(input);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
return result.authority;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceExecutionDirectory(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspacePath(input.workspaceDirectory);
|
||||
}
|
||||
|
||||
export function requireWorkspaceExecutionDirectory(input: {
|
||||
workspaceId?: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string {
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
});
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error(
|
||||
input.workspaceId
|
||||
? `Workspace directory is missing for workspace ${input.workspaceId}`
|
||||
: "Workspace directory is missing.",
|
||||
);
|
||||
}
|
||||
return workspaceDirectory;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceAuthorityResult | null {
|
||||
const result = getWorkspaceExecutionAuthority(input);
|
||||
return result.ok ? result.authority : null;
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
requireWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceRouteId,
|
||||
} from "./workspace-execution";
|
||||
} from "./workspace-identity";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
@@ -40,7 +38,7 @@ describe("resolveWorkspaceRouteId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
describe("resolveWorkspaceIdByDirectory", () => {
|
||||
it("matches workspace directories", () => {
|
||||
const workspaces = [
|
||||
createWorkspace({
|
||||
@@ -51,7 +49,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
@@ -68,7 +66,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
@@ -115,66 +113,3 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace execution authority", () => {
|
||||
it("returns an explicit failure when workspace id is missing", () => {
|
||||
expect(
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspaces: new Map(),
|
||||
workspaceId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "workspace_id_missing",
|
||||
message: "Workspace id is required.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an explicit failure when workspace directory is missing", () => {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>([
|
||||
[
|
||||
"workspace-1",
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
workspaceDirectory: " ",
|
||||
projectRootPath: "/repo",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspaces,
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "Workspace directory is missing for workspace workspace-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("never falls back to project root metadata", () => {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>([
|
||||
[
|
||||
"workspace-1",
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
requireWorkspaceExecutionAuthority({
|
||||
workspaces,
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceId: "workspace-1",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
workspace: workspaces.get("workspace-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -22,3 +24,56 @@ export function normalizeWorkspacePath(value: string | null | undefined): string
|
||||
const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, "");
|
||||
return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/";
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRouteId(input: {
|
||||
routeWorkspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
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<WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaces = input.workspaces;
|
||||
if (!workspaces) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspaces.has(normalizedWorkspaceId)) {
|
||||
return normalizedWorkspaceId;
|
||||
}
|
||||
|
||||
for (const [workspaceKey, workspace] of workspaces) {
|
||||
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
|
||||
return workspaceKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
function normalizeWorkspaceId(value: string | null | undefined): string {
|
||||
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
|
||||
return normalizeWorkspacePath(value) ?? "";
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
@@ -32,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
@@ -44,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
|
||||
@@ -55,7 +55,13 @@ export async function runCreateCommand(
|
||||
);
|
||||
}
|
||||
|
||||
const worktreePath = workspace.workspaceDirectory ?? workspace.id;
|
||||
if (!workspace.workspaceDirectory) {
|
||||
throw cmdError(
|
||||
"WORKTREE_CREATE_FAILED",
|
||||
"Failed to create worktree: workspace directory missing from daemon response",
|
||||
);
|
||||
}
|
||||
const worktreePath = workspace.workspaceDirectory;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function subscribeToEvents(
|
||||
await client.connect();
|
||||
|
||||
await client.workspaces.list({
|
||||
filter: { idPrefix: workspaceId },
|
||||
subscribe: { subscriptionId: `workspace-${workspaceId}` },
|
||||
});
|
||||
|
||||
|
||||
@@ -241,7 +241,6 @@ test("workspace handles keep identity and refresh snapshots through existing dri
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "workspace-refetch-request",
|
||||
filter: { idPrefix: "workspace_sdk" },
|
||||
page: { limit: 25 },
|
||||
});
|
||||
|
||||
|
||||
@@ -414,9 +414,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
|
||||
id,
|
||||
latest: () => latest,
|
||||
refetch: async (options) => {
|
||||
// Best-effort: fetches one page and matches by id client-side, so a workspace beyond
|
||||
// the first page won't be found. TODO: add a "get workspace by id" lookup and resolve
|
||||
// by exact id instead of paging.
|
||||
const result = await daemonClient.fetchWorkspaces({
|
||||
requestId: options?.requestId,
|
||||
filter: { idPrefix: id },
|
||||
page: { limit: 25 },
|
||||
});
|
||||
latest = result.entries.find((entry) => entry.id === id) ?? null;
|
||||
|
||||
@@ -948,6 +948,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
|
||||
.object({
|
||||
query: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
// Unused: accepted so older clients still parse, but the server does not filter on it.
|
||||
idPrefix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@@ -1627,6 +1628,7 @@ export const LegacyOpenInEditorRequestSchema = z.object({
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal("open_project_request"),
|
||||
// Path used only for workspace lookup/creation. Use the returned workspace.id for all subsequent references.
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -849,7 +849,6 @@ export class AgentManager {
|
||||
agentId?: string,
|
||||
options?: {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
@@ -867,7 +866,6 @@ export class AgentManager {
|
||||
const session = await client.createSession(launchConfig, launchContext, createOptions);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
workspaceId: options?.workspaceId,
|
||||
initialTitle: options?.initialTitle,
|
||||
});
|
||||
}
|
||||
@@ -2321,7 +2319,6 @@ export class AgentManager {
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
options?: {
|
||||
workspaceId?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
@@ -2369,7 +2366,6 @@ export class AgentManager {
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
|
||||
await this.persistSnapshot(managed, {
|
||||
workspaceId: options?.workspaceId,
|
||||
title: initialPersistedTitle,
|
||||
});
|
||||
if (!options?.publishWhenReady) {
|
||||
@@ -2378,7 +2374,7 @@ export class AgentManager {
|
||||
|
||||
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
|
||||
await this.persistSnapshot(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
this.subscribeToSession(managed);
|
||||
return { ...managed };
|
||||
@@ -2639,7 +2635,7 @@ export class AgentManager {
|
||||
|
||||
private async persistSnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { workspaceId?: string; title?: string | null; internal?: boolean },
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
@@ -2648,10 +2644,6 @@ export class AgentManager {
|
||||
if (agent.internal) {
|
||||
return;
|
||||
}
|
||||
if (options?.workspaceId !== undefined) {
|
||||
await this.registry.applySnapshot(agent, options.workspaceId, options);
|
||||
return;
|
||||
}
|
||||
await this.registry.applySnapshot(agent, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -191,23 +191,19 @@ export class AgentStorage {
|
||||
|
||||
async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
|
||||
await this.load();
|
||||
await this.waitForPendingWrite(agent.id);
|
||||
const existing = (await this.get(agent.id)) ?? null;
|
||||
const hasTitleOverride =
|
||||
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
|
||||
const hasInternalOverride =
|
||||
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
|
||||
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
|
||||
createdAt: existing?.createdAt,
|
||||
internal: hasInternalOverride
|
||||
? nextOptions?.internal
|
||||
: (agent.internal ?? existing?.internal),
|
||||
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
|
||||
});
|
||||
|
||||
// Preserve soft-delete/archive status across snapshot flushes.
|
||||
|
||||
@@ -26,6 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
|
||||
archiveAgentForClose: (agentId: string) => Promise<unknown>;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
emitAgentRemove: (agentId: string) => void;
|
||||
@@ -207,6 +208,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
|
||||
@@ -38,7 +38,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
|
||||
explicitTitle: "Explicit title",
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
resolveWorkspace: async () => ({ workspaceId: "workspace-1" }),
|
||||
});
|
||||
|
||||
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
|
||||
|
||||
@@ -32,10 +32,6 @@ import {
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "../timeline-append.js";
|
||||
|
||||
export interface CreateAgentWorkspace {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentSessionWorktreeResult {
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
@@ -79,7 +75,6 @@ export interface CreateAgentFromSessionInput {
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
) => Promise<CreateAgentSessionWorktreeResult>;
|
||||
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
@@ -134,7 +129,6 @@ interface ResolvedCreateAgent {
|
||||
|
||||
interface AgentCreateOptions {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
initialTitle?: string | null;
|
||||
@@ -196,10 +190,6 @@ async function resolveSessionCreateAgent(
|
||||
input.worktreeName,
|
||||
input.firstAgentContext,
|
||||
);
|
||||
const workspace = await input.resolveWorkspace({
|
||||
cwd: sessionConfig.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||
const clientMessageId = normalizeClientMessageId(input.clientMessageId);
|
||||
@@ -215,7 +205,6 @@ async function resolveSessionCreateAgent(
|
||||
config: sessionConfig,
|
||||
createOptions: {
|
||||
labels: input.labels,
|
||||
workspaceId: workspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
env: input.env,
|
||||
initialTitle: input.provisionalTitle,
|
||||
|
||||
@@ -96,15 +96,16 @@ export async function archiveAgentCommand(
|
||||
agentId: string,
|
||||
): Promise<ArchiveAgentResult> {
|
||||
const liveAgent = dependencies.agentManager.getAgent(agentId);
|
||||
let record: StoredAgentRecord | null;
|
||||
if (liveAgent) {
|
||||
await cancelAgentRunCommand(dependencies, agentId);
|
||||
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
|
||||
await dependencies.agentManager.archiveAgent(agentId);
|
||||
record = await dependencies.agentStorage.get(agentId);
|
||||
} else {
|
||||
await archiveStoredAgent(dependencies, agentId);
|
||||
record = await archiveStoredAgent(dependencies, agentId);
|
||||
}
|
||||
|
||||
const record = await dependencies.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after archive: ${agentId}`);
|
||||
}
|
||||
@@ -174,16 +175,16 @@ export async function setAgentModeCommand(
|
||||
async function archiveStoredAgent(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
): Promise<StoredAgentRecord> {
|
||||
const existing = await dependencies.agentStorage.get(agentId);
|
||||
if (!existing) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
if (existing.archivedAt) {
|
||||
return;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
await dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
|
||||
return dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
|
||||
}
|
||||
|
||||
@@ -1258,7 +1258,7 @@ describe("create_agent MCP tool", () => {
|
||||
baseRefName: "main",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/pr-123",
|
||||
workspaceId: "ws-pr-123",
|
||||
projectId: REPO_CWD,
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
@@ -1395,7 +1395,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
expect(setupContinuations).toEqual([undefined]);
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(broadcasts[0]).toContain("tool-worktree");
|
||||
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1444,6 +1444,7 @@ describe("create_agent MCP tool", () => {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving,
|
||||
@@ -1473,16 +1474,14 @@ describe("create_agent MCP tool", () => {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
|
||||
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
||||
[created.structuredContent.worktreePath],
|
||||
["ws-archive-tool-worktree"],
|
||||
expect.any(String),
|
||||
);
|
||||
expect(clearWorkspaceArchiving).toHaveBeenCalledWith([
|
||||
created.structuredContent.worktreePath,
|
||||
]);
|
||||
expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]);
|
||||
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
|
||||
created.structuredContent.worktreePath,
|
||||
"ws-archive-tool-worktree",
|
||||
]);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
@@ -1528,6 +1527,7 @@ describe("create_agent MCP tool", () => {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
|
||||
archiveWorkspaceRecord: vi.fn(async () => undefined),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface AgentMcpServerOptions {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>;
|
||||
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
|
||||
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||
@@ -2406,6 +2407,9 @@ function archiveWorktreeDependencies(
|
||||
if (!options.archiveWorkspaceRecord) {
|
||||
throw new Error("Workspace registry archiver is required to archive worktrees");
|
||||
}
|
||||
if (!options.resolveWorkspaceIdForCwd) {
|
||||
throw new Error("Workspace resolver is required to archive worktrees");
|
||||
}
|
||||
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
|
||||
throw new Error("Workspace update emitter is required to archive worktrees");
|
||||
}
|
||||
@@ -2422,6 +2426,7 @@ function archiveWorktreeDependencies(
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: context.agentManager,
|
||||
agentStorage: context.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||
|
||||
@@ -93,6 +93,7 @@ function createHarness(overrides?: {
|
||||
agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
|
||||
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
|
||||
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
||||
archiveWorkspaceRecord: vi.fn(),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
clearWorkspaceArchiving: vi.fn(),
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AutoArchiveArchiveOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
@@ -102,6 +103,7 @@ export async function archiveIfSafe(input: {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: options.agentManager,
|
||||
agentStorage: options.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||
|
||||
@@ -109,6 +109,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 { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
|
||||
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
|
||||
@@ -665,6 +666,9 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
});
|
||||
};
|
||||
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
|
||||
};
|
||||
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
@@ -699,6 +703,7 @@ export async function createPaseoDaemon(
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
logger,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
@@ -721,6 +726,7 @@ export async function createPaseoDaemon(
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Logger } from "pino";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
@@ -19,6 +18,7 @@ export interface ArchivePaseoWorktreeDependencies {
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
@@ -57,7 +57,6 @@ export async function archivePaseoWorktree(
|
||||
|
||||
const archivedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
.listAgents()
|
||||
@@ -65,7 +64,6 @@ export async function archivePaseoWorktree(
|
||||
for (const agent of liveAgents) {
|
||||
archivedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
|
||||
}
|
||||
|
||||
let storedRecords: StoredAgentRecord[] = [];
|
||||
@@ -84,10 +82,12 @@ export async function archivePaseoWorktree(
|
||||
for (const record of matchingStoredRecords) {
|
||||
archivedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
}
|
||||
|
||||
const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds);
|
||||
const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds(
|
||||
dependencies,
|
||||
affectedWorkspaceCwds,
|
||||
);
|
||||
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
|
||||
|
||||
try {
|
||||
@@ -176,6 +176,28 @@ export async function archivePaseoWorktree(
|
||||
return Array.from(archivedAgents);
|
||||
}
|
||||
|
||||
async function resolveAffectedWorkspaceIds(
|
||||
dependencies: Pick<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"resolveWorkspaceIdForCwd" | "sessionLogger"
|
||||
>,
|
||||
cwds: Iterable<string>,
|
||||
): Promise<string[]> {
|
||||
const workspaceIds = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
const workspaceId = await dependencies.resolveWorkspaceIdForCwd(cwd);
|
||||
if (!workspaceId) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ cwd },
|
||||
"Skipping workspace archive update for unregistered directory",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
workspaceIds.add(workspaceId);
|
||||
}
|
||||
return Array.from(workspaceIds);
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
|
||||
@@ -34,7 +34,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
displayName: "acme/repo",
|
||||
});
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: repoDir,
|
||||
workspaceId: "ws-main-checkout",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
|
||||
expect(result.workspace.kind).toBe("worktree");
|
||||
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||
expect(result.workspace.displayName).toBe("feature-one");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
@@ -76,7 +77,7 @@ test("registers a new worktree in the existing root project after the main check
|
||||
displayName: "acme/repo",
|
||||
});
|
||||
const existingWorktree = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: path.join(tempDir, "existing-worktree"),
|
||||
workspaceId: "ws-existing-worktree",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: path.join(tempDir, "existing-worktree"),
|
||||
kind: "worktree",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
@@ -195,11 +196,14 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
deps: Pick<
|
||||
CreatePaseoWorktreeDeps,
|
||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
||||
>;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
|
||||
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
|
||||
const normalizedCwd = resolve(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
const existingWorkspace = await findWorkspaceByDirectory(
|
||||
normalizedCwd,
|
||||
options.deps.workspaceRegistry,
|
||||
@@ -211,7 +215,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
existingWorkspace,
|
||||
deps: options.deps,
|
||||
});
|
||||
const workspaceId = normalizedCwd;
|
||||
const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await options.deps.projectRegistry.upsert(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { realpathSync } from "node:fs";
|
||||
import type { FSWatcher } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, resolve, sep } from "path";
|
||||
import { resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type { ToolSet } from "ai";
|
||||
@@ -154,10 +154,10 @@ import {
|
||||
} from "./agent/import-sessions.js";
|
||||
import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||
deriveProjectGroupingName,
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
deriveWorkspaceDisplayName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
@@ -962,6 +962,7 @@ export class Session {
|
||||
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
|
||||
this.createPaseoWorktreeWorkflow(input, workflowOptions),
|
||||
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
emitAgentRemove: (agentId) => {
|
||||
@@ -1607,6 +1608,9 @@ export class Session {
|
||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
|
||||
}
|
||||
|
||||
@@ -1623,14 +1627,14 @@ export class Session {
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<string> {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
if (options?.refreshGit === false) {
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd);
|
||||
return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd);
|
||||
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
|
||||
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
|
||||
return resolve(checkout.worktreeRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
private async buildProjectPlacementForWorkspace(
|
||||
@@ -1661,12 +1665,16 @@ export class Session {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
// An agent can run in a directory that has no registered workspace yet
|
||||
// (e.g. a fresh non-git folder). Synthesize a directory-scoped placement so
|
||||
// the agent still emits updates. projectKey/cwd are paths here — a project
|
||||
// grouping key, not a workspace id — so this stays opaque-id-safe.
|
||||
const resolvedCwd = resolve(cwd);
|
||||
return {
|
||||
projectKey: normalizedCwd,
|
||||
projectName: deriveProjectGroupingName(normalizedCwd),
|
||||
projectKey: resolvedCwd,
|
||||
projectName: deriveProjectGroupingName(resolvedCwd),
|
||||
checkout: {
|
||||
cwd: normalizedCwd,
|
||||
cwd: resolvedCwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
@@ -3169,8 +3177,6 @@ export class Session {
|
||||
firstAgentContext,
|
||||
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
|
||||
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
|
||||
resolveWorkspace: ({ cwd, workspaceId }) =>
|
||||
this.resolveCreateAgentWorkspace(cwd, workspaceId),
|
||||
},
|
||||
);
|
||||
createdAgentId = snapshot.id;
|
||||
@@ -3228,20 +3234,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCreateAgentWorkspace(
|
||||
cwd: string,
|
||||
workspaceId?: string,
|
||||
): Promise<{ workspaceId: string }> {
|
||||
const resolvedWorkspace = workspaceId
|
||||
? await this.workspaceRegistry.get(workspaceId)
|
||||
: ((await this.findWorkspaceByDirectory(cwd)) ??
|
||||
(await this.findOrCreateWorkspaceForDirectory(cwd)));
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
return { workspaceId: resolvedWorkspace.workspaceId };
|
||||
}
|
||||
|
||||
private async handleResumeAgentRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -4845,7 +4837,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
|
||||
if (target) {
|
||||
this.closeWorkspaceGitWatchTarget(target);
|
||||
@@ -4854,7 +4846,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private removeWorkspaceGitSubscription(cwd: string): void {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
|
||||
if (target) {
|
||||
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
|
||||
@@ -4877,11 +4869,20 @@ export class Session {
|
||||
]);
|
||||
}
|
||||
|
||||
private resolveWorkspaceGitWatchTarget(workspaceId: string): WorkspaceGitWatchTarget | null {
|
||||
for (const target of this.workspaceGitWatchTargets.values()) {
|
||||
if (target.workspaceId === workspaceId) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private shouldSkipWorkspaceGitWatchUpdate(
|
||||
workspaceId: string,
|
||||
workspace: WorkspaceDescriptorPayload | null,
|
||||
): boolean {
|
||||
const target = this.workspaceGitWatchTargets.get(workspaceId);
|
||||
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
@@ -4897,7 +4898,7 @@ export class Session {
|
||||
workspaceId: string,
|
||||
workspace: WorkspaceDescriptorPayload | null,
|
||||
): void {
|
||||
const target = this.workspaceGitWatchTargets.get(workspaceId);
|
||||
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
@@ -4906,7 +4907,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
|
||||
const target = this.workspaceGitWatchTargets.get(resolve(cwd));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
@@ -4934,7 +4935,7 @@ export class Session {
|
||||
cwd: string,
|
||||
options: { isGit: boolean; workspaceId: string },
|
||||
): void {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
if (!options.isGit) {
|
||||
this.removeWorkspaceGitSubscription(normalizedCwd);
|
||||
return;
|
||||
@@ -5792,6 +5793,7 @@ export class Session {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
@@ -6179,7 +6181,7 @@ export class Session {
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < pairs.length; i += 1) {
|
||||
placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]);
|
||||
placementsByCwd.set(resolve(pairs[i].workspace.cwd), placements[i]);
|
||||
}
|
||||
|
||||
return placementsByCwd;
|
||||
@@ -6248,17 +6250,14 @@ export class Session {
|
||||
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
|
||||
if (activePlacementsByCwd) {
|
||||
agents = agents.filter(
|
||||
(agent) =>
|
||||
!agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)),
|
||||
(agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)),
|
||||
);
|
||||
}
|
||||
|
||||
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
|
||||
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
|
||||
if (activePlacementsByCwd) {
|
||||
return Promise.resolve(
|
||||
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
|
||||
);
|
||||
return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null);
|
||||
}
|
||||
const existing = placementByCwd.get(cwd);
|
||||
if (existing) {
|
||||
@@ -6509,10 +6508,15 @@ export class Session {
|
||||
private resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string {
|
||||
): string | null {
|
||||
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
private matchesWorkspaceFilter(input: {
|
||||
workspace: WorkspaceDescriptorPayload;
|
||||
filter: FetchWorkspacesRequestFilter | undefined;
|
||||
@@ -6600,7 +6604,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const inputCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const inputCwd = resolve(cwd);
|
||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
||||
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
|
||||
refreshGit: false,
|
||||
@@ -6608,22 +6612,26 @@ export class Session {
|
||||
if (existingWorkspace) {
|
||||
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const displayName = basename(inputCwd) || inputCwd;
|
||||
const projectRecord = createPersistedProjectRecord({
|
||||
projectId: inputCwd,
|
||||
rootPath: inputCwd,
|
||||
kind: "non_git",
|
||||
displayName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
|
||||
const projectRecord = await this.resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await this.projectRegistry.upsert(projectRecord);
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: inputCwd,
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: inputCwd,
|
||||
kind: "directory",
|
||||
displayName,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
@@ -6652,7 +6660,7 @@ export class Session {
|
||||
await this.projectRegistry.upsert(projectRecord);
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: membership.workspaceId,
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd,
|
||||
kind: membership.workspaceKind,
|
||||
@@ -6681,7 +6689,6 @@ export class Session {
|
||||
const displayName = membership.workspaceDisplayName;
|
||||
|
||||
if (
|
||||
input.workspace.workspaceId === membership.workspaceId &&
|
||||
input.workspace.projectId === projectId &&
|
||||
input.workspace.kind === kind &&
|
||||
input.workspace.displayName === displayName
|
||||
@@ -6693,7 +6700,7 @@ export class Session {
|
||||
|
||||
const nextWorkspace = {
|
||||
...input.workspace,
|
||||
workspaceId: membership.workspaceId,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
projectId,
|
||||
cwd: input.cwd,
|
||||
kind,
|
||||
@@ -6797,7 +6804,10 @@ export class Session {
|
||||
projectRegistry: this.projectRegistry,
|
||||
});
|
||||
if (!existingWorkspace) {
|
||||
this.removeWorkspaceGitSubscription(workspaceId);
|
||||
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||
if (watchTarget) {
|
||||
this.removeWorkspaceGitSubscription(watchTarget.cwd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6817,9 +6827,22 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
|
||||
this.removeWorkspaceGitSubscription(workspaceId);
|
||||
await this.teardownArchivedWorkspace({
|
||||
workspaceId: existingWorkspace.workspaceId,
|
||||
cwd: existingWorkspace.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
// Git watch and subscription state is keyed by directory; the script runtime
|
||||
// store is keyed by the opaque workspace id. Each cleanup uses its own key so an
|
||||
// opaque id is never resolved as a filesystem path.
|
||||
private async teardownArchivedWorkspace(input: {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
await this.removeWorkspaceGitWatchTarget(input.cwd);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
|
||||
this.removeWorkspaceGitSubscription(input.cwd);
|
||||
}
|
||||
|
||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||
@@ -6854,9 +6877,10 @@ export class Session {
|
||||
result.changesApplied.map(async (change) => {
|
||||
switch (change.kind) {
|
||||
case "workspace_archived":
|
||||
await this.removeWorkspaceGitWatchTarget(change.directory);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(change.directory);
|
||||
this.removeWorkspaceGitSubscription(change.workspaceId);
|
||||
await this.teardownArchivedWorkspace({
|
||||
workspaceId: change.workspaceId,
|
||||
cwd: change.directory,
|
||||
});
|
||||
changedWorkspaceIds.add(change.workspaceId);
|
||||
break;
|
||||
case "workspace_updated":
|
||||
@@ -6912,7 +6936,7 @@ export class Session {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
|
||||
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||
if (watchTarget && this.onBranchChanged) {
|
||||
const newBranchName = nextWorkspace?.name ?? null;
|
||||
if (newBranchName !== watchTarget.lastBranchName) {
|
||||
@@ -6961,6 +6985,9 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
|
||||
}
|
||||
|
||||
@@ -7583,10 +7610,10 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
|
||||
}
|
||||
|
||||
const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd);
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
const clearableAgentIds = agents
|
||||
.filter((agent) => !agent.archivedAt)
|
||||
.filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd)
|
||||
.filter((agent) => resolve(agent.cwd) === workspaceCwd)
|
||||
.filter((agent) => agent.requiresAttention === true)
|
||||
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
|
||||
.filter((agent) => agent.attentionReason !== "permission")
|
||||
|
||||
@@ -495,6 +495,66 @@ describe("workspace git watch targets", () => {
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("archiving a workspace clears its script runtime entries by opaque workspace id", async () => {
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
runtimeStore.set({
|
||||
workspaceId: "ws-10",
|
||||
scriptName: "app",
|
||||
type: "service",
|
||||
lifecycle: "running",
|
||||
terminalId: "term-app",
|
||||
exitCode: null,
|
||||
});
|
||||
|
||||
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests({
|
||||
scriptRuntimeStore: runtimeStore,
|
||||
});
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
name: "main",
|
||||
});
|
||||
|
||||
await asInternals<{ archiveWorkspaceRecord: (workspaceId: string) => Promise<void> }>(
|
||||
session,
|
||||
).archiveWorkspaceRecord("ws-10");
|
||||
|
||||
expect(runtimeStore.listForWorkspace("ws-10")).toEqual([]);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("archiving a workspace releases its git watch subscription for the directory", async () => {
|
||||
const { session, projects, workspaces, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = asInternals<
|
||||
SessionInternals & { archiveWorkspaceRecord: (workspaceId: string) => Promise<void> }
|
||||
>(session);
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||
expect(subscriptions).toHaveLength(1);
|
||||
|
||||
await sessionAny.archiveWorkspaceRecord("ws-10");
|
||||
|
||||
// Re-observing the directory establishes a fresh subscription only if archive
|
||||
// tore down the prior one, which is keyed by cwd — not the opaque workspace id.
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||
expect(subscriptions).toHaveLength(2);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
||||
const { session, emitted, projects, workspaces, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
|
||||
@@ -204,7 +204,7 @@ const PARENT_CHILD = path.join(PARENT, "child");
|
||||
|
||||
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: rootPath,
|
||||
workspaceId: `ws-${path.basename(rootPath) || "root"}`,
|
||||
projectId: rootPath,
|
||||
cwd: rootPath,
|
||||
kind: "local_checkout",
|
||||
@@ -217,7 +217,7 @@ function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
|
||||
|
||||
function dirWorkspace(cwd: string, archivedAt: string | null = null) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: cwd,
|
||||
workspaceId: `ws-${path.basename(cwd) || "root"}`,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
@@ -252,6 +252,17 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceByCwd(
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>,
|
||||
cwd: string,
|
||||
): PersistedWorkspaceRecord | null {
|
||||
return Array.from(workspaces.values()).find((workspace) => workspace.cwd === cwd) ?? null;
|
||||
}
|
||||
|
||||
function hasWorkspaceCwd(workspaces: Map<string, PersistedWorkspaceRecord>, cwd: string): boolean {
|
||||
return workspaceByCwd(workspaces, cwd) !== null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S1. Open a fresh git repo: creates a workspace at the canonical root.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -262,7 +273,7 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
expect(h.workspaces.has(FOO)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -276,7 +287,7 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
expect(h.workspaces.has(BAR)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -291,9 +302,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
||||
});
|
||||
await openProject(h.session, FOO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe(FOO);
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
|
||||
expect(workspaceByCwd(h.workspaces, FOO)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -308,7 +319,7 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
|
||||
});
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe(FOO);
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
});
|
||||
|
||||
@@ -324,8 +335,8 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
||||
await openProject(h.session, BAR_BAZ);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
|
||||
expect(h.workspaces.has(BAR)).toBe(true);
|
||||
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, BAR_BAZ)).toBe(true);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
@@ -341,7 +352,7 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
@@ -357,8 +368,8 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(h.workspaces.has(FOO)).toBe(true);
|
||||
expect(h.workspaces.has(FOO_SUB)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
|
||||
expect(hasWorkspaceCwd(h.workspaces, FOO_SUB)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -373,8 +384,8 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
|
||||
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
|
||||
});
|
||||
await openProject(h.session, USERS_PROJECT);
|
||||
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
|
||||
expect(workspaceByCwd(h.workspaces, USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
|
||||
expect(hasWorkspaceCwd(h.workspaces, USERS_PROJECT)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -390,7 +401,7 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -414,8 +425,8 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
|
||||
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||
expect(hasWorkspaceCwd(h.workspaces, SOME_GIT_REPO)).toBe(true);
|
||||
expect(workspaceByCwd(h.workspaces, PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
@@ -434,11 +445,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
|
||||
await openProject(h.session, TOOLBOX);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.id).toBe(TOOLBOX);
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -490,8 +490,29 @@ function createSessionForWorkspaceTests(
|
||||
notifyAgentState: () => {},
|
||||
}),
|
||||
agentStorage: asAgentStorage({
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
list: async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
get: async (workspaceId: string) =>
|
||||
workspaceId === "ws-repo-running"
|
||||
? createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
})
|
||||
: null,
|
||||
upsert: async () => {},
|
||||
}),
|
||||
projectRegistry: {
|
||||
@@ -506,8 +527,29 @@ function createSessionForWorkspaceTests(
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
list: async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
get: async (workspaceId: string) =>
|
||||
workspaceId === "ws-repo-running"
|
||||
? createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
})
|
||||
: null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -853,10 +895,10 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("agent_update emits fallback placement when no workspace is registered", async () => {
|
||||
test("agent_update emits a directory-scoped placement when no workspace is registered", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const getSnapshot = vi.fn(async () => {
|
||||
throw new Error("getSnapshot should not be called for fallback agent_update placement");
|
||||
throw new Error("getSnapshot should not be called for unregistered agent_update placement");
|
||||
});
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({
|
||||
@@ -886,17 +928,15 @@ test("agent_update emits fallback placement when no workspace is registered", as
|
||||
);
|
||||
|
||||
expect(getSnapshot).not.toHaveBeenCalled();
|
||||
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectKey: UNREGISTERED_CWD,
|
||||
projectName: "unregistered",
|
||||
checkout: {
|
||||
cwd: UNREGISTERED_CWD,
|
||||
isGit: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
// An agent in a directory with no registered workspace must still emit an
|
||||
// upsert (live model/thinking switches depend on this). It gets a directory-
|
||||
// scoped placement keyed by the path — a project key, not a workspace id.
|
||||
const update = emitted.find((message) => message.type === "agent_update");
|
||||
if (update?.type !== "agent_update" || update.payload.kind !== "upsert") {
|
||||
throw new Error("expected an agent_update upsert");
|
||||
}
|
||||
expect(update.payload.agent.id).toBe("agent-1");
|
||||
expect(update.payload.project?.checkout.isGit).toBe(false);
|
||||
});
|
||||
|
||||
test("archive emits an authoritative agent_update upsert for subscribed clients", async () => {
|
||||
@@ -1158,7 +1198,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspaces = [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo-a",
|
||||
workspaceId: "ws-repo-a",
|
||||
projectId: "/tmp/repo-a",
|
||||
cwd: "/tmp/repo-a",
|
||||
kind: "directory",
|
||||
@@ -1167,7 +1207,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
}),
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo-b",
|
||||
workspaceId: "ws-repo-b",
|
||||
projectId: "/tmp/repo-b",
|
||||
cwd: "/tmp/repo-b",
|
||||
kind: "directory",
|
||||
@@ -2570,8 +2610,29 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
list: async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
get: async (workspaceId: string) =>
|
||||
workspaceId === "ws-repo-running"
|
||||
? createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
})
|
||||
: null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -2619,7 +2680,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
REPO_CWD,
|
||||
"ws-repo-running",
|
||||
{
|
||||
id: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
@@ -2638,7 +2699,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
REPO_CWD,
|
||||
"ws-repo-running",
|
||||
{
|
||||
id: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
@@ -2736,8 +2797,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
const workspaces = new Map();
|
||||
const projects = new Map();
|
||||
session.paseoHome = paseoHome;
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
@@ -2775,7 +2836,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
name: "worktree-123",
|
||||
status: "done",
|
||||
});
|
||||
expect(response?.payload.workspace?.id).toContain(path.join("worktree-123"));
|
||||
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
|
||||
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
|
||||
expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true);
|
||||
});
|
||||
@@ -2875,8 +2937,8 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -2904,10 +2966,13 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
requestId: "req-open",
|
||||
});
|
||||
|
||||
expect(workspaces.get(REPO_CWD)).toBeTruthy();
|
||||
const registeredWorkspace = Array.from(workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === REPO_CWD,
|
||||
);
|
||||
expect(registeredWorkspace).toBeTruthy();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
|
||||
expect(response?.payload.workspace?.id).toBe(registeredWorkspace?.workspaceId);
|
||||
});
|
||||
|
||||
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
|
||||
@@ -2927,8 +2992,8 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -2971,15 +3036,22 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
session.buildWorkspaceDescriptorMap = async () => {
|
||||
const workspace = Array.from(workspaces.values()).find(
|
||||
(candidate) => candidate.cwd === importedCwd,
|
||||
);
|
||||
if (!workspace) {
|
||||
return new Map();
|
||||
}
|
||||
return new Map([
|
||||
[
|
||||
importedCwd,
|
||||
workspace.workspaceId,
|
||||
{
|
||||
id: importedCwd,
|
||||
projectId: importedCwd,
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: "imported-project",
|
||||
projectRootPath: importedCwd,
|
||||
workspaceDirectory: importedCwd,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "imported-project",
|
||||
@@ -2988,6 +3060,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "import_agent_request",
|
||||
@@ -2997,12 +3070,17 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
|
||||
cwd: importedCwd,
|
||||
});
|
||||
|
||||
expect(workspaces.get(importedCwd)).toBeTruthy();
|
||||
const importedWorkspace = Array.from(workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === importedCwd,
|
||||
);
|
||||
expect(importedWorkspace).toBeTruthy();
|
||||
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
||||
expect(workspaceUpdates.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
workspaceUpdates.some(
|
||||
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
|
||||
(update) =>
|
||||
update.payload.kind === "upsert" &&
|
||||
update.payload.workspace.workspaceDirectory === importedCwd,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -3023,8 +3101,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3062,7 +3140,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
|
||||
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(cwd);
|
||||
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toBe(cwd);
|
||||
expect(response?.payload.workspace?.gitRuntime).toBeUndefined();
|
||||
expect(response?.payload.workspace?.githubRuntime).toBeUndefined();
|
||||
|
||||
@@ -3089,8 +3168,8 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3143,7 +3222,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
||||
.map((update) => update.payload)
|
||||
.filter(
|
||||
(payload): payload is WorkspaceUpsertPayload =>
|
||||
payload.kind === "upsert" && payload.workspace.id === cwd,
|
||||
payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd,
|
||||
)
|
||||
.find((payload) => payload.workspace.githubRuntime?.pullRequest);
|
||||
expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual(
|
||||
@@ -3155,6 +3234,7 @@ interface WorkspaceUpsertPayload {
|
||||
kind: "upsert";
|
||||
workspace: {
|
||||
id: string;
|
||||
workspaceDirectory?: string;
|
||||
githubRuntime?: {
|
||||
pullRequest?: { url?: string } | null;
|
||||
} | null;
|
||||
@@ -3202,8 +3282,8 @@ test("open_project_request does not match a new child directory to an existing p
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3220,9 +3300,11 @@ test("open_project_request does not match a new child directory to an existing p
|
||||
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(worktree);
|
||||
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
|
||||
expect(workspaces.get(worktree)).toBeTruthy();
|
||||
expect(Array.from(workspaces.values()).some((workspace) => workspace.cwd === worktree)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => {
|
||||
@@ -3269,8 +3351,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3287,7 +3369,7 @@ test("open_project_request does not unarchive an archived parent workspace for a
|
||||
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(worktree);
|
||||
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
|
||||
expect(workspaces.get(home)?.archivedAt).toBe(archivedAt);
|
||||
expect(projects.get(home)?.archivedAt).toBe(archivedAt);
|
||||
@@ -3308,6 +3390,7 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
);
|
||||
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
||||
const archivedAt = "2026-04-24T09:48:36.168Z";
|
||||
const workspaceId = "ws-desktop-daemon-settings";
|
||||
|
||||
projects.set(
|
||||
cwd,
|
||||
@@ -3322,9 +3405,9 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
cwd,
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
@@ -3344,8 +3427,8 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3386,9 +3469,9 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(projects.get(remoteProjectId)?.kind).toBe("git");
|
||||
expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId);
|
||||
expect(workspaces.get(cwd)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
});
|
||||
|
||||
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
|
||||
@@ -3427,10 +3510,12 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
updatedAt: "2026-04-24T09:40:00.000Z",
|
||||
}),
|
||||
);
|
||||
const workspaceId = "ws-desktop-daemon-settings-active";
|
||||
const repoWorkspaceId = "ws-paseo-main";
|
||||
workspaces.set(
|
||||
cwd,
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
@@ -3440,9 +3525,9 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
repoRoot,
|
||||
repoWorkspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: repoRoot,
|
||||
workspaceId: repoWorkspaceId,
|
||||
projectId: repoRoot,
|
||||
cwd: repoRoot,
|
||||
kind: "local_checkout",
|
||||
@@ -3461,8 +3546,8 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3502,9 +3587,9 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
|
||||
expect(workspaces.get(cwd)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
});
|
||||
|
||||
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
|
||||
@@ -3554,8 +3639,8 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3595,8 +3680,11 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
|
||||
expect(workspaces.get(cwd)?.kind).toBe("worktree");
|
||||
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === cwd,
|
||||
);
|
||||
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
|
||||
expect(worktreeWorkspace?.kind).toBe("worktree");
|
||||
});
|
||||
|
||||
test("open_project_request unarchives an existing archived workspace and project", async () => {
|
||||
@@ -3606,6 +3694,7 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
const cwd = REPO_CWD;
|
||||
const workspaceId = "ws-repo-archived";
|
||||
projects.set(
|
||||
cwd,
|
||||
createPersistedProjectRecord({
|
||||
@@ -3619,9 +3708,9 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
cwd,
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
@@ -3641,8 +3730,8 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3657,11 +3746,11 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
requestId: "req-open-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(cwd);
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
});
|
||||
|
||||
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||
@@ -3681,8 +3770,8 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
@@ -3842,8 +3931,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
|
||||
if (!existing) return;
|
||||
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
@@ -3948,8 +4037,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
|
||||
if (!existing) return;
|
||||
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
@@ -4044,8 +4133,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
@@ -5040,7 +5129,7 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
|
||||
const home = homedir();
|
||||
const childCwd = path.join(home, "projects/new-app");
|
||||
const homeWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: home,
|
||||
workspaceId: "ws-home",
|
||||
projectId: "proj-home",
|
||||
cwd: home,
|
||||
kind: "directory",
|
||||
@@ -5049,6 +5138,6 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBe(childCwd);
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe(home);
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { homedir } from "node:os";
|
||||
import { sep } from "node:path";
|
||||
import { resolve, sep } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
|
||||
const FETCH_WORKSPACES_SORT_KEYS = [
|
||||
"status_priority",
|
||||
@@ -52,6 +51,33 @@ type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"
|
||||
|
||||
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
|
||||
|
||||
export function resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string | null {
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd);
|
||||
if (exact) {
|
||||
return exact.workspaceId;
|
||||
}
|
||||
|
||||
const userHome = homedir();
|
||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||
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 (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
|
||||
bestMatch = workspace;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.workspaceId ?? null;
|
||||
}
|
||||
|
||||
export interface WorkspaceDirectoryDeps {
|
||||
logger: pino.Logger;
|
||||
projectRegistry: {
|
||||
@@ -176,9 +202,7 @@ export class WorkspaceDirectory {
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||
const workspaceIdsByDirectory = new Map(
|
||||
activeRecords.map(
|
||||
(workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
|
||||
),
|
||||
activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const),
|
||||
);
|
||||
|
||||
const includedWorkspaces = activeRecords.filter(
|
||||
@@ -228,7 +252,7 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd));
|
||||
const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd));
|
||||
if (workspaceId === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -252,7 +276,7 @@ export class WorkspaceDirectory {
|
||||
(agent) =>
|
||||
!agent.archivedAt &&
|
||||
this.deps.isProviderVisibleToClient(agent.provider) &&
|
||||
workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId,
|
||||
workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId,
|
||||
);
|
||||
const result = this.resolveStatusEnteredAt({
|
||||
workspaceId,
|
||||
@@ -366,28 +390,11 @@ export class WorkspaceDirectory {
|
||||
return candidates.at(-1) ?? null;
|
||||
}
|
||||
|
||||
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
||||
if (exact) {
|
||||
return exact.workspaceId;
|
||||
}
|
||||
|
||||
const userHome = homedir();
|
||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||
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 (!normalizedCwd.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
|
||||
bestMatch = workspace;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.workspaceId ?? normalizedCwd;
|
||||
resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string | null {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
@@ -415,12 +422,6 @@ export class WorkspaceDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
|
||||
if (!workspace.id.startsWith(filter.idPrefix.trim())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.query && filter.query.trim().length > 0) {
|
||||
const query = filter.query.trim().toLocaleLowerCase();
|
||||
const haystacks = [workspace.name, workspace.projectId, workspace.id];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import pLimit from "p-limit";
|
||||
import type pino from "pino";
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "./workspace-git-metadata.js";
|
||||
import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
@@ -401,7 +401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
params: { cwd: string },
|
||||
listener: WorkspaceGitListener,
|
||||
): WorkspaceGitSubscription {
|
||||
const cwd = normalizeWorkspaceId(params.cwd);
|
||||
const cwd = resolve(params.cwd);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
target.listeners.add(listener);
|
||||
if (target.listeners.size === 1) {
|
||||
@@ -432,7 +432,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
cwd: string,
|
||||
options?: WorkspaceGitSnapshotOptions,
|
||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
cwd = resolve(cwd);
|
||||
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
if (!request.force && target.latestSnapshot) {
|
||||
@@ -443,7 +443,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
try {
|
||||
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -481,7 +481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
cwd = resolve(cwd);
|
||||
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
options: CheckoutDiffCompare,
|
||||
readOptions?: WorkspaceGitReadOptions,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
|
||||
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
|
||||
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
|
||||
@@ -530,7 +530,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
ref: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitBranchValidationResult> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const normalizedRef = ref.trim();
|
||||
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
|
||||
return this.readAuxiliaryCache(this.branchValidationCache, key, options, () =>
|
||||
@@ -539,7 +539,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const normalizedBranch = branch.trim();
|
||||
const ref = `refs/heads/${normalizedBranch}`;
|
||||
const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
|
||||
@@ -558,7 +558,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
options?: WorkspaceGitBranchSuggestionsOptions,
|
||||
readOptions?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitBranchSuggestion[]> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const query = options?.query ?? "";
|
||||
const limit = options?.limit;
|
||||
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
|
||||
@@ -572,7 +572,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
options?: WorkspaceGitStashListOptions,
|
||||
readOptions?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitStashEntry[]> {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const paseoOnly = options?.paseoOnly !== false;
|
||||
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
|
||||
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
|
||||
@@ -606,15 +606,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
return snapshot.git.isPaseoOwnedWorktree
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd))
|
||||
: (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd));
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd))
|
||||
: (snapshot.git.repoRoot ?? resolve(cwd));
|
||||
}
|
||||
|
||||
async resolveDefaultBranch(
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<string> {
|
||||
const cwd = normalizeWorkspaceId(cwdOrRepoRoot);
|
||||
const cwd = resolve(cwdOrRepoRoot);
|
||||
const key = JSON.stringify(["default-branch", cwd]);
|
||||
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
|
||||
const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
|
||||
@@ -630,10 +630,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||
): Promise<WorkspaceGitMetadata> {
|
||||
const snapshot = await this.getSnapshot(cwd, options);
|
||||
const directoryName =
|
||||
options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd;
|
||||
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: normalizeWorkspaceId(cwd),
|
||||
cwd: resolve(cwd),
|
||||
directoryName,
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
@@ -652,7 +651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
cwd = resolve(cwd);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
await this.refreshWorkspaceTarget(target, {
|
||||
force: false,
|
||||
@@ -667,7 +666,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
cwd: string,
|
||||
onChange: () => void,
|
||||
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
cwd = resolve(cwd);
|
||||
const target = await this.ensureWorkingTreeWatchTarget(cwd);
|
||||
target.listeners.add(onChange);
|
||||
|
||||
@@ -680,7 +679,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): void {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
cwd = resolve(cwd);
|
||||
const target = this.workspaceTargets.get(cwd);
|
||||
if (target) {
|
||||
this.scheduleWorkspaceRefresh(target);
|
||||
@@ -688,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const target = this.workspaceTargets.get(normalizedCwd);
|
||||
if (!target || target.closed) {
|
||||
return;
|
||||
@@ -1120,7 +1119,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
): void {
|
||||
const target =
|
||||
typeof targetOrCwd === "string"
|
||||
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
|
||||
? this.workspaceTargets.get(resolve(targetOrCwd))
|
||||
: targetOrCwd;
|
||||
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ProjectRegistry,
|
||||
@@ -7,7 +8,6 @@ import type {
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
@@ -220,7 +220,7 @@ export class WorkspaceReconciliationService {
|
||||
if (project.kind !== "git") {
|
||||
continue;
|
||||
}
|
||||
const rootKey = normalizeWorkspaceId(project.rootPath);
|
||||
const rootKey = resolve(project.rootPath);
|
||||
const group = projectsByRoot.get(rootKey) ?? [];
|
||||
group.push(project);
|
||||
projectsByRoot.set(rootKey, group);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user