mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Treat every added folder as an independent project (#2098)
* fix(projects): detect when projects become Git repositories Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces. * fix(projects): preserve metadata across Git read failures Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata. * fix(projects): close project update races * fix(projects): refresh checkout metadata when reopening folders Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors. * test(projects): accept Windows aliases for repaired roots * test(server): retry transient Windows cleanup locks * fix(projects): propagate project metadata refreshes Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records. * fix(projects): refresh worktree source project kind * fix(projects): preserve exact-folder runtime isolation * fix(projects): preserve exact cwd across worktree lifecycle * fix(projects): harden exact-folder worktree flows * fix(projects): derive exact cwd from matched path identity * fix(projects): preserve nested worktree lifecycle * refactor(projects): centralize workspace placement * test(e2e): verify isolated server ports * fix(projects): preserve placement reshape compatibility * fix(projects): validate created worktree placement * fix(projects): preserve placement through workspace lifecycle Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation. * fix(worktrees): preserve source checkout root Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed. * fix(worktrees): compare filesystem identities Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity. * fix(worktrees): centralize path containment Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check. * fix(worktrees): remove duplicate ownership discovery * test(e2e): isolate daemon restart ownership * fix(worktrees): make lifecycle operations transactional * fix(workspaces): share git watches by cwd * test(worktrees): make teardown proof portable * fix(sync): integrate project updates with directory owner * fix(workspaces): close reconciliation edge cases * refactor(sync): remove duplicate project reconciliation Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation. * fix(sync): buffer project updates before hydration Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
This commit is contained in:
@@ -54,6 +54,14 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
for empty projects and without connected clients, then fans metadata changes through the WebSocket
|
||||
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -89,7 +97,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.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, 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/`
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Data Model
|
||||
|
||||
## Project identity
|
||||
|
||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||
|
||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
||||
service instead of constructing records independently. The workspace record is then the durable
|
||||
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
|
||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
@@ -422,19 +446,22 @@ Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
||||
workspace `projectId` is authoritative when it names an active project, regardless of cwd
|
||||
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
|
||||
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
|
||||
workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
@@ -444,21 +471,25 @@ emptied duplicate.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| 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` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| 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; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **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.
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
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.
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" 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.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **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`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
|
||||
@@ -177,6 +177,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
export interface WaitForServerOptions {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
@@ -22,6 +22,8 @@ interface WaitForServerOptions {
|
||||
getRecentOutput?: () => string;
|
||||
}
|
||||
|
||||
type ServerProbe = (host: string, port: number) => Promise<void>;
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -75,7 +77,25 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
async function connectToServer(host: string, port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
port: number,
|
||||
options: WaitForServerOptions,
|
||||
probe: ServerProbe = connectToServer,
|
||||
): Promise<void> {
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
@@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
await probe(host, port);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
@@ -116,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
);
|
||||
}
|
||||
|
||||
async function probeMetro(host: string, port: number): Promise<void> {
|
||||
const response = await fetch(`http://${host}:${port}/status`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
const body = (await response.text()).trim();
|
||||
if (response.status !== 200 || body !== "packager-status:running") {
|
||||
throw new Error(
|
||||
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
await waitForServer(port, options, probeMetro);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
@@ -556,13 +582,19 @@ async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<nu
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
interface RelayPorts {
|
||||
relayPort: number;
|
||||
inspectorPort: number;
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<RelayPorts> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -576,6 +608,10 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--inspector-ip",
|
||||
"127.0.0.1",
|
||||
"--inspector-port",
|
||||
String(inspectorPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
@@ -590,7 +626,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
|
||||
try {
|
||||
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||
return relayPort;
|
||||
return { relayPort, inspectorPort };
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
@@ -767,12 +803,19 @@ export default async function globalSetup() {
|
||||
await logSpeechHarnessConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro({
|
||||
metroPort,
|
||||
daemonPort: port,
|
||||
buffer: metroLineBuffer,
|
||||
});
|
||||
await waitForMetro(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
});
|
||||
|
||||
const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort]));
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
@@ -783,19 +826,11 @@ export default async function globalSetup() {
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
await waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
});
|
||||
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
@@ -809,7 +844,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
|
||||
@@ -26,13 +26,14 @@ interface E2EDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||
function resolveDaemonWsUrl(port?: number): string {
|
||||
return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
): Promise<ClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||
const client = new DaemonClient({
|
||||
url: resolveDaemonWsUrl(),
|
||||
url: resolveDaemonWsUrl(options.port),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -85,43 +86,61 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
child = await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
},
|
||||
|
||||
@@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
export async function connectNewWorkspaceDaemonClient(options?: {
|
||||
port?: number;
|
||||
}): Promise<NewWorkspaceDaemonClient> {
|
||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||
clientIdPrefix: "app-e2e-new-workspace",
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -158,10 +158,11 @@ export interface SeedDaemonClient {
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||
export async function connectSeedClient(options?: { port?: number }): Promise<SeedDaemonClient> {
|
||||
return connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
|
||||
@@ -46,24 +46,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -96,21 +97,24 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: "test-owner/test-repo",
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { restartTestDaemon } from "./helpers/daemon-restart";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -11,11 +12,12 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore after daemon restart", () => {
|
||||
const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
let daemon: IsolatedHostDaemon;
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
@@ -25,8 +27,9 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
daemon = await startIsolatedHostDaemon(serverId);
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
tempRepo = await createTempGitRepo("wt-restart-");
|
||||
});
|
||||
|
||||
@@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedBrowser(page: Page) {
|
||||
const nowIso = new Date().toISOString();
|
||||
await page.addInitScript(
|
||||
({ host, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{
|
||||
host: buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
||||
// worktree workspace is displayed under the same name. These are the values
|
||||
// the History table cells must show after restore — never "main".
|
||||
@@ -76,14 +99,16 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
||||
// Restart this spec's daemon on the same home and port so it rebuilds all
|
||||
// workspace/agent links from persisted state without replacing the shared
|
||||
// Playwright daemon owned by global setup.
|
||||
await client.close().catch(() => undefined);
|
||||
await worktreeClient.close().catch(() => undefined);
|
||||
await restartTestDaemon();
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
await daemon.restart();
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
|
||||
await seedBrowser(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
|
||||
@@ -249,7 +249,7 @@ test.describe("Worktree restore", () => {
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
"The source repository needed to restore this worktree no longer exists.",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
addProjectMethodEmptyText,
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("hides every mutating method when the host lacks stable project identity", () => {
|
||||
const outdatedHost = { ...HOST, canAddProject: false };
|
||||
|
||||
expect(buildAddProjectMethods(outdatedHost)).toEqual([]);
|
||||
expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project.");
|
||||
});
|
||||
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
|
||||
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
|
||||
}
|
||||
|
||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||
if (!host.canAddProject) return [];
|
||||
const options: AddProjectMethodOption[] = [];
|
||||
if (host.canAddProject) {
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
if (host.canBrowse) {
|
||||
options.push({
|
||||
id: "browse",
|
||||
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
|
||||
return options;
|
||||
}
|
||||
|
||||
export function addProjectMethodEmptyText(host: AddProjectHost | null): string {
|
||||
return host?.canAddProject === false
|
||||
? "Update the host to use Add Project."
|
||||
: "No matching options";
|
||||
}
|
||||
|
||||
function githubMethodDescription(host: AddProjectHost): string {
|
||||
if (!host.canCloneGithubRepositories) {
|
||||
return "Update this host to clone GitHub repositories";
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@/add-project-flow/model";
|
||||
import {
|
||||
buildAddProjectMethods,
|
||||
addProjectMethodEmptyText,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
buildSuggestedParentDirectories,
|
||||
@@ -161,9 +162,10 @@ function progressText(page: AddProjectPage): string {
|
||||
return "Adding project...";
|
||||
}
|
||||
|
||||
function emptyText(page: AddProjectPage): string {
|
||||
function emptyText(page: AddProjectPage, host: AddProjectHost | null): string {
|
||||
if (page.kind === "host") return "No connected hosts";
|
||||
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
|
||||
if (page.kind === "method") return addProjectMethodEmptyText(host);
|
||||
return "No matching options";
|
||||
}
|
||||
|
||||
@@ -297,6 +299,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity");
|
||||
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
|
||||
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
|
||||
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
|
||||
@@ -308,7 +312,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
() =>
|
||||
hosts.flatMap((host) => {
|
||||
if (connectionStatuses.get(host.serverId) !== "online") return [];
|
||||
const canAddProject = projectAddByHost.get(host.serverId) === true;
|
||||
const canAddProject =
|
||||
projectAddByHost.get(host.serverId) === true &&
|
||||
stableProjectIdentityByHost.get(host.serverId) === true;
|
||||
return [
|
||||
{
|
||||
serverId: host.serverId,
|
||||
@@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
hosts,
|
||||
localServerId,
|
||||
projectAddByHost,
|
||||
stableProjectIdentityByHost,
|
||||
],
|
||||
);
|
||||
const [state, setState] = useState(() =>
|
||||
@@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
rows.length === 0 &&
|
||||
page.kind !== "new-directory-name" ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-empty">
|
||||
{emptyText(page)}
|
||||
{emptyText(page, host ?? null)}
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
65
packages/app/src/e2e-metro-readiness.test.ts
Normal file
65
packages/app/src/e2e-metro-readiness.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { waitForMetro } from "../e2e/global-setup";
|
||||
|
||||
class MetroPort {
|
||||
private response = { status: 500, body: "fallback" };
|
||||
|
||||
private constructor(
|
||||
readonly port: number,
|
||||
private readonly server: Server,
|
||||
) {}
|
||||
|
||||
static async listen(): Promise<MetroPort> {
|
||||
let endpoint!: MetroPort;
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(endpoint.response.status, { "content-type": "text/plain" });
|
||||
response.end(endpoint.response.body);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("Failed to listen for Metro readiness test");
|
||||
}
|
||||
endpoint = new MetroPort(address.port, server);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
serveMetro(): void {
|
||||
this.response = { status: 200, body: "packager-status:running" };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint: MetroPort | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await endpoint?.close();
|
||||
endpoint = null;
|
||||
});
|
||||
|
||||
test("Metro readiness rejects another HTTP listener on the selected port", async () => {
|
||||
endpoint = await MetroPort.listen();
|
||||
|
||||
await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).rejects.toThrow(
|
||||
"Expected Metro status",
|
||||
);
|
||||
|
||||
endpoint.serveMetro();
|
||||
await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export function useOpenProject(
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const canAddProject = useSessionStore((state) =>
|
||||
normalizedServerId
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true &&
|
||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||
: false,
|
||||
);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
||||
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
fetchWorkspacesCalls = 0;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
>();
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
on<TType extends SessionOutboundMessage["type"]>(
|
||||
type: TType,
|
||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
||||
): () => void {
|
||||
const handlers = this.handlers.get(type) ?? new Set();
|
||||
const registered = handler as unknown as (message: SessionOutboundMessage) => void;
|
||||
handlers.add(registered);
|
||||
this.handlers.set(type, handlers);
|
||||
return () => handlers.delete(registered);
|
||||
}
|
||||
|
||||
emit<TType extends SessionOutboundMessage["type"]>(
|
||||
message: Extract<SessionOutboundMessage, { type: TType }>,
|
||||
): void {
|
||||
for (const handler of this.handlers.get(message.type) ?? []) handler(message);
|
||||
}
|
||||
|
||||
holdWorkspaceFetch(): (result: WorkspaceFetchResult) => void {
|
||||
let complete!: (result: WorkspaceFetchResult) => void;
|
||||
this.pendingWorkspaceFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
@@ -20,8 +49,13 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
async fetchWorkspaces(): Promise<Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>> {
|
||||
async fetchWorkspaces(): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
if (this.pendingWorkspaceFetch) {
|
||||
const pending = this.pendingWorkspaceFetch;
|
||||
this.pendingWorkspaceFetch = null;
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
@@ -110,4 +144,114 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(client.fetchAgentsCalls).toBe(1);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("buffers workspace and project updates in the same hydration transaction", async () => {
|
||||
const serverId = "workspace-project-transaction";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
const completeFetch = client.holdWorkspaceFetch();
|
||||
|
||||
const refresh = directory.refreshWorkspaces({ subscribe: true });
|
||||
await Promise.resolve();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "remove",
|
||||
id: "removed-workspace",
|
||||
emptyProject: {
|
||||
projectId: "workspace-project",
|
||||
projectDisplayName: "Project from workspace update",
|
||||
projectRootPath: "/repo/workspace-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "project.update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Renamed during hydration",
|
||||
projectRootPath: "/moved/snapshot-project",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Stale snapshot project",
|
||||
projectRootPath: "/repo/snapshot-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
});
|
||||
await refresh;
|
||||
|
||||
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
|
||||
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
|
||||
"snapshot-project",
|
||||
"workspace-project",
|
||||
]);
|
||||
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
|
||||
projectDisplayName: "Renamed during hydration",
|
||||
projectRootPath: "/moved/snapshot-project",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(emptyProjects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("buffers project updates from the online epoch before workspace hydration starts", async () => {
|
||||
const serverId = "project-before-workspace-hydration";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
|
||||
client.emit({
|
||||
type: "project.update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "early-project",
|
||||
projectDisplayName: "Early project",
|
||||
projectRootPath: "/repo/early-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(false);
|
||||
|
||||
await directory.refreshWorkspaces({ subscribe: true });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
|
||||
).toMatchObject({
|
||||
projectDisplayName: "Early project",
|
||||
projectRootPath: "/repo/early-project",
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,10 @@ export class DirectorySync {
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
const client = connection.client;
|
||||
const source = connection.source;
|
||||
this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
}));
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
||||
@@ -119,6 +123,12 @@ export class DirectorySync {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("project.update", (message) => {
|
||||
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.workspaceTransactions.record(source, message.payload)) {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("agent_deleted", (message) => {
|
||||
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
||||
this.agents.remove(message.payload.agentId);
|
||||
|
||||
@@ -55,3 +55,91 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
const serverId = "project-update-replica";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached"));
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||
const staleAttachedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Stale attached project",
|
||||
projectRootPath: "/repo/attached",
|
||||
projectKind: "git",
|
||||
});
|
||||
const removedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "removed",
|
||||
projectDisplayName: "Removed project",
|
||||
projectRootPath: "/repo/removed",
|
||||
projectKind: "git",
|
||||
});
|
||||
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "unchanged-empty",
|
||||
projectDisplayName: "Unchanged empty project",
|
||||
projectRootPath: "/repo/unchanged-empty",
|
||||
projectKind: "git",
|
||||
});
|
||||
|
||||
replica.commitSnapshot(
|
||||
{
|
||||
workspaces: new Map([
|
||||
[attachedMain.id, attachedMain],
|
||||
[attachedFeature.id, attachedFeature],
|
||||
[removed.id, removed],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
emptyProjects: new Map([
|
||||
[staleAttachedProject.projectId, staleAttachedProject],
|
||||
[removedProject.projectId, removedProject],
|
||||
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
||||
]),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "new-empty",
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{ kind: "remove", projectId: "removed" },
|
||||
],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectRootPath: "/moved/attached",
|
||||
});
|
||||
expect(session?.workspaces.has(removed.id)).toBe(false);
|
||||
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
|
||||
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
});
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -14,20 +14,50 @@ import {
|
||||
|
||||
export type WorkspaceDirectoryDelta = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
{ type: "workspace_update" | "project.update" }
|
||||
>["payload"];
|
||||
type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
}
|
||||
|
||||
function applyProjectDelta(
|
||||
snapshot: WorkspaceDirectorySnapshot,
|
||||
delta: ProjectDirectoryDelta,
|
||||
): void {
|
||||
if (delta.kind === "remove") {
|
||||
snapshot.emptyProjects.delete(delta.projectId);
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const project = normalizeEmptyProjectDescriptor(delta.project);
|
||||
let hasAttachedWorkspace = false;
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId !== project.projectId) continue;
|
||||
hasAttachedWorkspace = true;
|
||||
snapshot.workspaces.set(workspaceId, {
|
||||
...workspace,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
});
|
||||
}
|
||||
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
|
||||
else snapshot.emptyProjects.set(project.projectId, project);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryReplica {
|
||||
constructor(private readonly serverId: string) {}
|
||||
|
||||
applyDelta(delta: WorkspaceDirectoryDelta): void {
|
||||
const state = this.reconcile(this.read(), [delta]);
|
||||
this.commit(state, delta.kind === "remove" ? [delta.id] : []);
|
||||
this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []);
|
||||
}
|
||||
|
||||
commitSnapshot(
|
||||
@@ -35,9 +65,10 @@ export class WorkspaceDirectoryReplica {
|
||||
deltas: readonly WorkspaceDirectoryDelta[],
|
||||
): void {
|
||||
const removedWorkspaceIds = deltas.flatMap((delta) =>
|
||||
delta.kind === "remove" ? [delta.id] : [],
|
||||
delta.kind === "remove" && "id" in delta ? [delta.id] : [],
|
||||
);
|
||||
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||
}
|
||||
|
||||
private read(): WorkspaceDirectorySnapshot {
|
||||
@@ -60,6 +91,10 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
}
|
||||
for (const delta of deltas) {
|
||||
if ("projectId" in delta || "project" in delta) {
|
||||
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
||||
continue;
|
||||
}
|
||||
if (delta.kind === "remove") {
|
||||
workspaces.delete(delta.id);
|
||||
if (delta.emptyProject) {
|
||||
@@ -84,7 +119,6 @@ export class WorkspaceDirectoryReplica {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||
|
||||
@@ -17,6 +17,7 @@ function createWorkspace(
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectCustomName: input.projectCustomName ?? null,
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
|
||||
@@ -320,6 +320,8 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -368,6 +370,9 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -489,6 +494,13 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -567,6 +579,7 @@ function createInitialSessionState(
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1347,6 +1360,54 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1360,10 +1421,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1383,6 +1452,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1601,3 +1671,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export function useWorkspaceRestoreStatus(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceRestoreStatus | null {
|
||||
return useSessionStore((state) =>
|
||||
serverId && workspaceId
|
||||
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,6 +646,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
custom_mode_icons: true,
|
||||
project_updates: true,
|
||||
provider_subagents: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
@@ -657,6 +658,32 @@ test("advertises client capabilities in hello", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows callers to disable default client capabilities", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_capability_override_test",
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.projectUpdates]: false,
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ preserveSent: true });
|
||||
await connectPromise;
|
||||
|
||||
const hello = z
|
||||
.object({
|
||||
type: z.literal("hello"),
|
||||
capabilities: z.record(z.unknown()),
|
||||
})
|
||||
.parse(JSON.parse(assertStr(mock.sent[0])));
|
||||
expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false);
|
||||
});
|
||||
|
||||
test("sends new-agent run options when creating schedules", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -249,6 +249,10 @@ export type DaemonEvent =
|
||||
workspaceId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "project.update";
|
||||
payload: Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "workspace_setup_progress";
|
||||
workspaceId: string;
|
||||
@@ -5111,6 +5115,7 @@ export class DaemonClient {
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
...this.config.capabilities,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
@@ -5581,6 +5586,8 @@ export class DaemonClient {
|
||||
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "project.update":
|
||||
return { type: "project.update", payload: msg.payload };
|
||||
case "workspace_setup_progress":
|
||||
return {
|
||||
type: "workspace_setup_progress",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const CLIENT_CAPS = {
|
||||
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
||||
// child descriptors and timelines only to clients that understand the new messages.
|
||||
providerSubagents: "provider_subagents",
|
||||
// COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15.
|
||||
projectUpdates: "project_updates",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2687,6 +2687,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
forgeProviders: z.boolean().optional(),
|
||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||
selectiveAgentTimeline: z.boolean().optional(),
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3135,6 +3137,14 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const ProjectUpdateMessageSchema = z.object({
|
||||
type: z.literal("project.update"),
|
||||
payload: z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }),
|
||||
z.object({ kind: z.literal("remove"), projectId: z.string() }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("script_status_update"),
|
||||
payload: z.object({
|
||||
@@ -4847,6 +4857,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArtifactMessageSchema,
|
||||
AgentUpdateMessageSchema,
|
||||
WorkspaceUpdateMessageSchema,
|
||||
ProjectUpdateMessageSchema,
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
@@ -5409,6 +5420,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.projectUpdates]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -3,11 +3,7 @@ import type pino from "pino";
|
||||
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
type ActiveWorkspaceRef,
|
||||
resolveWorkspaceIdAtPath,
|
||||
} from "../workspace-archive-service.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
@@ -42,6 +38,10 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
type AutoArchiveTarget =
|
||||
| { kind: "agent-only" }
|
||||
| { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult };
|
||||
|
||||
export class CreateAgentLifecycleDispatch {
|
||||
private readonly autoArchiveAgentIds = new Set<string>();
|
||||
|
||||
@@ -72,10 +72,10 @@ export class CreateAgentLifecycleDispatch {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerAutoArchiveOnTerminalState(input.agentId, {
|
||||
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
|
||||
repoRoot: input.createdWorktree?.repoRoot ?? null,
|
||||
});
|
||||
this.registerAutoArchiveOnTerminalState(
|
||||
input.agentId,
|
||||
toAutoArchiveTarget(input.createdWorktree),
|
||||
);
|
||||
}
|
||||
|
||||
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
|
||||
@@ -89,8 +89,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId: null,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
createdWorktree,
|
||||
}).catch((archiveError) => {
|
||||
this.dependencies.logger.warn(
|
||||
{
|
||||
@@ -143,10 +142,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
private registerAutoArchiveOnTerminalState(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): void {
|
||||
private registerAutoArchiveOnTerminalState(agentId: string, target: AutoArchiveTarget): void {
|
||||
const unsubscribe = this.dependencies.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream") {
|
||||
@@ -160,27 +156,23 @@ export class CreateAgentLifecycleDispatch {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
void this.autoArchiveAgentOnce(agentId, options);
|
||||
void this.autoArchiveAgentOnce(agentId, target);
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
}
|
||||
|
||||
private async autoArchiveAgentOnce(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): Promise<void> {
|
||||
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
|
||||
if (this.autoArchiveAgentIds.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
this.autoArchiveAgentIds.add(agentId);
|
||||
|
||||
try {
|
||||
if (options.worktreePath) {
|
||||
if (target.kind === "created-worktree") {
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId,
|
||||
worktreePath: options.worktreePath,
|
||||
repoRoot: options.repoRoot,
|
||||
createdWorktree: target.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -193,10 +185,11 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
private async archiveAutoCreatedWorktree(options: {
|
||||
agentId: string | null;
|
||||
worktreePath: string;
|
||||
repoRoot: string | null;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult;
|
||||
}): Promise<void> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
|
||||
const { createdWorktree } = options;
|
||||
const worktreePath = createdWorktree.worktree.worktreePath;
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, {
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
});
|
||||
@@ -204,49 +197,39 @@ export class CreateAgentLifecycleDispatch {
|
||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceIdAtPath(
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
options.worktreePath,
|
||||
);
|
||||
|
||||
if (!workspaceId) {
|
||||
this.dependencies.logger.warn(
|
||||
{ worktreePath: options.worktreePath },
|
||||
"Could not resolve workspace for auto-archive; skipping",
|
||||
);
|
||||
} else {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds:
|
||||
this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.agentId) {
|
||||
this.dependencies.emitAgentRemove(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAutoArchiveTarget(
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null,
|
||||
): AutoArchiveTarget {
|
||||
return createdWorktree
|
||||
? { kind: "created-worktree", result: createdWorktree }
|
||||
: { kind: "agent-only" };
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
|
||||
// worktree service).
|
||||
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
||||
const worktreePath = join(args.repoRoot, "worktree");
|
||||
mkdirSync(worktreePath, { recursive: true });
|
||||
const workspaceCwd = join(worktreePath, "packages", "app");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
||||
({
|
||||
worktree: { worktreePath },
|
||||
intent: {},
|
||||
workspace: { workspaceId: args.createdWorkspaceId },
|
||||
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
@@ -321,6 +322,7 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -573,7 +573,7 @@ async function resolveMcpCwd(params: {
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolvedCwd: createdWorktree.worktree.worktreePath,
|
||||
resolvedCwd: createdWorktree.workspace.cwd,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
};
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from "../workspace-registry.js";
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
@@ -46,11 +49,13 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
|
||||
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
@@ -670,13 +675,68 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
paseoHome: options.paseoHome,
|
||||
deps: { forgeOverrides: { github } },
|
||||
});
|
||||
const workspaceRegistry = {
|
||||
const projectRegistry: ProjectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) =>
|
||||
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_test_${projects.size + 1}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
archive: async (projectId, archivedAt) => {
|
||||
const project = projects.get(projectId);
|
||||
if (project) projects.set(projectId, { ...project, archivedAt });
|
||||
},
|
||||
remove: async (projectId) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
};
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
const updated = updater(workspace);
|
||||
workspaces.set(workspaceId, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId, archivedAt) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
||||
},
|
||||
remove: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
};
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
||||
workspaceRegistry,
|
||||
@@ -710,14 +770,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
},
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
}),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
|
||||
@@ -486,8 +486,6 @@ describe("archiveIfSafe", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
|
||||
repoRoot: "/tmp/repo",
|
||||
paseoWorktreesBaseRoot: undefined,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -138,8 +138,6 @@ export async function archiveIfSafe(input: {
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -18,7 +19,9 @@ describe("bootstrap provider availability", () => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
tempRoots
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,12 +29,13 @@ describe("bootstrap provider availability", () => {
|
||||
const { createPaseoDaemon } = await import("./bootstrap.js");
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
||||
tempRoots.push(root);
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
||||
tempRoots.push(binDir);
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split(/\r?\n/)[0]
|
||||
.trim();
|
||||
process.env.PATH = path.dirname(gitPath);
|
||||
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
const staticDir = path.join(root, "static");
|
||||
const agentStoragePath = path.join(paseoHome, "agents");
|
||||
|
||||
56
packages/server/src/server/bootstrap.test.ts
Normal file
56
packages/server/src/server/bootstrap.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js";
|
||||
|
||||
test("reconciliation emits workspace updates when observer sync fails", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
const syncFailure = new Error("workspace observer unavailable");
|
||||
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: [
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {
|
||||
throw syncFailure;
|
||||
},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
|
||||
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceIds: ["ws-reclassified"],
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
});
|
||||
|
||||
test("reconciliation isolates workspace update failures between sessions", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
const warnings: unknown[] = [];
|
||||
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: [
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async () => {
|
||||
throw new Error("session closed");
|
||||
},
|
||||
},
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
|
||||
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceIds: ["ws-reclassified"],
|
||||
logger: {
|
||||
warn: (context) => {
|
||||
warnings.push(context);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
@@ -89,12 +89,42 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
||||
return listenTarget.path;
|
||||
}
|
||||
|
||||
export async function fanOutReconciledWorkspaceUpdates(input: {
|
||||
sessions: Iterable<{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void>;
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options: { skipReconcile: boolean },
|
||||
): Promise<void>;
|
||||
}>;
|
||||
workspaceIds: readonly string[];
|
||||
logger: Pick<Logger, "warn">;
|
||||
}): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(input.sessions, async (session) => {
|
||||
try {
|
||||
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(input.workspaceIds);
|
||||
} catch (error) {
|
||||
input.logger.warn(
|
||||
{ err: error },
|
||||
"Failed to sync workspace Git observers after reconciliation",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createRegisteredPaseoWorktree,
|
||||
createLocalCheckoutWorkspace,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
@@ -744,6 +774,12 @@ export async function createPaseoDaemon(
|
||||
forgeOverrides: { github },
|
||||
},
|
||||
});
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({
|
||||
logger: providerSnapshotLogger,
|
||||
@@ -788,21 +824,19 @@ export async function createPaseoDaemon(
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
workspaceGitService,
|
||||
onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update),
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: wsServer?.listActiveSessions() ?? [],
|
||||
workspaceIds,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
await workspaceReconciliation.start();
|
||||
void workspaceReconciliation.runOnce().catch((error) => {
|
||||
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await workspaceReconciliation.runOnce();
|
||||
logger.info(
|
||||
{
|
||||
elapsed: elapsed(),
|
||||
changeCount: result.changesApplied.length,
|
||||
},
|
||||
"Workspace registries reconciled",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Background workspace reconciliation failed");
|
||||
}
|
||||
})();
|
||||
await chatService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
@@ -833,9 +867,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
): Promise<string> => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
resolveFirstAgentPromptTitle(firstAgentContext),
|
||||
);
|
||||
if (firstAgentContext) {
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
@@ -854,6 +888,9 @@ export async function createPaseoDaemon(
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
kind: workspace.kind,
|
||||
worktreeRoot: workspace.worktreeRoot,
|
||||
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
}));
|
||||
};
|
||||
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
@@ -942,9 +979,8 @@ export async function createPaseoDaemon(
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
});
|
||||
},
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
@@ -1003,9 +1039,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
);
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1026,7 +1062,7 @@ export async function createPaseoDaemon(
|
||||
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
|
||||
return result;
|
||||
};
|
||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
|
||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string) => {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -1053,8 +1089,6 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot,
|
||||
paseoWorktreesBaseRoot: config.worktreesRoot,
|
||||
requestId: "schedule-run-finish",
|
||||
},
|
||||
);
|
||||
@@ -1065,7 +1099,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
@@ -1444,6 +1478,7 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
|
||||
@@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(added.project).not.toBeNull();
|
||||
const project = added.project!;
|
||||
expect(project).toMatchObject({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
});
|
||||
expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
|
||||
const workspaces = await client.fetchWorkspaces({
|
||||
filter: { projectId: project.projectId },
|
||||
@@ -73,7 +73,6 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
expect(workspaces.emptyProjects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
}),
|
||||
|
||||
@@ -29,7 +29,7 @@ afterEach(async () => {
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("openProject reclassifies an existing directory workspace into its parent git project", async () => {
|
||||
test("openProject preserves a worktree's exact-root project without rehoming it", async () => {
|
||||
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||
process.env.PASEO_SUPERVISED = "0";
|
||||
try {
|
||||
@@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g
|
||||
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
|
||||
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(response.workspace?.projectId).toBe(worktreeRoot);
|
||||
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
|
||||
repoRoot,
|
||||
);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
|
||||
).toBe(repoRoot);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind,
|
||||
).toBe("worktree");
|
||||
).toBe(worktreeRoot);
|
||||
} finally {
|
||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { type PersistedProjectRecord } from "../workspace-registry.js";
|
||||
|
||||
const cleanupPaths = new Set<string>();
|
||||
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
||||
const cleanupClients = new Set<DaemonClient>();
|
||||
const cleanupListeners = new Set<() => void>();
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
type ProjectUpdatePayload = Extract<DaemonEvent, { type: "project.update" }>["payload"];
|
||||
|
||||
function waitForProjectUpdate(
|
||||
client: DaemonClient,
|
||||
predicate: (payload: ProjectUpdatePayload) => boolean,
|
||||
): Promise<ProjectUpdatePayload> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = client.on("project.update", (message) => {
|
||||
if (!predicate(message.payload)) return;
|
||||
cleanupListeners.delete(unsubscribe);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
cleanupListeners.add(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const unsubscribe of cleanupListeners) unsubscribe();
|
||||
cleanupListeners.clear();
|
||||
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
||||
cleanupClients.clear();
|
||||
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
|
||||
cleanupDaemons.clear();
|
||||
await Promise.all(
|
||||
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
|
||||
);
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("an empty project becomes Git without changing its identity or creating a workspace", async () => {
|
||||
const projectRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-")),
|
||||
);
|
||||
const paseoHomeRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-home-")),
|
||||
);
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } });
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
projectDisplayName: path.basename(projectRoot),
|
||||
projectCustomName: null,
|
||||
projectRootPath: projectRoot,
|
||||
projectKind: "non_git",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === project.projectId &&
|
||||
payload.project.projectRootPath === projectRoot &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
const update = await withTimeout({
|
||||
promise: gitProjectUpdate,
|
||||
timeoutMs: 10_000,
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
await readFile(path.join(daemon.paseoHome, "projects", "projects.json"), "utf8"),
|
||||
) as PersistedProjectRecord[];
|
||||
expect(persistedProjects).toContainEqual({
|
||||
projectId: project.projectId,
|
||||
rootPath: projectRoot,
|
||||
kind: "git",
|
||||
displayName: project.projectDisplayName,
|
||||
customName: null,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
});
|
||||
}, 30_000);
|
||||
@@ -32,7 +32,7 @@ import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
@@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: firstAgentContext?.prompt ?? null },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
firstAgentContext?.prompt ?? null,
|
||||
);
|
||||
return workspace.workspaceId;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
@@ -10,17 +10,19 @@ import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -69,10 +71,307 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.workspace.displayName).toBe("feature-one");
|
||||
expect(result.workspace.baseBranch).toBe("main");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"project:remote:github.com/acme/repo",
|
||||
`workspace:${result.workspace.workspaceId}`,
|
||||
]);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||
});
|
||||
|
||||
test("refreshes a source project that became Git while creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const sourceProject = {
|
||||
...createPersistedProjectRecordForTest({
|
||||
projectId: "prj_existing-source",
|
||||
rootPath: repoDir,
|
||||
displayName: "Repository",
|
||||
}),
|
||||
kind: "non_git" as const,
|
||||
customName: "My project",
|
||||
};
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-main-checkout",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
});
|
||||
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "project-became-git",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(sourceProject.projectId);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toMatchObject({
|
||||
projectId: sourceProject.projectId,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "Repository",
|
||||
customName: "My project",
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("repairs a legacy source workspace whose project record is missing", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-missing-project",
|
||||
projectId: "project-missing",
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
});
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "repaired-source",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId);
|
||||
const repairedProject = deps.projects.get(result.workspace.projectId);
|
||||
expect(repairedProject).toMatchObject({
|
||||
projectId: result.workspace.projectId,
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses an equivalent source workspace path when creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "app");
|
||||
mkdirSync(sourceDir);
|
||||
writeFileSync(path.join(repoDir, "app", ".gitkeep"), "");
|
||||
commitAll(repoDir, "add app");
|
||||
const deps = createDeps();
|
||||
const sourceProject = createPersistedProjectRecordForTest({
|
||||
projectId: "prj_source-folder",
|
||||
rootPath: sourceDir,
|
||||
displayName: "app",
|
||||
});
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-source-folder",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: `${sourceDir}${path.sep}`,
|
||||
kind: "local_checkout",
|
||||
displayName: "app",
|
||||
});
|
||||
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "equivalent-source",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(sourceProject.projectId);
|
||||
});
|
||||
|
||||
test("creates a worktree workspace at the selected project subdirectory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const project = createPersistedProjectRecordForTest({
|
||||
projectId: "prj_selected-subdirectory",
|
||||
rootPath: sourceDir,
|
||||
displayName: "app",
|
||||
});
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "selected-subdirectory",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace).toMatchObject({
|
||||
projectId: project.projectId,
|
||||
cwd: path.join(result.worktree.worktreePath, "packages", "app"),
|
||||
worktreeRoot: result.worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
});
|
||||
});
|
||||
|
||||
test("seeds an uncommitted exact-project config into the mapped worktree directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const config = JSON.stringify({ worktree: { setup: ["npm install"] } });
|
||||
writeFileSync(path.join(sourceDir, "paseo.json"), config);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "seed-nested-config",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(config);
|
||||
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
execFileSync("git", ["branch", "without-subproject"], { cwd: repoDir, stdio: "pipe" });
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(
|
||||
await getPaseoWorktreesRoot(repoDir, paseoHome),
|
||||
"missing-subproject",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
action: "checkout",
|
||||
refName: "without-subproject",
|
||||
worktreeSlug: "missing-subproject",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("Selected project directory is missing from the worktree");
|
||||
|
||||
expect(deps.workspaces.size).toBe(0);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.includes("missing-subproject"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when workspace persistence fails", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(
|
||||
await getPaseoWorktreesRoot(repoDir, paseoHome),
|
||||
"persistence-failure",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: "missing-project",
|
||||
worktreeSlug: "persistence-failure",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
createDeps(),
|
||||
),
|
||||
).rejects.toThrow("Unknown project: missing-project");
|
||||
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.includes("persistence-failure"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const projectDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(path.join(projectDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const source = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "source-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app");
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
worktreeSlug: "nested-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app"));
|
||||
expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace);
|
||||
});
|
||||
|
||||
test("rejects source checkout planning before creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const deps = createDeps();
|
||||
deps.workspaceGitService.getCheckout = async () => {
|
||||
throw new Error("source checkout unavailable");
|
||||
};
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "must-not-create",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("source checkout unavailable");
|
||||
|
||||
expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false);
|
||||
expect(Array.from(deps.workspaces.values())).toEqual([]);
|
||||
});
|
||||
|
||||
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
|
||||
@@ -109,6 +408,35 @@ test("registers a new worktree in the existing root project after the main check
|
||||
expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]);
|
||||
});
|
||||
|
||||
test("an explicit project FK remains unchanged when its worktree comes from another checkout", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const project = {
|
||||
...createPersistedProjectRecordForTest({
|
||||
projectId: "prj_explicitproject",
|
||||
rootPath: path.join(tempDir, "unrelated"),
|
||||
displayName: "unrelated",
|
||||
}),
|
||||
kind: "non_git" as const,
|
||||
};
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "attached-worktree",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(project.projectId);
|
||||
expect(deps.projects.get(project.projectId)).toEqual(project);
|
||||
});
|
||||
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"reuses an existing worktree and still upserts the workspace",
|
||||
@@ -152,19 +480,6 @@ test.skipIf(isPlatform("win32"))(
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a distinct local checkout workspace for the same cwd on every call", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
|
||||
const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
|
||||
expect(first.cwd).toBe(second.cwd);
|
||||
expect(first.workspaceId).not.toBe(second.workspaceId);
|
||||
expect(deps.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -679,7 +994,6 @@ test.skipIf(isPlatform("win32"))(
|
||||
);
|
||||
|
||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
projects: Map<string, PersistedProjectRecord>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
}
|
||||
@@ -692,28 +1006,73 @@ function createDeps(options?: {
|
||||
const events = options?.events ?? [];
|
||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
||||
const projectRegistry: ProjectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath),
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecordForTest({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: input.rootPath,
|
||||
displayName: input.displayName,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (project) => {
|
||||
projects.set(project.projectId, project);
|
||||
},
|
||||
archive: async (projectId, archivedAt) => {
|
||||
const project = projects.get(projectId);
|
||||
if (project) projects.set(projectId, { ...project, archivedAt });
|
||||
},
|
||||
remove: async (projectId) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
};
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
const updated = updater(workspace);
|
||||
workspaces.set(workspaceId, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
events.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId, archivedAt) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
||||
},
|
||||
remove: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
};
|
||||
const workspaceGitService = createWorkspaceGitServiceStub();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
return {
|
||||
github: createGitHubServiceStub(),
|
||||
projects,
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
list: async () => Array.from(projects.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
},
|
||||
workspaceRegistry: {
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
},
|
||||
workspaceGitService: createWorkspaceGitServiceStub(),
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -808,15 +1167,30 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
getCheckout: async (cwd) => ({
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
getCheckout: async (cwd) => {
|
||||
try {
|
||||
const snapshot = createWorkspaceGitSnapshot(cwd);
|
||||
return {
|
||||
cwd,
|
||||
isGit: snapshot.git.isGit,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
worktreeRoot: snapshot.git.repoRoot,
|
||||
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
resolveForge: async () => null,
|
||||
resolveRepoRoot: async (cwd) => {
|
||||
@@ -902,6 +1276,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function commitAll(repoDir: string, message: string): void {
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", message], { cwd: repoDir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { getRealpathAwareRelativePath } from "../utils/path.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import {
|
||||
mapWorkspaceRelativeCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
seedPaseoConfigFile,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
@@ -56,36 +55,89 @@ export interface AttemptFirstAgentBranchAutoNameResult {
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
|
||||
}
|
||||
|
||||
export async function createPaseoWorktree(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
deps,
|
||||
});
|
||||
try {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
if (!(await isDirectory(workspaceCwd))) {
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
|
||||
}
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
if (createdWorktree.created) {
|
||||
await seedPaseoConfigFile({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
targetCwd: workspaceCwd,
|
||||
});
|
||||
}
|
||||
const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
cwd: workspaceCwd,
|
||||
worktreeRoot: createdWorktree.worktree.worktreePath,
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
});
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!createdWorktree.created) {
|
||||
throw error;
|
||||
}
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: createdWorktree.repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
...(input.runSetup === false ? { teardownCwds: [] } : {}),
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesBaseRoot: input.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirectory(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(targetPath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function planWorkspaceCwdForWorktree(
|
||||
inputCwd: string,
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
|
||||
): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> {
|
||||
const normalizedInputCwd = resolve(inputCwd);
|
||||
const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd);
|
||||
const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd;
|
||||
const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd);
|
||||
if (relativeWorkspaceCwd === null) {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`);
|
||||
}
|
||||
return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd };
|
||||
}
|
||||
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
@@ -213,257 +265,3 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
baseBranch?: string | null;
|
||||
title?: string | null;
|
||||
deps: Pick<
|
||||
CreatePaseoWorktreeDeps,
|
||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
||||
>;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
// Creation never deduplicates by directory: a worktree directory may back
|
||||
// more than one workspace. We still resolve the source project from the
|
||||
// originating checkout, but always mint a fresh workspace record.
|
||||
const sourceProject = await resolveSourceProjectForWorktree({
|
||||
inputCwd: normalizedInputCwd,
|
||||
projectId: options.projectId,
|
||||
repoRoot: normalizedRepoRoot,
|
||||
existingWorkspace: null,
|
||||
deps: options.deps,
|
||||
});
|
||||
const workspaceId = generateWorkspaceId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await options.deps.projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: sourceProject.projectId,
|
||||
rootPath: sourceProject.rootPath,
|
||||
kind: sourceProject.kind,
|
||||
displayName: sourceProject.displayName,
|
||||
customName: sourceProject.customName,
|
||||
createdAt: sourceProject.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
branch: options.worktree.branchName || null,
|
||||
baseBranch: options.baseBranch ?? null,
|
||||
title: options.title ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await options.deps.workspaceRegistry.upsert(workspace);
|
||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||
}
|
||||
|
||||
export interface CreateLocalCheckoutWorkspaceDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list" | "upsert">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
|
||||
}
|
||||
|
||||
// Always create a NEW workspace record backed by the existing directory `cwd`.
|
||||
// Never reuses a same-cwd record: a directory may back any number of
|
||||
// workspaces. Used by explicit user creation.
|
||||
export async function createLocalCheckoutWorkspace(
|
||||
options: { cwd: string; title?: string | null },
|
||||
deps: CreateLocalCheckoutWorkspaceDeps,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.cwd);
|
||||
const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const now = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForMembership({
|
||||
membership,
|
||||
timestamp: now,
|
||||
projectRegistry: deps.projectRegistry,
|
||||
});
|
||||
await deps.projectRegistry.upsert(projectRecord);
|
||||
|
||||
const trimmedTitle = options.title?.trim();
|
||||
// Persist the live git branch into the dedicated `branch` field so
|
||||
// buildWorkspaceCheckout reports the real branch for directory/local_checkout
|
||||
// workspaces too (it reads workspace.branch). Same source deriveWorkspaceDisplayName
|
||||
// reads. HEAD/detached resolves to null — there is no branch to report.
|
||||
const currentBranch = checkout.currentBranch?.trim() ?? null;
|
||||
const branch = currentBranch && currentBranch.toUpperCase() !== "HEAD" ? currentBranch : null;
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
branch,
|
||||
title: trimmedTitle ? trimmedTitle : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await deps.workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForMembership(options: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list">;
|
||||
}) {
|
||||
const rootPath = options.membership.projectRootPath;
|
||||
const projects = await options.projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: options.membership.projectKey,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
displayName: options.membership.projectName,
|
||||
createdAt: options.timestamp,
|
||||
updatedAt: options.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
archivedAt: null,
|
||||
updatedAt: options.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceProjectForWorktree {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
kind: "git";
|
||||
displayName: string;
|
||||
customName: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
function sourceProjectFromRecord(record: {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
createdAt?: string | null;
|
||||
}): SourceProjectForWorktree {
|
||||
return {
|
||||
projectId: record.projectId,
|
||||
rootPath: record.rootPath,
|
||||
kind: "git",
|
||||
displayName: record.displayName,
|
||||
customName: record.customName ?? null,
|
||||
createdAt: record.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExplicitProjectForWorktree(options: {
|
||||
projectId: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const project = await options.projectRegistry.get(options.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found for worktree: ${options.projectId}`);
|
||||
}
|
||||
return sourceProjectFromRecord(project);
|
||||
}
|
||||
|
||||
async function resolveWorkspaceProjectForWorktree(options: {
|
||||
sourceWorkspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.sourceWorkspace.projectId,
|
||||
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId),
|
||||
customName: sourceProject?.customName ?? null,
|
||||
createdAt: sourceProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFallbackProjectForWorktree(options: {
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const existingFallbackProject = await options.projectRegistry.get(options.repoRoot);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.repoRoot,
|
||||
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
||||
customName: existingFallbackProject?.customName ?? null,
|
||||
createdAt: existingFallbackProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
if (options.projectId) {
|
||||
return resolveExplicitProjectForWorktree({
|
||||
projectId: options.projectId,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceWorkspace =
|
||||
options.existingWorkspace ??
|
||||
(await findWorkspaceForSource({
|
||||
inputCwd: options.inputCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
workspaceRegistry: options.deps.workspaceRegistry,
|
||||
}));
|
||||
|
||||
if (sourceWorkspace) {
|
||||
return resolveWorkspaceProjectForWorktree({
|
||||
sourceWorkspace,
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveFallbackProjectForWorktree({
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
async function findWorkspaceForSource(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||
}): Promise<PersistedWorkspaceRecord | null> {
|
||||
const workspaces = await options.workspaceRegistry.list();
|
||||
return (
|
||||
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
|
||||
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
|
||||
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||
import {
|
||||
@@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false;
|
||||
|
||||
type TestScheduleServiceOptions = Omit<
|
||||
ScheduleServiceOptions,
|
||||
| "createAgent"
|
||||
| "createLocalCheckoutWorkspace"
|
||||
| "createPaseoWorktreeWorkspace"
|
||||
| "archiveWorkspace"
|
||||
"createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace"
|
||||
> & {
|
||||
agentManager: AgentManager;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||
createAgent?: ScheduleServiceOptions["createAgent"];
|
||||
createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
|
||||
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
|
||||
};
|
||||
@@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
let workspaceCounter = 0;
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async (
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async (
|
||||
input,
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -141,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: null,
|
||||
requestId: "schedule-service-test",
|
||||
},
|
||||
);
|
||||
@@ -163,7 +159,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
},
|
||||
input,
|
||||
)),
|
||||
createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace,
|
||||
createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace,
|
||||
createPaseoWorktreeWorkspace:
|
||||
options.createPaseoWorktreeWorkspace ??
|
||||
(async (input) => {
|
||||
@@ -182,7 +178,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
|
||||
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
|
||||
workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createArchiveWorkspace: (input: {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -200,12 +196,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: async (input) => {
|
||||
return createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: input.firstAgentContext.prompt },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
createDirectoryWorkspace: async (input) => {
|
||||
return workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
input.firstAgentContext.prompt,
|
||||
);
|
||||
},
|
||||
createArchiveWorkspace:
|
||||
@@ -240,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: null,
|
||||
requestId: "schedule-service-test",
|
||||
},
|
||||
);
|
||||
@@ -493,7 +493,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
|
||||
test("new-agent schedule records create no workspace until run time", async () => {
|
||||
const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } =
|
||||
const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } =
|
||||
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -501,7 +501,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
@@ -531,7 +531,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -545,7 +545,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -599,7 +599,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -620,7 +620,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -663,7 +663,7 @@ describe("ScheduleService", () => {
|
||||
test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -678,7 +678,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -1904,7 +1904,7 @@ describe("ScheduleService", () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
|
||||
const archiveCalls: string[] = [];
|
||||
now = new Date("2026-01-01T00:10:00.000Z");
|
||||
const service2 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -1914,13 +1914,13 @@ describe("ScheduleService", () => {
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
||||
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||
archiveCalls.push(archivedWorkspaceId);
|
||||
},
|
||||
});
|
||||
await service2.start();
|
||||
|
||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
||||
expect(archiveCalls).toEqual([workspaceId]);
|
||||
const inspected = await service2.inspect(created.id);
|
||||
expect(inspected.runs[0]).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -1972,7 +1972,7 @@ describe("ScheduleService", () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
|
||||
const archiveCalls: string[] = [];
|
||||
now = new Date("2026-01-01T00:10:00.000Z");
|
||||
const service2 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -1982,13 +1982,13 @@ describe("ScheduleService", () => {
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
||||
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||
archiveCalls.push(archivedWorkspaceId);
|
||||
},
|
||||
});
|
||||
await service2.start();
|
||||
|
||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
||||
expect(archiveCalls).toEqual([workspaceId]);
|
||||
const inspected = await service2.inspect(created.id);
|
||||
expect(inspected.runs[0]).toMatchObject({
|
||||
status: "failed",
|
||||
|
||||
@@ -218,13 +218,13 @@ export interface ScheduleServiceOptions {
|
||||
agentManager: ScheduleAgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
createLocalCheckoutWorkspace: (
|
||||
createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
now?: () => Date;
|
||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||
}
|
||||
@@ -235,13 +235,13 @@ export class ScheduleService {
|
||||
private readonly agentManager: ScheduleAgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly createAgent: BoundCreateAgentCommand;
|
||||
private readonly createLocalCheckoutWorkspace: (
|
||||
private readonly createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
private readonly createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
private readonly archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
private readonly now: () => Date;
|
||||
private readonly runner: (
|
||||
schedule: StoredSchedule,
|
||||
@@ -256,7 +256,7 @@ export class ScheduleService {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgent = options.createAgent;
|
||||
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
|
||||
this.createDirectoryWorkspace = options.createDirectoryWorkspace;
|
||||
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
||||
this.archiveWorkspace = options.archiveWorkspace;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
@@ -579,7 +579,6 @@ export class ScheduleService {
|
||||
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
|
||||
const interruptedWorkspaces: Array<{
|
||||
workspaceId: string;
|
||||
repoRoot: string;
|
||||
agentId: string | null;
|
||||
runId: string;
|
||||
}> = [];
|
||||
@@ -601,7 +600,6 @@ export class ScheduleService {
|
||||
) {
|
||||
interruptedWorkspaces.push({
|
||||
workspaceId: runningRun.workspaceId,
|
||||
repoRoot: updated.target.config.cwd,
|
||||
agentId: runningRun.agentId,
|
||||
runId: runningRun.id,
|
||||
});
|
||||
@@ -639,7 +637,7 @@ export class ScheduleService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
|
||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
@@ -930,7 +928,7 @@ export class ScheduleService {
|
||||
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
|
||||
) {
|
||||
try {
|
||||
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
|
||||
await this.archiveWorkspace(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
@@ -954,7 +952,7 @@ export class ScheduleService {
|
||||
const firstAgentContext = { prompt };
|
||||
switch (config.isolation ?? "local") {
|
||||
case "local":
|
||||
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
case "worktree":
|
||||
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
||||
.workspace;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
@@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -42,6 +43,18 @@ function createGitRepo(): string {
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createGitRepoWithNestedDirectory(): string {
|
||||
const repoDir = createGitRepo();
|
||||
mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true });
|
||||
writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), "");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
|
||||
async function createAgentInBranchOffWorktree(options?: {
|
||||
autoArchive?: boolean;
|
||||
branchName?: string;
|
||||
repoDir?: string;
|
||||
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
|
||||
const repoDir = createGitRepo();
|
||||
const repoDir = options?.repoDir ?? createGitRepo();
|
||||
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
@@ -142,6 +156,109 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir });
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: nestedCwd,
|
||||
},
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-auto-archive",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
initialPrompt: "Say done.",
|
||||
});
|
||||
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: created.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(
|
||||
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd),
|
||||
).toBe(true);
|
||||
await ctx.client.waitForFinish(created.id, 10000);
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd);
|
||||
return workspaces.entries.some((workspace) =>
|
||||
matchesCreatedWorkspace(workspace.workspaceDirectory),
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(false);
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
expect(existsSync(source.worktreePath)).toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("failed nested worktree creation cleans up the created workspace and backing directory", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({
|
||||
branchName: "nested-failure-source",
|
||||
repoDir,
|
||||
});
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
config: { provider: "unknown-provider", cwd: nestedCwd },
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-failure-cleanup",
|
||||
base: "main",
|
||||
},
|
||||
initialPrompt: "This agent cannot be created.",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir });
|
||||
return (
|
||||
listed.worktrees.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
listed.worktrees[0]?.worktreePath ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
return (
|
||||
workspaces.entries.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
workspaces.entries[0]?.workspaceDirectory ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
const created = await ctx.client.createAgent({
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||
import {
|
||||
@@ -294,6 +295,7 @@ interface SessionForTestOptions {
|
||||
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
||||
resolveForge?: ReturnType<typeof vi.fn>;
|
||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||
getProjectSlug?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
@@ -342,6 +344,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
// Mirror production: invalidateForge resolves the forge and busts the
|
||||
// adapter's cache. The resolved forge here is github, so delegate to it.
|
||||
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
|
||||
getProjectSlug: vi.fn(),
|
||||
...options.workspaceGitService,
|
||||
};
|
||||
const messages = options.messages ?? [];
|
||||
@@ -369,14 +372,16 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
...options.agentStorage,
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
getOrCreateActiveByRoot: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
...options.projectRegistry,
|
||||
},
|
||||
workspaceRegistry: options.workspaceRegistry ?? {
|
||||
get: vi.fn(),
|
||||
@@ -525,12 +530,20 @@ describe("project command-center RPCs", () => {
|
||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||
const directoryPath = join(parentDirectory, "new-project");
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const projectUpsert = vi.fn().mockResolvedValue(undefined);
|
||||
const projectAllocation = vi.fn(async (input) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_created_directory",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
}),
|
||||
);
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: projectUpsert,
|
||||
getOrCreateActiveByRoot: projectAllocation,
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
@@ -554,7 +567,12 @@ describe("project command-center RPCs", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(directoryPath)).toBe(true);
|
||||
expect(projectUpsert).toHaveBeenCalledOnce();
|
||||
expect(projectAllocation).toHaveBeenCalledWith({
|
||||
rootPath: directoryPath,
|
||||
kind: "non_git",
|
||||
displayName: "new-project",
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.create_directory.response",
|
||||
@@ -562,7 +580,7 @@ describe("project command-center RPCs", () => {
|
||||
requestId: "req-create-directory",
|
||||
directoryPath,
|
||||
project: {
|
||||
projectId: directoryPath,
|
||||
projectId: "prj_created_directory",
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: directoryPath,
|
||||
@@ -585,8 +603,7 @@ describe("project command-center RPCs", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
@@ -4041,17 +4058,17 @@ describe("session paseo worktree creation handling", () => {
|
||||
});
|
||||
|
||||
describe("session workspace script handling", () => {
|
||||
test("passes service-owned git metadata into workspace script spawning", async () => {
|
||||
test("passes the project slug and cached branch into workspace script spawning", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
workspaceDisplayName: "feature/service-scripts",
|
||||
projectSlug: "paseo",
|
||||
const snapshot = createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "feature/service-scripts",
|
||||
}),
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
},
|
||||
});
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => snapshot),
|
||||
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
|
||||
};
|
||||
const workspaceRegistry = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
@@ -4084,8 +4101,6 @@ describe("session workspace script handling", () => {
|
||||
requestId: "request-script",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoRoot: "/tmp/repo",
|
||||
@@ -4773,6 +4788,39 @@ test("keeps selective delivery scoped per socket when a retained session also ha
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends project updates only to capable sockets in a retained session", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
|
||||
const session = createSessionForTest({ messages, targetedMessages });
|
||||
const legacySocket = {};
|
||||
const capableSocket = {};
|
||||
session.updateClientCapabilities(null, legacySocket);
|
||||
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
|
||||
|
||||
session.emitProjectUpdate({
|
||||
kind: "upsert",
|
||||
project: createPersistedProjectRecord({
|
||||
projectId: "project-capable-socket",
|
||||
rootPath: "/tmp/project-capable-socket",
|
||||
kind: "git",
|
||||
displayName: "project-capable-socket",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(targetedMessages).toEqual([
|
||||
{
|
||||
source: capableSocket,
|
||||
message: expect.objectContaining({
|
||||
type: "project.update",
|
||||
payload: expect.objectContaining({ kind: "upsert" }),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("agent config setters", () => {
|
||||
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import {
|
||||
@@ -61,6 +61,7 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
CLIENT_SHUTDOWN_RPC_REASON,
|
||||
normalizeClientRestartRpcReason,
|
||||
@@ -119,6 +120,7 @@ import {
|
||||
} from "./agent/import-sessions.js";
|
||||
import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
checkoutFromPersistedWorkspacePlacement,
|
||||
deriveWorkspaceDisplayName,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
||||
@@ -165,6 +167,7 @@ import {
|
||||
} from "./session/git-mutation/git-mutation-service.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
WorkspaceProvisioningError,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
@@ -199,13 +202,13 @@ import type { ForgeService } from "../services/forge-service.js";
|
||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
import {
|
||||
summarizeFetchWorkspacesEntries,
|
||||
workspaceIdsForProjects,
|
||||
workspaceIdsOnCheckout,
|
||||
WorkspaceDirectory,
|
||||
type WorkspaceUpdatesFilter,
|
||||
} from "./workspace-directory.js";
|
||||
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
||||
import {
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
@@ -222,17 +225,12 @@ import {
|
||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||
} from "./worktree-session.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
||||
import {
|
||||
WorktreeRequestError,
|
||||
toWorktreeRequestError,
|
||||
toWorktreeWireError,
|
||||
} from "./worktree-errors.js";
|
||||
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
createProjectDirectory,
|
||||
ProjectDirectoryRequestError,
|
||||
} from "./project-directory-service.js";
|
||||
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
|
||||
@@ -259,49 +257,6 @@ function resolveSubscriptionId(
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
function buildWorkspaceCheckout(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord,
|
||||
// The persisted `branch` field is the source of truth, but it is null for
|
||||
// records created before branch was lifted to its own field (no migrations,
|
||||
// per data-model.md) and for any path that didn't backfill it. Fall back to
|
||||
// the live git branch so checkout.currentBranch never regresses to null.
|
||||
fallbackBranch?: string | null,
|
||||
): ProjectPlacementPayload["checkout"] {
|
||||
if (project.kind !== "git") {
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
const currentBranch = workspace.branch ?? fallbackBranch ?? null;
|
||||
if (workspace.kind === "worktree") {
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
isGit: true,
|
||||
currentBranch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: workspace.cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: project.rootPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
isGit: true,
|
||||
currentBranch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: workspace.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
|
||||
if (!appVersion) return false;
|
||||
// Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
|
||||
@@ -589,7 +544,7 @@ export class Session {
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
private viewedTimelineAgentIds = new Set<string>();
|
||||
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
|
||||
private readonly selectiveTimelineCapabilityBySource = new Map<object, boolean>();
|
||||
private readonly clientCapabilitiesBySource = new Map<object, ReadonlySet<ClientCapability>>();
|
||||
private readonly defaultTimelineSubscriptionSource = {};
|
||||
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
|
||||
private readonly agentUpdates: AgentUpdatesService;
|
||||
@@ -727,10 +682,11 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
getProject: (projectId) => this.projectRegistry.get(projectId),
|
||||
isDirectory: (path) => this.filesystem.isDirectory(path),
|
||||
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
|
||||
unarchiveWorkspace: async (workspace) => {
|
||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
},
|
||||
@@ -907,6 +863,7 @@ export class Session {
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
terminalManager: this.terminalManager,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
@@ -976,10 +933,7 @@ export class Session {
|
||||
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void {
|
||||
this.clientCapabilities = parseClientCapabilities(capabilities);
|
||||
if (source) {
|
||||
this.selectiveTimelineCapabilityBySource.set(
|
||||
source,
|
||||
this.supports(CLIENT_CAPS.selectiveAgentTimeline),
|
||||
);
|
||||
this.clientCapabilitiesBySource.set(source, this.clientCapabilities);
|
||||
}
|
||||
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
|
||||
this.viewedTimelineAgentIdsBySource.clear();
|
||||
@@ -988,7 +942,7 @@ export class Session {
|
||||
}
|
||||
|
||||
clearAgentTimelineSubscription(source: object): void {
|
||||
this.selectiveTimelineCapabilityBySource.delete(source);
|
||||
this.clientCapabilitiesBySource.delete(source);
|
||||
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
|
||||
this.rebuildViewedTimelineAgentIds();
|
||||
}
|
||||
@@ -1010,11 +964,11 @@ export class Session {
|
||||
}
|
||||
|
||||
private usesSelectiveTimelineDelivery(): boolean {
|
||||
if (this.selectiveTimelineCapabilityBySource.size === 0) {
|
||||
if (this.clientCapabilitiesBySource.size === 0) {
|
||||
return this.supports(CLIENT_CAPS.selectiveAgentTimeline);
|
||||
}
|
||||
for (const capable of this.selectiveTimelineCapabilityBySource.values()) {
|
||||
if (!capable) return false;
|
||||
for (const capabilities of this.clientCapabilitiesBySource.values()) {
|
||||
if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1023,7 +977,7 @@ export class Session {
|
||||
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
|
||||
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
||||
): void {
|
||||
if (this.selectiveTimelineCapabilityBySource.size === 0 || !this.onMessageToSource) {
|
||||
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
|
||||
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
|
||||
this.emit({
|
||||
type: "agent_attention_required",
|
||||
@@ -1047,7 +1001,8 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [source, supportsSelectiveDelivery] of this.selectiveTimelineCapabilityBySource) {
|
||||
for (const [source, capabilities] of this.clientCapabilitiesBySource) {
|
||||
const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline);
|
||||
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
|
||||
this.onMessageToSource(source, {
|
||||
type: "agent_attention_required",
|
||||
@@ -1079,10 +1034,28 @@ export class Session {
|
||||
}
|
||||
|
||||
supportsForSource(capability: ClientCapability, source: object): boolean {
|
||||
if (capability === CLIENT_CAPS.selectiveAgentTimeline) {
|
||||
return this.selectiveTimelineCapabilityBySource.get(source) ?? this.supports(capability);
|
||||
return (
|
||||
this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability)
|
||||
);
|
||||
}
|
||||
|
||||
emitProjectUpdate(update: ProjectUpdate): void {
|
||||
const message: SessionOutboundMessage = {
|
||||
type: "project.update",
|
||||
payload:
|
||||
update.kind === "upsert"
|
||||
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
|
||||
: update,
|
||||
};
|
||||
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
|
||||
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
|
||||
return;
|
||||
}
|
||||
for (const [source, capabilities] of this.clientCapabilitiesBySource) {
|
||||
if (capabilities.has(CLIENT_CAPS.projectUpdates)) {
|
||||
this.onMessageToSource(source, message);
|
||||
}
|
||||
}
|
||||
return this.supports(capability);
|
||||
}
|
||||
|
||||
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
@@ -1118,8 +1091,24 @@ export class Session {
|
||||
this.clearWorkspaceArchiving(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { skipReconcile?: boolean },
|
||||
): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
|
||||
}
|
||||
|
||||
async syncWorkspaceGitObserversForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(new Set(workspaceIds)).map(async (workspaceId) => {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (workspace && !workspace.archivedAt) {
|
||||
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
@@ -1473,9 +1462,15 @@ export class Session {
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
||||
}
|
||||
const liveBranch =
|
||||
this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
|
||||
const checkout = buildWorkspaceCheckout(workspace, project, liveBranch);
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const checkout = checkoutFromPersistedWorkspacePlacement({
|
||||
workspace,
|
||||
// COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15.
|
||||
// Legacy records can lack branch and worktreeRoot because persisted registries
|
||||
// are not migrated in place.
|
||||
fallbackBranch: snapshot?.git.currentBranch ?? null,
|
||||
fallbackWorktreeRoot: snapshot?.git.repoRoot,
|
||||
});
|
||||
return {
|
||||
projectKey: project.projectId,
|
||||
projectName: resolveProjectDisplayName(project),
|
||||
@@ -1652,8 +1647,7 @@ export class Session {
|
||||
const agentIds = [...new Set(msg.agentIds)].sort();
|
||||
if (
|
||||
source
|
||||
? (this.selectiveTimelineCapabilityBySource.get(source) ??
|
||||
this.supports(CLIENT_CAPS.selectiveAgentTimeline))
|
||||
? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)
|
||||
: this.supports(CLIENT_CAPS.selectiveAgentTimeline)
|
||||
) {
|
||||
this.replaceAgentTimelineSubscription(source, agentIds);
|
||||
@@ -2756,7 +2750,7 @@ export class Session {
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
||||
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
||||
? { ...config, cwd: createdWorktree.workspace.cwd }
|
||||
: config;
|
||||
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
|
||||
{
|
||||
@@ -3891,7 +3885,7 @@ export class Session {
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd),
|
||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
|
||||
...(resolvedProjectRecord
|
||||
? {
|
||||
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
||||
@@ -3967,7 +3961,7 @@ export class Session {
|
||||
projectCustomName: projectRecord?.customName ?? null,
|
||||
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
||||
workspaceDirectory: result.workspace.cwd,
|
||||
projectKind: "git",
|
||||
projectKind: projectRecord?.kind ?? "git",
|
||||
workspaceKind: result.workspace.kind,
|
||||
name: resolveWorkspaceName({
|
||||
title: result.workspace.title,
|
||||
@@ -3999,7 +3993,7 @@ export class Session {
|
||||
projectRecord?: PersistedProjectRecord | null;
|
||||
includeGitData: boolean;
|
||||
}): Promise<WorkspaceDescriptorPayload> {
|
||||
if (input.includeGitData && input.projectRecord?.kind === "git") {
|
||||
if (input.includeGitData && input.workspace.kind !== "directory") {
|
||||
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
|
||||
}
|
||||
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
|
||||
@@ -4130,65 +4124,6 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
|
||||
});
|
||||
}
|
||||
const project = await this.projectRegistry.get(workspace.projectId);
|
||||
if (!project) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`,
|
||||
});
|
||||
}
|
||||
const projectRootExists = await this.filesystem
|
||||
.isDirectory(project.rootPath)
|
||||
.catch(() => false);
|
||||
if (!projectRootExists) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Archiving through the default path (scope "workspace", worktreePath only)
|
||||
// resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/
|
||||
// `prune` is skipped and the admin registration survives — pinning the
|
||||
// branch as "already checked out". Prune here frees any stale registration
|
||||
// whose working dir is missing (a no-op for live worktrees) so the recreate
|
||||
// below succeeds regardless of how the worktree was archived.
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 });
|
||||
} catch {
|
||||
// not critical; git will prune lazily
|
||||
}
|
||||
|
||||
let result: WorktreeConfig;
|
||||
try {
|
||||
result = await createWorktree({
|
||||
cwd: project.rootPath,
|
||||
worktreeSlug: basename(workspace.cwd),
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
if (normalize(result.worktreePath) !== normalize(workspace.cwd)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
@@ -4237,9 +4172,8 @@ export class Session {
|
||||
...(options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
workspaceProvisioning: this.workspaceProvisioning,
|
||||
});
|
||||
void Promise.all([
|
||||
this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"),
|
||||
@@ -4261,6 +4195,9 @@ export class Session {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
kind: workspace.kind,
|
||||
worktreeRoot: workspace.worktreeRoot,
|
||||
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4292,21 +4229,12 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
await this.teardownArchivedWorkspace({
|
||||
workspaceId: existingWorkspace.workspaceId,
|
||||
cwd: existingWorkspace.cwd,
|
||||
});
|
||||
await this.teardownArchivedWorkspace(existingWorkspace.workspaceId);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
this.workspaceGitObserver.removeForCwd(input.cwd);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
|
||||
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
||||
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||
@@ -4341,16 +4269,12 @@ export class Session {
|
||||
result.changesApplied.map(async (change) => {
|
||||
switch (change.kind) {
|
||||
case "workspace_archived":
|
||||
await this.teardownArchivedWorkspace({
|
||||
workspaceId: change.workspaceId,
|
||||
cwd: change.directory,
|
||||
});
|
||||
await this.teardownArchivedWorkspace(change.workspaceId);
|
||||
changedWorkspaceIds.add(change.workspaceId);
|
||||
break;
|
||||
case "workspace_updated":
|
||||
changedWorkspaceIds.add(change.workspaceId);
|
||||
break;
|
||||
case "project_archived":
|
||||
case "project_updated":
|
||||
changedProjectIds.add(change.projectId);
|
||||
break;
|
||||
@@ -4359,10 +4283,11 @@ export class Session {
|
||||
);
|
||||
|
||||
if (changedProjectIds.size > 0) {
|
||||
for (const workspace of await this.workspaceRegistry.list()) {
|
||||
if (changedProjectIds.has(workspace.projectId)) {
|
||||
changedWorkspaceIds.add(workspace.workspaceId);
|
||||
}
|
||||
for (const workspaceId of workspaceIdsForProjects(
|
||||
await this.workspaceRegistry.list(),
|
||||
changedProjectIds,
|
||||
)) {
|
||||
changedWorkspaceIds.add(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4770,6 +4695,7 @@ export class Session {
|
||||
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
|
||||
"Failed to create workspace",
|
||||
);
|
||||
const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined;
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
@@ -4777,6 +4703,7 @@ export class Session {
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: message,
|
||||
errorCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4807,13 +4734,10 @@ export class Session {
|
||||
|
||||
const explicitTitle = request.title?.trim() || null;
|
||||
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: explicitTitle ?? promptTitle },
|
||||
{
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
explicitTitle ?? promptTitle,
|
||||
request.source.projectId,
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
@@ -5287,10 +5211,10 @@ export class Session {
|
||||
// Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's
|
||||
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
|
||||
private buildWorkspaceScriptPayloadSnapshot(
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord | null,
|
||||
): WorkspaceDescriptorPayload["scripts"] {
|
||||
return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory);
|
||||
return this.workspaceScripts.buildSnapshot(workspace, project);
|
||||
}
|
||||
|
||||
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||
@@ -5401,11 +5325,6 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
|
||||
const gitSnapshot = await this.workspaceGitService
|
||||
.getSnapshot(existing.cwd)
|
||||
.catch(() => null);
|
||||
const repoRoot = gitSnapshot?.git?.repoRoot ?? null;
|
||||
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -5428,8 +5347,6 @@ export class Session {
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
repoRoot,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -472,7 +472,6 @@ describe("workspace git watch targets", () => {
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
@@ -482,7 +481,7 @@ describe("workspace git watch targets", () => {
|
||||
name: "old-branch",
|
||||
});
|
||||
|
||||
syncGitObserver(session, "/tmp/repo", "ws-10");
|
||||
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]);
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
@@ -498,16 +497,6 @@ describe("workspace git watch targets", () => {
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,22 @@ function createHarness(input: {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: string) => projects.get(id) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === allocation.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -310,10 +326,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo
|
||||
// root, returns the existing workspace. (Per "always go to the nearest git".)
|
||||
// S4. Every selected path is an exact lexical root, even inside a Git checkout.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
||||
test("S4: open subdir of active git workspace creates an independent exact-root workspace", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
@@ -321,8 +336,9 @@ 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(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(resp?.workspace?.projectId).not.toBe(workspaceByCwd(h.workspaces, FOO)?.projectId);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -343,10 +359,10 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S6. Open the EXACT path of an archived git workspace: this IS explicit user
|
||||
// intent to re-open what they archived. Unarchive is correct here.
|
||||
// S6. Explicit project opening allocates a fresh identity when only archived
|
||||
// records exist. Agent restore is the separate path that restores ownership.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
||||
test("S6: re-opening an archived git workspace by exact path creates a fresh project and workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -354,8 +370,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
const fresh = Array.from(h.workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === TOOLBOX && !workspace.archivedAt,
|
||||
);
|
||||
expect(fresh?.workspaceId).not.toBe("ws-toolbox");
|
||||
expect(fresh?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -433,11 +453,10 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S11. Archive then re-add round-trip (project-level): opening the exact path
|
||||
// of an archived project unarchives both the project and its workspace,
|
||||
// reusing the same path-derived ids.
|
||||
// S11. Archive then re-add produces a fresh opaque identity; it never reuses
|
||||
// archived compatibility IDs.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
||||
test("S11: re-opening an archived project by exact path keeps archived records and allocates fresh ids", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -447,12 +466,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(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(resp?.workspace?.id).not.toBe("ws-toolbox");
|
||||
expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
expect(h.projects.size).toBe(2);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +500,7 @@ test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefi
|
||||
// being explicit. To get git features back the user unarchives the parent
|
||||
// (S6/S11).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
||||
test("S13: subfolder of an archived git repo opens as its own git-backed workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -492,5 +510,5 @@ test("S13: subfolder of an archived git repo opens as a directory workspace", as
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
@@ -33,12 +33,12 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import {
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
import { WorktreeRequestError } from "./worktree-errors.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
@@ -128,7 +128,6 @@ interface SessionTestAccess {
|
||||
agentUpdates: AgentUpdatesService;
|
||||
workspaceUpdatesSubscription: unknown;
|
||||
interruptAgentIfRunning(agentId: string): unknown;
|
||||
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
|
||||
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
||||
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
changed: boolean;
|
||||
@@ -154,6 +153,10 @@ interface SessionTestAccess {
|
||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
||||
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { skipReconcile?: boolean },
|
||||
): Promise<void>;
|
||||
emit(message: unknown): void;
|
||||
onMessage(message: unknown): void;
|
||||
paseoHome: string;
|
||||
@@ -652,6 +655,15 @@ function createSessionForWorkspaceTests(
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
getOrCreateActiveByRoot: async (input) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_0000000000000000",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
}),
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -844,7 +856,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-child",
|
||||
@@ -857,7 +868,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
||||
).resolves.toMatchObject({
|
||||
projectKey: parent,
|
||||
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
checkout: { cwd: child },
|
||||
});
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
@@ -869,6 +880,163 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request launches from an exact subdirectory in a created worktree", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-worktree-cwd-"));
|
||||
try {
|
||||
const parent = path.join(workdir, "parent");
|
||||
const child = path.join(parent, "packages", "app");
|
||||
mkdirSync(child, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: parent, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||
cwd: parent,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: parent, stdio: "pipe" });
|
||||
writeFileSync(path.join(child, "README.md"), "app\n");
|
||||
execFileSync("git", ["add", "."], { cwd: parent, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: parent, stdio: "pipe" });
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger: asSessionLogger(logger),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: parent,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
resolveRepoRoot: async () => parent,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
});
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-parent",
|
||||
rootPath: parent,
|
||||
kind: "git",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-parent",
|
||||
projectId: "proj-parent",
|
||||
cwd: parent,
|
||||
kind: "local_checkout",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: child, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService,
|
||||
workspaceAutoName: new WorkspaceAutoName({
|
||||
agentManager,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
||||
gitMutation: { notifyGitMutation: async () => {} },
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
logger: asSessionLogger(logger),
|
||||
}),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-worktree-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
|
||||
});
|
||||
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: createdAgent!.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(
|
||||
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(
|
||||
createdAgent?.cwd ?? "",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_created",
|
||||
agent: { cwd: createdAgent?.cwd },
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
|
||||
@@ -2106,6 +2274,93 @@ test("non-git workspace uses deterministic directory name and no unknown branch
|
||||
expect(result.entries[0]?.name).not.toBe("Unknown branch");
|
||||
});
|
||||
|
||||
test("workspace placements preserve checkout facts independently from the project", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const manualWorktree = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-manual-worktree",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/manual-worktree",
|
||||
kind: "worktree",
|
||||
displayName: "manual",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const explicitDirectory = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-explicit-directory",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/plain-directory",
|
||||
kind: "directory",
|
||||
displayName: "plain",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const paseoSubdirectory = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-paseo-subdirectory",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/paseo-worktree/packages/app",
|
||||
kind: "worktree",
|
||||
displayName: "app",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-manual-worktree",
|
||||
rootPath: "/tmp/main-repo",
|
||||
kind: "git",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
[manualWorktree, explicitDirectory, paseoSubdirectory].find(
|
||||
(workspace) => workspace.workspaceId === workspaceId,
|
||||
) ?? null;
|
||||
session.projectRegistry.get = async () => project;
|
||||
session.workspaceGitService.peekSnapshot = (cwd: string) =>
|
||||
cwd === paseoSubdirectory.cwd
|
||||
? createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: { repoRoot: "/tmp/paseo-worktree" },
|
||||
})
|
||||
: null;
|
||||
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
checkout: expect.objectContaining({
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(explicitDirectory.workspaceId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
checkout: expect.objectContaining({
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(paseoSubdirectory.workspaceId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
checkout: expect.objectContaining({
|
||||
cwd: paseoSubdirectory.cwd,
|
||||
worktreeRoot: "/tmp/paseo-worktree",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const archivedAt = "2026-03-02T12:00:00.000Z";
|
||||
@@ -3345,7 +3600,7 @@ test("project.remove.request removes an already-empty project", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
||||
test("create paseo worktree response preserves an explicit non-Git project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const createdAt = "2026-05-12T12:00:00.000Z";
|
||||
vi.setSystemTime(new Date(createdAt));
|
||||
@@ -3408,7 +3663,15 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
);
|
||||
|
||||
const workspaces = new Map();
|
||||
const projects = new Map();
|
||||
const explicitProject = createPersistedProjectRecord({
|
||||
projectId: "prj_explicit_non_git",
|
||||
rootPath: path.join(tempDir, "selected-project"),
|
||||
kind: "non_git",
|
||||
displayName: "Selected project",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
const projects = new Map([[explicitProject.projectId, explicitProject]]);
|
||||
session.paseoHome = paseoHome;
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
@@ -3420,6 +3683,22 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === input.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -3432,6 +3711,7 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
await session.handleCreatePaseoWorktreeRequest({
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: repoDir,
|
||||
projectId: explicitProject.projectId,
|
||||
worktreeSlug: "worktree-123",
|
||||
requestId: "req-worktree",
|
||||
});
|
||||
@@ -3444,8 +3724,10 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace).toMatchObject({
|
||||
projectDisplayName: "repo",
|
||||
projectKind: "git",
|
||||
projectId: explicitProject.projectId,
|
||||
projectDisplayName: explicitProject.displayName,
|
||||
projectRootPath: explicitProject.rootPath,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "worktree",
|
||||
name: "worktree-123",
|
||||
status: "done",
|
||||
@@ -3454,7 +3736,7 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
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);
|
||||
expect(projects.get(explicitProject.projectId)).toEqual(explicitProject);
|
||||
});
|
||||
|
||||
test("workspace update fanout for multiple cwd values is deduplicated", async () => {
|
||||
@@ -3547,6 +3829,18 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_githubruntime",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -3860,6 +4154,18 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_githubruntime",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -4085,7 +4391,6 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
"orchestrate",
|
||||
"desktop-daemon-settings",
|
||||
);
|
||||
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
||||
const archivedAt = "2026-04-24T09:48:36.168Z";
|
||||
const workspaceId = "ws-desktop-daemon-settings";
|
||||
|
||||
@@ -4163,12 +4468,9 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(projects.get(remoteProjectId)?.kind).toBe("git");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(projects.get(cwd)?.archivedAt).toBe(archivedAt);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
|
||||
@@ -4282,14 +4584,11 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(response?.payload.workspace?.projectId).toBe(cwd);
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(cwd);
|
||||
});
|
||||
|
||||
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
|
||||
test("open_project_request gives a plain git worktree its own exact-root project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4375,16 +4674,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === cwd,
|
||||
);
|
||||
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
|
||||
expect(worktreeWorkspace?.kind).toBe("worktree");
|
||||
expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("open_project_request unarchives an existing archived workspace and project", async () => {
|
||||
test("open_project_request keeps archived records and allocates a fresh workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4443,14 +4740,15 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
requestId: "req-open-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).not.toBeNull();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("open_project_request recreates a missing project record when unarchiving its workspace", async () => {
|
||||
test("open_project_request does not repurpose an orphaned archived workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4497,18 +4795,12 @@ test("open_project_request recreates a missing project record when unarchiving i
|
||||
requestId: "req-open-removed-project",
|
||||
});
|
||||
|
||||
expect(projects.get(cwd)).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: cwd,
|
||||
displayName: "repo",
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)).toBeUndefined();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
||||
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("workspace recovery stays accepted when git observer warming fails", async () => {
|
||||
@@ -5099,68 +5391,6 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const branch = "feature/keep";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const worktreesRoot = path.join(tempDir, "worktrees");
|
||||
const paseoHome = path.join(tempDir, "paseo-home");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "keep",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreePath = realpathSync(created.worktreePath);
|
||||
|
||||
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const workspaceId = "ws-missing-root";
|
||||
const projectId = repoDir;
|
||||
const missingRoot = path.join(tempDir, "does-not-exist");
|
||||
projects.set(
|
||||
projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath: missingRoot,
|
||||
kind: "git",
|
||||
displayName: "worktree-project",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId,
|
||||
cwd: worktreePath,
|
||||
kind: "worktree",
|
||||
branch,
|
||||
displayName: branch,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
});
|
||||
workspaces.set(workspaceId, workspaceRecord);
|
||||
|
||||
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.filesystem.isDirectory = async (target: string) =>
|
||||
existsSync(target) && statSync(target).isDirectory();
|
||||
|
||||
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
|
||||
WorktreeRequestError,
|
||||
);
|
||||
// Guard fires before createWorktree, so archivedAt is untouched.
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
@@ -5747,6 +5977,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
||||
expect(descriptors).toEqual([baselineDescriptor]);
|
||||
});
|
||||
|
||||
test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-explicit-directory",
|
||||
rootPath: "/tmp/explicit-directory",
|
||||
kind: "non_git",
|
||||
displayName: "directory project",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-explicit-checkout",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
|
||||
|
||||
const descriptors = Array.from(
|
||||
(await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(),
|
||||
) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>;
|
||||
|
||||
expect(descriptors[0]).toMatchObject({
|
||||
gitRuntime: { currentBranch: "main" },
|
||||
githubRuntime: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
|
||||
const setupSession = () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
@@ -6188,6 +6452,90 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
|
||||
]);
|
||||
});
|
||||
|
||||
test("external workspace updates emit one deduplicated batch without reconciling", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-observer-batch",
|
||||
rootPath: "/tmp/observer-batch",
|
||||
kind: "non_git",
|
||||
displayName: "observer-batch",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const main = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-observer-main",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/observer-batch/main",
|
||||
kind: "directory",
|
||||
displayName: "main",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const feature = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-observer-feature",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/observer-batch/feature",
|
||||
kind: "directory",
|
||||
displayName: "feature",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const snapshotReads = { projects: 0, workspaces: 0 };
|
||||
session.projectRegistry.list = async () => {
|
||||
snapshotReads.projects += 1;
|
||||
return [project];
|
||||
};
|
||||
session.workspaceRegistry.list = async () => {
|
||||
snapshotReads.workspaces += 1;
|
||||
return [main, feature];
|
||||
};
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-observer-batch",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
session.onMessage = (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
[main.workspaceId, feature.workspaceId, main.workspaceId],
|
||||
{ skipReconcile: true },
|
||||
);
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: expect.objectContaining({
|
||||
id: main.workspaceId,
|
||||
projectId: project.projectId,
|
||||
workspaceDirectory: main.cwd,
|
||||
name: main.displayName,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: expect.objectContaining({
|
||||
id: feature.workspaceId,
|
||||
projectId: project.projectId,
|
||||
workspaceDirectory: feature.cwd,
|
||||
name: feature.displayName,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 });
|
||||
});
|
||||
|
||||
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
@@ -7497,6 +7845,18 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_worktreefixture",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -7691,7 +8051,7 @@ test("workspace auto-name replaces the unchanged prompt title", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace auto-name applies title once when branch auto-name is rejected", async () => {
|
||||
test("workspace auto-name uses the backing root for a nested worktree", async () => {
|
||||
vi.useFakeTimers();
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
@@ -7711,15 +8071,19 @@ test("workspace auto-name applies title once when branch auto-name is rejected",
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||
placeholderBranchName: "placeholder-branch",
|
||||
});
|
||||
const workspaceCwd = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-rejected-branch-title",
|
||||
projectId: "proj-rejected-branch-title",
|
||||
cwd: repoDir,
|
||||
cwd: workspaceCwd,
|
||||
kind: "worktree",
|
||||
displayName: "Fix checkout title",
|
||||
title: "Fix checkout title",
|
||||
branch: "placeholder-branch",
|
||||
worktreeRoot: repoDir,
|
||||
isPaseoOwnedWorktree: true,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
@@ -7779,7 +8143,7 @@ test("workspace auto-name applies title once when branch auto-name is rejected",
|
||||
},
|
||||
});
|
||||
expect(gitMutations).toEqual([]);
|
||||
expect(emittedCwds).toEqual([repoDir]);
|
||||
expect(emittedCwds).toEqual([workspaceCwd]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
@@ -7957,3 +8321,93 @@ test("workspace create stays out of a non-matching workspace subscription", asyn
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
||||
});
|
||||
|
||||
test("workspace.create.request attaches a directory workspace to its explicit active project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const projects = new Map([
|
||||
[
|
||||
"prj_explicit",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_explicit",
|
||||
rootPath: path.join(REPO_CWD, "unrelated"),
|
||||
kind: "non_git",
|
||||
displayName: "unrelated",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const workspace = record as PersistedWorkspaceRecord;
|
||||
workspaces.set(workspace.workspaceId, workspace);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-explicit-project",
|
||||
source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" },
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.create.response");
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "req-explicit-project",
|
||||
error: null,
|
||||
workspace: { projectId: "prj_explicit" },
|
||||
});
|
||||
const workspaceId = response?.payload.workspace?.id;
|
||||
expect(workspaceId).toEqual(expect.any(String));
|
||||
expect(workspaces.get(workspaceId as string)).toMatchObject({
|
||||
cwd: REPO_CWD,
|
||||
projectId: "prj_explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace.create.request reports an unknown explicit project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-unknown-project",
|
||||
source: { kind: "directory", path: REPO_CWD, projectId: "prj_missing" },
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
|
||||
requestId: "req-unknown-project",
|
||||
workspace: null,
|
||||
errorCode: "unknown_project",
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace.create.request reports an archived explicit project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const archivedProject = createPersistedProjectRecord({
|
||||
projectId: "prj_archived",
|
||||
rootPath: path.join(REPO_CWD, "unrelated"),
|
||||
kind: "non_git",
|
||||
displayName: "unrelated",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
session.projectRegistry.get = async (projectId: string) =>
|
||||
projectId === archivedProject.projectId ? archivedProject : null;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-archived-project",
|
||||
source: { kind: "directory", path: REPO_CWD, projectId: "prj_archived" },
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
|
||||
requestId: "req-archived-project",
|
||||
workspace: null,
|
||||
errorCode: "archived_project",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeDescriptor(overrides: {
|
||||
id: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind?: string;
|
||||
workspaceKind?: "directory" | "local_checkout" | "worktree";
|
||||
name?: string | null;
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
@@ -31,6 +32,7 @@ function makeDescriptor(overrides: {
|
||||
id: overrides.id,
|
||||
workspaceDirectory: overrides.workspaceDirectory,
|
||||
projectKind: overrides.projectKind ?? "git",
|
||||
workspaceKind: overrides.workspaceKind ?? "local_checkout",
|
||||
name: overrides.name ?? null,
|
||||
diffStat: overrides.diffStat ?? null,
|
||||
} as unknown as WorkspaceDescriptorPayload;
|
||||
@@ -135,11 +137,24 @@ describe("syncObservers", () => {
|
||||
test("does not register a non-git workspace", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("registers a Git workspace even when its owning project is non-Git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "non_git" }),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("is idempotent — re-syncing the same git workspace does not re-register", () => {
|
||||
const h = buildHarness();
|
||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
|
||||
@@ -148,14 +163,46 @@ describe("syncObservers", () => {
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("shares one cwd subscription across distinct workspace identities", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("tears down the subscription when a git workspace becomes non-git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("keeps a shared subscription when one sibling becomes non-git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
|
||||
]);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
h.emitSnapshot(WS1, "feature");
|
||||
expect(h.branchChanges).toEqual([["ws2", null, "feature"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git snapshot listener", () => {
|
||||
@@ -202,6 +249,13 @@ describe("shouldSkipUpdate", () => {
|
||||
expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true);
|
||||
expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false);
|
||||
});
|
||||
|
||||
test("starts from the descriptor state recorded during observer sync", () => {
|
||||
const h = buildHarness();
|
||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" });
|
||||
h.service.syncObservers([descriptor]);
|
||||
expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordDescriptorState", () => {
|
||||
@@ -239,10 +293,25 @@ describe("teardown", () => {
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("removeForCwd unsubscribes and stops the observer", () => {
|
||||
test("keeps a shared cwd subscription until its last workspace is removed", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||
h.service.removeForCwd(WS1);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }),
|
||||
]);
|
||||
|
||||
h.emitSnapshot(WS1, "feature");
|
||||
h.service.removeForWorkspaceId("ws1");
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
|
||||
h.emitSnapshot(WS1, "next");
|
||||
expect(h.branchChanges).toEqual([
|
||||
["ws1", "main", "feature"],
|
||||
["ws2", "main", "feature"],
|
||||
["ws2", "feature", "next"],
|
||||
]);
|
||||
|
||||
h.service.removeForWorkspaceId("ws2");
|
||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
||||
expect(() => h.emitSnapshot(WS1, "x")).toThrow();
|
||||
});
|
||||
|
||||
@@ -10,8 +10,11 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||
|
||||
interface WorkspaceGitWatchTarget {
|
||||
workspaceIds: Set<string>;
|
||||
}
|
||||
|
||||
interface WorkspaceGitWatchState {
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
latestDescriptorStateKey: string | null;
|
||||
lastBranchName: string | null;
|
||||
}
|
||||
@@ -20,8 +23,9 @@ interface WorkspaceGitWatchTarget {
|
||||
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
|
||||
* live update fan-out: branch-change notifications, workspace-card refreshes, and
|
||||
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
|
||||
* subscription handles, so the registration / dedupe / teardown lifecycle lives in one
|
||||
* module instead of being smeared across the client session.
|
||||
* subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and
|
||||
* branch state remain keyed by workspace id, so same-directory workspace records share one
|
||||
* watch without sharing identity or teardown lifetime.
|
||||
*
|
||||
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
|
||||
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop
|
||||
@@ -37,7 +41,6 @@ export interface WorkspaceGitObserverService {
|
||||
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
|
||||
handleBranchSnapshot(cwd: string, branchName: string | null): void;
|
||||
removeForWorkspaceId(workspaceId: string): void;
|
||||
removeForCwd(cwd: string): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
} = deps;
|
||||
|
||||
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
const workspaceStates = new Map<string, WorkspaceGitWatchState>();
|
||||
const subscriptions = new Map<string, () => void>();
|
||||
|
||||
function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
|
||||
@@ -79,71 +83,93 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveTargetByWorkspaceId(workspaceId: string): WorkspaceGitWatchTarget | null {
|
||||
for (const target of watchTargets.values()) {
|
||||
if (target.workspaceId === workspaceId) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rememberDescriptorState(
|
||||
workspaceId: string,
|
||||
workspace: WorkspaceDescriptorPayload | null,
|
||||
): void {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (!target) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
target.latestDescriptorStateKey = descriptorStateKey(workspace);
|
||||
target.lastBranchName = workspace?.name ?? null;
|
||||
state.latestDescriptorStateKey = descriptorStateKey(workspace);
|
||||
state.lastBranchName = workspace?.name ?? null;
|
||||
}
|
||||
|
||||
function removeForCwd(cwd: string): void {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const target = watchTargets.get(normalizedCwd);
|
||||
for (const workspaceId of target?.workspaceIds ?? []) {
|
||||
workspaceStates.delete(workspaceId);
|
||||
}
|
||||
watchTargets.delete(normalizedCwd);
|
||||
subscriptions.get(normalizedCwd)?.();
|
||||
subscriptions.delete(normalizedCwd);
|
||||
}
|
||||
|
||||
function removeForWorkspaceId(workspaceId: string): void {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
workspaceStates.delete(workspaceId);
|
||||
const target = watchTargets.get(state.cwd);
|
||||
target?.workspaceIds.delete(workspaceId);
|
||||
if (target?.workspaceIds.size === 0) {
|
||||
removeForCwd(state.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||
const target = watchTargets.get(resolve(cwd));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBranchName = target.lastBranchName;
|
||||
if (branchName === previousBranchName) {
|
||||
return;
|
||||
for (const workspaceId of target.workspaceIds) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
continue;
|
||||
}
|
||||
const previousBranchName = state.lastBranchName;
|
||||
if (branchName === previousBranchName) {
|
||||
continue;
|
||||
}
|
||||
state.lastBranchName = branchName;
|
||||
onBranchChanged?.(workspaceId, previousBranchName, branchName);
|
||||
}
|
||||
|
||||
target.lastBranchName = branchName;
|
||||
onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
|
||||
}
|
||||
|
||||
function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const currentState = workspaceStates.get(options.workspaceId);
|
||||
if (currentState && currentState.cwd !== normalizedCwd) {
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
}
|
||||
if (!options.isGit) {
|
||||
removeForCwd(normalizedCwd);
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = watchTargets.get(normalizedCwd) ?? {
|
||||
workspaceIds: new Set<string>(),
|
||||
};
|
||||
watchTargets.set(normalizedCwd, target);
|
||||
target.workspaceIds.add(options.workspaceId);
|
||||
if (!workspaceStates.has(options.workspaceId)) {
|
||||
workspaceStates.set(options.workspaceId, {
|
||||
cwd: normalizedCwd,
|
||||
latestDescriptorStateKey: null,
|
||||
lastBranchName: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (subscriptions.has(normalizedCwd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceGitWatchTarget = {
|
||||
cwd: normalizedCwd,
|
||||
workspaceId: options.workspaceId,
|
||||
latestDescriptorStateKey: null,
|
||||
lastBranchName: null,
|
||||
};
|
||||
watchTargets.set(normalizedCwd, target);
|
||||
|
||||
const subscription = workspaceGitService.registerWorkspace(
|
||||
{ cwd: normalizedCwd },
|
||||
(snapshot) => {
|
||||
let subscription: ReturnType<WorkspaceGitService["registerWorkspace"]>;
|
||||
try {
|
||||
subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
|
||||
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
||||
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
|
||||
logger.warn(
|
||||
@@ -152,18 +178,21 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
);
|
||||
});
|
||||
emitStatusUpdate(normalizedCwd, snapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
throw error;
|
||||
}
|
||||
subscriptions.set(normalizedCwd, subscription.unsubscribe);
|
||||
}
|
||||
|
||||
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||
for (const workspace of workspaces) {
|
||||
syncObserver(workspace.workspaceDirectory, {
|
||||
isGit: workspace.projectKind === "git",
|
||||
isGit: workspace.workspaceKind !== "directory",
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
rememberDescriptorState(workspace.workspaceDirectory, workspace);
|
||||
rememberDescriptorState(workspace.id, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,24 +211,24 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
},
|
||||
|
||||
shouldSkipUpdate(workspaceId, workspace) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (!target) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
const nextStateKey = descriptorStateKey(workspace);
|
||||
if (target.latestDescriptorStateKey === nextStateKey) {
|
||||
if (state.latestDescriptorStateKey === nextStateKey) {
|
||||
return true;
|
||||
}
|
||||
target.latestDescriptorStateKey = nextStateKey;
|
||||
state.latestDescriptorStateKey = nextStateKey;
|
||||
return false;
|
||||
},
|
||||
|
||||
recordDescriptorState(workspaceId, nextWorkspace) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (target && onBranchChanged) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (state && onBranchChanged) {
|
||||
const newBranchName = nextWorkspace?.name ?? null;
|
||||
if (newBranchName !== target.lastBranchName) {
|
||||
onBranchChanged(workspaceId, target.lastBranchName, newBranchName);
|
||||
if (newBranchName !== state.lastBranchName) {
|
||||
onBranchChanged(workspaceId, state.lastBranchName, newBranchName);
|
||||
}
|
||||
}
|
||||
rememberDescriptorState(workspaceId, nextWorkspace);
|
||||
@@ -207,14 +236,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
|
||||
handleBranchSnapshot,
|
||||
|
||||
removeForWorkspaceId(workspaceId) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (target) {
|
||||
removeForCwd(target.cwd);
|
||||
}
|
||||
},
|
||||
|
||||
removeForCwd,
|
||||
removeForWorkspaceId,
|
||||
|
||||
dispose() {
|
||||
for (const unsubscribe of subscriptions.values()) {
|
||||
@@ -222,6 +244,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
}
|
||||
subscriptions.clear();
|
||||
watchTargets.clear();
|
||||
workspaceStates.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
type PersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
WorkspaceProvisioningError,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./workspace-provisioning-service.js";
|
||||
|
||||
@@ -27,6 +30,8 @@ const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
let tmpDir: string;
|
||||
let gitRoots: Set<string>;
|
||||
let gitBranches: Map<string, string | null>;
|
||||
let checkoutFailure: Error | null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
let provisioning: WorkspaceProvisioningService;
|
||||
@@ -35,6 +40,7 @@ function gitService() {
|
||||
return createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
if (checkoutFailure) throw checkoutFailure;
|
||||
let worktreeRoot: string | null = null;
|
||||
for (const root of gitRoots) {
|
||||
if (
|
||||
@@ -47,7 +53,7 @@ function gitService() {
|
||||
return {
|
||||
cwd,
|
||||
isGit: worktreeRoot !== null,
|
||||
currentBranch: worktreeRoot ? "main" : null,
|
||||
currentBranch: worktreeRoot ? (gitBranches.get(worktreeRoot) ?? "main") : null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
@@ -60,6 +66,8 @@ function gitService() {
|
||||
beforeEach(async () => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-"));
|
||||
gitRoots = new Set();
|
||||
gitBranches = new Map();
|
||||
checkoutFailure = null;
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
@@ -112,6 +120,72 @@ test("re-opening an active workspace by exact path returns the same record witho
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => {
|
||||
const cwd = path.join(tmpDir, "workspace");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
await workspaceRegistry.upsert({ ...created, cwd: `${cwd}${path.sep}` });
|
||||
|
||||
const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(active.workspaceId).toBe(created.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/refresh");
|
||||
|
||||
const refreshed = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
workspaceId: first.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/refresh",
|
||||
displayName: first.displayName,
|
||||
title: "Pinned work",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
expect(await workspaceRegistry.get(first.workspaceId)).toEqual(refreshed);
|
||||
expect((await projectRegistry.get(first.projectId))?.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("persists manual worktree ownership separately from its workspace kind", async () => {
|
||||
const cwd = path.join(tmpDir, "manual-worktree");
|
||||
const mainRepoRoot = path.join(tmpDir, "main-repo");
|
||||
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "feature/manual",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
|
||||
expect(workspace).toMatchObject({
|
||||
kind: "worktree",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot,
|
||||
});
|
||||
});
|
||||
|
||||
test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
@@ -124,6 +198,110 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
|
||||
expect(reopened.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("reopening archived exact-root records restores the fresh Git project", async () => {
|
||||
const cwd = path.join(tmpDir, "repo");
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-archived-root",
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: ARCHIVED_AT,
|
||||
updatedAt: ARCHIVED_AT,
|
||||
archivedAt: ARCHIVED_AT,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
|
||||
const archivedProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: workspace.workspaceId,
|
||||
kind: "local_checkout",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(await projectRegistry.get(project.projectId)).toMatchObject({
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses one workspace snapshot when reopening an archived workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
|
||||
const archived = (await workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.workspaceId === created.workspaceId,
|
||||
);
|
||||
let reads = 0;
|
||||
const snapshotRegistry: WorkspaceRegistry = {
|
||||
initialize: () => workspaceRegistry.initialize(),
|
||||
existsOnDisk: () => workspaceRegistry.existsOnDisk(),
|
||||
list: async () => (reads++ === 0 ? archived : []),
|
||||
get: (workspaceId) => workspaceRegistry.get(workspaceId),
|
||||
upsert: (workspace) => workspaceRegistry.upsert(workspace),
|
||||
archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt),
|
||||
remove: (workspaceId) => workspaceRegistry.remove(workspaceId),
|
||||
};
|
||||
const snapshotProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
});
|
||||
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reopening an archived workspace refreshes placement without renaming it", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
gitRoots.delete(repo);
|
||||
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
projectId: created.projectId,
|
||||
title: "Pinned archived work",
|
||||
kind: "directory",
|
||||
branch: null,
|
||||
displayName: created.displayName,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(reopened.updatedAt).toEqual(expect.any(String));
|
||||
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened);
|
||||
});
|
||||
|
||||
test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
@@ -138,7 +316,7 @@ test("opening a subpath of an archived git workspace mints a fresh workspace at
|
||||
expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
|
||||
});
|
||||
|
||||
test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => {
|
||||
test("ensureWorkspaceRecordUnarchived restores the owning archived project with the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -154,6 +332,24 @@ test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its
|
||||
expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("does not unarchive either record when checkout refresh fails", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
const archivedProject = await projectRegistry.get(created.projectId);
|
||||
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
|
||||
checkoutFailure = new Error("Git read failed");
|
||||
|
||||
await expect(provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!)).rejects.toThrow(
|
||||
"Git read failed",
|
||||
);
|
||||
|
||||
expect(await projectRegistry.get(created.projectId)).toEqual(archivedProject);
|
||||
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(archivedWorkspace);
|
||||
});
|
||||
|
||||
test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => {
|
||||
// The branch only reads workspace.workspaceId off the worktree result.
|
||||
const createdWorktree = {
|
||||
@@ -207,15 +403,90 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
|
||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => {
|
||||
test("directory creation persists the live branch and a trimmed title", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(repo, " Focused work ");
|
||||
expect(workspace).toMatchObject({ branch: "main", title: "Focused work" });
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => {
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "elsewhere"),
|
||||
kind: "non_git",
|
||||
displayName: "elsewhere",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
);
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind", async () => {
|
||||
const rootPath = path.join(tmpDir, "repo");
|
||||
gitRoots.add(rootPath);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "Saved project name",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
await projectRegistry.upsert({ ...project, customName: "Pinned project name" });
|
||||
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
rootPath,
|
||||
null,
|
||||
project.projectId,
|
||||
);
|
||||
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
expect(await projectRegistry.get(project.projectId)).toMatchObject({
|
||||
projectId: project.projectId,
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "Saved project name",
|
||||
customName: "Pinned project name",
|
||||
});
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
|
||||
).rejects.toMatchObject({
|
||||
code: "unknown_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "archived"),
|
||||
kind: "non_git",
|
||||
displayName: "archived",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.archive(project.projectId, "2026-03-02T00:00:00.000Z");
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "archived_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory keeps nested selected roots independent", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
|
||||
const first = await provisioning.findOrCreateProjectForDirectory(repo);
|
||||
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
|
||||
|
||||
expect(second.projectId).toBe(first.projectId);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
expect(second.projectId).not.toBe(first.projectId);
|
||||
expect(first.rootPath).toBe(repo);
|
||||
expect(second.rootPath).toBe(path.join(repo, "sub"));
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace uses an active requested workspace without creating another", async () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { resolve } from "node:path";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
classifyDirectoryForProjectMembership,
|
||||
generateWorkspaceId,
|
||||
initialWorkspacePlacement,
|
||||
reconcileWorkspacePlacement,
|
||||
} from "../../workspace-registry-model.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -15,20 +14,8 @@ import {
|
||||
} from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
|
||||
/**
|
||||
* Resolves which workspace and project records a directory belongs to, creating,
|
||||
* reclassifying, or unarchiving them as needed. Every path that needs a workspace
|
||||
* for a cwd — opening a project, importing an agent, creating an agent, restoring
|
||||
* an archived worktree — funnels through this one module, so the
|
||||
* classify → resolve-project → persist → unarchive sequence (and the
|
||||
* archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases)
|
||||
* lives in a single place instead of being smeared across the session.
|
||||
*
|
||||
* Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts;
|
||||
* this module owns the create-and-persist side.
|
||||
*/
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
requestedWorkspaceId?: string;
|
||||
@@ -46,6 +33,17 @@ export interface ImportWorkspaceResult<T> {
|
||||
createdWorkspace: PersistedWorkspaceRecord | null;
|
||||
}
|
||||
|
||||
export interface CreateWorktreeWorkspaceInput {
|
||||
sourceCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
cwd: string;
|
||||
worktreeRoot: string;
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceProvisioningService {
|
||||
runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
@@ -56,6 +54,10 @@ export interface WorkspaceProvisioningService {
|
||||
createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
createWorkspaceForWorktree(
|
||||
input: CreateWorktreeWorkspaceInput,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||
ensureWorkspaceRecordUnarchived(
|
||||
@@ -63,6 +65,22 @@ export interface WorkspaceProvisioningService {
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
}
|
||||
|
||||
export type WorkspaceProvisioningErrorCode = "unknown_project" | "archived_project";
|
||||
|
||||
export class WorkspaceProvisioningError extends Error {
|
||||
constructor(
|
||||
readonly code: WorkspaceProvisioningErrorCode,
|
||||
projectId: string,
|
||||
) {
|
||||
super(
|
||||
code === "unknown_project"
|
||||
? `Unknown project: ${projectId}`
|
||||
: `Archived project: ${projectId}`,
|
||||
);
|
||||
this.name = "WorkspaceProvisioningError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
@@ -134,224 +152,220 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspaceDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<string> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
if (options?.refreshGit === false) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(normalizedCwd);
|
||||
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
return resolve(checkout.worktreeRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
async function findExactWorkspaceByDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<PersistedWorkspaceRecord | null> {
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd, options);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForPlacement(input: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const rootPath = input.membership.projectRootPath;
|
||||
const kind = input.membership.projectKind;
|
||||
const projects = await projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.membership.projectKey,
|
||||
rootPath,
|
||||
kind,
|
||||
displayName: input.membership.projectName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind,
|
||||
archivedAt: null,
|
||||
updatedAt: input.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function reclassifyOrUnarchiveWorkspaceForDirectory(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
project: PersistedProjectRecord | null;
|
||||
cwd: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(input.cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout });
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const rootPath = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(rootPath);
|
||||
const timestamp = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
return projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: checkout.isGit ? "git" : "non_git",
|
||||
displayName: basename(rootPath) || rootPath,
|
||||
timestamp,
|
||||
});
|
||||
const projectId = projectRecord.projectId;
|
||||
const kind = membership.workspaceKind;
|
||||
const displayName = membership.workspaceDisplayName;
|
||||
}
|
||||
|
||||
if (
|
||||
input.workspace.projectId === projectId &&
|
||||
input.workspace.kind === kind &&
|
||||
input.workspace.displayName === displayName
|
||||
) {
|
||||
if (!input.project) {
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
}
|
||||
return ensureWorkspaceRecordUnarchived(input.workspace);
|
||||
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
|
||||
const project = await projectRegistry.get(projectId);
|
||||
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
|
||||
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const project = projectId
|
||||
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
|
||||
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
|
||||
await findOrCreateProjectForDirectory(normalizedCwd);
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function createWorkspaceForWorktree(
|
||||
input: CreateWorktreeWorkspaceInput,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const sourceCwd = resolve(input.sourceCwd);
|
||||
const repoRoot = resolve(input.repoRoot);
|
||||
const cwd = resolve(input.cwd);
|
||||
const worktreeRoot = resolve(input.worktreeRoot);
|
||||
const project = await resolveSourceProjectForWorktree({
|
||||
sourceCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot,
|
||||
});
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({
|
||||
source: "created_worktree",
|
||||
cwd,
|
||||
worktreeRoot,
|
||||
branch: input.branch,
|
||||
baseBranch: input.baseBranch,
|
||||
mainRepoRoot: repoRoot,
|
||||
}),
|
||||
title: input.title,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(input: {
|
||||
sourceCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
if (input.projectId) {
|
||||
return refreshProjectKind(await requireActiveProject(input.projectId));
|
||||
}
|
||||
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const sourceWorkspace =
|
||||
workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd),
|
||||
) ??
|
||||
workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.repoRoot),
|
||||
);
|
||||
if (sourceWorkspace) {
|
||||
const project = await projectRegistry.get(sourceWorkspace.projectId);
|
||||
if (project) return refreshProjectKind(project);
|
||||
// COMPAT(worktreeMissingSourceProject): added in v0.1.107, remove after 2027-01-15.
|
||||
// Orphaned legacy workspace FKs fall through to exact-root allocation.
|
||||
}
|
||||
|
||||
const nextWorkspace = {
|
||||
...input.workspace,
|
||||
projectId,
|
||||
cwd: input.cwd,
|
||||
kind,
|
||||
displayName,
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await workspaceRegistry.upsert(nextWorkspace);
|
||||
return nextWorkspace;
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: input.repoRoot,
|
||||
kind: "git",
|
||||
displayName: basename(input.repoRoot) || input.repoRoot,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return refreshProjectKind(project);
|
||||
}
|
||||
|
||||
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const inputCwd = resolve(cwd);
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd);
|
||||
const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, {
|
||||
refreshGit: false,
|
||||
});
|
||||
if (existingWorkspace) {
|
||||
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
|
||||
const timestamp = new Date().toISOString();
|
||||
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 resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: inputCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
return reclassifyOrUnarchiveWorkspaceForDirectory({
|
||||
workspace: existingWorkspace,
|
||||
project: await projectRegistry.get(existingWorkspace.projectId),
|
||||
cwd: normalizedCwd,
|
||||
});
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const active = workspaces
|
||||
.filter(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (active) return refreshWorkspaceRecord(active);
|
||||
const archived = workspaces
|
||||
.filter(
|
||||
(workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (archived) {
|
||||
const project = await projectRegistry.get(archived.projectId);
|
||||
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
|
||||
}
|
||||
|
||||
return createWorkspaceForDirectory(normalizedCwd);
|
||||
}
|
||||
|
||||
async function resolveOrCreateWorkspaceIdForCreateAgent(
|
||||
input: ResolveOrCreateWorkspaceIdInput,
|
||||
): Promise<string> {
|
||||
if (input.createdWorktree) {
|
||||
return input.createdWorktree.workspace.workspaceId;
|
||||
}
|
||||
|
||||
if (input.requestedWorkspaceId) {
|
||||
return input.requestedWorkspaceId;
|
||||
}
|
||||
|
||||
if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId;
|
||||
if (input.requestedWorkspaceId) return input.requestedWorkspaceId;
|
||||
return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
title: title ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
return projectRecord;
|
||||
}
|
||||
|
||||
async function ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
|
||||
const timestamp = new Date().toISOString();
|
||||
let unarchivedWorkspace = workspace;
|
||||
if (workspace.archivedAt) {
|
||||
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
|
||||
await workspaceRegistry.upsert(unarchivedWorkspace);
|
||||
}
|
||||
if (project?.archivedAt) {
|
||||
await projectRegistry.upsert({
|
||||
...project,
|
||||
archivedAt: null,
|
||||
const checkout =
|
||||
workspace.archivedAt || project.archivedAt
|
||||
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||
: null;
|
||||
let next: PersistedWorkspaceRecord | null = null;
|
||||
if (workspace.archivedAt && checkout) {
|
||||
const placementUpdate = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
next = {
|
||||
...(placementUpdate?.workspace ?? workspace),
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
return unarchivedWorkspace;
|
||||
if (checkout && (project.archivedAt || workspace.archivedAt)) {
|
||||
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind = projectCheckout.isGit ? "git" : "non_git";
|
||||
if (project.archivedAt || project.kind !== kind) {
|
||||
await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp });
|
||||
}
|
||||
}
|
||||
if (!next) return workspace;
|
||||
await workspaceRegistry.upsert(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function refreshWorkspaceRecord(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (project && !project.archivedAt) {
|
||||
await refreshProjectKind(project, workspace.cwd, checkout);
|
||||
}
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!update) return workspace;
|
||||
await workspaceRegistry.upsert(update.workspace);
|
||||
return update.workspace;
|
||||
}
|
||||
|
||||
async function refreshProjectKind(
|
||||
project: PersistedProjectRecord,
|
||||
workspaceCwd?: string,
|
||||
workspaceCheckout?: Awaited<ReturnType<WorkspaceGitService["getCheckout"]>>,
|
||||
): Promise<PersistedProjectRecord> {
|
||||
const projectCheckout =
|
||||
workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd)
|
||||
? workspaceCheckout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
|
||||
if (project.kind === kind) return project;
|
||||
const refreshed = { ...project, kind, updatedAt: new Date().toISOString() };
|
||||
await projectRegistry.upsert(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -359,6 +373,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
findOrCreateWorkspaceForDirectory,
|
||||
resolveOrCreateWorkspaceIdForCreateAgent,
|
||||
createWorkspaceForDirectory,
|
||||
createWorkspaceForWorktree,
|
||||
findOrCreateProjectForDirectory,
|
||||
ensureWorkspaceRecordUnarchived,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createWorktree } from "../../../utils/worktree.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -8,15 +23,23 @@ import {
|
||||
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
|
||||
|
||||
const NOW = "2026-07-11T10:12:30.752Z";
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
function createProject(): PersistedProjectRecord {
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProject(overrides: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: "/repo",
|
||||
rootPath: "/repo",
|
||||
projectId: "/project",
|
||||
rootPath: "/project",
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
displayName: "project",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,12 +48,15 @@ function createWorkspace(
|
||||
): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
projectId: "/repo",
|
||||
projectId: "/project",
|
||||
cwd: "/worktrees/trigger-1525443412986298439",
|
||||
kind: "worktree",
|
||||
displayName: "diagnose-repro-tdd",
|
||||
title: "Codex TDD reproduction",
|
||||
title: "TDD reproduction",
|
||||
branch: "diagnose-repro-tdd",
|
||||
worktreeRoot: "/worktrees/trigger-1525443412986298439",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: NOW,
|
||||
@@ -42,55 +68,53 @@ function createHarness(input?: {
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
directories?: string[];
|
||||
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
}) {
|
||||
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
|
||||
const project = input?.project === undefined ? createProject() : input.project;
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const recreated: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspace?.workspaceId === workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
|
||||
isDirectory: async (path) => directories.has(path),
|
||||
recreateWorktree: async (record) => {
|
||||
recreated.push(record.workspaceId);
|
||||
await input?.recreate?.(record);
|
||||
},
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
return { service, recreated, unarchived };
|
||||
return { service, unarchived };
|
||||
}
|
||||
|
||||
describe("workspace recovery", () => {
|
||||
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
|
||||
const { service, recreated, unarchived } = createHarness();
|
||||
test("describes a missing archived worktree from persisted placement", async () => {
|
||||
const { service, unarchived } = createHarness();
|
||||
|
||||
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
kind: "recoverable",
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
workspaceName: "Codex TDD reproduction",
|
||||
workspaceName: "TDD reproduction",
|
||||
action: "restore",
|
||||
branch: "diagnose-repro-tdd",
|
||||
});
|
||||
expect(recreated).toEqual([]);
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("describes an archived workspace whose directory remains as unarchivable", async () => {
|
||||
test("unarchives an archived workspace whose exact directory remains", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({
|
||||
const { service, unarchived } = createHarness({
|
||||
workspace,
|
||||
directories: ["/repo", workspace.cwd],
|
||||
directories: [workspace.cwd],
|
||||
});
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
|
||||
kind: "recoverable",
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "unarchive",
|
||||
});
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
||||
@@ -105,27 +129,160 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
|
||||
let attempts = 0;
|
||||
const { service, recreated, unarchived } = createHarness({
|
||||
recreate: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("git branch diagnose-repro-tdd is unavailable");
|
||||
}
|
||||
test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/mixed-project";
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "mixed-project",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const projectRoot = join(tempDir, "explicit-non-git-project");
|
||||
mkdirSync(projectRoot);
|
||||
const project = createProject({
|
||||
projectId: "explicit-non-git-project",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
});
|
||||
const workspace = createWorkspace({
|
||||
workspaceId: "ws-mixed-project-recreate",
|
||||
projectId: project.projectId,
|
||||
cwd: workspaceCwd,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspaceId === workspace.workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
|
||||
"git branch diagnose-repro-tdd is unavailable",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "restore",
|
||||
});
|
||||
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
|
||||
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/without-subproject";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "without-subproject",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const project = createProject({ rootPath: repoDir });
|
||||
const workspace = createWorkspace({
|
||||
workspaceId: "ws-missing-restored-subdirectory",
|
||||
cwd: workspaceCwd,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspaceId === workspace.workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
isDirectory: async (targetPath) =>
|
||||
existsSync(targetPath) && statSync(targetPath).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
|
||||
"Selected project directory is missing from the restored worktree",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
expect(existsSync(worktreeRoot)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.includes("without-subproject"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when its persisted source repository is missing", async () => {
|
||||
const workspace = createWorkspace({ mainRepoRoot: "/missing-source" });
|
||||
const { service, unarchived } = createHarness({
|
||||
workspace,
|
||||
directories: ["/project"],
|
||||
});
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
workspaceId: workspace.workspaceId,
|
||||
reason: "project_directory_missing",
|
||||
message: "The source repository needed to restore this worktree no longer exists.",
|
||||
});
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
|
||||
"The source repository needed to restore this worktree no longer exists.",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createGitRepository(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-"));
|
||||
tempDirectories.push(tempDir);
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(join(repoDir, "README.md"), "initial\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
import {
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
} from "../../../utils/worktree.js";
|
||||
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import {
|
||||
resolveWorkspaceDisplayName,
|
||||
type PersistedProjectRecord,
|
||||
@@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService {
|
||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
}
|
||||
|
||||
type RecoveryPlan =
|
||||
| {
|
||||
kind: "unarchive";
|
||||
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
}
|
||||
| {
|
||||
kind: "restore";
|
||||
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
sourceRepoRoot: string;
|
||||
};
|
||||
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}): WorkspaceRecoveryService {
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||
async function resolveRecovery(
|
||||
workspaceId: string,
|
||||
): Promise<UnavailableRecoveryState | RecoveryPlan> {
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace) {
|
||||
return {
|
||||
@@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
}
|
||||
|
||||
if (await deps.isDirectory(workspace.cwd)) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "unarchive",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
return createRecoveryPlan({ action: "unarchive", workspace });
|
||||
}
|
||||
|
||||
if (workspace.kind !== "worktree") {
|
||||
@@ -94,42 +117,148 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: "The archived worktree has no branch recorded, so it cannot be restored.",
|
||||
};
|
||||
}
|
||||
if (!(await deps.isDirectory(project.rootPath))) {
|
||||
|
||||
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
|
||||
// lack placement ownership; remove the project-root fallback after 2027-01-17.
|
||||
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
|
||||
if (!(await deps.isDirectory(sourceRepoRoot))) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "project_directory_missing",
|
||||
message: "The project directory needed to restore this worktree no longer exists.",
|
||||
message: "The source repository needed to restore this worktree no longer exists.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "restore",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot });
|
||||
}
|
||||
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
return resolved.kind === "unavailable" ? resolved : resolved.state;
|
||||
}
|
||||
|
||||
async function restore(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||
const state = await inspect(workspaceId);
|
||||
if (state.kind === "unavailable") {
|
||||
throw new Error(state.message);
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
if (resolved.kind === "unavailable") {
|
||||
throw new Error(resolved.message);
|
||||
}
|
||||
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace?.archivedAt) {
|
||||
throw new Error("The archived workspace changed before it could be recovered.");
|
||||
if (resolved.kind === "restore") {
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
|
||||
}
|
||||
if (state.action === "restore") {
|
||||
await deps.recreateWorktree(workspace);
|
||||
await deps.unarchiveWorkspace(resolved.workspace);
|
||||
return { workspaceId, action: resolved.kind };
|
||||
}
|
||||
|
||||
async function recreateArchivedWorktree(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
sourceRepoRoot: string,
|
||||
): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
|
||||
} catch {
|
||||
// A stale worktree registration is not guaranteed; creation reports any real conflict.
|
||||
}
|
||||
|
||||
let previousWorktreePath = workspace.worktreeRoot;
|
||||
if (!previousWorktreePath) {
|
||||
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
|
||||
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
previousWorktreePath = ownership.allowed
|
||||
? (ownership.worktreePath ?? workspace.cwd)
|
||||
: workspace.cwd;
|
||||
}
|
||||
|
||||
let recreatedWorktreePath: string;
|
||||
try {
|
||||
const result = await createWorktree({
|
||||
cwd: sourceRepoRoot,
|
||||
worktreeSlug: basename(previousWorktreePath),
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
targetWorktreePath: recreatedWorktreePath,
|
||||
});
|
||||
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
if (!(await deps.isDirectory(recreatedWorkspacePath))) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: sourceRepoRoot,
|
||||
worktreePath: recreatedWorktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesBaseRoot: deps.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
await deps.unarchiveWorkspace(workspace);
|
||||
return { workspaceId, action: state.action };
|
||||
}
|
||||
|
||||
return { inspect, restore };
|
||||
}
|
||||
|
||||
function createRecoveryPlan(
|
||||
input:
|
||||
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
|
||||
| { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string },
|
||||
): RecoveryPlan {
|
||||
const state = {
|
||||
kind: "recoverable" as const,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(input.workspace),
|
||||
branch: input.workspace.branch,
|
||||
};
|
||||
if (input.action === "restore") {
|
||||
return {
|
||||
kind: input.action,
|
||||
state: { ...state, action: input.action },
|
||||
workspace: input.workspace,
|
||||
sourceRepoRoot: input.sourceRepoRoot,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: input.action,
|
||||
state: {
|
||||
...state,
|
||||
action: input.action,
|
||||
},
|
||||
workspace: input.workspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pino } from "pino";
|
||||
@@ -6,34 +6,29 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
} from "../../worktree-bootstrap.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata},
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
|
||||
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
||||
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const gitMetadata: WorkspaceGitMetadata = {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "repo",
|
||||
workspaceDisplayName: "repo",
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: null,
|
||||
};
|
||||
|
||||
function fakeWorkspaceRegistry(
|
||||
record: PersistedWorkspaceRecord | null,
|
||||
): Pick<WorkspaceRegistry, "get"> {
|
||||
@@ -44,13 +39,28 @@ function fakeWorkspaceRegistry(
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) {
|
||||
function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick<ProjectRegistry, "get"> {
|
||||
return {
|
||||
async get() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService() {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
|
||||
snapshot.git = {
|
||||
...snapshot.git,
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
hasRemote: true,
|
||||
};
|
||||
|
||||
return {
|
||||
peekSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return metadata;
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,7 +74,9 @@ interface BuildOptions {
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
|
||||
terminalManager?: TerminalManager | null;
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
spawnThrows?: string;
|
||||
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
}
|
||||
|
||||
function buildService(options: BuildOptions = {}) {
|
||||
@@ -87,7 +99,8 @@ function buildService(options: BuildOptions = {}) {
|
||||
terminalManager:
|
||||
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||
workspaceGitService: fakeGitService(),
|
||||
projectRegistry: fakeProjectRegistry(options.project ?? null),
|
||||
workspaceGitService: options.gitService ?? fakeGitService(),
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
@@ -130,28 +143,77 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("buildSnapshot", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", async () => {
|
||||
const { service } = buildService({ serviceProxy: null });
|
||||
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns no scripts when the runtime store is unavailable", () => {
|
||||
test("returns no scripts when the runtime store is unavailable", async () => {
|
||||
const { service } = buildService({ scriptRuntimeStore: null });
|
||||
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns no scripts for a workspace without a paseo.json", () => {
|
||||
test("returns no scripts for a workspace without a paseo.json", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(dir);
|
||||
const { service } = buildService();
|
||||
expect(service.buildSnapshot("ws-1", dir)).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("projects service hostnames without a Git snapshot", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(directory);
|
||||
writeFileSync(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
|
||||
);
|
||||
const project = {
|
||||
projectId: "prj_no_snapshot",
|
||||
rootPath: directory,
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const workspace = {
|
||||
workspaceId: "ws-no-snapshot",
|
||||
projectId: project.projectId,
|
||||
cwd: directory,
|
||||
branch: "feature/persisted",
|
||||
} as PersistedWorkspaceRecord;
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service, spawnCalls } = buildService({
|
||||
workspace,
|
||||
project,
|
||||
serviceProxy,
|
||||
gitService: { peekSnapshot: () => undefined },
|
||||
});
|
||||
|
||||
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
branchName: workspace.branch,
|
||||
scriptName: "app",
|
||||
daemonPort: 6767,
|
||||
}).hostname,
|
||||
);
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
expect(spawnCalls[0]?.branchName).toBe(workspace.branch);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitStatusUpdate", () => {
|
||||
test("emits one script_status_update carrying the snapshot", () => {
|
||||
test("emits one script_status_update carrying the snapshot", async () => {
|
||||
const { service, emitted } = buildService();
|
||||
service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||
await service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||
expect(emitted).toEqual([
|
||||
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
|
||||
]);
|
||||
@@ -225,6 +287,101 @@ describe("start", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the exact project root for a service hostname", async () => {
|
||||
const workspace = {
|
||||
workspaceId: "ws-app",
|
||||
projectId: "prj-app",
|
||||
cwd: "/repo/apps/app",
|
||||
} as PersistedWorkspaceRecord;
|
||||
const project = {
|
||||
projectId: "prj-app",
|
||||
rootPath: "/repo/apps/app",
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const { service, spawnCalls } = buildService({ workspace, project });
|
||||
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
|
||||
expect(spawnCalls[0]).toMatchObject({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps same-named service projects distinct", async () => {
|
||||
const projectA = {
|
||||
projectId: "prj-app-a",
|
||||
rootPath: "/repo-a/app",
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" };
|
||||
const workspaceA = {
|
||||
workspaceId: "ws-app-a",
|
||||
projectId: projectA.projectId,
|
||||
cwd: projectA.rootPath,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const workspaceB = {
|
||||
workspaceId: "ws-app-b",
|
||||
projectId: projectB.projectId,
|
||||
cwd: projectB.rootPath,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const first = buildService({ workspace: workspaceA, project: projectA });
|
||||
const second = buildService({ workspace: workspaceB, project: projectB });
|
||||
|
||||
await first.service.start({ ...request, workspaceId: workspaceA.workspaceId });
|
||||
await second.service.start({ ...request, workspaceId: workspaceB.workspaceId });
|
||||
|
||||
expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug);
|
||||
});
|
||||
|
||||
test("predicts the same service hostname that start registers", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(directory);
|
||||
writeFileSync(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
|
||||
);
|
||||
const project = {
|
||||
projectId: "prj_hostname",
|
||||
rootPath: directory,
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const workspace = {
|
||||
workspaceId: "ws-hostname",
|
||||
projectId: project.projectId,
|
||||
cwd: directory,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
|
||||
|
||||
const snapshot = service.buildSnapshot(workspace, project);
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
|
||||
const started = spawnCalls[0]!;
|
||||
expect(snapshot[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: started.projectSlug,
|
||||
branchName: started.branchName,
|
||||
scriptName: started.scriptName,
|
||||
daemonPort: started.daemonPort,
|
||||
}).hostname,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports the launcher error when spawning fails", async () => {
|
||||
const { service, emitted } = buildService({ spawnThrows: "boom" });
|
||||
await service.start(request);
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
@@ -18,15 +23,10 @@ import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
readPaseoConfigForProjection,
|
||||
} from "../../script-status-projection.js";
|
||||
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||
|
||||
interface WorkspaceScriptGitMetadata {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
||||
* snapshot, emit a script_status_update to clients, and start a script.
|
||||
@@ -37,21 +37,22 @@ interface WorkspaceScriptGitMetadata {
|
||||
* that assembly and guard across the session.
|
||||
*/
|
||||
export interface WorkspaceScriptsService {
|
||||
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
|
||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
|
||||
buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project?: PersistedProjectRecord | null,
|
||||
): WorkspaceScriptsPayload;
|
||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||
}
|
||||
|
||||
type WorkspaceScriptsGitSource = Pick<
|
||||
WorkspaceGitService,
|
||||
"peekSnapshot" | "getWorkspaceGitMetadata"
|
||||
>;
|
||||
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
|
||||
export function createWorkspaceScriptsService(deps: {
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
terminalManager: TerminalManager | null;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
workspaceGitService: WorkspaceScriptsGitSource;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
@@ -66,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
scriptRuntimeStore,
|
||||
terminalManager,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
@@ -76,45 +78,60 @@ export function createWorkspaceScriptsService(deps: {
|
||||
spawnWorkspaceScript,
|
||||
} = deps;
|
||||
|
||||
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
function resolveGitMetadata(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: { projectId: string; rootPath: string } | null,
|
||||
) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null;
|
||||
if (project) {
|
||||
return {
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
currentBranch,
|
||||
};
|
||||
}
|
||||
if (!snapshot) return undefined;
|
||||
return {
|
||||
projectSlug: deriveProjectSlug(
|
||||
workspaceDirectory,
|
||||
workspace.cwd,
|
||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
||||
),
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload {
|
||||
function buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord | null = null,
|
||||
): WorkspaceScriptsPayload {
|
||||
if (!serviceProxy || !scriptRuntimeStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
|
||||
serviceProxy,
|
||||
runtimeStore: scriptRuntimeStore,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
gitMetadata: resolveGitMetadata(workspaceDirectory),
|
||||
gitMetadata: resolveGitMetadata(workspace, project),
|
||||
resolveHealth: resolveScriptHealth ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
||||
emit({
|
||||
type: "script_status_update",
|
||||
payload: {
|
||||
workspaceId,
|
||||
scripts: buildSnapshot(workspaceId, workspaceDirectory),
|
||||
},
|
||||
});
|
||||
async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> {
|
||||
try {
|
||||
const workspace = await workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) return;
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
emit({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
|
||||
}
|
||||
}
|
||||
|
||||
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||
@@ -127,13 +144,23 @@ export function createWorkspaceScriptsService(deps: {
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
const projectSlug = project
|
||||
? deriveProjectServiceSlug(project)
|
||||
: deriveProjectSlug(
|
||||
workspace.cwd,
|
||||
workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null,
|
||||
);
|
||||
const branchName =
|
||||
workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ??
|
||||
workspace.branch ??
|
||||
null;
|
||||
|
||||
const serviceResult = await spawnWorkspaceScript({
|
||||
repoRoot: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: gitMetadata.projectSlug,
|
||||
branchName: gitMetadata.currentBranch,
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
||||
@@ -143,11 +170,11 @@ export function createWorkspaceScriptsService(deps: {
|
||||
terminalManager,
|
||||
logger,
|
||||
onLifecycleChanged: () => {
|
||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
},
|
||||
});
|
||||
|
||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
emit({
|
||||
type: "start_workspace_script_response",
|
||||
payload: {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "../workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "../workspace-git-metadata.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
@@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService(
|
||||
registerWorkspace: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
onSnapshotUpdated: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
@@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService(
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd,
|
||||
directoryName: options?.directoryName ?? basename(cwd),
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
},
|
||||
resolveForge: async () => null,
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
|
||||
@@ -921,6 +921,20 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("advertises stable project identity in initial server_info", async () => {
|
||||
const server = createServer();
|
||||
const socket = new MockSocket();
|
||||
|
||||
const serverInfo = await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-stable-project-identity",
|
||||
});
|
||||
|
||||
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
const server = createServer({ speechReadiness });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||
import { basename, join } from "path";
|
||||
import { join } from "path";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks";
|
||||
@@ -10,6 +10,7 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
@@ -35,7 +36,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
@@ -190,17 +191,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
getWorkspaceGitMetadata: async (cwd: string, options) => {
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd,
|
||||
directoryName: options?.directoryName ?? basename(cwd),
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
},
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
@@ -223,6 +216,16 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
getOrCreateActiveByRoot: async (input) => ({
|
||||
projectId: "prj_noop",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
customName: null,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
archivedAt: null,
|
||||
}),
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -767,6 +770,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
public publishProjectUpdate(update: ProjectUpdate): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectUpdate(update);
|
||||
}
|
||||
|
||||
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
||||
}
|
||||
@@ -1293,6 +1300,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
forgeProviders: true,
|
||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||
selectiveAgentTimeline: true,
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
type SessionOutboundMessage,
|
||||
WSHelloMessageSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import type { AgentTimelineRow } from "./agent/agent-manager.js";
|
||||
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
|
||||
const LegacyTimelineEntryPayloadSchema = z.object({
|
||||
provider: z.enum(["claude", "codex", "opencode"]),
|
||||
@@ -288,8 +292,8 @@ function createSessionForWireCompatTest(options?: {
|
||||
async resolveRepoRemoteUrl() {
|
||||
return null;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return null;
|
||||
async getProjectSlug() {
|
||||
return "project";
|
||||
},
|
||||
} as unknown as SessionOptions["workspaceGitService"],
|
||||
daemonConfigStore:
|
||||
@@ -326,6 +330,99 @@ async function emitTimelineResponse(
|
||||
}
|
||||
|
||||
describe("wire compatibility", () => {
|
||||
test("sends project updates only to clients that declare support", () => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project",
|
||||
kind: "git",
|
||||
displayName: "project",
|
||||
customName: "Favorite project",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const legacyMessages: SessionOutboundMessage[] = [];
|
||||
const capableMessages: SessionOutboundMessage[] = [];
|
||||
const legacy = createSessionForWireCompatTest({ messages: legacyMessages });
|
||||
const capable = createSessionForWireCompatTest({
|
||||
clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true },
|
||||
messages: capableMessages,
|
||||
});
|
||||
|
||||
legacy.emitProjectUpdate({ kind: "upsert", project });
|
||||
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
|
||||
capable.emitProjectUpdate({ kind: "upsert", project });
|
||||
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
|
||||
|
||||
expect(legacyMessages).toEqual([]);
|
||||
expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([
|
||||
{
|
||||
type: "project.update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Favorite project",
|
||||
projectCustomName: "Favorite project",
|
||||
projectRootPath: "/tmp/project",
|
||||
projectKind: "git",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "project.update",
|
||||
payload: { kind: "remove", projectId: "project-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("hello parses with and without the project update capability", () => {
|
||||
const legacy = WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "legacy-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
});
|
||||
const capable = WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "capable-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: { [CLIENT_CAPS.projectUpdates]: true },
|
||||
});
|
||||
|
||||
expect([legacy, capable]).toEqual([
|
||||
{
|
||||
type: "hello",
|
||||
clientId: "legacy-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
{
|
||||
type: "hello",
|
||||
clientId: "capable-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: { project_updates: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("server info accepts legacy feature payloads without stable project identity", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "legacy-server",
|
||||
features: { workspaceGithubClone: true },
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
status: "server_info",
|
||||
serverId: "legacy-server",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("assistant timeline message ids are optional on the wire", () => {
|
||||
expect(
|
||||
AgentTimelineItemPayloadSchema.parse({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino, { type Logger } from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
@@ -205,7 +206,6 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-last-ref-workspace",
|
||||
},
|
||||
);
|
||||
@@ -217,8 +217,23 @@ describe("archiveByScope", () => {
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace scope keeps the directory when a sibling workspace still references it", async () => {
|
||||
test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/shared-teardown.log', 'ok')\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared teardown"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace");
|
||||
const workspaceA = "ws-sibling-a";
|
||||
@@ -234,7 +249,6 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: workspaceA },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-sibling-workspace",
|
||||
},
|
||||
);
|
||||
@@ -244,34 +258,228 @@ describe("archiveByScope", () => {
|
||||
removedDirectory: false,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
expect(readFileSync(path.join(repoDir, "shared-teardown.log"), "utf8")).toBe("ok");
|
||||
});
|
||||
|
||||
test("worktree scope archives every workspace on the directory and removes it", async () => {
|
||||
test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
|
||||
const workspaceA = "ws-worktree-a";
|
||||
const workspaceB = "ws-worktree-b";
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-sibling");
|
||||
const sourceWorkspaceId = "ws-subdirectory-source";
|
||||
const siblingWorkspaceId = "ws-subdirectory-sibling";
|
||||
const siblingDirectory = path.join(worktree.worktreePath, "packages", "app");
|
||||
mkdirSync(siblingDirectory, { recursive: true });
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
|
||||
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" },
|
||||
{
|
||||
workspaceId: sourceWorkspaceId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
{
|
||||
workspaceId: siblingWorkspaceId,
|
||||
cwd: siblingDirectory,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: sourceWorkspaceId },
|
||||
requestId: "req-subdirectory-sibling",
|
||||
},
|
||||
);
|
||||
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [sourceWorkspaceId],
|
||||
removedDirectory: false,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("archiving a subdirectory workspace keeps its active worktree root", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-target");
|
||||
const rootWorkspaceId = "ws-subdirectory-root";
|
||||
const subdirectoryWorkspaceId = "ws-subdirectory-target";
|
||||
const subdirectory = path.join(worktree.worktreePath, "packages", "app");
|
||||
mkdirSync(subdirectory, { recursive: true });
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId: rootWorkspaceId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
{
|
||||
workspaceId: subdirectoryWorkspaceId,
|
||||
cwd: subdirectory,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId },
|
||||
requestId: "req-subdirectory-target",
|
||||
},
|
||||
);
|
||||
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [subdirectoryWorkspaceId],
|
||||
removedDirectory: false,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("workspace scope runs teardown from the exact nested workspace before deleting its worktree", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const nestedRelative = path.join("packages", "app");
|
||||
const sourceNested = path.join(repoDir, nestedRelative);
|
||||
mkdirSync(sourceNested, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(sourceNested, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/nested-teardown.log', process.cwd())\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "nested teardown"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "nested-teardown");
|
||||
const workspaceCwd = path.join(worktree.worktreePath, nestedRelative);
|
||||
const matchesWorkspaceCwd = createRealpathAwarePathMatcher(workspaceCwd);
|
||||
const workspaceId = "ws-nested-teardown";
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId,
|
||||
cwd: workspaceCwd,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-nested-teardown",
|
||||
},
|
||||
);
|
||||
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [workspaceId],
|
||||
removedDirectory: true,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
expect(
|
||||
matchesWorkspaceCwd(readFileSync(path.join(repoDir, "nested-teardown.log"), "utf8")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const nestedRelative = path.join("packages", "app");
|
||||
const sourceNested = path.join(repoDir, nestedRelative);
|
||||
mkdirSync(sourceNested, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"const fs=require('fs');const out=process.env.PASEO_SOURCE_CHECKOUT_PATH+'/root-scope-teardown.log';if(fs.existsSync(out))process.exit(2);fs.writeFileSync(out,'ok')\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(sourceNested, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH+'/nested-scope-teardown.log','ok')\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "scope teardown"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
|
||||
const workspaceA = "ws-worktree-a";
|
||||
const workspaceB = "ws-worktree-b";
|
||||
const workspaceC = "ws-worktree-subdirectory";
|
||||
const subdirectory = path.join(worktree.worktreePath, nestedRelative);
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId: workspaceA,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
{
|
||||
workspaceId: workspaceB,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
{
|
||||
workspaceId: workspaceC,
|
||||
cwd: subdirectory,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-worktree-scope",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB]));
|
||||
expect(result.archivedWorkspaceIds).toHaveLength(2);
|
||||
expect(result.archivedWorkspaceIds).toEqual(
|
||||
expect.arrayContaining([workspaceA, workspaceB, workspaceC]),
|
||||
);
|
||||
expect(result.archivedWorkspaceIds).toHaveLength(3);
|
||||
expect(result.removedDirectory).toBe(true);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
expect(readFileSync(path.join(repoDir, "root-scope-teardown.log"), "utf8")).toBe("ok");
|
||||
expect(readFileSync(path.join(repoDir, "nested-scope-teardown.log"), "utf8")).toBe("ok");
|
||||
});
|
||||
|
||||
test("workspace scope never removes a non-Paseo-owned directory", async () => {
|
||||
@@ -286,7 +494,6 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: null,
|
||||
requestId: "req-local-checkout",
|
||||
},
|
||||
);
|
||||
@@ -322,7 +529,6 @@ describe("archiveByScope", () => {
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-partial-failure",
|
||||
});
|
||||
|
||||
@@ -347,7 +553,6 @@ describe("archiveByScope", () => {
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId: "ws-does-not-exist" },
|
||||
repoRoot: null,
|
||||
requestId: "req-unknown-workspace",
|
||||
});
|
||||
|
||||
@@ -372,7 +577,6 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-zero-records",
|
||||
},
|
||||
);
|
||||
@@ -452,7 +656,6 @@ describe("archiveByScope", () => {
|
||||
|
||||
await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-lifecycle",
|
||||
});
|
||||
|
||||
@@ -506,7 +709,6 @@ describe("archiveByScope", () => {
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-snapshot-scope",
|
||||
});
|
||||
|
||||
@@ -540,7 +742,6 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-worktree-scope-n3",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,22 +9,21 @@ import type { ForgeService } from "../services/forge-service.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
runWorktreeTeardownCommands,
|
||||
WorktreeTeardownError,
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
export interface ActiveWorkspaceRef {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
kind?: "local_checkout" | "worktree" | "directory";
|
||||
}
|
||||
export type ActiveWorkspaceRef = Pick<
|
||||
PersistedWorkspaceRecord,
|
||||
"workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot"
|
||||
>;
|
||||
|
||||
export interface ArchiveDependencies {
|
||||
paseoHome?: string;
|
||||
// Base directory that may hold worktrees across repositories. Used as a fallback
|
||||
// when the request does not supply a per-repo root.
|
||||
// Base directory that may hold worktrees across repositories.
|
||||
paseoWorktreesBaseRoot?: string;
|
||||
github: ForgeService;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
@@ -65,22 +64,29 @@ export interface ArchiveResult {
|
||||
|
||||
export interface ArchiveByScopeRequest {
|
||||
scope: ArchiveScope;
|
||||
repoRoot: string | null;
|
||||
// Per-repository worktree root, used to remove the actual directory.
|
||||
repoWorktreesRoot?: string;
|
||||
// Base directory that may hold worktrees across repositories; falls back to the
|
||||
// dependency's base root for ownership checks and path resolution.
|
||||
paseoWorktreesBaseRoot?: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
interface BackingDirectory {
|
||||
path: string;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
mainRepoRoot: string | null;
|
||||
paseoWorktreesRoot: string | null;
|
||||
}
|
||||
|
||||
interface ArchiveTarget {
|
||||
backing: BackingDirectory | null;
|
||||
teardownTargets: Array<{ workspaceId: string | null; cwd: string }>;
|
||||
workspaceIds: string[];
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceIdAtPath(
|
||||
dependencies: Pick<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
|
||||
targetPath: string,
|
||||
): Promise<string | null> {
|
||||
const targetDir = resolve(targetPath);
|
||||
const matchesTarget = createRealpathAwarePathMatcher(targetPath);
|
||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
||||
const exactMatches = activeWorkspaces.filter((workspace) => resolve(workspace.cwd) === targetDir);
|
||||
const exactMatches = activeWorkspaces.filter((workspace) => matchesTarget(workspace.cwd));
|
||||
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
|
||||
if (worktreeMatch) {
|
||||
return worktreeMatch.workspaceId;
|
||||
@@ -88,18 +94,15 @@ export async function resolveWorkspaceIdAtPath(
|
||||
return dependencies.findWorkspaceIdForCwd(targetPath);
|
||||
}
|
||||
|
||||
// THE single archive entry. Resolves the in-scope record set, tears each down
|
||||
// Resolves the in-scope record set, tears each down
|
||||
// (agents + terminals + record), then removes the backing directory iff it is
|
||||
// Paseo-owned AND no active workspace still references it.
|
||||
export async function archiveByScope(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: ArchiveByScopeRequest,
|
||||
): Promise<ArchiveResult> {
|
||||
const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets(
|
||||
dependencies,
|
||||
request.scope,
|
||||
request.paseoWorktreesBaseRoot,
|
||||
);
|
||||
const target = await resolveArchiveTarget(dependencies, request.scope);
|
||||
const targetWorkspaceIds = target.workspaceIds;
|
||||
|
||||
if (targetWorkspaceIds.length > 0) {
|
||||
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
|
||||
@@ -118,25 +121,25 @@ export async function archiveByScope(
|
||||
request.requestId,
|
||||
);
|
||||
|
||||
if (request.repoRoot) {
|
||||
if (target.backing?.mainRepoRoot) {
|
||||
try {
|
||||
await dependencies.workspaceGitService.getSnapshot(request.repoRoot, {
|
||||
await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, cwd: request.repoRoot, requestId: request.requestId },
|
||||
{ err: error, cwd: target.backing.mainRepoRoot, requestId: request.requestId },
|
||||
"Failed to force-refresh workspace git snapshot after archiving",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDir !== null) {
|
||||
if (target.backing !== null) {
|
||||
removedDirectory = await maybeRemoveDirectory(
|
||||
dependencies,
|
||||
request,
|
||||
targetDir,
|
||||
target,
|
||||
archivedWorkspaceIds,
|
||||
);
|
||||
}
|
||||
@@ -154,11 +157,10 @@ export async function archiveByScope(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveArchiveTargets(
|
||||
async function resolveArchiveTarget(
|
||||
dependencies: ArchiveDependencies,
|
||||
scope: ArchiveScope,
|
||||
paseoWorktreesBaseRoot?: string,
|
||||
): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> {
|
||||
): Promise<ArchiveTarget> {
|
||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
||||
|
||||
if (scope.kind === "workspace") {
|
||||
@@ -169,24 +171,89 @@ async function resolveArchiveTargets(
|
||||
{ workspaceId },
|
||||
"Workspace not found for archive-by-scope; skipping",
|
||||
);
|
||||
return { targetDir: null, targetWorkspaceIds: [] };
|
||||
return { backing: null, teardownTargets: [], workspaceIds: [] };
|
||||
}
|
||||
return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] };
|
||||
return {
|
||||
backing: await resolveWorkspaceBackingDirectory(record, dependencies),
|
||||
teardownTargets: [{ workspaceId, cwd: record.cwd }],
|
||||
workspaceIds: [workspaceId],
|
||||
};
|
||||
}
|
||||
|
||||
let targetPath = scope.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
const backing = await resolveBackingDirectory(scope.targetPath, dependencies);
|
||||
const matchesBackingDirectory = createRealpathAwarePathMatcher(backing.path);
|
||||
const targetWorkspaces = (
|
||||
await Promise.all(
|
||||
activeWorkspaces.map(async (workspace) => {
|
||||
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
|
||||
return matchesBackingDirectory(backingDirectory.path) ? workspace : null;
|
||||
}),
|
||||
)
|
||||
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
|
||||
const persistedMainRepoRoot = targetWorkspaces.find(
|
||||
(workspace) => workspace.mainRepoRoot,
|
||||
)?.mainRepoRoot;
|
||||
return {
|
||||
backing: {
|
||||
...backing,
|
||||
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
|
||||
},
|
||||
teardownTargets:
|
||||
targetWorkspaces.length > 0
|
||||
? targetWorkspaces.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
}))
|
||||
: [{ workspaceId: null, cwd: scope.targetPath }],
|
||||
workspaceIds: targetWorkspaces.map((workspace) => workspace.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWorkspaceBackingDirectory(
|
||||
workspace: ActiveWorkspaceRef,
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
): Promise<BackingDirectory> {
|
||||
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
|
||||
return {
|
||||
path: resolve(workspace.worktreeRoot),
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
}
|
||||
const targetDir = resolve(targetPath);
|
||||
const targetWorkspaceIds = activeWorkspaces
|
||||
.filter((workspace) => resolve(workspace.cwd) === targetDir)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
return { targetDir, targetWorkspaceIds };
|
||||
if (workspace.kind !== "worktree") {
|
||||
return {
|
||||
path: resolve(workspace.cwd),
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
// COMPAT(archiveMissingWorkspacePlacement): worktree records created before v0.1.110
|
||||
// lack durable backing ownership; remove filesystem discovery after 2027-01-17.
|
||||
const backing = await resolveBackingDirectory(
|
||||
workspace.worktreeRoot ?? workspace.cwd,
|
||||
dependencies,
|
||||
);
|
||||
return { ...backing, mainRepoRoot: workspace.mainRepoRoot ?? backing.mainRepoRoot };
|
||||
}
|
||||
|
||||
async function resolveBackingDirectory(
|
||||
cwd: string,
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
): Promise<BackingDirectory> {
|
||||
const options = {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
};
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, options);
|
||||
return {
|
||||
path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd),
|
||||
isPaseoOwnedWorktree: ownership.allowed,
|
||||
mainRepoRoot: ownership.repoRoot ?? null,
|
||||
paseoWorktreesRoot: ownership.worktreeRoot ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function archiveTargetRecords(
|
||||
@@ -224,37 +291,72 @@ async function archiveTargetRecords(
|
||||
|
||||
async function maybeRemoveDirectory(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: Omit<ArchiveByScopeRequest, "scope">,
|
||||
targetDir: string,
|
||||
request: Pick<ArchiveByScopeRequest, "requestId">,
|
||||
target: ArchiveTarget,
|
||||
archivedWorkspaceIds: string[],
|
||||
): Promise<boolean> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(targetDir, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
const backing = target.backing;
|
||||
if (!backing?.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
|
||||
const teardownCwds = uniqueFilesystemPaths(
|
||||
target.teardownTargets
|
||||
.filter(
|
||||
(teardownTarget) =>
|
||||
teardownTarget.workspaceId === null ||
|
||||
archivedWorkspaceIdSet.has(teardownTarget.workspaceId),
|
||||
)
|
||||
.map((teardownTarget) => teardownTarget.cwd),
|
||||
);
|
||||
|
||||
try {
|
||||
for (const teardownCwd of teardownCwds) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: backing.path,
|
||||
teardownCwd,
|
||||
repoRootPath: backing.mainRepoRoot ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeTeardownError) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath: backing.path, requestId: request.requestId },
|
||||
"Worktree teardown failed during archive; workspace already archived",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const remainingActive = await dependencies.listActiveWorkspaces();
|
||||
if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) {
|
||||
if (
|
||||
!(await isDirectoryUnreferenced(
|
||||
remainingActive,
|
||||
backing.path,
|
||||
new Set(archivedWorkspaceIds),
|
||||
dependencies,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePaseoWorktree({
|
||||
cwd: request.repoRoot,
|
||||
worktreePath: targetDir,
|
||||
worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot,
|
||||
cwd: backing.mainRepoRoot,
|
||||
worktreePath: backing.path,
|
||||
teardownCwds: [],
|
||||
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
dependencies.github.invalidate({ cwd: targetDir });
|
||||
dependencies.github.invalidate({ cwd: backing.path });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeTeardownError) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath: targetDir, requestId: request.requestId },
|
||||
{ err: error, targetPath: backing.path, requestId: request.requestId },
|
||||
"Worktree disk removal failed during archive; workspace already archived",
|
||||
);
|
||||
return false;
|
||||
@@ -263,6 +365,16 @@ async function maybeRemoveDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueFilesystemPaths(paths: string[]): string[] {
|
||||
const unique: string[] = [];
|
||||
for (const candidate of paths) {
|
||||
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing)(candidate))) {
|
||||
unique.push(candidate);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||
ArchiveDependencies,
|
||||
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
|
||||
@@ -323,19 +435,23 @@ export async function archiveWorkspaceContents(
|
||||
return archivedAgents;
|
||||
}
|
||||
|
||||
// EXACTLY one last-reference predicate in the module. True when, after archiving
|
||||
// True when, after archiving
|
||||
// the in-scope records, no active workspace still points at targetDir. Derived
|
||||
// from records each call — no stored counter.
|
||||
function isDirectoryUnreferenced(
|
||||
async function isDirectoryUnreferenced(
|
||||
activeWorkspaces: ActiveWorkspaceRef[],
|
||||
targetDir: string,
|
||||
archivedWorkspaceIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
): Promise<boolean> {
|
||||
const target = resolve(targetDir);
|
||||
return !activeWorkspaces.some(
|
||||
(workspace) =>
|
||||
!archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target,
|
||||
);
|
||||
const matchesTarget = createRealpathAwarePathMatcher(target);
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
|
||||
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
|
||||
if (matchesTarget(backingDirectory.path)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function killTerminalsForWorkspace(
|
||||
|
||||
@@ -108,13 +108,14 @@ export class WorkspaceAutoName {
|
||||
firstAgentContext: FirstAgentContext;
|
||||
currentSelection: CurrentSelection;
|
||||
}): Promise<void> {
|
||||
const worktreeRoot = input.workspace.worktreeRoot ?? input.workspace.cwd;
|
||||
let generated: GeneratedWorkspaceName | null = null;
|
||||
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
|
||||
cwd: input.workspace.cwd,
|
||||
cwd: worktreeRoot,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
|
||||
generateBranchNameFromContext: ({ firstAgentContext }) => {
|
||||
return this.generateFromContext({
|
||||
cwd,
|
||||
cwd: input.workspace.cwd,
|
||||
firstAgentContext,
|
||||
currentSelection: input.currentSelection,
|
||||
}).then((nextGenerated) => {
|
||||
@@ -146,7 +147,7 @@ export class WorkspaceAutoName {
|
||||
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
});
|
||||
if (result.renamed) {
|
||||
await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch");
|
||||
await this.gitMutation.notifyGitMutation(worktreeRoot, "rename-branch");
|
||||
}
|
||||
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,17 @@ export function workspaceIdsOnCheckout(
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
export function workspaceIdsForProjects(
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
projectIds: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const workspaceIds = new Set<string>();
|
||||
for (const workspace of workspaces) {
|
||||
if (projectIds.has(workspace.projectId)) workspaceIds.add(workspace.workspaceId);
|
||||
}
|
||||
return Array.from(workspaceIds);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectory {
|
||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
deriveProjectServiceSlug,
|
||||
deriveProjectSlug,
|
||||
parseGitHubRepoNameFromRemote,
|
||||
} from "./workspace-git-metadata.js";
|
||||
@@ -171,46 +171,12 @@ describe("deriveProjectSlug", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceGitMetadataFromSnapshot", () => {
|
||||
test("uses owner/repo as the display name for GitHub remotes", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/some-dir",
|
||||
directoryName: "some-dir",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/some-dir",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@github.com:acme/widgets.git",
|
||||
});
|
||||
describe("deriveProjectServiceSlug", () => {
|
||||
test("keeps same-basename exact projects distinct and stable", () => {
|
||||
const first = { projectId: "prj_aaaaaaaaaaaaaaaa", rootPath: "/repo-a/app" };
|
||||
const second = { projectId: "prj_bbbbbbbbbbbbbbbb", rootPath: "/repo-b/app" };
|
||||
|
||||
expect(result.projectDisplayName).toBe("acme/widgets");
|
||||
});
|
||||
|
||||
test("uses owner/repo as the display name for non-GitHub remotes", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/random-name",
|
||||
directoryName: "random-name",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/random-name",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@gitlab.com:acme/app.git",
|
||||
});
|
||||
|
||||
expect(result.projectDisplayName).toBe("acme/app");
|
||||
});
|
||||
|
||||
test("falls back to the directory name when there is no remote", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/local-only",
|
||||
directoryName: "local-only",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/local-only",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
});
|
||||
|
||||
expect(result.projectDisplayName).toBe("local-only");
|
||||
expect(deriveProjectServiceSlug(first)).toBe(deriveProjectServiceSlug(first));
|
||||
expect(deriveProjectServiceSlug(first)).not.toBe(deriveProjectServiceSlug(second));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { basename } from "path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
|
||||
import { slugify } from "../utils/worktree.js";
|
||||
import { deriveProjectGroupingKey, deriveProjectGroupingName } from "./workspace-registry-model.js";
|
||||
|
||||
export interface WorkspaceGitMetadata {
|
||||
projectKind: "git" | "directory";
|
||||
projectDisplayName: string;
|
||||
workspaceDisplayName: string;
|
||||
gitRemote: string | null;
|
||||
isWorktree: boolean;
|
||||
projectSlug: string;
|
||||
repoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
}
|
||||
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||
return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null;
|
||||
@@ -34,49 +22,7 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null):
|
||||
return slugify(sourceName) || "untitled";
|
||||
}
|
||||
|
||||
export function buildWorkspaceGitMetadataFromSnapshot(input: {
|
||||
cwd: string;
|
||||
directoryName: string;
|
||||
isGit: boolean;
|
||||
repoRoot: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceGitMetadata {
|
||||
if (!input.isGit) {
|
||||
return {
|
||||
projectKind: "directory",
|
||||
projectDisplayName: input.directoryName,
|
||||
workspaceDisplayName: input.directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: deriveProjectSlug(input.cwd),
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const isWorktree =
|
||||
input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot;
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: input.repoRoot ?? input.cwd,
|
||||
remoteUrl: input.remoteUrl,
|
||||
mainRepoRoot: input.mainRepoRoot,
|
||||
});
|
||||
const projectDisplayName = projectKey.startsWith("remote:")
|
||||
? deriveProjectGroupingName(projectKey)
|
||||
: input.directoryName;
|
||||
|
||||
return {
|
||||
projectKind: "git",
|
||||
projectDisplayName,
|
||||
workspaceDisplayName: input.currentBranch ?? input.directoryName,
|
||||
gitRemote: input.remoteUrl,
|
||||
isWorktree,
|
||||
projectSlug: deriveProjectSlug(input.cwd, input.remoteUrl),
|
||||
repoRoot: input.repoRoot,
|
||||
currentBranch: input.currentBranch,
|
||||
remoteUrl: input.remoteUrl,
|
||||
};
|
||||
export function deriveProjectServiceSlug(project: { projectId: string; rootPath: string }): string {
|
||||
const identity = createHash("sha256").update(project.projectId).digest("hex").slice(0, 8);
|
||||
return `${deriveProjectSlug(project.rootPath)}-${identity}`;
|
||||
}
|
||||
|
||||
@@ -390,6 +390,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("getCheckout surfaces an unexpected Git read failure", async () => {
|
||||
const service = createService({
|
||||
getCheckoutStatus: vi.fn(async () => {
|
||||
throw new Error("Git read failed");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.getCheckout(REPO_CWD)).rejects.toThrow("Git read failed");
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("getSnapshot returns the current snapshot without shelling out", async () => {
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
@@ -1735,7 +1747,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("getWorkspaceGitMetadata derives reconciliation metadata from the snapshot cache", async () => {
|
||||
test("getProjectSlug derives the slug from the snapshot cache", async () => {
|
||||
let nowMs = 0;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
@@ -1749,22 +1761,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }),
|
||||
).resolves.toEqual({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
workspaceDisplayName: "feature/service-metadata",
|
||||
gitRemote: "https://github.com/getpaseo/paseo.git",
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/service-metadata",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
});
|
||||
await expect(service.getProjectSlug(REPO_CWD)).resolves.toBe("paseo");
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
|
||||
await service.getProjectSlug(join(REPO_CWD, "."));
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import pLimit from "p-limit";
|
||||
import type pino from "pino";
|
||||
@@ -41,10 +41,7 @@ import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "./workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||
@@ -162,10 +159,7 @@ export interface WorkspaceGitService {
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||
getWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||
): Promise<WorkspaceGitMetadata>;
|
||||
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
|
||||
@@ -473,31 +467,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
try {
|
||||
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
if (!status.isGit) {
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
}
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
repoRoot: status.repoRoot,
|
||||
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
});
|
||||
} catch {
|
||||
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
if (!status.isGit) {
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
@@ -507,6 +482,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
}
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
repoRoot: status.repoRoot,
|
||||
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||
@@ -654,21 +637,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||
): Promise<WorkspaceGitMetadata> {
|
||||
async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string> {
|
||||
const snapshot = await this.getSnapshot(cwd, options);
|
||||
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: resolve(cwd),
|
||||
directoryName,
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(resolve(cwd), snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
}
|
||||
|
||||
async resolveRepoRemoteUrl(
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
type PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
type ProjectRootWatch,
|
||||
type ProjectUpdate,
|
||||
type ReconciliationClock,
|
||||
type ReconciliationTimer,
|
||||
WorkspaceReconciliationService,
|
||||
} from "./workspace-reconciliation-service.js";
|
||||
|
||||
const TIMESTAMP = "2026-07-15T00:00:00.000Z";
|
||||
const DEBOUNCE_MS = 10;
|
||||
const RESCAN_INTERVAL_MS = 50;
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const target of cleanupPaths.splice(0)) rmSync(target, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface ProjectSpec {
|
||||
id: string;
|
||||
root: string;
|
||||
workspaces?: Array<{ id: string; cwd: string }>;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
interface Gate {
|
||||
started: Promise<void>;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
function createGate(): Gate & { arrive(): Promise<void> } {
|
||||
let markStarted!: () => void;
|
||||
let release!: () => void;
|
||||
const started = new Promise<void>((resolve) => (markStarted = resolve));
|
||||
const released = new Promise<void>((resolve) => (release = resolve));
|
||||
return { started, release, arrive: async () => (markStarted(), released) };
|
||||
}
|
||||
|
||||
class ObservedProjectRegistry extends FileBackedProjectRegistry {
|
||||
private nextRead: ReturnType<typeof createGate> | null = null;
|
||||
|
||||
holdNextRead(): Gate {
|
||||
return (this.nextRead = createGate());
|
||||
}
|
||||
|
||||
override async list() {
|
||||
if (this.nextRead) {
|
||||
const pending = this.nextRead;
|
||||
this.nextRead = null;
|
||||
await pending.arrive();
|
||||
}
|
||||
return super.list();
|
||||
}
|
||||
}
|
||||
|
||||
class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry {
|
||||
private nextRead: ReturnType<typeof createGate> | null = null;
|
||||
private nextError: Error | null = null;
|
||||
|
||||
holdNextRead(): Gate {
|
||||
return (this.nextRead = createGate());
|
||||
}
|
||||
|
||||
failNextRead(error: Error): void {
|
||||
this.nextError = error;
|
||||
}
|
||||
|
||||
override async list() {
|
||||
if (this.nextRead) {
|
||||
const pending = this.nextRead;
|
||||
this.nextRead = null;
|
||||
await pending.arrive();
|
||||
}
|
||||
if (this.nextError) {
|
||||
const error = this.nextError;
|
||||
this.nextError = null;
|
||||
throw error;
|
||||
}
|
||||
return super.list();
|
||||
}
|
||||
}
|
||||
|
||||
interface TestTimer extends ReconciliationTimer {
|
||||
callback: () => void | Promise<void>;
|
||||
dueAt: number;
|
||||
intervalMs: number | null;
|
||||
}
|
||||
|
||||
class TestClock implements ReconciliationClock {
|
||||
private now = 0;
|
||||
private readonly timers = new Set<TestTimer>();
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.timers.size;
|
||||
}
|
||||
|
||||
setTimeout(callback: () => void | Promise<void>, delayMs: number): TestTimer {
|
||||
return this.add(callback, delayMs, null);
|
||||
}
|
||||
|
||||
clearTimeout(timer: TestTimer): void {
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
setInterval(callback: () => void | Promise<void>, delayMs: number): TestTimer {
|
||||
return this.add(callback, delayMs, delayMs);
|
||||
}
|
||||
|
||||
clearInterval(timer: TestTimer): void {
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
async advanceBy(elapsedMs: number): Promise<void> {
|
||||
const target = this.now + elapsedMs;
|
||||
for (;;) {
|
||||
const next = [...this.timers]
|
||||
.filter((timer) => timer.dueAt <= target)
|
||||
.sort((left, right) => left.dueAt - right.dueAt)[0];
|
||||
if (!next) break;
|
||||
this.now = next.dueAt;
|
||||
if (next.intervalMs === null) this.timers.delete(next);
|
||||
else next.dueAt += next.intervalMs;
|
||||
await next.callback();
|
||||
}
|
||||
this.now = target;
|
||||
}
|
||||
|
||||
private add(
|
||||
callback: () => void | Promise<void>,
|
||||
delayMs: number,
|
||||
intervalMs: number | null,
|
||||
): TestTimer {
|
||||
const timer = { callback, dueAt: this.now + delayMs, intervalMs, unref: () => undefined };
|
||||
this.timers.add(timer);
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
|
||||
interface RootWatch {
|
||||
rootPath: string;
|
||||
onChange: (event: string, filename: string | Buffer | null) => void;
|
||||
onError: (error: Error) => void;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** One public behavioral seam for the complete observed-placement lifecycle. */
|
||||
class ObservedPlacements {
|
||||
private readonly home = mkdtempSync(path.join(tmpdir(), "observed-placement-"));
|
||||
private readonly projects: ObservedProjectRegistry;
|
||||
private readonly workspaces: ObservedWorkspaceRegistry;
|
||||
private readonly clock = new TestClock();
|
||||
private readonly watches: RootWatch[] = [];
|
||||
private readonly checkoutByCwd = new Map<string, ProjectCheckoutLitePayload>();
|
||||
private readonly rootByProjectId = new Map<string, string>();
|
||||
private readonly failedWatchRoots = new Set<string>();
|
||||
private readonly projectEvents: ProjectUpdate[] = [];
|
||||
private readonly workspaceEvents: string[][] = [];
|
||||
private readonly workspaceEventWaiters: Array<() => void> = [];
|
||||
private readonly lifecycleEvents: string[] = [];
|
||||
private readonly service: WorkspaceReconciliationService;
|
||||
private started = false;
|
||||
private checkoutReadCount = 0;
|
||||
|
||||
constructor(private readonly specs: ProjectSpec[]) {
|
||||
cleanupPaths.push(this.home);
|
||||
const logger = createTestLogger();
|
||||
this.projects = new ObservedProjectRegistry(path.join(this.home, "projects.json"), logger);
|
||||
this.workspaces = new ObservedWorkspaceRegistry(
|
||||
path.join(this.home, "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
const watchProjectRoot: ProjectRootWatch = (rootPath, _options, onChange, onError) => {
|
||||
if (this.failedWatchRoots.delete(rootPath)) throw new Error("root unavailable");
|
||||
const watch = { rootPath, onChange, onError, closed: false };
|
||||
this.watches.push(watch);
|
||||
this.lifecycleEvents.push(`watch installed:${path.relative(this.home, rootPath)}`);
|
||||
return { close: () => (watch.closed = true) };
|
||||
};
|
||||
this.service = new WorkspaceReconciliationService({
|
||||
projectRegistry: this.projects,
|
||||
workspaceRegistry: this.workspaces,
|
||||
workspaceGitService: { getCheckout: async (cwd) => this.readCheckout(cwd) },
|
||||
logger,
|
||||
watchProjectRoot,
|
||||
clock: this.clock,
|
||||
debounceMs: DEBOUNCE_MS,
|
||||
rescanIntervalMs: RESCAN_INTERVAL_MS,
|
||||
onProjectUpdate: (update) => {
|
||||
this.projectEvents.push(update);
|
||||
this.lifecycleEvents.push(
|
||||
update.kind === "upsert"
|
||||
? `project published:upsert:${update.project.projectId}`
|
||||
: `project published:remove:${update.projectId}`,
|
||||
);
|
||||
},
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
this.workspaceEvents.push(workspaceIds);
|
||||
this.workspaceEventWaiters.shift()?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.started) {
|
||||
for (const spec of this.specs) await this.seed(spec);
|
||||
this.started = true;
|
||||
}
|
||||
await this.service.start();
|
||||
}
|
||||
|
||||
async add(spec: ProjectSpec): Promise<void> {
|
||||
await this.seed(spec);
|
||||
this.lifecycleEvents.push(`registry mutation resolved:${spec.id}`);
|
||||
}
|
||||
|
||||
async archive(projectId: string): Promise<void> {
|
||||
await this.projects.archive(projectId, TIMESTAMP);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
await this.projects.remove(projectId);
|
||||
}
|
||||
|
||||
failNextWatch(root: string): void {
|
||||
this.failedWatchRoots.add(this.absolute(root));
|
||||
}
|
||||
|
||||
watcherFailed(root: string): void {
|
||||
this.activeWatch(root)?.onError(new Error("watch failed"));
|
||||
}
|
||||
|
||||
change(root: string, filename: string | null): void {
|
||||
this.activeWatch(root)?.onChange("rename", filename);
|
||||
}
|
||||
|
||||
makeProjectGit(projectId: string, branch = "main"): void {
|
||||
const rootPath = this.rootByProjectId.get(projectId);
|
||||
if (!rootPath) throw new Error(`Unknown project: ${projectId}`);
|
||||
this.checkoutByCwd.set(rootPath, {
|
||||
cwd: rootPath,
|
||||
isGit: true,
|
||||
currentBranch: branch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: rootPath,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
}
|
||||
|
||||
failNextReconciliation(error: Error): void {
|
||||
this.workspaces.failNextRead(error);
|
||||
}
|
||||
|
||||
holdNextRegistryRead(): Gate {
|
||||
return this.projects.holdNextRead();
|
||||
}
|
||||
|
||||
holdNextReconciliation(): Gate {
|
||||
return this.workspaces.holdNextRead();
|
||||
}
|
||||
|
||||
async advanceBy(elapsedMs: number): Promise<void> {
|
||||
await this.clock.advanceBy(elapsedMs);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.service.dispose();
|
||||
}
|
||||
|
||||
watchedRoots(): string[] {
|
||||
return this.watches
|
||||
.filter((watch) => !watch.closed)
|
||||
.map((watch) => path.relative(this.home, watch.rootPath));
|
||||
}
|
||||
|
||||
closedRoots(): string[] {
|
||||
return this.watches
|
||||
.filter((watch) => watch.closed)
|
||||
.map((watch) => path.relative(this.home, watch.rootPath));
|
||||
}
|
||||
|
||||
async placement(workspaceId: string): Promise<PersistedWorkspaceRecord | null> {
|
||||
return this.workspaces.get(workspaceId);
|
||||
}
|
||||
|
||||
async deleteWorkspaceDirectory(workspaceId: string): Promise<void> {
|
||||
const workspace = await this.workspaces.get(workspaceId);
|
||||
if (!workspace) throw new Error(`Unknown workspace: ${workspaceId}`);
|
||||
rmSync(workspace.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
get projectUpdates(): ProjectUpdate[] {
|
||||
return [...this.projectEvents];
|
||||
}
|
||||
|
||||
get workspaceBatches(): string[][] {
|
||||
return this.workspaceEvents.map((batch) => [...batch]);
|
||||
}
|
||||
|
||||
waitForWorkspaceBatch(): Promise<void> {
|
||||
return new Promise((resolve) => this.workspaceEventWaiters.push(resolve));
|
||||
}
|
||||
|
||||
get lifecycle(): string[] {
|
||||
return [...this.lifecycleEvents];
|
||||
}
|
||||
|
||||
get gitReads(): number {
|
||||
return this.checkoutReadCount;
|
||||
}
|
||||
|
||||
get pendingTimers(): number {
|
||||
return this.clock.pendingCount;
|
||||
}
|
||||
|
||||
private async seed(spec: ProjectSpec): Promise<void> {
|
||||
const rootPath = this.absolute(spec.root);
|
||||
mkdirSync(rootPath, { recursive: true });
|
||||
this.rootByProjectId.set(spec.id, rootPath);
|
||||
await this.projects.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: spec.id,
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: spec.id,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
archivedAt: spec.archived ? TIMESTAMP : null,
|
||||
}),
|
||||
);
|
||||
for (const workspace of spec.workspaces ?? []) {
|
||||
const cwd = this.absolute(workspace.cwd);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
await this.workspaces.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: workspace.id,
|
||||
projectId: spec.id,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: `Durable ${workspace.id}`,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async readCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
this.checkoutReadCount += 1;
|
||||
const configured = [...this.checkoutByCwd.entries()].find(([root]) =>
|
||||
areEquivalentPaths(root, cwd),
|
||||
)?.[1];
|
||||
return (
|
||||
configured ?? {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private activeWatch(root: string): RootWatch | undefined {
|
||||
const target = this.absolute(root);
|
||||
return this.watches.find(
|
||||
(watch) => !watch.closed && areEquivalentPaths(watch.rootPath, target),
|
||||
);
|
||||
}
|
||||
|
||||
private absolute(relative: string): string {
|
||||
return path.join(this.home, relative);
|
||||
}
|
||||
}
|
||||
|
||||
describe("observed workspace placement", () => {
|
||||
test("installs and publishes a new project before add resolves without Git feedback", async () => {
|
||||
const observed = new ObservedPlacements([]);
|
||||
await observed.start();
|
||||
|
||||
await observed.add({ id: "project-new", root: "new" });
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
|
||||
expect(observed.watchedRoots()).toEqual(["new"]);
|
||||
expect(observed.projectUpdates).toEqual([
|
||||
{ kind: "upsert", project: expect.objectContaining({ projectId: "project-new" }) },
|
||||
]);
|
||||
expect(observed.lifecycle).toEqual([
|
||||
"watch installed:new",
|
||||
"project published:upsert:project-new",
|
||||
"registry mutation resolved:project-new",
|
||||
]);
|
||||
expect(observed.gitReads).toBe(0);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("deduplicates active root watches and tears them down on archive and remove", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo" },
|
||||
{ id: "project-duplicate", root: "repo" },
|
||||
{ id: "project-remove", root: "other" },
|
||||
{ id: "project-archived", root: "archived", archived: true },
|
||||
]);
|
||||
|
||||
await observed.start();
|
||||
await observed.start();
|
||||
expect(observed.watchedRoots()).toEqual(["repo", "other"]);
|
||||
|
||||
await observed.archive("project-one");
|
||||
expect(observed.watchedRoots()).toEqual(["repo", "other"]);
|
||||
await observed.remove("project-duplicate");
|
||||
await observed.remove("project-remove");
|
||||
|
||||
expect(observed.watchedRoots()).toEqual([]);
|
||||
expect(observed.closedRoots()).toEqual(["repo", "other"]);
|
||||
expect(observed.projectUpdates).toEqual([
|
||||
{ kind: "remove", projectId: "project-one" },
|
||||
{ kind: "remove", projectId: "project-duplicate" },
|
||||
{ kind: "remove", projectId: "project-remove" },
|
||||
]);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("filters unrelated files and coalesces Git change bursts", async () => {
|
||||
const observed = new ObservedPlacements([{ id: "project-one", root: "repo" }]);
|
||||
await observed.start();
|
||||
|
||||
observed.change("repo", "README.md");
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
expect(observed.gitReads).toBe(0);
|
||||
|
||||
observed.change("repo", ".git");
|
||||
observed.change("repo", ".git");
|
||||
observed.change("repo", null);
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
expect(observed.gitReads).toBe(1);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("recovers errored and temporarily unavailable watchers on the periodic pass", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-errored", root: "errored" },
|
||||
{ id: "project-unavailable", root: "unavailable" },
|
||||
]);
|
||||
observed.failNextWatch("unavailable");
|
||||
await observed.start();
|
||||
expect(observed.watchedRoots()).toEqual(["errored"]);
|
||||
|
||||
observed.watcherFailed("errored");
|
||||
expect(observed.watchedRoots()).toEqual([]);
|
||||
await observed.advanceBy(RESCAN_INTERVAL_MS);
|
||||
|
||||
expect(observed.watchedRoots()).toEqual(["errored", "unavailable"]);
|
||||
expect(observed.closedRoots()).toEqual(["errored"]);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("archives missing workspace directories on the periodic pass", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
]);
|
||||
await observed.start();
|
||||
await observed.deleteWorkspaceDirectory("workspace-one");
|
||||
|
||||
await observed.advanceBy(RESCAN_INTERVAL_MS);
|
||||
|
||||
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
|
||||
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("preserves a periodic full pass queued behind metadata reconciliation", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
]);
|
||||
await observed.start();
|
||||
const metadataRead = observed.holdNextReconciliation();
|
||||
observed.change("repo", ".git");
|
||||
const metadataPass = observed.advanceBy(DEBOUNCE_MS);
|
||||
await metadataRead.started;
|
||||
await observed.deleteWorkspaceDirectory("workspace-one");
|
||||
const workspaceBatch = observed.waitForWorkspaceBatch();
|
||||
|
||||
await observed.advanceBy(RESCAN_INTERVAL_MS);
|
||||
metadataRead.release();
|
||||
await metadataPass;
|
||||
await workspaceBatch;
|
||||
|
||||
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
|
||||
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("contains a failed reconciliation and converges on the next change", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
]);
|
||||
await observed.start();
|
||||
observed.makeProjectGit("project-one", "main");
|
||||
observed.failNextReconciliation(new Error("registry unavailable"));
|
||||
|
||||
observed.change("repo", ".git");
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
expect((await observed.placement("workspace-one"))?.kind).toBe("directory");
|
||||
|
||||
observed.change("repo", ".git");
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
expect(await observed.placement("workspace-one")).toMatchObject({
|
||||
kind: "local_checkout",
|
||||
branch: "main",
|
||||
displayName: "Durable workspace-one",
|
||||
});
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("deduplicates direct placement and project-derived workspace fanout", async () => {
|
||||
const observed = new ObservedPlacements([
|
||||
{
|
||||
id: "project-one",
|
||||
root: "repo",
|
||||
workspaces: [
|
||||
{ id: "workspace-one", cwd: "repo" },
|
||||
{ id: "workspace-two", cwd: "repo/feature" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await observed.start();
|
||||
observed.makeProjectGit("project-one", "main");
|
||||
|
||||
observed.change("repo", ".git");
|
||||
await observed.advanceBy(DEBOUNCE_MS);
|
||||
|
||||
expect(observed.projectUpdates).toHaveLength(1);
|
||||
expect(observed.workspaceBatches).toEqual([["workspace-one", "workspace-two"]]);
|
||||
expect(new Set(observed.workspaceBatches[0]).size).toBe(2);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
test("disposal suppresses in-flight mutations and reconciliation fanout", async () => {
|
||||
const mutation = new ObservedPlacements([{ id: "project-one", root: "repo" }]);
|
||||
await mutation.start();
|
||||
const registryRead = mutation.holdNextRegistryRead();
|
||||
const adding = mutation.add({ id: "project-late", root: "late" });
|
||||
await registryRead.started;
|
||||
mutation.dispose();
|
||||
registryRead.release();
|
||||
await adding;
|
||||
expect(mutation.projectUpdates).toEqual([]);
|
||||
expect(mutation.watchedRoots()).toEqual([]);
|
||||
|
||||
const reconciliation = new ObservedPlacements([
|
||||
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
|
||||
]);
|
||||
await reconciliation.start();
|
||||
reconciliation.makeProjectGit("project-one");
|
||||
const workspaceRead = reconciliation.holdNextReconciliation();
|
||||
reconciliation.change("repo", ".git");
|
||||
const advancing = reconciliation.advanceBy(DEBOUNCE_MS);
|
||||
await workspaceRead.started;
|
||||
reconciliation.dispose();
|
||||
workspaceRead.release();
|
||||
await advancing;
|
||||
|
||||
expect(reconciliation.workspaceBatches).toEqual([]);
|
||||
expect(reconciliation.watchedRoots()).toEqual([]);
|
||||
expect(reconciliation.pendingTimers).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type pino from "pino";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -14,7 +15,10 @@ import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
type ReconciliationChange,
|
||||
WorkspaceReconciliationService,
|
||||
} from "./workspace-reconciliation-service.js";
|
||||
|
||||
function createTestRegistries() {
|
||||
const projects = new Map<string, PersistedProjectRecord>();
|
||||
@@ -25,6 +29,22 @@ function createTestRegistries() {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: string) => projects.get(id) ?? null,
|
||||
getOrCreateActiveByRoot: async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === input.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const record = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size}`,
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
projects.set(record.projectId, record);
|
||||
return record;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -64,11 +84,11 @@ function createTestRegistries() {
|
||||
function createTestLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
trace: () => undefined,
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
return logger as unknown as pino.Logger;
|
||||
}
|
||||
@@ -106,35 +126,62 @@ function createWorkspaceGitServiceStub(
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => {
|
||||
getCheckout: async (cwd: string) => {
|
||||
const metadata = metadataByCwd[cwd];
|
||||
const directoryName = options?.directoryName ?? path.basename(cwd);
|
||||
if (!metadata) {
|
||||
return {
|
||||
projectKind: "directory" as const,
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "untitled",
|
||||
repoRoot: null,
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
gitRemote: metadata.gitRemote ?? null,
|
||||
isWorktree: false,
|
||||
projectSlug: "repo",
|
||||
repoRoot: cwd,
|
||||
currentBranch: metadata.workspaceDisplayName,
|
||||
cwd,
|
||||
isGit: metadata.projectKind === "git",
|
||||
currentBranch: metadata.currentBranch ?? metadata.workspaceDisplayName,
|
||||
remoteUrl: metadata.gitRemote ?? null,
|
||||
...metadata,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCheckout(
|
||||
cwd: string,
|
||||
overrides: Partial<ProjectCheckoutLitePayload> = {},
|
||||
): ProjectCheckoutLitePayload {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class TestCheckouts {
|
||||
readonly reads: string[] = [];
|
||||
private readonly checkouts = new Map<string, ProjectCheckoutLitePayload>();
|
||||
|
||||
set(cwd: string, checkout: ProjectCheckoutLitePayload): void {
|
||||
this.checkouts.set(cwd, checkout);
|
||||
}
|
||||
|
||||
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
this.reads.push(cwd);
|
||||
return this.checkouts.get(cwd) ?? createCheckout(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
function initGitRepoInDir(dir: string): void {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
|
||||
@@ -169,6 +216,262 @@ describe("WorkspaceReconciliationService", () => {
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("metadata reconciliation leaves missing workspaces active while a full pass archives them", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-metadata-only-")));
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "metadata-only",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "missing-workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const metadataResult = await service.reconcileGitMetadata();
|
||||
|
||||
expect(metadataResult.changesApplied).toEqual([]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toBeNull();
|
||||
|
||||
const fullResult = await service.runOnce();
|
||||
|
||||
expect(fullResult.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("reads fresh checkout facts on every metadata pass", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-fresh-git-")));
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const git = new TestCheckouts();
|
||||
git.set(projectRoot, createCheckout(projectRoot));
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "fresh-git",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const beforeGitInit = await service.reconcileGitMetadata();
|
||||
git.set(
|
||||
projectRoot,
|
||||
createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
}),
|
||||
);
|
||||
const afterGitInit = await service.reconcileGitMetadata();
|
||||
|
||||
expect(beforeGitInit.changesApplied).toEqual([]);
|
||||
expect(afterGitInit.changesApplied).toEqual([
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: "p1",
|
||||
directory: projectRoot,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
]);
|
||||
expect(git.reads).toEqual([projectRoot, projectRoot]);
|
||||
expect(projects.get("p1")?.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("deduplicates equivalent project and workspace paths across legacy duplicate projects", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-global-root-")));
|
||||
const workspaceRoot = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-global-workspace-")),
|
||||
);
|
||||
tempDirs.push(projectRoot, workspaceRoot);
|
||||
const equivalentProjectRoot = `${projectRoot}${path.sep}.`;
|
||||
const equivalentWorkspaceRoot = `${workspaceRoot}${path.sep}.`;
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const git = new TestCheckouts();
|
||||
const projectCheckout = createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
});
|
||||
const workspaceCheckout = createCheckout(workspaceRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "topic",
|
||||
worktreeRoot: workspaceRoot,
|
||||
});
|
||||
git.set(projectRoot, projectCheckout);
|
||||
git.set(equivalentProjectRoot, projectCheckout);
|
||||
git.set(workspaceRoot, workspaceCheckout);
|
||||
git.set(equivalentWorkspaceRoot, workspaceCheckout);
|
||||
|
||||
for (const [projectId, rootPath] of [
|
||||
["p1", projectRoot],
|
||||
["p2", equivalentProjectRoot],
|
||||
] as const) {
|
||||
projects.set(
|
||||
projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: projectId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [workspaceId, projectId, cwd] of [
|
||||
["w1", "p1", workspaceRoot],
|
||||
["w2", "p2", equivalentWorkspaceRoot],
|
||||
] as const) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId,
|
||||
cwd,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
branch: "topic",
|
||||
worktreeRoot: workspaceRoot,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(git.reads).toEqual([projectRoot, workspaceRoot]);
|
||||
});
|
||||
|
||||
test("updates mutable Git facts without changing project or workspace identity", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-stable-project-")));
|
||||
const workspaceRoot = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-explicit-workspace-")),
|
||||
);
|
||||
tempDirs.push(projectRoot, workspaceRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const originalProject = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "Stable project name",
|
||||
customName: "Pinned project name",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const originalWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: workspaceRoot,
|
||||
kind: "local_checkout",
|
||||
displayName: "Stable workspace name",
|
||||
title: "Pinned workspace name",
|
||||
branch: "stale-branch",
|
||||
baseBranch: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
projects.set(originalProject.projectId, originalProject);
|
||||
workspaces.set(originalWorkspace.workspaceId, originalWorkspace);
|
||||
const git = new TestCheckouts();
|
||||
git.set(
|
||||
projectRoot,
|
||||
createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
}),
|
||||
);
|
||||
git.set(workspaceRoot, createCheckout(workspaceRoot));
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: "p1",
|
||||
directory: projectRoot,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "w1",
|
||||
directory: workspaceRoot,
|
||||
fields: {
|
||||
branch: null,
|
||||
kind: "directory",
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.changesApplied).toHaveLength(2);
|
||||
expect(projects.get("p1")).toEqual({
|
||||
...originalProject,
|
||||
kind: "git",
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(workspaces.get("w1")).toEqual({
|
||||
...originalWorkspace,
|
||||
kind: "directory",
|
||||
branch: null,
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("archives workspaces whose directories no longer exist", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
@@ -213,17 +516,15 @@ describe("WorkspaceReconciliationService", () => {
|
||||
test("keeps a project active after all its workspaces are archived", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "non_git",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "non_git",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
@@ -245,9 +546,32 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
||||
expect(projChange).toBeUndefined();
|
||||
expect(projects.get("p1")!.archivedAt).toBeFalsy();
|
||||
expect(result.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")).toEqual({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
title: null,
|
||||
pinnedAt: null,
|
||||
branch: null,
|
||||
worktreeRoot: null,
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
});
|
||||
expect(projects.get("p1")).toEqual(project);
|
||||
});
|
||||
|
||||
test("updates project kind when a directory becomes a git repo", async () => {
|
||||
@@ -357,7 +681,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(workspaces.get("w1")!.kind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
test("moves workspaces from a path-keyed duplicate project to the existing remote-keyed project", async () => {
|
||||
test("keeps legacy duplicate projects and workspace membership intact", async () => {
|
||||
const repoDir = createTempGitRepo("reconcile-duplicate-project-");
|
||||
tempDirs.push(repoDir);
|
||||
const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat");
|
||||
@@ -442,33 +766,35 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "gigantic-blowfish",
|
||||
fields: { projectId: "remote:github.com/blank-dot-page/editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_updated",
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
fields: { customName: "Editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_archived",
|
||||
projectId: repoDir,
|
||||
reason: "merged_duplicate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(workspaces.get("gigantic-blowfish")!.projectId).toBe(
|
||||
"remote:github.com/blank-dot-page/editor",
|
||||
);
|
||||
expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor");
|
||||
expect(projects.get(repoDir)!.archivedAt).toBeTruthy();
|
||||
expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([
|
||||
"workspace_updated",
|
||||
"workspace_updated",
|
||||
]);
|
||||
expect(projects.get("remote:github.com/blank-dot-page/editor")).toMatchObject({
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
rootPath: repoDir,
|
||||
displayName: "blank-dot-page/editor",
|
||||
customName: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(projects.get(repoDir)).toMatchObject({
|
||||
projectId: repoDir,
|
||||
rootPath: repoDir,
|
||||
displayName: "editor",
|
||||
customName: "Editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(workspaces.get("focused-bat")).toMatchObject({
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(workspaces.get("gigantic-blowfish")).toMatchObject({
|
||||
projectId: repoDir,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("updates project display name when git remote changes", async () => {
|
||||
test("keeps project display name stable when git remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -520,12 +846,11 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined();
|
||||
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||
});
|
||||
|
||||
test("preserves customName even when the derived displayName changes", async () => {
|
||||
test("keeps custom and default names stable when the remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-customname-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -577,10 +902,151 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||
expect(projects.get("p1")!.customName).toBe("My Fork");
|
||||
});
|
||||
|
||||
test("keeps persisted Git metadata when a workspace checkout read fails", async () => {
|
||||
const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-checkout-read-project-"));
|
||||
const workspaceRoot = path.join(projectRoot, "workspace");
|
||||
mkdirSync(workspaceRoot);
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: project.projectId,
|
||||
cwd: workspaceRoot,
|
||||
kind: "local_checkout",
|
||||
displayName: "workspace",
|
||||
branch: "feature",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
workspaces.set(workspace.workspaceId, workspace);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: {
|
||||
getCheckout: async (cwd) => {
|
||||
if (cwd === workspaceRoot) throw new Error("Git read failed");
|
||||
return createCheckout(cwd, { isGit: true, currentBranch: "main", worktreeRoot: cwd });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(projects.get(project.projectId)).toEqual(project);
|
||||
expect(workspaces.get(workspace.workspaceId)).toEqual(workspace);
|
||||
});
|
||||
|
||||
test("archives non-directory workspaces without blocking sibling reconciliation", async () => {
|
||||
const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-file-workspace-"));
|
||||
const replacedWorkspace = path.join(projectRoot, "replaced-workspace");
|
||||
const siblingWorkspace = path.join(projectRoot, "sibling-workspace");
|
||||
writeFileSync(replacedWorkspace, "not a directory\n");
|
||||
mkdirSync(siblingWorkspace);
|
||||
tempDirs.push(projectRoot);
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"replaced",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "replaced",
|
||||
projectId: "p1",
|
||||
cwd: replacedWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "replaced-workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"sibling",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "sibling",
|
||||
projectId: "p1",
|
||||
cwd: siblingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "sibling-workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: {
|
||||
getCheckout: async (cwd) => {
|
||||
if (cwd === replacedWorkspace) {
|
||||
throw new Error("Git cannot use a regular file as cwd");
|
||||
}
|
||||
return createCheckout(cwd, {
|
||||
isGit: true,
|
||||
currentBranch: cwd === siblingWorkspace ? "feature/sibling" : "main",
|
||||
worktreeRoot: cwd,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "replaced",
|
||||
directory: replacedWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: "p1",
|
||||
directory: projectRoot,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "sibling",
|
||||
directory: siblingWorkspace,
|
||||
fields: {
|
||||
kind: "local_checkout",
|
||||
branch: "feature/sibling",
|
||||
worktreeRoot: siblingWorkspace,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("replaced")?.archivedAt).toEqual(expect.any(String));
|
||||
expect(workspaces.get("sibling")).toMatchObject({
|
||||
kind: "local_checkout",
|
||||
branch: "feature/sibling",
|
||||
});
|
||||
});
|
||||
|
||||
test("updates workspace branch metadata without clobbering the workspace name", async () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
tempDirs.push(dir);
|
||||
@@ -707,18 +1173,24 @@ describe("WorkspaceReconciliationService", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const onChanges = vi.fn();
|
||||
const reportedChanges: ReconciliationChange[] = [];
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
onChanges,
|
||||
onChanges: (changes) => reportedChanges.push(...changes),
|
||||
});
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(onChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
|
||||
expect(reportedChanges).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("logs reconciliation changes with affected paths and reasons", async () => {
|
||||
@@ -791,4 +1263,69 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
expect(infoRecords).toEqual([]);
|
||||
});
|
||||
|
||||
test("backfills persisted worktree ownership from the current checkout", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-worktree-owner-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const checkouts = new TestCheckouts();
|
||||
checkouts.set(
|
||||
rootPath,
|
||||
createCheckout(rootPath, {
|
||||
isGit: true,
|
||||
worktreeRoot: rootPath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
}),
|
||||
);
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "worktree",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: rootPath,
|
||||
kind: "worktree",
|
||||
displayName: "worktree",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService: checkouts,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "w1",
|
||||
directory: rootPath,
|
||||
fields: {
|
||||
worktreeRoot: rootPath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")).toMatchObject({
|
||||
worktreeRoot: rootPath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { statSync, watch as watchPath } from "node:fs";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ProjectRegistry,
|
||||
@@ -8,49 +8,71 @@ import type {
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
deriveProjectKind,
|
||||
reconcileWorkspacePlacement,
|
||||
type MutableWorkspacePlacement,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { workspaceIdsForProjects } from "./workspace-directory.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 100;
|
||||
|
||||
function deriveWorkspaceKindFromMetadata(metadata: {
|
||||
projectKind: "git" | "directory";
|
||||
isWorktree: boolean;
|
||||
}): PersistedWorkspaceRecord["kind"] {
|
||||
if (metadata.projectKind !== "git") return "directory";
|
||||
if (metadata.isWorktree) return "worktree";
|
||||
return "local_checkout";
|
||||
export type ProjectUpdate =
|
||||
| { kind: "upsert"; project: PersistedProjectRecord }
|
||||
| { kind: "remove"; projectId: string };
|
||||
|
||||
interface ProjectRootWatcher {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord {
|
||||
return [...projects].sort((left, right) => {
|
||||
const leftRemote = left.projectId.startsWith("remote:");
|
||||
const rightRemote = right.projectId.startsWith("remote:");
|
||||
if (leftRemote !== rightRemote) {
|
||||
return leftRemote ? -1 : 1;
|
||||
}
|
||||
const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
||||
if (createdAt !== 0) {
|
||||
return createdAt;
|
||||
}
|
||||
return left.projectId.localeCompare(right.projectId);
|
||||
})[0]!;
|
||||
export interface ProjectRootWatch {
|
||||
(
|
||||
rootPath: string,
|
||||
options: { recursive: false },
|
||||
onChange: (event: string, filename: string | Buffer | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): ProjectRootWatcher;
|
||||
}
|
||||
|
||||
export interface ReconciliationTimer {
|
||||
unref?(): void;
|
||||
}
|
||||
|
||||
export interface ReconciliationClock {
|
||||
setTimeout(callback: () => void | Promise<void>, delayMs: number): ReconciliationTimer;
|
||||
clearTimeout(timer: ReconciliationTimer): void;
|
||||
setInterval(callback: () => void | Promise<void>, delayMs: number): ReconciliationTimer;
|
||||
clearInterval(timer: ReconciliationTimer): void;
|
||||
}
|
||||
|
||||
const systemClock: ReconciliationClock = {
|
||||
setTimeout: (callback, delayMs) => setTimeout(() => void callback(), delayMs),
|
||||
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
||||
setInterval: (callback, delayMs) => setInterval(() => void callback(), delayMs),
|
||||
clearInterval: (timer) => clearInterval(timer as ReturnType<typeof setInterval>),
|
||||
};
|
||||
|
||||
const watchProjectRoot: ProjectRootWatch = (rootPath, options, onChange, onError) => {
|
||||
const watcher = watchPath(rootPath, options, onChange);
|
||||
watcher.on("error", onError);
|
||||
return watcher;
|
||||
};
|
||||
|
||||
export type ReconciliationChange =
|
||||
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
||||
| {
|
||||
kind: "project_updated";
|
||||
projectId: string;
|
||||
directory: string;
|
||||
fields: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
|
||||
>;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind">>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
workspaceId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
|
||||
fields: Partial<MutableWorkspacePlacement>;
|
||||
};
|
||||
|
||||
export interface ReconciliationResult {
|
||||
@@ -62,62 +84,130 @@ export interface WorkspaceReconciliationServiceOptions {
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: pino.Logger;
|
||||
intervalMs?: number;
|
||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getWorkspaceGitMetadata">;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getCheckout">;
|
||||
onProjectUpdate?: (update: ProjectUpdate) => void;
|
||||
onWorkspacesChanged?: (workspaceIds: string[]) => Promise<void>;
|
||||
watchProjectRoot?: ProjectRootWatch;
|
||||
clock?: ReconciliationClock;
|
||||
rescanIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface ProjectReconciliationInput {
|
||||
project: PersistedProjectRecord;
|
||||
siblings: PersistedWorkspaceRecord[];
|
||||
currentGit: ProjectCheckoutLitePayload;
|
||||
readCheckout: (cwd: string) => Promise<ProjectCheckoutLitePayload>;
|
||||
changes: ReconciliationChange[];
|
||||
}
|
||||
|
||||
interface CachedCheckoutRead {
|
||||
cwd: string;
|
||||
checkout: Promise<ProjectCheckoutLitePayload>;
|
||||
}
|
||||
|
||||
type DirectoryState = "directory" | "missing" | "unreadable";
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getWorkspaceGitMetadata"> | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getCheckout"> | null;
|
||||
private readonly onProjectUpdate: ((update: ProjectUpdate) => void) | null;
|
||||
private readonly onWorkspacesChanged: ((workspaceIds: string[]) => Promise<void>) | null;
|
||||
private readonly watchProjectRoot: ProjectRootWatch;
|
||||
private readonly clock: ReconciliationClock;
|
||||
private readonly rescanIntervalMs: number;
|
||||
private readonly debounceMs: number;
|
||||
private readonly watchers: Array<{ rootPath: string; watcher: ProjectRootWatcher }> = [];
|
||||
private unsubscribeRegistry: (() => void) | null = null;
|
||||
private rescanTimer: ReconciliationTimer | null = null;
|
||||
private debounceTimer: ReconciliationTimer | null = null;
|
||||
private disposed = false;
|
||||
private started = false;
|
||||
private reconciling = false;
|
||||
private reconcileQueuedMode: "metadata" | "full" | null = null;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||
this.onChanges = options.onChanges ?? null;
|
||||
this.workspaceGitService = options.workspaceGitService ?? null;
|
||||
this.onProjectUpdate = options.onProjectUpdate ?? null;
|
||||
this.onWorkspacesChanged = options.onWorkspacesChanged ?? null;
|
||||
this.watchProjectRoot = options.watchProjectRoot ?? watchProjectRoot;
|
||||
this.clock = options.clock ?? systemClock;
|
||||
this.rescanIntervalMs = options.rescanIntervalMs ?? DEFAULT_RESCAN_INTERVAL_MS;
|
||||
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
|
||||
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
|
||||
// Run once immediately on start
|
||||
void this.runSafe();
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.unsubscribeRegistry =
|
||||
this.projectRegistry.subscribeToMutations?.(async (mutation) => {
|
||||
try {
|
||||
// Project creation does not resolve until its root watch is installed,
|
||||
// closing the git-init race for newly added empty projects.
|
||||
await this.syncProjectRootWatches();
|
||||
if (this.disposed) return;
|
||||
if (mutation.kind === "upsert" && mutation.project && !mutation.project.archivedAt) {
|
||||
this.onProjectUpdate?.({ kind: "upsert", project: mutation.project });
|
||||
} else {
|
||||
this.onProjectUpdate?.({ kind: "remove", projectId: mutation.projectId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Project reconciliation mutation handling failed");
|
||||
}
|
||||
}) ?? null;
|
||||
await this.syncProjectRootWatches();
|
||||
this.rescanTimer = this.clock.setInterval(
|
||||
() => this.reconcileObservedGitMetadata("full"),
|
||||
this.rescanIntervalMs,
|
||||
);
|
||||
this.rescanTimer.unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.unsubscribeRegistry?.();
|
||||
this.unsubscribeRegistry = null;
|
||||
if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer);
|
||||
if (this.debounceTimer) this.clock.clearTimeout(this.debounceTimer);
|
||||
for (const { watcher } of this.watchers) watcher.close();
|
||||
this.watchers.length = 0;
|
||||
}
|
||||
|
||||
/** Reconciles mutable Git facts only; never archives missing records. */
|
||||
async reconcileGitMetadata(): Promise<ReconciliationResult> {
|
||||
const start = Date.now();
|
||||
const changes: ReconciliationChange[] = [];
|
||||
const [projects, workspaces] = await Promise.all([
|
||||
this.projectRegistry.list(),
|
||||
this.workspaceRegistry.list(),
|
||||
]);
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue;
|
||||
const siblings = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
siblings.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, siblings);
|
||||
}
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
projects.filter(
|
||||
(project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory",
|
||||
),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
if (changes.length > 0) this.onChanges?.(changes);
|
||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async runOnce(): Promise<ReconciliationResult> {
|
||||
return this.reconcile();
|
||||
}
|
||||
|
||||
private async runSafe(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.reconcile();
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Reconciliation pass failed");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<ReconciliationResult> {
|
||||
const start = Date.now();
|
||||
const changes: ReconciliationChange[] = [];
|
||||
|
||||
@@ -126,16 +216,23 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
|
||||
const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({
|
||||
workspace,
|
||||
state: this.inspectDirectory(workspace.cwd),
|
||||
}));
|
||||
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
for (const workspace of activeWorkspaces) {
|
||||
for (const { workspace, state } of workspaceDirectoryStates) {
|
||||
if (state !== "directory") continue;
|
||||
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
list.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, list);
|
||||
}
|
||||
|
||||
// 1. Archive workspaces whose directories no longer exist
|
||||
const missingWorkspaces = activeWorkspaces.filter((workspace) => !existsSync(workspace.cwd));
|
||||
const missingWorkspaces = workspaceDirectoryStates
|
||||
.filter(({ state }) => state === "missing")
|
||||
.map(({ workspace }) => workspace);
|
||||
await Promise.all(
|
||||
missingWorkspaces.map(async (workspace) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -156,29 +253,13 @@ export class WorkspaceReconciliationService {
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Merge duplicate active project records that point at the same repo root.
|
||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
||||
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist.
|
||||
// 2. Reconcile mutable git metadata without changing identity or membership.
|
||||
// Projects persist until explicitly removed, even when they currently have
|
||||
// zero active workspaces, so they still reconcile their own metadata.
|
||||
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
||||
// don't resurrect them by upserting a stale, non-archived copy.
|
||||
const archivedProjectIds = new Set(
|
||||
changes
|
||||
.filter((change) => change.kind === "project_archived")
|
||||
.map((change) => change.projectId),
|
||||
);
|
||||
const projectsToReconcile = activeProjects.filter((project) => {
|
||||
if (project.archivedAt) return false;
|
||||
if (archivedProjectIds.has(project.projectId)) return false;
|
||||
if (!existsSync(project.rootPath)) return false;
|
||||
return true;
|
||||
});
|
||||
await Promise.all(
|
||||
projectsToReconcile.map((project) =>
|
||||
this.reconcileProject(project, workspacesByProject.get(project.projectId) ?? [], changes),
|
||||
),
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
|
||||
if (changes.length > 0 && this.onChanges) {
|
||||
@@ -188,136 +269,72 @@ export class WorkspaceReconciliationService {
|
||||
const result = { changesApplied: changes, durationMs: Date.now() - start };
|
||||
if (changes.length > 0) {
|
||||
this.logger.info(
|
||||
{
|
||||
changeCount: changes.length,
|
||||
durationMs: result.durationMs,
|
||||
changes,
|
||||
},
|
||||
{ changeCount: changes.length, durationMs: result.durationMs, changes },
|
||||
"Workspace reconciliation applied changes",
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectsByRoot(
|
||||
activeProjects: PersistedProjectRecord[],
|
||||
private async reconcileGitMetadataForProjects(
|
||||
projectsToReconcile: PersistedProjectRecord[],
|
||||
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
const projectsByRoot = new Map<string, PersistedProjectRecord[]>();
|
||||
for (const project of activeProjects) {
|
||||
if (project.kind !== "git") {
|
||||
continue;
|
||||
}
|
||||
const rootKey = resolve(project.rootPath);
|
||||
const group = projectsByRoot.get(rootKey) ?? [];
|
||||
group.push(project);
|
||||
projectsByRoot.set(rootKey, group);
|
||||
}
|
||||
|
||||
for (const duplicates of projectsByRoot.values()) {
|
||||
if (duplicates.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const canonical = chooseCanonicalProject(duplicates);
|
||||
const duplicateProjects = duplicates.filter(
|
||||
(project) => project.projectId !== canonical.projectId,
|
||||
const checkoutReads: CachedCheckoutRead[] = [];
|
||||
const readCheckout = (cwd: string): Promise<ProjectCheckoutLitePayload> => {
|
||||
const existing = checkoutReads.find((read) => areEquivalentPaths(read.cwd, cwd));
|
||||
if (existing) return existing.checkout;
|
||||
const checkout = this.readCheckout(cwd);
|
||||
checkoutReads.push({ cwd, checkout });
|
||||
return checkout;
|
||||
};
|
||||
const roots: Array<{ rootPath: string; projects: PersistedProjectRecord[] }> = [];
|
||||
for (const project of projectsToReconcile) {
|
||||
const root = roots.find((candidate) =>
|
||||
areEquivalentPaths(candidate.rootPath, project.rootPath),
|
||||
);
|
||||
await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes);
|
||||
await Promise.all(
|
||||
duplicateProjects.flatMap((project) =>
|
||||
(workspacesByProject.get(project.projectId) ?? []).map(async (workspace) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedWorkspace = {
|
||||
...workspace,
|
||||
projectId: canonical.projectId,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await this.workspaceRegistry.upsert(updatedWorkspace);
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
fields: {
|
||||
projectId: canonical.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const canonicalSiblings = workspacesByProject.get(canonical.projectId) ?? [];
|
||||
canonicalSiblings.push(updatedWorkspace);
|
||||
workspacesByProject.set(canonical.projectId, canonicalSiblings);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const project of duplicateProjects) {
|
||||
workspacesByProject.set(project.projectId, []);
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.projectId, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
reason: "merged_duplicate",
|
||||
});
|
||||
}
|
||||
if (root) root.projects.push(project);
|
||||
else roots.push({ rootPath: project.rootPath, projects: [project] });
|
||||
}
|
||||
await Promise.all(
|
||||
roots.map(async ({ rootPath, projects }) => {
|
||||
try {
|
||||
const rootGit = await readCheckout(rootPath);
|
||||
await Promise.all(
|
||||
projects.map((project) =>
|
||||
this.reconcileProject({
|
||||
project,
|
||||
siblings: workspacesByProject.get(project.projectId) ?? [],
|
||||
currentGit: rootGit,
|
||||
readCheckout,
|
||||
changes,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, rootPath },
|
||||
"Skipped workspace reconciliation after Git read failed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectCustomName(
|
||||
canonical: PersistedProjectRecord,
|
||||
duplicateProjects: PersistedProjectRecord[],
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
if (canonical.customName) {
|
||||
return;
|
||||
}
|
||||
const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null;
|
||||
if (!customName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.upsert({
|
||||
...canonical,
|
||||
customName,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
canonical.customName = customName;
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: canonical.projectId,
|
||||
directory: canonical.rootPath,
|
||||
fields: { customName },
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcileProject(
|
||||
project: PersistedProjectRecord,
|
||||
siblings: PersistedWorkspaceRecord[],
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
const directoryName = project.rootPath.split(/[\\/]/).findLast(Boolean) ?? project.rootPath;
|
||||
const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName);
|
||||
|
||||
const projectUpdates: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
|
||||
> = {};
|
||||
|
||||
const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git";
|
||||
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
|
||||
const { project, siblings, currentGit, readCheckout, changes } = input;
|
||||
const workspaceCheckouts = await Promise.all(
|
||||
siblings.map(async (workspace) => ({
|
||||
workspace,
|
||||
checkout: await readCheckout(workspace.cwd),
|
||||
})),
|
||||
);
|
||||
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
|
||||
const mappedKind = deriveProjectKind(currentGit);
|
||||
|
||||
if (project.kind !== mappedKind) {
|
||||
projectUpdates.kind = mappedKind;
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
}
|
||||
|
||||
if (
|
||||
project.kind === "git" &&
|
||||
currentGit.projectKind === "git" &&
|
||||
project.displayName !== currentGit.projectDisplayName
|
||||
) {
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
}
|
||||
|
||||
if (Object.keys(projectUpdates).length > 0) {
|
||||
@@ -335,58 +352,163 @@ export class WorkspaceReconciliationService {
|
||||
});
|
||||
}
|
||||
|
||||
const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd));
|
||||
await Promise.all(
|
||||
existingSiblings.map(async (workspace) => {
|
||||
const wsDirName = workspace.cwd.split(/[\\/]/).findLast(Boolean) ?? workspace.cwd;
|
||||
const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
|
||||
|
||||
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
|
||||
|
||||
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">> = {};
|
||||
|
||||
if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) {
|
||||
workspaceUpdates.branch = wsGit.currentBranch;
|
||||
}
|
||||
|
||||
if (workspace.kind !== expectedKind) {
|
||||
workspaceUpdates.kind = expectedKind;
|
||||
}
|
||||
|
||||
if (Object.keys(workspaceUpdates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.upsert({
|
||||
...workspace,
|
||||
...workspaceUpdates,
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout: wsGit,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
if (!update) return;
|
||||
|
||||
await this.workspaceRegistry.upsert(update.workspace);
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
fields: workspaceUpdates,
|
||||
fields: update.fields,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async readWorkspaceGitMetadata(cwd: string, directoryName: string) {
|
||||
private async syncProjectRootWatches(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
const projects = await this.projectRegistry.list();
|
||||
if (this.disposed) return;
|
||||
const activeProjects = projects.filter((project) => !project.archivedAt);
|
||||
|
||||
for (let index = this.watchers.length - 1; index >= 0; index -= 1) {
|
||||
const target = this.watchers[index]!;
|
||||
const stillActive = activeProjects.some((project) =>
|
||||
areEquivalentPaths(project.rootPath, target.rootPath),
|
||||
);
|
||||
if (stillActive) continue;
|
||||
target.watcher.close();
|
||||
this.watchers.splice(index, 1);
|
||||
}
|
||||
|
||||
for (const project of activeProjects) {
|
||||
const alreadyWatching = this.watchers.some((target) =>
|
||||
areEquivalentPaths(target.rootPath, project.rootPath),
|
||||
);
|
||||
if (alreadyWatching) continue;
|
||||
try {
|
||||
let watcher: ProjectRootWatcher;
|
||||
watcher = this.watchProjectRoot(
|
||||
project.rootPath,
|
||||
{ recursive: false },
|
||||
(_event, filename) => {
|
||||
if (filename === null || filename.toString() === ".git") {
|
||||
this.scheduleObservedReconciliation();
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
watcher.close();
|
||||
const index = this.watchers.findIndex((target) => target.watcher === watcher);
|
||||
if (index >= 0) this.watchers.splice(index, 1);
|
||||
this.logger.warn(
|
||||
{ err: error, rootPath: project.rootPath },
|
||||
"Project root watch failed",
|
||||
);
|
||||
},
|
||||
);
|
||||
this.watchers.push({ rootPath: project.rootPath, watcher });
|
||||
} catch (error) {
|
||||
// The periodic reconciliation is the convergence path for roots that
|
||||
// are temporarily missing or unwatchable.
|
||||
this.logger.debug(
|
||||
{ err: error, rootPath: project.rootPath },
|
||||
"Project root is not watchable yet",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleObservedReconciliation(): void {
|
||||
if (this.disposed || this.debounceTimer) return;
|
||||
this.debounceTimer = this.clock.setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
return this.reconcileObservedGitMetadata();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private async reconcileObservedGitMetadata(
|
||||
mode: "metadata" | "full" = "metadata",
|
||||
): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.reconciling) {
|
||||
if (mode === "full" || this.reconcileQueuedMode === null) {
|
||||
this.reconcileQueuedMode = mode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.reconciling = true;
|
||||
try {
|
||||
await this.syncProjectRootWatches();
|
||||
const result = mode === "full" ? await this.runOnce() : await this.reconcileGitMetadata();
|
||||
const workspaceIds = new Set<string>();
|
||||
const projectIds = new Set<string>();
|
||||
for (const change of result.changesApplied) {
|
||||
if (change.kind === "workspace_updated" || change.kind === "workspace_archived") {
|
||||
workspaceIds.add(change.workspaceId);
|
||||
}
|
||||
if (change.kind === "project_updated") projectIds.add(change.projectId);
|
||||
}
|
||||
if (projectIds.size > 0) {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
for (const workspaceId of workspaceIdsForProjects(workspaces, projectIds)) {
|
||||
workspaceIds.add(workspaceId);
|
||||
}
|
||||
}
|
||||
if (!this.disposed && workspaceIds.size > 0) {
|
||||
await this.onWorkspacesChanged?.(Array.from(workspaceIds));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.disposed) {
|
||||
this.logger.warn({ err: error }, "Workspace reconciliation failed");
|
||||
}
|
||||
} finally {
|
||||
this.reconciling = false;
|
||||
if (this.reconcileQueuedMode) {
|
||||
const queuedMode = this.reconcileQueuedMode;
|
||||
this.reconcileQueuedMode = null;
|
||||
void this.reconcileObservedGitMetadata(queuedMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async readCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
if (!this.workspaceGitService) {
|
||||
return {
|
||||
projectKind: "directory" as const,
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "untitled",
|
||||
repoRoot: null,
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName });
|
||||
return this.workspaceGitService.getCheckout(cwd);
|
||||
}
|
||||
|
||||
private inspectDirectory(targetPath: string): DirectoryState {
|
||||
try {
|
||||
return statSync(targetPath).isDirectory() ? "directory" : "missing";
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return "missing";
|
||||
this.logger.warn(
|
||||
{ err: error, targetPath },
|
||||
"Skipped workspace reconciliation after directory inspection failed",
|
||||
);
|
||||
return "unreadable";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import {
|
||||
deriveProjectKind,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
type PersistedProjectKind,
|
||||
type PersistedWorkspaceKind,
|
||||
} from "./workspace-registry-model.js";
|
||||
|
||||
// COMPAT(legacyRegistryBootstrap): added in v0.1.109 on 2026-07-15; remove after
|
||||
// 2027-01-15, once every supported install has materialized its registry files.
|
||||
interface DirectoryProjectMembership {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
workspaceDirectoryKey: string;
|
||||
workspaceKind: PersistedWorkspaceKind;
|
||||
workspaceDisplayName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: PersistedProjectKind;
|
||||
}
|
||||
|
||||
export function classifyDirectoryForProjectMembership(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): DirectoryProjectMembership {
|
||||
const cwd = resolve(input.cwd);
|
||||
const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd };
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: checkout.worktreeRoot ?? cwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd,
|
||||
checkout,
|
||||
workspaceDirectoryKey: deriveWorkspaceDirectoryKey(cwd, checkout),
|
||||
workspaceKind: deriveWorkspaceKind(checkout),
|
||||
workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }),
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
projectRootPath: deriveProjectRootPath({ cwd, checkout }),
|
||||
projectKind: deriveProjectKind(checkout),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string {
|
||||
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
|
||||
return worktreeRoot ?? resolve(cwd);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) return null;
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let host: string | null = null;
|
||||
let remotePath: string | null = null;
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
remotePath = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) return null;
|
||||
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4);
|
||||
if (!cleanedPath.includes("/")) return null;
|
||||
|
||||
return `remote:${host.toLowerCase()}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingKey(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||
if (remoteKey) return remoteKey;
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim();
|
||||
return mainRepoRoot || options.cwd;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingName(projectKey: string): string {
|
||||
if (projectKey.startsWith("remote:")) {
|
||||
const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1);
|
||||
if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/");
|
||||
if (pathSegments.length === 1) return pathSegments[0];
|
||||
return projectKey;
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectKey;
|
||||
}
|
||||
|
||||
function deriveProjectRootPath(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
return input.checkout.isGit && input.checkout.mainRepoRoot
|
||||
? input.checkout.mainRepoRoot
|
||||
: input.cwd;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -11,8 +11,10 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
|
||||
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
|
||||
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
|
||||
let NON_GIT_PROJECT: string;
|
||||
let ARCHIVED_PROJECT: string;
|
||||
let GIT_PROJECT: string;
|
||||
let GIT_WORKTREE: string;
|
||||
|
||||
describe("bootstrapWorkspaceRegistries", () => {
|
||||
let tmpDir: string;
|
||||
@@ -25,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
|
||||
NON_GIT_PROJECT = path.join(tmpDir, "non-git-project");
|
||||
ARCHIVED_PROJECT = path.join(tmpDir, "archived-project");
|
||||
GIT_PROJECT = path.join(tmpDir, "legacy-git-project");
|
||||
GIT_WORKTREE = path.join(tmpDir, "legacy-git-project-feature");
|
||||
paseoHome = path.join(tmpDir, ".paseo");
|
||||
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
@@ -36,12 +42,133 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
logger,
|
||||
);
|
||||
workspaceGitService = createNoopWorkspaceGitService();
|
||||
for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("skips a legacy agent whose directory no longer exists", async () => {
|
||||
const missingDirectory = path.join(tmpDir, "missing-project");
|
||||
const getCheckout = async () => {
|
||||
throw new Error("Git must not inspect a missing directory");
|
||||
};
|
||||
workspaceGitService = { ...createNoopWorkspaceGitService(), getCheckout };
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-missing-directory",
|
||||
provider: "codex",
|
||||
cwd: missingDirectory,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: null,
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a legacy agent whose cwd is a file", async () => {
|
||||
const cwd = path.join(tmpDir, "not-a-directory");
|
||||
writeFileSync(cwd, "not a directory");
|
||||
workspaceGitService = {
|
||||
...createNoopWorkspaceGitService(),
|
||||
getCheckout: async () => {
|
||||
throw new Error("Git must not inspect a file");
|
||||
},
|
||||
};
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-file-cwd",
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: null,
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("propagates a Git failure for an existing legacy directory", async () => {
|
||||
const gitFailure = new Error("Git is unavailable");
|
||||
workspaceGitService = {
|
||||
...createNoopWorkspaceGitService(),
|
||||
getCheckout: async () => {
|
||||
throw gitFailure;
|
||||
},
|
||||
};
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-existing-directory",
|
||||
provider: "codex",
|
||||
cwd: NON_GIT_PROJECT,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: null,
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
}),
|
||||
).rejects.toBe(gitFailure);
|
||||
});
|
||||
|
||||
test("materializes workspace registries from non-archived agent records", async () => {
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
@@ -175,6 +302,81 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
||||
});
|
||||
|
||||
test("materializes legacy remote worktrees into one readable project", async () => {
|
||||
workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: cwd === GIT_PROJECT ? "main" : "feature/plain",
|
||||
remoteUrl: "git@github.com:acme/legacy-project.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: cwd === GIT_PROJECT ? null : GIT_PROJECT,
|
||||
}),
|
||||
});
|
||||
await agentStorage.initialize();
|
||||
for (const [id, cwd] of [
|
||||
["main-agent", GIT_PROJECT],
|
||||
["worktree-agent", GIT_WORKTREE],
|
||||
]) {
|
||||
await agentStorage.upsert({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
const projects = await projectRegistry.list();
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]).toMatchObject({
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
rootPath: GIT_PROJECT,
|
||||
kind: "git",
|
||||
displayName: "acme/legacy-project",
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
expect(
|
||||
workspaces
|
||||
.map(({ projectId, cwd, kind, displayName }) => ({ projectId, cwd, kind, displayName }))
|
||||
.sort((left, right) => left.cwd.localeCompare(right.cwd)),
|
||||
).toEqual([
|
||||
{
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
cwd: GIT_PROJECT,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
},
|
||||
{
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
cwd: GIT_WORKTREE,
|
||||
kind: "worktree",
|
||||
displayName: "feature/plain",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await workspaceRegistry.initialize();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js";
|
||||
import { generateWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
@@ -72,7 +71,17 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
]),
|
||||
);
|
||||
const records = await options.agentStorage.list();
|
||||
const activeRecords = records.filter((record) => !record.archivedAt);
|
||||
// A legacy agent can outlive its working directory. Reconciliation treats a
|
||||
// missing directory as absent rather than asking Git about it; bootstrap must
|
||||
// do the same before materializing its first workspace record.
|
||||
const activeRecords = records.filter((record) => {
|
||||
if (record.archivedAt) return false;
|
||||
try {
|
||||
return statSync(record.cwd).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const recordsByDirectoryKey = new Map<
|
||||
string,
|
||||
{
|
||||
|
||||
@@ -1,226 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { basename, isAbsolute, resolve } from "node:path";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDirectoryKey,
|
||||
checkoutFromPersistedWorkspacePlacement,
|
||||
deriveWorkspaceKind,
|
||||
detectStaleWorkspaces,
|
||||
generateWorkspaceId,
|
||||
generateProjectId,
|
||||
initialWorkspacePlacement,
|
||||
reconcileWorkspacePlacement,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
function createWorkspaceRecord(
|
||||
cwd: string,
|
||||
workspaceId: string,
|
||||
overrides?: { createdAt?: string; archivedAt?: string },
|
||||
) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: basename(cwd) || cwd,
|
||||
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: overrides?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("deriveProjectGroupingName", () => {
|
||||
test("returns owner/repo for a github remote project key", () => {
|
||||
expect(deriveProjectGroupingName("remote:github.com/acme/app")).toBe("acme/app");
|
||||
describe("opaque registry ids", () => {
|
||||
test("generates opaque project ids", () => {
|
||||
expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("returns owner/repo for a gitlab remote project key", () => {
|
||||
expect(deriveProjectGroupingName("remote:gitlab.com/acme/app")).toBe("acme/app");
|
||||
});
|
||||
|
||||
test("returns last two segments for a self-hosted remote project key", () => {
|
||||
expect(deriveProjectGroupingName("remote:git.acme.internal/platform/api")).toBe("platform/api");
|
||||
});
|
||||
|
||||
test("returns last two segments for a deeply-nested remote project key", () => {
|
||||
expect(deriveProjectGroupingName("remote:gitlab.com/group/sub/app")).toBe("sub/app");
|
||||
});
|
||||
|
||||
test("returns the lone path segment when only one segment follows the host", () => {
|
||||
expect(deriveProjectGroupingName("remote:github.com/solo")).toBe("solo");
|
||||
});
|
||||
|
||||
test("returns the trailing path segment for a non-remote project key", () => {
|
||||
expect(deriveProjectGroupingName("/repo/local")).toBe("local");
|
||||
});
|
||||
|
||||
test("returns the project key itself when no segments are present", () => {
|
||||
expect(deriveProjectGroupingName("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStaleWorkspaces", () => {
|
||||
test("returns workspace ids whose directories no longer exist", async () => {
|
||||
const checkedDirectories: string[] = [];
|
||||
const existingDirectories = new Set(["/tmp/existing"]);
|
||||
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/existing", "ws-existing"),
|
||||
createWorkspaceRecord("/tmp/missing", "ws-missing"),
|
||||
],
|
||||
checkDirectoryExists: async (cwd) => {
|
||||
checkedDirectories.push(cwd);
|
||||
return existingDirectories.has(cwd);
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]);
|
||||
expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]);
|
||||
});
|
||||
|
||||
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-repo"),
|
||||
createWorkspaceRecord("/tmp/other", "ws-other"),
|
||||
],
|
||||
checkDirectoryExists: async () => true,
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps workspaces with no agents when directory exists", async () => {
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/active", "ws-active"),
|
||||
createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"),
|
||||
],
|
||||
checkDirectoryExists: async () => true,
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkspaceDirectoryKey", () => {
|
||||
test("uses git worktree root when available", () => {
|
||||
expect(
|
||||
deriveWorkspaceDirectoryKey("/tmp/repo/packages/app", {
|
||||
cwd: "/tmp/repo/packages/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/tmp/repo",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
test("falls back to normalized cwd when git worktree root contains multiple lines", () => {
|
||||
const cwd = String.raw`E:\project\node-ai`;
|
||||
|
||||
expect(
|
||||
deriveWorkspaceDirectoryKey(cwd, {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: `--path-format=absolute\n${cwd}`,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe(resolve(cwd));
|
||||
});
|
||||
|
||||
test("falls back to normalized cwd for non-git directories", () => {
|
||||
const cwd = "/tmp/repo/../repo/scratch";
|
||||
|
||||
expect(
|
||||
deriveWorkspaceDirectoryKey(cwd, {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe(resolve("/tmp/repo/scratch"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("opaque workspace id versus directory key", () => {
|
||||
test("generates opaque workspace ids that are not filesystem paths", () => {
|
||||
const workspaceId = generateWorkspaceId();
|
||||
|
||||
expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/);
|
||||
expect(isAbsolute(workspaceId)).toBe(false);
|
||||
});
|
||||
|
||||
test("derives a path-shaped directory key that is never an opaque workspace id", () => {
|
||||
const directoryKey = deriveWorkspaceDirectoryKey("/tmp/repo/scratch", {
|
||||
cwd: "/tmp/repo/scratch",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(directoryKey).toBe(resolve("/tmp/repo/scratch"));
|
||||
expect(directoryKey.startsWith("wks_")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git worktree grouping", () => {
|
||||
test("classifies plain git worktrees for project membership from git facts", () => {
|
||||
const membership = classifyDirectoryForProjectMembership({
|
||||
cwd: "/tmp/repo-feature",
|
||||
checkout: {
|
||||
cwd: "/tmp/repo-feature",
|
||||
isGit: true,
|
||||
currentBranch: "feature/plain",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/tmp/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).toMatchObject({
|
||||
// Path-derived directory key, distinct from the opaque workspace id (generated separately).
|
||||
cwd: resolve("/tmp/repo-feature"),
|
||||
workspaceDirectoryKey: "/tmp/repo-feature",
|
||||
workspaceKind: "worktree",
|
||||
workspaceDisplayName: "feature/plain",
|
||||
projectKey: "remote:github.com/acme/repo",
|
||||
projectName: "acme/repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses mainRepoRoot as the project root for plain git worktrees", () => {
|
||||
expect(
|
||||
deriveProjectRootPath({
|
||||
cwd: "/tmp/repo-feature",
|
||||
checkout: {
|
||||
cwd: "/tmp/repo-feature",
|
||||
isGit: true,
|
||||
currentBranch: "feature/plain",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/tmp/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
},
|
||||
}),
|
||||
).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
describe("workspace kind", () => {
|
||||
test("classifies plain git worktrees as workspaces of kind worktree", () => {
|
||||
expect(
|
||||
deriveWorkspaceKind({
|
||||
@@ -235,3 +39,119 @@ describe("git worktree grouping", () => {
|
||||
).toBe("worktree");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace placement", () => {
|
||||
test("defines checkout and created-worktree placement completely", () => {
|
||||
expect(
|
||||
initialWorkspacePlacement({
|
||||
source: "checkout",
|
||||
cwd: "/repo",
|
||||
checkout: {
|
||||
cwd: "/repo",
|
||||
isGit: true,
|
||||
currentBranch: " main ",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/repo",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: "/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
branch: "main",
|
||||
worktreeRoot: "/repo",
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
expect(
|
||||
initialWorkspacePlacement({
|
||||
source: "created_worktree",
|
||||
cwd: "/repo-feature/app",
|
||||
worktreeRoot: "/repo-feature",
|
||||
branch: "feature/placement",
|
||||
baseBranch: "main",
|
||||
mainRepoRoot: "/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: "/repo-feature/app",
|
||||
kind: "worktree",
|
||||
displayName: "feature/placement",
|
||||
branch: "feature/placement",
|
||||
worktreeRoot: "/repo-feature",
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("updates live placement while preserving its durable name and base branch", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/repo-feature",
|
||||
kind: "worktree",
|
||||
displayName: "Keep this name",
|
||||
branch: "old-branch",
|
||||
worktreeRoot: "/old-root",
|
||||
baseBranch: "release",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout: {
|
||||
cwd: workspace.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "renamed-branch",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/repo",
|
||||
},
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(update?.fields).toEqual({
|
||||
branch: "renamed-branch",
|
||||
worktreeRoot: "/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
});
|
||||
expect(update?.workspace).toMatchObject({
|
||||
displayName: "Keep this name",
|
||||
baseBranch: "release",
|
||||
branch: "renamed-branch",
|
||||
});
|
||||
});
|
||||
|
||||
test("projects persisted placement to the wire checkout", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/repo-feature/app",
|
||||
kind: "worktree",
|
||||
displayName: "feature",
|
||||
branch: "feature",
|
||||
worktreeRoot: "/repo-feature",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(checkoutFromPersistedWorkspacePlacement({ workspace })).toEqual({
|
||||
cwd: "/repo-feature/app",
|
||||
isGit: true,
|
||||
currentBranch: "feature",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/repo-feature",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,154 +1,20 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
ProjectCheckoutLitePayload,
|
||||
ProjectPlacementPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
export type PersistedProjectKind = "git" | "non_git";
|
||||
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
||||
|
||||
export interface DirectoryProjectMembership {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
workspaceDirectoryKey: string;
|
||||
workspaceKind: PersistedWorkspaceKind;
|
||||
workspaceDisplayName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: PersistedProjectKind;
|
||||
}
|
||||
|
||||
export interface DetectStaleWorkspacesInput {
|
||||
activeWorkspaces: PersistedWorkspaceRecord[];
|
||||
checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function generateWorkspaceId(): string {
|
||||
return `wks_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
// Path-derived grouping key for a workspace directory. This is NOT the opaque
|
||||
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
|
||||
export function deriveWorkspaceDirectoryKey(
|
||||
cwd: string,
|
||||
checkout: ProjectCheckoutLitePayload,
|
||||
): string {
|
||||
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
|
||||
return worktreeRoot ?? resolve(cwd);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: string | null = null;
|
||||
let remotePath: string | null = null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
remotePath = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) {
|
||||
cleanedPath = cleanedPath.slice(0, -4);
|
||||
}
|
||||
if (!cleanedPath.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase();
|
||||
if (cleanedHost === "github.com") {
|
||||
return `remote:github.com/${cleanedPath}`;
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingKey(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||
if (remoteKey) {
|
||||
return remoteKey;
|
||||
}
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim();
|
||||
if (mainRepoRoot) {
|
||||
return mainRepoRoot;
|
||||
}
|
||||
|
||||
return options.cwd;
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingName(projectKey: string): string {
|
||||
if (projectKey.startsWith("remote:")) {
|
||||
const remainder = projectKey.slice("remote:".length);
|
||||
const pathSegments = remainder.split("/").filter(Boolean).slice(1);
|
||||
if (pathSegments.length >= 2) {
|
||||
return pathSegments.slice(-2).join("/");
|
||||
}
|
||||
if (pathSegments.length === 1) {
|
||||
return pathSegments[0];
|
||||
}
|
||||
return projectKey;
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectKey;
|
||||
}
|
||||
|
||||
function deriveWorkspaceDirectoryName(cwd: string): string {
|
||||
const normalized = cwd.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? cwd;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceDisplayName(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
const branch = input.checkout.currentBranch?.trim() ?? null;
|
||||
if (branch && branch.toUpperCase() !== "HEAD") {
|
||||
return branch;
|
||||
}
|
||||
return deriveWorkspaceDirectoryName(input.cwd);
|
||||
}
|
||||
|
||||
export function deriveProjectRootPath(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
if (input.checkout.isGit && input.checkout.mainRepoRoot) {
|
||||
return input.checkout.mainRepoRoot;
|
||||
}
|
||||
return input.cwd;
|
||||
export function generateProjectId(): string {
|
||||
return `prj_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
|
||||
@@ -162,6 +28,161 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
||||
return checkout.mainRepoRoot ? "worktree" : "local_checkout";
|
||||
}
|
||||
|
||||
export function deriveWorkspaceDisplayName(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
const branch = input.checkout.currentBranch?.trim() ?? null;
|
||||
if (branch && branch.toUpperCase() !== "HEAD") return branch;
|
||||
|
||||
const segments = input.cwd.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? input.cwd;
|
||||
}
|
||||
|
||||
export type PersistedWorkspacePlacement = Pick<
|
||||
PersistedWorkspaceRecord,
|
||||
| "cwd"
|
||||
| "kind"
|
||||
| "displayName"
|
||||
| "branch"
|
||||
| "worktreeRoot"
|
||||
| "baseBranch"
|
||||
| "isPaseoOwnedWorktree"
|
||||
| "mainRepoRoot"
|
||||
>;
|
||||
|
||||
export type MutableWorkspacePlacement = Pick<
|
||||
PersistedWorkspaceRecord,
|
||||
"kind" | "branch" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot"
|
||||
>;
|
||||
|
||||
export type InitialWorkspacePlacementInput =
|
||||
| {
|
||||
source: "checkout";
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}
|
||||
| {
|
||||
source: "created_worktree";
|
||||
cwd: string;
|
||||
worktreeRoot: string;
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
mainRepoRoot: string;
|
||||
};
|
||||
|
||||
export interface WorkspacePlacementUpdate {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
fields: Partial<MutableWorkspacePlacement>;
|
||||
}
|
||||
|
||||
/** Defines the complete persisted placement for every new workspace. */
|
||||
export function initialWorkspacePlacement(
|
||||
input: InitialWorkspacePlacementInput,
|
||||
): PersistedWorkspacePlacement {
|
||||
if (input.source === "created_worktree") {
|
||||
return {
|
||||
cwd: input.cwd,
|
||||
kind: "worktree",
|
||||
displayName: input.branch || input.cwd,
|
||||
branch: input.branch,
|
||||
worktreeRoot: input.worktreeRoot,
|
||||
baseBranch: input.baseBranch,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: input.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
const branch = normalizeBranch(input.checkout.currentBranch);
|
||||
return {
|
||||
cwd: input.cwd,
|
||||
kind: deriveWorkspaceKind(input.checkout),
|
||||
displayName: deriveWorkspaceDisplayName(input),
|
||||
branch,
|
||||
worktreeRoot: input.checkout.isGit ? (input.checkout.worktreeRoot ?? input.cwd) : null,
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: input.checkout.isGit && input.checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: input.checkout.isGit ? input.checkout.mainRepoRoot : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies live placement facts without rewriting the workspace's durable name
|
||||
* or its creation-time base branch.
|
||||
*/
|
||||
export function reconcileWorkspacePlacement(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
updatedAt: string;
|
||||
}): WorkspacePlacementUpdate | null {
|
||||
const observed = initialWorkspacePlacement({
|
||||
source: "checkout",
|
||||
cwd: input.workspace.cwd,
|
||||
checkout: input.checkout,
|
||||
});
|
||||
const fields: Partial<MutableWorkspacePlacement> = {};
|
||||
if (input.workspace.kind !== observed.kind) fields.kind = observed.kind;
|
||||
if (input.workspace.branch !== observed.branch) fields.branch = observed.branch;
|
||||
if (input.workspace.worktreeRoot !== observed.worktreeRoot)
|
||||
fields.worktreeRoot = observed.worktreeRoot;
|
||||
if (input.workspace.isPaseoOwnedWorktree !== observed.isPaseoOwnedWorktree)
|
||||
fields.isPaseoOwnedWorktree = observed.isPaseoOwnedWorktree;
|
||||
if (input.workspace.mainRepoRoot !== observed.mainRepoRoot)
|
||||
fields.mainRepoRoot = observed.mainRepoRoot;
|
||||
|
||||
if (Object.keys(fields).length === 0) return null;
|
||||
return {
|
||||
workspace: { ...input.workspace, ...fields, updatedAt: input.updatedAt },
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects persisted placement onto the checkout shape sent over the wire. */
|
||||
export function checkoutFromPersistedWorkspacePlacement(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
fallbackBranch?: string | null;
|
||||
fallbackWorktreeRoot?: string | null;
|
||||
}): ProjectPlacementPayload["checkout"] {
|
||||
const { workspace } = input;
|
||||
if (workspace.kind === "directory") {
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
const checkout = {
|
||||
cwd: workspace.cwd,
|
||||
currentBranch: workspace.branch ?? input.fallbackBranch ?? null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: workspace.worktreeRoot ?? input.fallbackWorktreeRoot ?? workspace.cwd,
|
||||
};
|
||||
if (workspace.isPaseoOwnedWorktree && workspace.mainRepoRoot) {
|
||||
return {
|
||||
...checkout,
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...checkout,
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBranch(branch: string | null | undefined): string | null {
|
||||
const normalized = branch?.trim() ?? null;
|
||||
return normalized && normalized.toUpperCase() !== "HEAD" ? normalized : null;
|
||||
}
|
||||
|
||||
export function checkoutLiteFromGitSnapshot(
|
||||
cwd: string,
|
||||
git: {
|
||||
@@ -205,70 +226,3 @@ export function checkoutLiteFromGitSnapshot(
|
||||
mainRepoRoot: git.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectStaleWorkspaces(
|
||||
input: DetectStaleWorkspacesInput,
|
||||
): Promise<Set<string>> {
|
||||
const staleWorkspaceIds = new Set<string>();
|
||||
|
||||
const existenceChecks = await Promise.all(
|
||||
input.activeWorkspaces.map(async (workspace) => ({
|
||||
workspace,
|
||||
exists: await input.checkDirectoryExists(workspace.cwd),
|
||||
})),
|
||||
);
|
||||
for (const { workspace, exists } of existenceChecks) {
|
||||
if (!exists) {
|
||||
staleWorkspaceIds.add(workspace.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return staleWorkspaceIds;
|
||||
}
|
||||
|
||||
export function buildProjectPlacementForCwd(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): ProjectPlacementPayload {
|
||||
const membership = classifyDirectoryForProjectMembership(input);
|
||||
return {
|
||||
projectKey: membership.projectKey,
|
||||
projectName: membership.projectName,
|
||||
checkout: membership.checkout,
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyDirectoryForProjectMembership(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): DirectoryProjectMembership {
|
||||
const normalizedCwd = resolve(input.cwd);
|
||||
const checkout: ProjectCheckoutLitePayload = {
|
||||
...input.checkout,
|
||||
cwd: normalizedCwd,
|
||||
};
|
||||
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: checkout.worktreeRoot ?? normalizedCwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
workspaceDirectoryKey: deriveWorkspaceDirectoryKey(normalizedCwd, checkout),
|
||||
workspaceKind: deriveWorkspaceKind(checkout),
|
||||
workspaceDisplayName: deriveWorkspaceDisplayName({
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
}),
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
projectRootPath: deriveProjectRootPath({
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
}),
|
||||
projectKind: deriveProjectKind(checkout),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -99,43 +99,228 @@ describe("workspace registries", () => {
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("PIN: two checkouts of the same git remote collapse into a single project record", async () => {
|
||||
// Reproduces the situation in #987: two directories that share a git remote
|
||||
// both derive the same projectKey/displayName. Because the registry is keyed
|
||||
// by projectId, the second upsert overwrites the first — so the registry can
|
||||
// only ever hold one record per remote, and there is no way to distinguish
|
||||
// the two checkouts in the UI.
|
||||
test("publishes only project mutations that change the persisted lifecycle", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const mutations: Array<{
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: ReturnType<typeof createPersistedProjectRecord> | null;
|
||||
}> = [];
|
||||
const unsubscribe = projectRegistry.subscribeToMutations((mutation) => {
|
||||
mutations.push(mutation);
|
||||
});
|
||||
const active = createPersistedProjectRecord({
|
||||
projectId: "project-one",
|
||||
rootPath: "/tmp/project-one",
|
||||
kind: "non_git",
|
||||
displayName: "project-one",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const archived = {
|
||||
...active,
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const remoteKey = "remote:github.com/acme/repo";
|
||||
await projectRegistry.upsert(active);
|
||||
await projectRegistry.archive(active.projectId, archived.archivedAt);
|
||||
await projectRegistry.archive(active.projectId, "2026-03-03T00:00:00.000Z");
|
||||
await projectRegistry.archive("project-unknown", "2026-03-03T00:00:00.000Z");
|
||||
await projectRegistry.remove(active.projectId);
|
||||
await projectRegistry.remove(active.projectId);
|
||||
await projectRegistry.remove("project-unknown");
|
||||
|
||||
expect(mutations).toEqual([
|
||||
{ kind: "upsert", projectId: active.projectId, project: active },
|
||||
{ kind: "archive", projectId: active.projectId, project: archived },
|
||||
{ kind: "remove", projectId: active.projectId, project: null },
|
||||
]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("atomically allocates one opaque project for concurrent exact-root adds", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const rootPath = path.join(tmpDir, "same-root");
|
||||
const projects = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "same-root",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(new Set(projects.map((project) => project.projectId))).toEqual(
|
||||
new Set([projects[0]!.projectId]),
|
||||
);
|
||||
expect(projects[0]!.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps readable legacy IDs alongside newly allocated opaque IDs", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/work/repo",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/legacy",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const opaque = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "/tmp/new",
|
||||
kind: "non_git",
|
||||
displayName: "new",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect((await projectRegistry.get("remote:github.com/acme/repo"))?.rootPath).toBe(
|
||||
"/tmp/legacy",
|
||||
);
|
||||
expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("allocates a fresh opaque ID when only an archived exact root exists", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const rootPath = path.join(tmpDir, "archived-root");
|
||||
const archived = createPersistedProjectRecord({
|
||||
projectId: "prj_archived",
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "archived-root",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.upsert(archived);
|
||||
|
||||
const created = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "archived-root",
|
||||
timestamp: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({ rootPath, archivedAt: null });
|
||||
expect(created.projectId).not.toBe(archived.projectId);
|
||||
expect(await projectRegistry.get(archived.projectId)).toEqual(archived);
|
||||
});
|
||||
|
||||
test("refreshes the oldest active legacy duplicate kind without rewriting its identity", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const rootPath = path.join(tmpDir, "legacy-root");
|
||||
const oldest = createPersistedProjectRecord({
|
||||
projectId: "remote:oldest",
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "oldest",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const duplicate = createPersistedProjectRecord({
|
||||
projectId: "remote:duplicate",
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "duplicate",
|
||||
createdAt: "2026-03-02T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.upsert(oldest);
|
||||
await projectRegistry.upsert(duplicate);
|
||||
|
||||
await expect(
|
||||
projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "new-name",
|
||||
timestamp: "2026-03-03T00:00:00.000Z",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...oldest,
|
||||
kind: "non_git",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await projectRegistry.list()).toEqual([
|
||||
{ ...oldest, kind: "non_git", updatedAt: "2026-03-03T00:00:00.000Z" },
|
||||
duplicate,
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses an active project for Windows lexical-equivalent root spellings", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const first = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "C:\\Users\\Paseo\\Repo",
|
||||
kind: "git",
|
||||
displayName: "Repo",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const second = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "c:/users/paseo/repo/.",
|
||||
kind: "git",
|
||||
displayName: "Repo",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(await projectRegistry.list()).toEqual([first]);
|
||||
});
|
||||
|
||||
test("keeps lexical and symlink root spellings distinct without realpath", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const target = path.join(tmpDir, "target");
|
||||
const link = path.join(tmpDir, "link");
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
||||
|
||||
const targetProject = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: target,
|
||||
kind: "non_git",
|
||||
displayName: "target",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const linkProject = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: link,
|
||||
kind: "non_git",
|
||||
displayName: "link",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(linkProject.projectId).not.toBe(targetProject.projectId);
|
||||
expect(await projectRegistry.list()).toEqual([targetProject, linkProject]);
|
||||
});
|
||||
|
||||
test("retries a generated project ID collision", async () => {
|
||||
const generatedIds = ["prj_collision", "prj_fresh"];
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(tmpDir, "projects", "projects.json"),
|
||||
logger,
|
||||
{ projectIdFactory: () => generatedIds.shift() ?? "prj_unexpected" },
|
||||
);
|
||||
await projectRegistry.initialize();
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_collision",
|
||||
rootPath: path.join(tmpDir, "existing"),
|
||||
kind: "non_git",
|
||||
displayName: "existing",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/scratch/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const created = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "new"),
|
||||
kind: "non_git",
|
||||
displayName: "new",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const all = await projectRegistry.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.displayName).toBe("acme/repo");
|
||||
// Second upsert wins — the first rootPath is lost.
|
||||
expect(all[0]?.rootPath).toBe("/home/me/scratch/repo");
|
||||
expect(created.projectId).toBe("prj_fresh");
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("project record schema accepts records without customName (legacy on-disk records)", async () => {
|
||||
@@ -212,6 +397,29 @@ describe("workspace registries", () => {
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("refreshes workspace archive timestamps when an archive is repeated", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z");
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z");
|
||||
|
||||
expect(await workspaceRegistry.get("workspace-one")).toMatchObject({
|
||||
archivedAt: "2026-03-03T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("composes concurrent workspace field updates without losing either change", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { writeJsonFileAtomic } from "./atomic-file.js";
|
||||
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
generateProjectId,
|
||||
type PersistedProjectKind,
|
||||
type PersistedWorkspaceKind,
|
||||
} from "./workspace-registry-model.js";
|
||||
|
||||
const PersistedProjectRecordSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -45,6 +50,11 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
// Exact checkout/worktree root backing cwd. This differs from cwd when the
|
||||
// selected project is a subdirectory inside a repository. Persist it so
|
||||
// archive and recovery do not need the directory to still exist in order to
|
||||
// recover placement.
|
||||
worktreeRoot: z.string().nullable().default(null),
|
||||
// The base branch the worktree was created from (normalized like worktree.json's
|
||||
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
|
||||
// worktrees and directory/local_checkout workspaces leave it null.
|
||||
@@ -53,6 +63,8 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
isPaseoOwnedWorktree: z.boolean().default(false),
|
||||
mainRepoRoot: z.string().nullable().default(null),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -71,9 +83,23 @@ export interface ProjectRegistry {
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedProjectRecord[]>;
|
||||
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
||||
getOrCreateActiveByRoot(input: {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord>;
|
||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||
remove(projectId: string): Promise<void>;
|
||||
/** Central lifecycle seam for daemon-global project observers. */
|
||||
subscribeToMutations?(
|
||||
listener: (mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}) => void | Promise<void>,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRegistry {
|
||||
@@ -162,24 +188,43 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
if (!existing) return;
|
||||
await this.persistArchive(existing, archivedAt);
|
||||
}
|
||||
|
||||
protected async archiveIfActive(id: string, archivedAt: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing || existing.archivedAt) {
|
||||
return null;
|
||||
}
|
||||
return this.persistArchive(existing, archivedAt);
|
||||
}
|
||||
|
||||
private async persistArchive(existing: TRecord, archivedAt: string): Promise<TRecord> {
|
||||
const next = this.schema.parse({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
});
|
||||
this.cache.set(id, next);
|
||||
this.cache.set(this.getId(next), next);
|
||||
await this.enqueuePersist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.removeIfPresent(id);
|
||||
}
|
||||
|
||||
protected async removeIfPresent(id: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
if (!this.cache.delete(id)) {
|
||||
return;
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
this.cache.delete(id);
|
||||
await this.enqueuePersist();
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
@@ -219,7 +264,17 @@ export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
private allocationQueue: Promise<void> = Promise.resolve();
|
||||
private readonly projectIdFactory: () => string;
|
||||
private readonly mutationListeners = new Set<
|
||||
(mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}) => void | Promise<void>
|
||||
>();
|
||||
|
||||
constructor(filePath: string, logger: Logger, options?: { projectIdFactory?: () => string }) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
@@ -227,6 +282,89 @@ export class FileBackedProjectRegistry
|
||||
getId: (record) => record.projectId,
|
||||
component: "projects",
|
||||
});
|
||||
this.projectIdFactory = options?.projectIdFactory ?? generateProjectId;
|
||||
}
|
||||
|
||||
async getOrCreateActiveByRoot(input: {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const previous = this.allocationQueue;
|
||||
let release!: () => void;
|
||||
this.allocationQueue = new Promise<void>((resolve) => (release = resolve));
|
||||
await previous;
|
||||
try {
|
||||
const active = (await this.list())
|
||||
.filter(
|
||||
(project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.projectId.localeCompare(right.projectId),
|
||||
)[0];
|
||||
if (active) {
|
||||
if (active.kind === input.kind) return active;
|
||||
const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp };
|
||||
await this.upsert(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const projectId = this.projectIdFactory();
|
||||
if (await this.get(projectId)) continue;
|
||||
const record = createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
await this.upsert(record);
|
||||
return record;
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToMutations(
|
||||
listener: (mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}) => void | Promise<void>,
|
||||
): () => void {
|
||||
this.mutationListeners.add(listener);
|
||||
return () => this.mutationListeners.delete(listener);
|
||||
}
|
||||
|
||||
override async upsert(record: PersistedProjectRecord): Promise<void> {
|
||||
await super.upsert(record);
|
||||
await this.notifyMutation({ kind: "upsert", projectId: record.projectId, project: record });
|
||||
}
|
||||
|
||||
override async archive(projectId: string, archivedAt: string): Promise<void> {
|
||||
const project = await this.archiveIfActive(projectId, archivedAt);
|
||||
if (!project) return;
|
||||
await this.notifyMutation({ kind: "archive", projectId, project });
|
||||
}
|
||||
|
||||
override async remove(projectId: string): Promise<void> {
|
||||
const project = await this.removeIfPresent(projectId);
|
||||
if (!project) return;
|
||||
await this.notifyMutation({ kind: "remove", projectId, project: null });
|
||||
}
|
||||
|
||||
private async notifyMutation(mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}): Promise<void> {
|
||||
await Promise.all([...this.mutationListeners].map((listener) => listener(mutation)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +412,10 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
displayName: string;
|
||||
title?: string | null;
|
||||
branch?: string | null;
|
||||
worktreeRoot?: string | null;
|
||||
baseBranch?: string | null;
|
||||
isPaseoOwnedWorktree?: boolean;
|
||||
mainRepoRoot?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
@@ -284,7 +425,10 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
...input,
|
||||
title: input.title ?? null,
|
||||
branch: input.branch ?? null,
|
||||
worktreeRoot: input.worktreeRoot ?? null,
|
||||
baseBranch: input.baseBranch ?? null,
|
||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
pinnedAt: input.pinnedAt ?? null,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
|
||||
// workspaceId-scoped archive tear these terminals down.
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
workspaceCwd?: string;
|
||||
shouldBootstrap?: boolean;
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
@@ -503,7 +504,8 @@ async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
runtimeEnv: WorktreeRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
||||
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd);
|
||||
if (terminalSpecs.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -540,7 +542,7 @@ async function runWorktreeTerminalBootstrap(
|
||||
terminalSpecs.map(async (spec): Promise<WorktreeBootstrapTerminalResult> => {
|
||||
try {
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: options.worktree.worktreePath,
|
||||
cwd: workspaceCwd,
|
||||
name: spec.name,
|
||||
env: runtimeEnv,
|
||||
workspaceId: options.workspaceId,
|
||||
@@ -597,6 +599,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||
let liveEmitQueue = Promise.resolve();
|
||||
|
||||
const queueLiveRunningEmit = () => {
|
||||
@@ -632,12 +635,12 @@ export async function runAsyncWorktreeBootstrap(
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
options.terminalManager?.registerCwdEnv({
|
||||
cwd: options.worktree.worktreePath,
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
|
||||
@@ -32,8 +32,15 @@ import {
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
type CreatePaseoWorktreeFn,
|
||||
@@ -41,6 +48,7 @@ import {
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
|
||||
interface LegacyCreateWorktreeTestOptions {
|
||||
branchName: string;
|
||||
@@ -294,6 +302,70 @@ function createPaseoWorktreeForTest(options: {
|
||||
forgeOverrides: { github: createGitHubServiceStub() },
|
||||
},
|
||||
});
|
||||
const projectRegistry: ProjectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) =>
|
||||
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_test_${projects.size + 1}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
options.events?.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
archive: async (projectId, archivedAt) => {
|
||||
const project = projects.get(projectId);
|
||||
if (project) projects.set(projectId, { ...project, archivedAt });
|
||||
},
|
||||
remove: async (projectId) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
};
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
const updated = updater(workspace);
|
||||
workspaces.set(workspaceId, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
options.events?.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId, archivedAt) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
||||
},
|
||||
remove: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
};
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
return (input, serviceOptions) => {
|
||||
return createPaseoWorktreeService(input, {
|
||||
@@ -301,22 +373,8 @@ function createPaseoWorktreeForTest(options: {
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
upsert: async (record) => {
|
||||
options.events?.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
},
|
||||
workspaceRegistry: {
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
upsert: async (record) => {
|
||||
options.events?.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
},
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -548,6 +606,62 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceWorkspaceCwd = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceWorkspaceCwd, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(sourceWorkspaceCwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["pwd > setup-cwd.txt"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature-subdirectory-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-subdirectory-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: () => {},
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
},
|
||||
{
|
||||
requestCwd: sourceWorkspaceCwd,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "ws-subdirectory-setup",
|
||||
worktree: createdWorktree,
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-subdirectory-setup",
|
||||
worktreePath: createdWorktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
@@ -1325,7 +1439,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
workspace: {
|
||||
workspaceId: "ws-fix-attached-pr-context",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app",
|
||||
kind: "worktree" as const,
|
||||
displayName: "fix-attached-pr-context",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
@@ -1382,7 +1496,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context");
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ export async function buildAgentSessionConfig(
|
||||
),
|
||||
},
|
||||
);
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
cwd = createdWorktree.workspace.cwd;
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
||||
} else if (normalized.createNewBranch) {
|
||||
@@ -627,6 +627,7 @@ export async function createPaseoWorktreeWorkflow(
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
slug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
@@ -641,6 +642,7 @@ export async function createPaseoWorktreeWorkflow(
|
||||
agentId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
workspaceCwd: workspace.cwd,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
terminalManager: setupContinuation.terminalManager,
|
||||
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
|
||||
@@ -683,6 +685,7 @@ export async function runWorktreeSetupInBackground(
|
||||
shouldBootstrap: boolean;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
workspaceCwd?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
let worktree: WorktreeConfig = options.worktree;
|
||||
@@ -721,7 +724,8 @@ export async function runWorktreeSetupInBackground(
|
||||
if (!options.shouldBootstrap) {
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
|
||||
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
|
||||
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
|
||||
if (setupCommands.length === 0) {
|
||||
setupStarted = true;
|
||||
emitSetupProgress("completed", null);
|
||||
@@ -732,12 +736,12 @@ export async function runWorktreeSetupInBackground(
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager?.registerCwdEnv({
|
||||
cwd: worktree.worktreePath,
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupStarted = true;
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: worktree.worktreePath,
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
repoRootPath: options.repoRoot,
|
||||
|
||||
@@ -122,15 +122,14 @@ export async function archiveCommand(
|
||||
dependencies: ArchiveCommandDependencies,
|
||||
input: ArchiveCommandInput,
|
||||
): Promise<ArchiveCommandResult> {
|
||||
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
|
||||
const targetPath = await resolveArchiveTarget(dependencies, input);
|
||||
const scope = input.scope ?? "workspace";
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
|
||||
if (scope === "worktree") {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
|
||||
if (!ownership.allowed) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -141,10 +140,7 @@ export async function archiveCommand(
|
||||
}
|
||||
|
||||
const result = await archiveByScope(dependencies, {
|
||||
scope: { kind: "worktree", targetPath: resolvedTarget.targetPath },
|
||||
repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null,
|
||||
repoWorktreesRoot: ownership.worktreeRoot,
|
||||
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
scope: { kind: "worktree", targetPath },
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
@@ -155,11 +151,11 @@ export async function archiveCommand(
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath));
|
||||
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, targetPath));
|
||||
|
||||
if (!workspaceId) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ targetPath: resolvedTarget.targetPath },
|
||||
{ targetPath },
|
||||
"Could not resolve workspace for archive; skipping",
|
||||
);
|
||||
return {
|
||||
@@ -170,8 +166,6 @@ export async function archiveCommand(
|
||||
|
||||
const result = await archiveByScope(dependencies, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: resolvedTarget.repoRoot,
|
||||
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
@@ -181,28 +175,20 @@ export async function archiveCommand(
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedArchiveTarget {
|
||||
targetPath: string;
|
||||
repoRoot: string | null;
|
||||
}
|
||||
|
||||
async function resolveArchiveTarget(
|
||||
dependencies: ArchiveCommandDependencies,
|
||||
input: ArchiveCommandInput,
|
||||
): Promise<ResolvedArchiveTarget> {
|
||||
): Promise<string> {
|
||||
const repoRoot = input.repoRoot ?? null;
|
||||
if (input.worktreePath) {
|
||||
return { targetPath: input.worktreePath, repoRoot };
|
||||
return input.worktreePath;
|
||||
}
|
||||
|
||||
if (input.worktreeSlug) {
|
||||
if (!repoRoot) {
|
||||
throw new Error("repoRoot is required when worktreeSlug is supplied");
|
||||
}
|
||||
return {
|
||||
targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug),
|
||||
repoRoot,
|
||||
};
|
||||
return resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug);
|
||||
}
|
||||
|
||||
if (repoRoot && input.branchName) {
|
||||
@@ -211,7 +197,7 @@ async function resolveArchiveTarget(
|
||||
if (!match) {
|
||||
throw new Error(`Paseo worktree not found for branch ${input.branchName}`);
|
||||
}
|
||||
return { targetPath: match.path, repoRoot };
|
||||
return match.path;
|
||||
}
|
||||
|
||||
throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required");
|
||||
|
||||
@@ -307,6 +307,13 @@ describe("checkout git utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a real non-git directory as non-git", async () => {
|
||||
const nonGitDir = join(tempDir, "not-git-status");
|
||||
mkdirSync(nonGitDir, { recursive: true });
|
||||
|
||||
await expect(getCheckoutStatus(nonGitDir)).resolves.toEqual({ isGit: false });
|
||||
});
|
||||
|
||||
it("returns null for getCurrentBranch in a repo with no commits", async () => {
|
||||
const emptyRepo = join(tempDir, "empty-repo");
|
||||
mkdirSync(emptyRepo, { recursive: true });
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktr
|
||||
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
LC_ALL: "C",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -804,11 +805,11 @@ export type CheckoutSnapshotFacts =
|
||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||
};
|
||||
|
||||
function isGitError(error: unknown): boolean {
|
||||
function isNotGitRepositoryError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return /not a git repository/i.test(error.message) || /git repository/i.test(error.message);
|
||||
return /not a git repository \(or any of the parent directories\): \.git/i.test(error.message);
|
||||
}
|
||||
|
||||
async function requireGitRepo(cwd: string): Promise<void> {
|
||||
@@ -865,8 +866,11 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
|
||||
logger: context?.logger,
|
||||
});
|
||||
return parseGitRevParsePath(stdout);
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (isNotGitRepositoryError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1533,35 +1537,29 @@ async function inspectCheckoutContext(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutInspectionContext | null> {
|
||||
try {
|
||||
const root = await getWorktreeRoot(cwd, context);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] =
|
||||
await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
resolveAbsoluteGitDir(cwd),
|
||||
resolveGitCommonDir(cwd),
|
||||
getPaseoWorktreeForCwd(cwd, context, root),
|
||||
]);
|
||||
|
||||
return {
|
||||
worktreeRoot: root,
|
||||
currentBranch,
|
||||
remoteUrl,
|
||||
absoluteGitDir,
|
||||
gitCommonDir,
|
||||
paseoWorktree,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isGitError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
const root = await getWorktreeRoot(cwd, context);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all(
|
||||
[
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
resolveAbsoluteGitDir(cwd),
|
||||
resolveGitCommonDir(cwd),
|
||||
getPaseoWorktreeForCwd(cwd, context, root),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
worktreeRoot: root,
|
||||
currentBranch,
|
||||
remoteUrl,
|
||||
absoluteGitDir,
|
||||
gitCommonDir,
|
||||
paseoWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPullRequestLookupTargetFromBranchConfig(
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
areEquivalentPaths,
|
||||
createPathEquivalenceMatcher,
|
||||
getRealpathAwareRelativePath,
|
||||
isPathInsideRoot,
|
||||
} from "./path.js";
|
||||
|
||||
describe("path equivalence", () => {
|
||||
test.each([
|
||||
@@ -33,4 +41,23 @@ describe("path equivalence", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"derives the contained suffix from a realpath-equivalent root",
|
||||
() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-"));
|
||||
try {
|
||||
const realRoot = join(tempDir, "real-root");
|
||||
const nestedPath = join(realRoot, "packages", "app");
|
||||
const aliasRoot = join(tempDir, "root-alias");
|
||||
mkdirSync(nestedPath, { recursive: true });
|
||||
symlinkSync(realRoot, aliasRoot, "dir");
|
||||
|
||||
expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app"));
|
||||
expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
|
||||
}
|
||||
|
||||
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
||||
return getRelativePathInsideRoot(root, candidate) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the candidate's relative suffix when it is inside root.
|
||||
*
|
||||
* The suffix is derived from the same lexical path pair used to prove
|
||||
* containment. Callers that map an existing filesystem path into a new root
|
||||
* must keep those two operations coupled.
|
||||
*/
|
||||
export function getRealpathAwareRelativePath(root: string, candidate: string): string | null {
|
||||
const rootVariants = collectPathVariants(root);
|
||||
const candidateVariants = collectPathVariants(candidate);
|
||||
|
||||
for (const rootVariant of rootVariants) {
|
||||
for (const candidateVariant of candidateVariants) {
|
||||
const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant);
|
||||
if (relativePath !== null) return relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
return getRealpathAwareRelativePath(root, candidate) !== null;
|
||||
}
|
||||
|
||||
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
const compareAsWindows = shouldCompareAsWindows(root, candidate);
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
const rootVariants = collectPathVariants(root);
|
||||
const candidateVariants = collectPathVariants(candidate);
|
||||
|
||||
return rootVariants.some((rootVariant) =>
|
||||
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
|
||||
);
|
||||
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative))
|
||||
? relative
|
||||
: null;
|
||||
}
|
||||
|
||||
function collectPathVariants(value: string): string[] {
|
||||
|
||||
@@ -4,14 +4,24 @@ import {
|
||||
deriveWorktreeProjectHash,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
slugify,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { createRealpathAwarePathMatcher } from "./path";
|
||||
|
||||
interface LegacyCreateWorktreeTestOptions {
|
||||
branchName: string;
|
||||
@@ -86,6 +96,12 @@ describe("paseo worktree manager", () => {
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
||||
).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
worktreePath: created.worktreePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects paths that are not under the paseo worktrees root", async () => {
|
||||
@@ -97,6 +113,72 @@ describe("paseo worktree manager", () => {
|
||||
expect(ownership.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the source checkout root separately from Git's common directory", async () => {
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "placement-root-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "placement-root",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
|
||||
expect(ownership.allowed).toBe(true);
|
||||
expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true);
|
||||
expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps only root-contained workspace paths into a replacement worktree", () => {
|
||||
const sourceWorktreePath = join(tempDir, "source-worktree");
|
||||
const targetWorktreePath = join(tempDir, "target-worktree");
|
||||
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
|
||||
|
||||
expect(
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: sourceWorktreePath,
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toBe(targetWorktreePath);
|
||||
expect(
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: nestedWorkspaceCwd,
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toBe(join(targetWorktreePath, "packages", "app"));
|
||||
expect(() =>
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: join(tempDir, "outside-worktree"),
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toThrow("outside its source worktree");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"maps a realpath-equivalent source workspace into the matching target subdirectory",
|
||||
() => {
|
||||
const sourceWorktreePath = join(tempDir, "source-worktree");
|
||||
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
|
||||
const sourceAlias = join(tempDir, "source-alias");
|
||||
const targetWorktreePath = join(tempDir, "target-worktree");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
|
||||
|
||||
expect(
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: sourceAlias,
|
||||
workspaceCwd,
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toBe(join(targetWorktreePath, "packages", "app"));
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const worktreesRoot = join(paseoHome, "worktrees");
|
||||
|
||||
@@ -35,7 +35,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import { expandTilde } from "./path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
|
||||
export async function runWorktreeTeardownCommands(options: {
|
||||
worktreePath: string;
|
||||
teardownCwd?: string;
|
||||
branchName?: string;
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
|
||||
const teardownCwd = options.teardownCwd ?? options.worktreePath;
|
||||
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
|
||||
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
|
||||
}
|
||||
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
|
||||
if (teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
cwd: teardownCwd,
|
||||
env: teardownEnv,
|
||||
});
|
||||
results.push(result);
|
||||
@@ -772,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function seedPaseoConfigFile(options: {
|
||||
sourceCwd: string;
|
||||
targetCwd: string;
|
||||
}): Promise<void> {
|
||||
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
|
||||
const targetConfigPath = join(options.targetCwd, "paseo.json");
|
||||
try {
|
||||
await stat(targetConfigPath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await copyFile(sourceConfigPath, targetConfigPath).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the git common directory (shared across worktrees) for a given cwd.
|
||||
* This is where refs, objects, etc. are stored.
|
||||
@@ -845,6 +866,36 @@ export async function computeWorktreePath(
|
||||
return join(projectWorktreesRoot, slug);
|
||||
}
|
||||
|
||||
export function mapWorkspaceCwdToWorktree(input: {
|
||||
sourceWorktreePath: string;
|
||||
workspaceCwd: string;
|
||||
targetWorktreePath: string;
|
||||
}): string {
|
||||
const relativeWorkspaceCwd = getRealpathAwareRelativePath(
|
||||
input.sourceWorktreePath,
|
||||
input.workspaceCwd,
|
||||
);
|
||||
if (relativeWorkspaceCwd === null) {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
|
||||
}
|
||||
|
||||
return mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd,
|
||||
targetWorktreePath: input.targetWorktreePath,
|
||||
});
|
||||
}
|
||||
|
||||
export function mapWorkspaceRelativeCwdToWorktree(input: {
|
||||
relativeWorkspaceCwd: string;
|
||||
targetWorktreePath: string;
|
||||
}): string {
|
||||
const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd);
|
||||
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
|
||||
throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`);
|
||||
}
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
@@ -878,13 +929,13 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep;
|
||||
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
|
||||
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
|
||||
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
|
||||
// path shape alone is sufficient proof of ownership, even when git has already
|
||||
// forgotten about the worktree.
|
||||
if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) {
|
||||
if (relativePath === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
@@ -892,8 +943,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
};
|
||||
}
|
||||
|
||||
const relative = resolvedCwd.slice(paseoWorktreesPrefix.length);
|
||||
const parts = relative.split(sep).filter((part) => part.length > 0);
|
||||
const parts = relativePath.split(sep).filter((part) => part.length > 0);
|
||||
if (parts.length < 2) {
|
||||
return {
|
||||
allowed: false,
|
||||
@@ -907,7 +957,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
allowed: true,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: resolvedCwd,
|
||||
worktreePath: join(worktreesRoot, parts[1]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -977,10 +1027,9 @@ export async function listPaseoWorktrees({
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
|
||||
return parseWorktreeList(stdout)
|
||||
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
||||
.filter((entry) => entry.path.startsWith(rootPrefix))
|
||||
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
|
||||
.map((entry) =>
|
||||
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
||||
);
|
||||
@@ -1018,76 +1067,25 @@ export async function resolveExistingWorktreeForSlug({
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePaseoWorktreeRootForCwd(
|
||||
cwd: string,
|
||||
options?: WorktreeRootOptions,
|
||||
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
|
||||
let gitCommonDir: string;
|
||||
try {
|
||||
gitCommonDir = await getGitCommonDir(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(
|
||||
cwd,
|
||||
options?.paseoHome,
|
||||
options?.worktreesRoot,
|
||||
);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
|
||||
let worktreeRoot: string | null = null;
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
worktreeRoot = parseGitRevParsePath(stdout);
|
||||
} catch {
|
||||
worktreeRoot = null;
|
||||
}
|
||||
|
||||
if (!worktreeRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot);
|
||||
if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownWorktrees = await listPaseoWorktrees({
|
||||
cwd,
|
||||
paseoHome: options?.paseoHome,
|
||||
worktreesRoot: options?.worktreesRoot,
|
||||
});
|
||||
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: gitCommonDir,
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: match.path,
|
||||
};
|
||||
export interface DeletePaseoWorktreeOptions {
|
||||
cwd: string | null;
|
||||
worktreePath?: string;
|
||||
teardownCwds?: string[];
|
||||
worktreeSlug?: string;
|
||||
worktreesRoot?: string;
|
||||
paseoHome?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds,
|
||||
worktreeSlug,
|
||||
worktreesRoot,
|
||||
paseoHome,
|
||||
worktreesBaseRoot,
|
||||
}: {
|
||||
cwd: string | null;
|
||||
worktreePath?: string;
|
||||
worktreeSlug?: string;
|
||||
worktreesRoot?: string;
|
||||
paseoHome?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
}): Promise<void> {
|
||||
}: DeletePaseoWorktreeOptions): Promise<void> {
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
}
|
||||
@@ -1104,25 +1102,30 @@ export async function deletePaseoWorktree({
|
||||
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
|
||||
}
|
||||
|
||||
const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep;
|
||||
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
});
|
||||
const resolvedWorktree =
|
||||
(
|
||||
await resolvePaseoWorktreeRootForCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
})
|
||||
)?.worktreePath ?? resolvedRequested;
|
||||
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||
|
||||
if (!resolvedWorktree.startsWith(resolvedRoot)) {
|
||||
const relativeWorktreePath = getRealpathAwareRelativePath(
|
||||
resolvedWorktreesRoot,
|
||||
resolvedWorktree,
|
||||
);
|
||||
if (relativeWorktreePath === null || relativeWorktreePath === "") {
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
if (await pathExists(resolvedWorktree)) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
teardownCwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
@@ -1150,6 +1153,27 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
if (cleanupError) {
|
||||
const failure = new Error(
|
||||
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
||||
{ cause },
|
||||
);
|
||||
Object.assign(failure, { cleanupError });
|
||||
throw failure;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
@@ -1243,18 +1267,7 @@ export const createWorktree = async ({
|
||||
: {}),
|
||||
});
|
||||
|
||||
// If paseo.json exists in the main repo but wasn't checked into the worktree
|
||||
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
|
||||
// commands and scripts pick up the user's intended config.
|
||||
const mainConfigPath = join(cwd, "paseo.json");
|
||||
const worktreeConfigPath = join(worktreePath, "paseo.json");
|
||||
try {
|
||||
await stat(worktreeConfigPath);
|
||||
} catch {
|
||||
await copyFile(mainConfigPath, worktreeConfigPath).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
});
|
||||
}
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
|
||||
Reference in New Issue
Block a user