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/`.
|
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:**
|
**Key modules:**
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
@@ -89,7 +97,7 @@ code imports from `@getpaseo/client`.
|
|||||||
|
|
||||||
Cross-platform React Native app that connects to one or more daemons.
|
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
|
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||||
- `SessionContext` wraps the daemon client for the active session
|
- `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/`
|
- 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
|
# 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.
|
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`).
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
@@ -422,19 +446,22 @@ Array of project records.
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `projectId` | `string` | Primary key |
|
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||||
| `rootPath` | `string` | Filesystem root of the project |
|
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||||
| `kind` | `"git" \| "non_git"` | |
|
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||||
| `displayName` | `string` | |
|
| `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". |
|
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||||
|
|
||||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
||||||
emptied duplicate.
|
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.
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| 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. |
|
| `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 |
|
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||||
| `cwd` | `string` | Filesystem path |
|
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
| `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`". |
|
| `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. |
|
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `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. |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
| `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
|
`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
|
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.
|
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.
|
- **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.
|
- **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`).
|
- **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".
|
- **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`).
|
- **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).
|
- **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.
|
- **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`).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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-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`.
|
- **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.
|
- 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`.
|
- 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.
|
- 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
|
## Agent authentication in tests
|
||||||
|
|
||||||
|
|||||||
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
|
|||||||
|
|
||||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||||
expect(readded.error).toBeNull();
|
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);
|
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||||
|
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
|||||||
|
|
||||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||||
|
|
||||||
interface WaitForServerOptions {
|
export interface WaitForServerOptions {
|
||||||
host?: string;
|
host?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -22,6 +22,8 @@ interface WaitForServerOptions {
|
|||||||
getRecentOutput?: () => string;
|
getRecentOutput?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerProbe = (host: string, port: number) => Promise<void>;
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -75,7 +77,25 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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 { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
@@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await probe(host, port);
|
||||||
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);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastConnectionError = 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 {
|
function parseRelayStartupFailure(line: string): string | null {
|
||||||
const clean = stripAnsi(line);
|
const clean = stripAnsi(line);
|
||||||
if (/Address already in use/i.test(clean)) {
|
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 relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
const maxRelayStartupAttempts = 5;
|
const maxRelayStartupAttempts = 5;
|
||||||
let lastRelayStartupError: unknown = null;
|
let lastRelayStartupError: unknown = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||||
|
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
|
||||||
const buffer = createLineBuffer();
|
const buffer = createLineBuffer();
|
||||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||||
|
|
||||||
@@ -576,6 +608,10 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
|||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"--port",
|
"--port",
|
||||||
String(relayPort),
|
String(relayPort),
|
||||||
|
"--inspector-ip",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--inspector-port",
|
||||||
|
String(inspectorPort),
|
||||||
"--live-reload=false",
|
"--live-reload=false",
|
||||||
"--show-interactive-dev-session=false",
|
"--show-interactive-dev-session=false",
|
||||||
],
|
],
|
||||||
@@ -590,7 +626,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||||
return relayPort;
|
return { relayPort, inspectorPort };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastRelayStartupError = error;
|
lastRelayStartupError = error;
|
||||||
await stopProcess(relayProcess);
|
await stopProcess(relayProcess);
|
||||||
@@ -767,12 +803,19 @@ export default async function globalSetup() {
|
|||||||
await logSpeechHarnessConfig();
|
await logSpeechHarnessConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
|
||||||
metroProcess = startMetro({
|
metroProcess = startMetro({
|
||||||
metroPort,
|
metroPort,
|
||||||
daemonPort: port,
|
daemonPort: port,
|
||||||
buffer: metroLineBuffer,
|
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({
|
daemonProcess = startDaemon({
|
||||||
port,
|
port,
|
||||||
relayPort,
|
relayPort,
|
||||||
@@ -783,19 +826,11 @@ export default async function globalSetup() {
|
|||||||
buffer: daemonLineBuffer,
|
buffer: daemonLineBuffer,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await waitForServer(port, {
|
||||||
waitForServer(port, {
|
label: "Paseo daemon",
|
||||||
label: "Paseo daemon",
|
childProcess: daemonProcess,
|
||||||
childProcess: daemonProcess,
|
getRecentOutput: daemonLineBuffer.dump,
|
||||||
getRecentOutput: daemonLineBuffer.dump,
|
});
|
||||||
}),
|
|
||||||
waitForServer(metroPort, {
|
|
||||||
label: "Metro web server",
|
|
||||||
timeoutMs: 120000,
|
|
||||||
childProcess: metroProcess,
|
|
||||||
getRecentOutput: metroLineBuffer.dump,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const offer = await waitForPairingOfferFromDaemon({
|
const offer = await waitForPairingOfferFromDaemon({
|
||||||
port,
|
port,
|
||||||
@@ -809,7 +844,7 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_PASEO_HOME = paseoHome;
|
process.env.E2E_PASEO_HOME = paseoHome;
|
||||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||||
console.log(
|
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 () => {
|
return async () => {
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ interface E2EDaemonClientConfig {
|
|||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDaemonWsUrl(): string {
|
function resolveDaemonWsUrl(port?: number): string {
|
||||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectDaemonClientOptions {
|
export interface ConnectDaemonClientOptions {
|
||||||
clientIdPrefix: string;
|
clientIdPrefix: string;
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +46,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
|||||||
): Promise<ClientInstance> {
|
): Promise<ClientInstance> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: resolveDaemonWsUrl(),
|
url: resolveDaemonWsUrl(options.port),
|
||||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
appVersion: options.appVersion ?? loadAppVersion(),
|
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 {
|
export interface IsolatedHostDaemon {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
restart(): Promise<void>;
|
||||||
close(): 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 paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||||
const serverDir = path.resolve(__dirname, "../../../server");
|
const serverDir = path.resolve(__dirname, "../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||||
cwd: serverDir,
|
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||||
env: withDisabledE2ESpeechEnv({
|
cwd: serverDir,
|
||||||
...process.env,
|
env: withDisabledE2ESpeechEnv({
|
||||||
PASEO_HOME: paseoHome,
|
...process.env,
|
||||||
PASEO_SERVER_ID: serverId,
|
PASEO_HOME: paseoHome,
|
||||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
PASEO_SERVER_ID: serverId,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||||
PASEO_RELAY_ENABLED: "0",
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
PASEO_NODE_ENV: "development",
|
PASEO_RELAY_ENABLED: "0",
|
||||||
NODE_ENV: "development",
|
PASEO_NODE_ENV: "development",
|
||||||
}),
|
NODE_ENV: "development",
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
}),
|
||||||
detached: false,
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
});
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
stderr += chunk.toString("utf8");
|
stderr += chunk.toString("utf8");
|
||||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
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 {
|
try {
|
||||||
await waitForServer(port, child);
|
child = await spawnDaemon();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
throw new Error(
|
throw error;
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
port,
|
port,
|
||||||
|
restart: async () => {
|
||||||
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||||
|
await stopProcess(child);
|
||||||
|
child = await spawnDaemon();
|
||||||
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
await stopProcess(child);
|
await stopProcess(child);
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
export async function connectNewWorkspaceDaemonClient(options?: {
|
||||||
|
port?: number;
|
||||||
|
}): Promise<NewWorkspaceDaemonClient> {
|
||||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||||
clientIdPrefix: "app-e2e-new-workspace",
|
clientIdPrefix: "app-e2e-new-workspace",
|
||||||
|
port: options?.port,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,10 +158,11 @@ export interface SeedDaemonClient {
|
|||||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
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>({
|
return connectDaemonClient<SeedDaemonClient>({
|
||||||
clientIdPrefix: "seed",
|
clientIdPrefix: "seed",
|
||||||
appVersion: loadAppVersion(),
|
appVersion: loadAppVersion(),
|
||||||
|
port: options?.port,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
|
|||||||
page,
|
page,
|
||||||
gitlabRemoteProject,
|
gitlabRemoteProject,
|
||||||
}) => {
|
}) => {
|
||||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
|
||||||
await openProjects(page);
|
await openProjects(page);
|
||||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||||
await editWorktreeSetup(page, updatedSetup);
|
await editWorktreeSetup(page, updatedSetup);
|
||||||
|
|||||||
@@ -46,24 +46,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Sidebar workspace list", () => {
|
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({
|
const workspace = await seedWorkspace({
|
||||||
repoPrefix: "sidebar-remote-",
|
repoPrefix: "sidebar-remote-",
|
||||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const projectName = path.basename(workspace.repoPath);
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
await waitForSidebarProject(page, projectName);
|
||||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const projectRow = page
|
const projectRow = page
|
||||||
.locator('[data-testid^="sidebar-project-row-"]')
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
.filter({ hasText: "test-owner/test-repo" })
|
.filter({ hasText: projectName })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
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 {
|
} finally {
|
||||||
await workspace.cleanup();
|
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({
|
const workspace = await seedWorkspace({
|
||||||
repoPrefix: "sidebar-header-",
|
repoPrefix: "sidebar-header-",
|
||||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const projectName = path.basename(workspace.repoPath);
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
await waitForSidebarProject(page, projectName);
|
||||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||||
|
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: "test-owner/test-repo",
|
subtitle: projectName,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
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 {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
@@ -11,11 +12,12 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { connectSeedClient } from "./helpers/seed-client";
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
test.describe("Worktree restore after daemon restart", () => {
|
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 client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
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.describe.configure({ retries: 0, timeout: 180_000 });
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
client = await connectSeedClient();
|
daemon = await startIsolatedHostDaemon(serverId);
|
||||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
client = await connectSeedClient({ port: daemon.port });
|
||||||
|
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||||
tempRepo = await createTempGitRepo("wt-restart-");
|
tempRepo = await createTempGitRepo("wt-restart-");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => {
|
|||||||
await client?.close().catch(() => undefined);
|
await client?.close().catch(() => undefined);
|
||||||
await worktreeClient?.close().catch(() => undefined);
|
await worktreeClient?.close().catch(() => undefined);
|
||||||
await tempRepo?.cleanup().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 ({
|
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
|
||||||
|
|
||||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
// 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
|
// worktree workspace is displayed under the same name. These are the values
|
||||||
// the History table cells must show after restore — never "main".
|
// 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 })
|
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||||
.toBe(false);
|
.toBe(false);
|
||||||
|
|
||||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
// Restart this spec's daemon on the same home and port so it rebuilds all
|
||||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
// workspace/agent links from persisted state without replacing the shared
|
||||||
|
// Playwright daemon owned by global setup.
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await worktreeClient.close().catch(() => undefined);
|
await worktreeClient.close().catch(() => undefined);
|
||||||
await restartTestDaemon();
|
await daemon.restart();
|
||||||
client = await connectSeedClient();
|
client = await connectSeedClient({ port: daemon.port });
|
||||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||||
|
|
||||||
|
await seedBrowser(page);
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await openSessions(page);
|
await openSessions(page);
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ test.describe("Worktree restore", () => {
|
|||||||
try {
|
try {
|
||||||
await page.getByTestId("workspace-recovery-action").click();
|
await page.getByTestId("workspace-recovery-action").click();
|
||||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
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");
|
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AddProjectHost,
|
type AddProjectHost,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import {
|
import {
|
||||||
|
addProjectMethodEmptyText,
|
||||||
buildAddProjectMethods,
|
buildAddProjectMethods,
|
||||||
buildCloneLocationOptions,
|
buildCloneLocationOptions,
|
||||||
buildManualGithubRepositoryChoices,
|
buildManualGithubRepositoryChoices,
|
||||||
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Add Project options", () => {
|
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", () => {
|
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||||
expect(
|
expect(
|
||||||
buildAddProjectMethods({
|
buildAddProjectMethods({
|
||||||
|
|||||||
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||||
|
if (!host.canAddProject) return [];
|
||||||
const options: AddProjectMethodOption[] = [];
|
const options: AddProjectMethodOption[] = [];
|
||||||
if (host.canAddProject) {
|
options.push({
|
||||||
options.push({
|
id: "directory-search",
|
||||||
id: "directory-search",
|
label: "Search for directory",
|
||||||
label: "Search for directory",
|
description: `Find a directory on ${host.label}`,
|
||||||
description: `Find a directory on ${host.label}`,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
if (host.canBrowse) {
|
if (host.canBrowse) {
|
||||||
options.push({
|
options.push({
|
||||||
id: "browse",
|
id: "browse",
|
||||||
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
|
|||||||
return options;
|
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 {
|
function githubMethodDescription(host: AddProjectHost): string {
|
||||||
if (!host.canCloneGithubRepositories) {
|
if (!host.canCloneGithubRepositories) {
|
||||||
return "Update this host to clone GitHub repositories";
|
return "Update this host to clone GitHub repositories";
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from "@/add-project-flow/model";
|
} from "@/add-project-flow/model";
|
||||||
import {
|
import {
|
||||||
buildAddProjectMethods,
|
buildAddProjectMethods,
|
||||||
|
addProjectMethodEmptyText,
|
||||||
buildCloneLocationOptions,
|
buildCloneLocationOptions,
|
||||||
buildManualGithubRepositoryChoices,
|
buildManualGithubRepositoryChoices,
|
||||||
buildSuggestedParentDirectories,
|
buildSuggestedParentDirectories,
|
||||||
@@ -161,9 +162,10 @@ function progressText(page: AddProjectPage): string {
|
|||||||
return "Adding project...";
|
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 === "host") return "No connected hosts";
|
||||||
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
|
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
|
||||||
|
if (page.kind === "method") return addProjectMethodEmptyText(host);
|
||||||
return "No matching options";
|
return "No matching options";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +299,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||||
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
|
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.
|
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
|
||||||
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
|
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
|
||||||
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
|
// 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) => {
|
hosts.flatMap((host) => {
|
||||||
if (connectionStatuses.get(host.serverId) !== "online") return [];
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
@@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
hosts,
|
hosts,
|
||||||
localServerId,
|
localServerId,
|
||||||
projectAddByHost,
|
projectAddByHost,
|
||||||
|
stableProjectIdentityByHost,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const [state, setState] = useState(() =>
|
const [state, setState] = useState(() =>
|
||||||
@@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
rows.length === 0 &&
|
rows.length === 0 &&
|
||||||
page.kind !== "new-directory-name" ? (
|
page.kind !== "new-directory-name" ? (
|
||||||
<Text style={styles.stateText} testID="add-project-flow-empty">
|
<Text style={styles.stateText} testID="add-project-flow-empty">
|
||||||
{emptyText(page)}
|
{emptyText(page, host ?? null)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</ScrollView>
|
</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 isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const canAddProject = useSessionStore((state) =>
|
const canAddProject = useSessionStore((state) =>
|
||||||
normalizedServerId
|
normalizedServerId
|
||||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true &&
|
||||||
|
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
||||||
|
|
||||||
|
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||||
|
|
||||||
class FakeDirectoryClient {
|
class FakeDirectoryClient {
|
||||||
fetchAgentsCalls = 0;
|
fetchAgentsCalls = 0;
|
||||||
fetchWorkspacesCalls = 0;
|
fetchWorkspacesCalls = 0;
|
||||||
|
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||||
|
private readonly handlers = new Map<
|
||||||
|
SessionOutboundMessage["type"],
|
||||||
|
Set<(message: SessionOutboundMessage) => void>
|
||||||
|
>();
|
||||||
|
|
||||||
on(): () => void {
|
on<TType extends SessionOutboundMessage["type"]>(
|
||||||
return () => undefined;
|
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"]>>> {
|
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;
|
this.fetchWorkspacesCalls += 1;
|
||||||
|
if (this.pendingWorkspaceFetch) {
|
||||||
|
const pending = this.pendingWorkspaceFetch;
|
||||||
|
this.pendingWorkspaceFetch = null;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
requestId: "workspaces",
|
requestId: "workspaces",
|
||||||
entries: [],
|
entries: [],
|
||||||
@@ -110,4 +144,114 @@ describe("DirectorySync session readiness", () => {
|
|||||||
expect(client.fetchAgentsCalls).toBe(1);
|
expect(client.fetchAgentsCalls).toBe(1);
|
||||||
directory.dispose();
|
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;
|
if (!connection.client || connection.status !== "online") return true;
|
||||||
const client = connection.client;
|
const client = connection.client;
|
||||||
const source = connection.source;
|
const source = connection.source;
|
||||||
|
this.workspaceTransactions.begin(source, () => ({
|
||||||
|
workspaces: new Map(),
|
||||||
|
emptyProjects: new Map(),
|
||||||
|
}));
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
client.on("agent_update", (message) => {
|
client.on("agent_update", (message) => {
|
||||||
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
||||||
@@ -119,6 +123,12 @@ export class DirectorySync {
|
|||||||
this.workspaces.applyDelta(message.payload);
|
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) => {
|
client.on("agent_deleted", (message) => {
|
||||||
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
||||||
this.agents.remove(message.payload.agentId);
|
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"]);
|
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
||||||
store.clearSession(serverId);
|
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<
|
export type WorkspaceDirectoryDelta = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_update" }
|
{ type: "workspace_update" | "project.update" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||||
|
|
||||||
export interface WorkspaceDirectorySnapshot {
|
export interface WorkspaceDirectorySnapshot {
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
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 {
|
export class WorkspaceDirectoryReplica {
|
||||||
constructor(private readonly serverId: string) {}
|
constructor(private readonly serverId: string) {}
|
||||||
|
|
||||||
applyDelta(delta: WorkspaceDirectoryDelta): void {
|
applyDelta(delta: WorkspaceDirectoryDelta): void {
|
||||||
const state = this.reconcile(this.read(), [delta]);
|
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(
|
commitSnapshot(
|
||||||
@@ -35,9 +65,10 @@ export class WorkspaceDirectoryReplica {
|
|||||||
deltas: readonly WorkspaceDirectoryDelta[],
|
deltas: readonly WorkspaceDirectoryDelta[],
|
||||||
): void {
|
): void {
|
||||||
const removedWorkspaceIds = deltas.flatMap((delta) =>
|
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);
|
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
|
||||||
|
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private read(): WorkspaceDirectorySnapshot {
|
private read(): WorkspaceDirectorySnapshot {
|
||||||
@@ -60,6 +91,10 @@ export class WorkspaceDirectoryReplica {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const delta of deltas) {
|
for (const delta of deltas) {
|
||||||
|
if ("projectId" in delta || "project" in delta) {
|
||||||
|
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (delta.kind === "remove") {
|
if (delta.kind === "remove") {
|
||||||
workspaces.delete(delta.id);
|
workspaces.delete(delta.id);
|
||||||
if (delta.emptyProject) {
|
if (delta.emptyProject) {
|
||||||
@@ -84,7 +119,6 @@ export class WorkspaceDirectoryReplica {
|
|||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
||||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
|
||||||
for (const workspaceId of removedWorkspaceIds) {
|
for (const workspaceId of removedWorkspaceIds) {
|
||||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createWorkspace(
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
projectId: input.projectId ?? "project-1",
|
projectId: input.projectId ?? "project-1",
|
||||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||||
|
projectCustomName: input.projectCustomName ?? null,
|
||||||
projectRootPath: input.projectRootPath ?? "/repo",
|
projectRootPath: input.projectRootPath ?? "/repo",
|
||||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
|
|||||||
@@ -320,6 +320,8 @@ export interface AgentTimelineCursorState {
|
|||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -368,6 +370,9 @@ export interface SessionState {
|
|||||||
// Project parents with no active workspaces, keyed by projectId. The
|
// Project parents with no active workspaces, keyed by projectId. The
|
||||||
// `emptyProjects` name is the existing protocol/store projection.
|
// `emptyProjects` name is the existing protocol/store projection.
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
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
|
// Permissions
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
@@ -489,6 +494,13 @@ interface SessionStoreActions {
|
|||||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||||
|
setWorkspaceRestoreStatus: (
|
||||||
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
status: WorkspaceRestoreStatus,
|
||||||
|
) => void;
|
||||||
|
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||||
|
|
||||||
// Agent activity timestamps
|
// Agent activity timestamps
|
||||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||||
setAgentLastActivityBatch: (
|
setAgentLastActivityBatch: (
|
||||||
@@ -567,6 +579,7 @@ function createInitialSessionState(
|
|||||||
agentDetails: new Map(),
|
agentDetails: new Map(),
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
emptyProjects: new Map(),
|
||||||
|
restoringWorkspaces: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
queuedMessages: 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) => {
|
mergeWorkspaces: (serverId, workspaces) => {
|
||||||
const nextEntries = Array.from(workspaces);
|
const nextEntries = Array.from(workspaces);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
@@ -1360,10 +1421,18 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
// empty: prune any stale empty descriptor so it stops governing the
|
// empty: prune any stale empty descriptor so it stops governing the
|
||||||
// project's rendered metadata.
|
// project's rendered metadata.
|
||||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
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) {
|
for (const workspace of nextEntries) {
|
||||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||||
changed = true;
|
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 existing = next.get(workspace.id);
|
||||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||||
if (existing === nextWorkspace) {
|
if (existing === nextWorkspace) {
|
||||||
@@ -1383,6 +1452,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...session,
|
...session,
|
||||||
workspaces: next,
|
workspaces: next,
|
||||||
emptyProjects: nextEmptyProjects,
|
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,
|
protocolVersion: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
custom_mode_icons: true,
|
custom_mode_icons: true,
|
||||||
|
project_updates: true,
|
||||||
provider_subagents: true,
|
provider_subagents: true,
|
||||||
reasoning_merge_enum: true,
|
reasoning_merge_enum: true,
|
||||||
terminal_reflowable_snapshot: 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 () => {
|
test("sends new-agent run options when creating schedules", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ export type DaemonEvent =
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "project.update";
|
||||||
|
payload: Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "workspace_setup_progress";
|
type: "workspace_setup_progress";
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -5111,6 +5115,7 @@ export class DaemonClient {
|
|||||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||||
[CLIENT_CAPS.providerSubagents]: true,
|
[CLIENT_CAPS.providerSubagents]: true,
|
||||||
|
[CLIENT_CAPS.projectUpdates]: true,
|
||||||
...this.config.capabilities,
|
...this.config.capabilities,
|
||||||
},
|
},
|
||||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
...(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,
|
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||||
payload: msg.payload,
|
payload: msg.payload,
|
||||||
};
|
};
|
||||||
|
case "project.update":
|
||||||
|
return { type: "project.update", payload: msg.payload };
|
||||||
case "workspace_setup_progress":
|
case "workspace_setup_progress":
|
||||||
return {
|
return {
|
||||||
type: "workspace_setup_progress",
|
type: "workspace_setup_progress",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const CLIENT_CAPS = {
|
|||||||
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
||||||
// child descriptors and timelines only to clients that understand the new messages.
|
// child descriptors and timelines only to clients that understand the new messages.
|
||||||
providerSubagents: "provider_subagents",
|
providerSubagents: "provider_subagents",
|
||||||
|
// COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15.
|
||||||
|
projectUpdates: "project_updates",
|
||||||
browserHost: "browser_host",
|
browserHost: "browser_host",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -2687,6 +2687,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
forgeProviders: z.boolean().optional(),
|
forgeProviders: z.boolean().optional(),
|
||||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||||
selectiveAgentTimeline: z.boolean().optional(),
|
selectiveAgentTimeline: z.boolean().optional(),
|
||||||
|
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||||
|
stableProjectIdentity: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.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({
|
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||||
type: z.literal("script_status_update"),
|
type: z.literal("script_status_update"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -4847,6 +4857,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
AgentUpdateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
WorkspaceUpdateMessageSchema,
|
WorkspaceUpdateMessageSchema,
|
||||||
|
ProjectUpdateMessageSchema,
|
||||||
ScriptStatusUpdateMessageSchema,
|
ScriptStatusUpdateMessageSchema,
|
||||||
WorkspaceSetupProgressMessageSchema,
|
WorkspaceSetupProgressMessageSchema,
|
||||||
WorkspaceSetupStatusResponseMessageSchema,
|
WorkspaceSetupStatusResponseMessageSchema,
|
||||||
@@ -5409,6 +5420,7 @@ export const WSHelloMessageSchema = z.object({
|
|||||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
|
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
|
||||||
|
[CLIENT_CAPS.projectUpdates]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import type pino from "pino";
|
|||||||
|
|
||||||
import type { ForgeService } from "../../services/forge-service.js";
|
import type { ForgeService } from "../../services/forge-service.js";
|
||||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||||
import {
|
import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||||
archiveByScope,
|
|
||||||
type ActiveWorkspaceRef,
|
|
||||||
resolveWorkspaceIdAtPath,
|
|
||||||
} from "../workspace-archive-service.js";
|
|
||||||
import type {
|
import type {
|
||||||
CreatePaseoWorktreeWorkflowFn,
|
CreatePaseoWorktreeWorkflowFn,
|
||||||
CreatePaseoWorktreeWorkflowResult,
|
CreatePaseoWorktreeWorkflowResult,
|
||||||
@@ -42,6 +38,10 @@ interface CreateAgentLifecycleDispatchDependencies {
|
|||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoArchiveTarget =
|
||||||
|
| { kind: "agent-only" }
|
||||||
|
| { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult };
|
||||||
|
|
||||||
export class CreateAgentLifecycleDispatch {
|
export class CreateAgentLifecycleDispatch {
|
||||||
private readonly autoArchiveAgentIds = new Set<string>();
|
private readonly autoArchiveAgentIds = new Set<string>();
|
||||||
|
|
||||||
@@ -72,10 +72,10 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registerAutoArchiveOnTerminalState(input.agentId, {
|
this.registerAutoArchiveOnTerminalState(
|
||||||
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
|
input.agentId,
|
||||||
repoRoot: input.createdWorktree?.repoRoot ?? null,
|
toAutoArchiveTarget(input.createdWorktree),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
|
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
|
||||||
@@ -89,8 +89,7 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
|
|
||||||
await this.archiveAutoCreatedWorktree({
|
await this.archiveAutoCreatedWorktree({
|
||||||
agentId: null,
|
agentId: null,
|
||||||
worktreePath: createdWorktree.worktree.worktreePath,
|
createdWorktree,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
|
||||||
}).catch((archiveError) => {
|
}).catch((archiveError) => {
|
||||||
this.dependencies.logger.warn(
|
this.dependencies.logger.warn(
|
||||||
{
|
{
|
||||||
@@ -143,10 +142,7 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerAutoArchiveOnTerminalState(
|
private registerAutoArchiveOnTerminalState(agentId: string, target: AutoArchiveTarget): void {
|
||||||
agentId: string,
|
|
||||||
options: { worktreePath: string | null; repoRoot: string | null },
|
|
||||||
): void {
|
|
||||||
const unsubscribe = this.dependencies.agentManager.subscribe(
|
const unsubscribe = this.dependencies.agentManager.subscribe(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.type !== "agent_stream") {
|
if (event.type !== "agent_stream") {
|
||||||
@@ -160,27 +156,23 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
void this.autoArchiveAgentOnce(agentId, options);
|
void this.autoArchiveAgentOnce(agentId, target);
|
||||||
},
|
},
|
||||||
{ agentId, replayState: false },
|
{ agentId, replayState: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async autoArchiveAgentOnce(
|
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
|
||||||
agentId: string,
|
|
||||||
options: { worktreePath: string | null; repoRoot: string | null },
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.autoArchiveAgentIds.has(agentId)) {
|
if (this.autoArchiveAgentIds.has(agentId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.autoArchiveAgentIds.add(agentId);
|
this.autoArchiveAgentIds.add(agentId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (options.worktreePath) {
|
if (target.kind === "created-worktree") {
|
||||||
await this.archiveAutoCreatedWorktree({
|
await this.archiveAutoCreatedWorktree({
|
||||||
agentId,
|
agentId,
|
||||||
worktreePath: options.worktreePath,
|
createdWorktree: target.result,
|
||||||
repoRoot: options.repoRoot,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -193,10 +185,11 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
|
|
||||||
private async archiveAutoCreatedWorktree(options: {
|
private async archiveAutoCreatedWorktree(options: {
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
worktreePath: string;
|
createdWorktree: CreatePaseoWorktreeWorkflowResult;
|
||||||
repoRoot: string | null;
|
|
||||||
}): Promise<void> {
|
}): 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,
|
paseoHome: this.dependencies.paseoHome,
|
||||||
worktreesRoot: this.dependencies.worktreesRoot,
|
worktreesRoot: this.dependencies.worktreesRoot,
|
||||||
});
|
});
|
||||||
@@ -204,49 +197,39 @@ export class CreateAgentLifecycleDispatch {
|
|||||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
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,
|
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
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) {
|
if (options.agentId) {
|
||||||
this.dependencies.emitAgentRemove(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).
|
// worktree service).
|
||||||
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
||||||
const worktreePath = join(args.repoRoot, "worktree");
|
const worktreePath = join(args.repoRoot, "worktree");
|
||||||
mkdirSync(worktreePath, { recursive: true });
|
const workspaceCwd = join(worktreePath, "packages", "app");
|
||||||
|
mkdirSync(workspaceCwd, { recursive: true });
|
||||||
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
||||||
({
|
({
|
||||||
worktree: { worktreePath },
|
worktree: { worktreePath },
|
||||||
intent: {},
|
intent: {},
|
||||||
workspace: { workspaceId: args.createdWorkspaceId },
|
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||||
repoRoot: args.repoRoot,
|
repoRoot: args.repoRoot,
|
||||||
created: true,
|
created: true,
|
||||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
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);
|
const storedChild = await storage.get(child.id);
|
||||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||||
|
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(workdir, { recursive: true, force: true });
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ async function resolveMcpCwd(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
resolvedCwd: createdWorktree.worktree.worktreePath,
|
resolvedCwd: createdWorktree.workspace.cwd,
|
||||||
setupContinuation: createdWorktree.setupContinuation,
|
setupContinuation: createdWorktree.setupContinuation,
|
||||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import {
|
|||||||
AgentSnapshotPayloadSchema,
|
AgentSnapshotPayloadSchema,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
type PersistedProjectRecord,
|
type PersistedProjectRecord,
|
||||||
type PersistedWorkspaceRecord,
|
type PersistedWorkspaceRecord,
|
||||||
|
type ProjectRegistry,
|
||||||
|
type WorkspaceRegistry,
|
||||||
} from "../workspace-registry.js";
|
} from "../workspace-registry.js";
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
@@ -46,11 +49,13 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
|
|||||||
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
||||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||||
import type { ForgeService } from "../../services/forge-service.js";
|
import type { ForgeService } from "../../services/forge-service.js";
|
||||||
|
import { areEquivalentPaths } from "../../utils/path.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.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 REPO_CWD = resolvePath("/tmp/repo");
|
||||||
const TARGET_CWD = resolvePath("/tmp/target");
|
const TARGET_CWD = resolvePath("/tmp/target");
|
||||||
@@ -670,13 +675,68 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
deps: { forgeOverrides: { github } },
|
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,
|
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||||
list: async () => Array.from(workspaces.values()),
|
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) => {
|
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||||
workspaces.set(record.workspaceId, record);
|
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({
|
const workspaceAutoName = new WorkspaceAutoName({
|
||||||
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
@@ -710,14 +770,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
...(workflowOptions?.resolveDefaultBranch
|
...(workflowOptions?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
|
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry: {
|
|
||||||
get: async (projectId) => projects.get(projectId) ?? null,
|
|
||||||
upsert: async (record) => {
|
|
||||||
projects.set(record.projectId, record);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
workspaceRegistry,
|
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
|
workspaceProvisioning,
|
||||||
}),
|
}),
|
||||||
warmWorkspaceGitData: async () => {},
|
warmWorkspaceGitData: async () => {},
|
||||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||||
|
|||||||
@@ -486,8 +486,6 @@ describe("archiveIfSafe", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
|
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
|
||||||
repoRoot: "/tmp/repo",
|
|
||||||
paseoWorktreesBaseRoot: undefined,
|
|
||||||
requestId: "auto-archive-on-merge",
|
requestId: "auto-archive-on-merge",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ export async function archiveIfSafe(input: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: ownership.repoRoot ?? null,
|
|
||||||
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
|
|
||||||
requestId: "auto-archive-on-merge",
|
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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -18,7 +19,9 @@ describe("bootstrap provider availability", () => {
|
|||||||
process.env.PATH = originalEnv.PATH;
|
process.env.PATH = originalEnv.PATH;
|
||||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
await Promise.all(
|
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 { createPaseoDaemon } = await import("./bootstrap.js");
|
||||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
||||||
tempRoots.push(root);
|
tempRoots.push(root);
|
||||||
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
|
||||||
tempRoots.push(binDir);
|
encoding: "utf8",
|
||||||
process.env.PATH = binDir;
|
})
|
||||||
if (process.platform === "win32") {
|
.split(/\r?\n/)[0]
|
||||||
process.env.PATHEXT = ".CMD";
|
.trim();
|
||||||
}
|
process.env.PATH = path.dirname(gitPath);
|
||||||
|
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
|
||||||
const paseoHome = path.join(root, ".paseo");
|
const paseoHome = path.join(root, ".paseo");
|
||||||
const staticDir = path.join(root, "static");
|
const staticDir = path.join(root, "static");
|
||||||
const agentStoragePath = path.join(paseoHome, "agents");
|
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;
|
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 { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||||
import { createGitHubService } from "../services/github-service.js";
|
import { createGitHubService } from "../services/github-service.js";
|
||||||
import {
|
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||||
createPaseoWorktree as createRegisteredPaseoWorktree,
|
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||||
createLocalCheckoutWorkspace,
|
|
||||||
} from "./paseo-worktree-service.js";
|
|
||||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||||
@@ -744,6 +774,12 @@ export async function createPaseoDaemon(
|
|||||||
forgeOverrides: { github },
|
forgeOverrides: { github },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
||||||
const providerSnapshotManager = new ProviderSnapshotManager({
|
const providerSnapshotManager = new ProviderSnapshotManager({
|
||||||
logger: providerSnapshotLogger,
|
logger: providerSnapshotLogger,
|
||||||
@@ -788,21 +824,19 @@ export async function createPaseoDaemon(
|
|||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger,
|
logger,
|
||||||
workspaceGitService,
|
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();
|
await chatService.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||||
const checkoutDiffManager = new CheckoutDiffManager({
|
const checkoutDiffManager = new CheckoutDiffManager({
|
||||||
@@ -833,9 +867,9 @@ export async function createPaseoDaemon(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
firstAgentContext?: FirstAgentContext,
|
firstAgentContext?: FirstAgentContext,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const workspace = await createLocalCheckoutWorkspace(
|
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
|
cwd,
|
||||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
resolveFirstAgentPromptTitle(firstAgentContext),
|
||||||
);
|
);
|
||||||
if (firstAgentContext) {
|
if (firstAgentContext) {
|
||||||
workspaceAutoName.scheduleForDirectory({
|
workspaceAutoName.scheduleForDirectory({
|
||||||
@@ -854,6 +888,9 @@ export async function createPaseoDaemon(
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
cwd: workspace.cwd,
|
cwd: workspace.cwd,
|
||||||
kind: workspace.kind,
|
kind: workspace.kind,
|
||||||
|
worktreeRoot: workspace.worktreeRoot,
|
||||||
|
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||||
|
mainRepoRoot: workspace.mainRepoRoot,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||||
@@ -942,9 +979,8 @@ export async function createPaseoDaemon(
|
|||||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry,
|
|
||||||
workspaceRegistry,
|
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
|
workspaceProvisioning,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
warmWorkspaceGitData: async (workspace) => {
|
warmWorkspaceGitData: async (workspace) => {
|
||||||
@@ -1003,9 +1039,9 @@ export async function createPaseoDaemon(
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
firstAgentContext: FirstAgentContext;
|
firstAgentContext: FirstAgentContext;
|
||||||
}) => {
|
}) => {
|
||||||
const workspace = await createLocalCheckoutWorkspace(
|
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
|
input.cwd,
|
||||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||||
);
|
);
|
||||||
workspaceAutoName.scheduleForDirectory({
|
workspaceAutoName.scheduleForDirectory({
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
@@ -1026,7 +1062,7 @@ export async function createPaseoDaemon(
|
|||||||
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
|
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
|
const archiveScheduleWorkspaceExternal = async (workspaceId: string) => {
|
||||||
await archiveByScope(
|
await archiveByScope(
|
||||||
{
|
{
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
@@ -1053,8 +1089,6 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot,
|
|
||||||
paseoWorktreesBaseRoot: config.worktreesRoot,
|
|
||||||
requestId: "schedule-run-finish",
|
requestId: "schedule-run-finish",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1065,7 +1099,7 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
createAgent,
|
createAgent,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
|
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
||||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||||
});
|
});
|
||||||
@@ -1444,6 +1478,7 @@ export async function createPaseoDaemon(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
|
workspaceReconciliation.dispose();
|
||||||
scriptHealthMonitor.stop();
|
scriptHealthMonitor.stop();
|
||||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||||
wsServer?.prepareForShutdown();
|
wsServer?.prepareForShutdown();
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => {
|
|||||||
expect(added.project).not.toBeNull();
|
expect(added.project).not.toBeNull();
|
||||||
const project = added.project!;
|
const project = added.project!;
|
||||||
expect(project).toMatchObject({
|
expect(project).toMatchObject({
|
||||||
projectId: repoRoot,
|
|
||||||
projectRootPath: repoRoot,
|
projectRootPath: repoRoot,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
});
|
});
|
||||||
|
expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
|
|
||||||
const workspaces = await client.fetchWorkspaces({
|
const workspaces = await client.fetchWorkspaces({
|
||||||
filter: { projectId: project.projectId },
|
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.entries).toEqual([]);
|
||||||
expect(workspaces.emptyProjects).toEqual([
|
expect(workspaces.emptyProjects).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
projectId: repoRoot,
|
|
||||||
projectRootPath: repoRoot,
|
projectRootPath: repoRoot,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ afterEach(async () => {
|
|||||||
cleanupPaths.clear();
|
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;
|
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||||
process.env.PASEO_SUPERVISED = "0";
|
process.env.PASEO_SUPERVISED = "0";
|
||||||
try {
|
try {
|
||||||
@@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g
|
|||||||
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
|
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
|
||||||
|
|
||||||
expect(response.error).toBeNull();
|
expect(response.error).toBeNull();
|
||||||
expect(response.workspace?.projectId).toBe(repoRoot);
|
expect(response.workspace?.projectId).toBe(worktreeRoot);
|
||||||
expect(response.workspace?.workspaceKind).toBe("worktree");
|
|
||||||
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
|
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
|
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
|
||||||
).toBe(repoRoot);
|
).toBe(worktreeRoot);
|
||||||
expect(
|
|
||||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind,
|
|
||||||
).toBe("worktree");
|
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
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 { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.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 { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
@@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{
|
|||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
await projectRegistry.initialize();
|
await projectRegistry.initialize();
|
||||||
const workspaceGitService = createNoopWorkspaceGitService();
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
||||||
const workspace = await createLocalCheckoutWorkspace(
|
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
{ cwd, title: firstAgentContext?.prompt ?? null },
|
cwd,
|
||||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
firstAgentContext?.prompt ?? null,
|
||||||
);
|
);
|
||||||
return workspace.workspaceId;
|
return workspace.workspaceId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
@@ -10,17 +10,19 @@ import type {
|
|||||||
PersistedProjectRecord,
|
PersistedProjectRecord,
|
||||||
PersistedWorkspaceRecord,
|
PersistedWorkspaceRecord,
|
||||||
ProjectRegistry,
|
ProjectRegistry,
|
||||||
|
WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||||
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
import {
|
import {
|
||||||
attemptFirstAgentBranchAutoName,
|
attemptFirstAgentBranchAutoName,
|
||||||
createLocalCheckoutWorkspace,
|
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
type CreatePaseoWorktreeDeps,
|
type CreatePaseoWorktreeDeps,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.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 { isPlatform } from "../test-utils/platform.js";
|
||||||
import { existsSync } from "node:fs";
|
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
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.displayName).toBe("feature-one");
|
||||||
expect(result.workspace.baseBranch).toBe("main");
|
expect(result.workspace.baseBranch).toBe("main");
|
||||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(events).toEqual([
|
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||||
"project:remote:github.com/acme/repo",
|
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||||
`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 () => {
|
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"]);
|
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.
|
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||||
test.skipIf(isPlatform("win32"))(
|
test.skipIf(isPlatform("win32"))(
|
||||||
"reuses an existing worktree and still upserts the workspace",
|
"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 () => {
|
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
@@ -679,7 +994,6 @@ test.skipIf(isPlatform("win32"))(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
|
||||||
projects: Map<string, PersistedProjectRecord>;
|
projects: Map<string, PersistedProjectRecord>;
|
||||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||||
}
|
}
|
||||||
@@ -692,28 +1006,73 @@ function createDeps(options?: {
|
|||||||
const events = options?.events ?? [];
|
const events = options?.events ?? [];
|
||||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
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 {
|
return {
|
||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
projects,
|
projects,
|
||||||
workspaces,
|
workspaces,
|
||||||
projectRegistry: {
|
workspaceGitService,
|
||||||
get: async (projectId) => projects.get(projectId) ?? null,
|
workspaceProvisioning,
|
||||||
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(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,15 +1167,30 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||||
getCheckout: async (cwd) => ({
|
getCheckout: async (cwd) => {
|
||||||
cwd,
|
try {
|
||||||
isGit: false,
|
const snapshot = createWorkspaceGitSnapshot(cwd);
|
||||||
currentBranch: null,
|
return {
|
||||||
remoteUrl: null,
|
cwd,
|
||||||
worktreeRoot: null,
|
isGit: snapshot.git.isGit,
|
||||||
isPaseoOwnedWorktree: false,
|
currentBranch: snapshot.git.currentBranch,
|
||||||
mainRepoRoot: null,
|
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),
|
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||||
resolveForge: async () => null,
|
resolveForge: async () => null,
|
||||||
resolveRepoRoot: async (cwd) => {
|
resolveRepoRoot: async (cwd) => {
|
||||||
@@ -902,6 +1276,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
|
|||||||
return { tempDir, repoDir };
|
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 } {
|
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
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 { resolve } from "node:path";
|
||||||
import {
|
|
||||||
type PersistedWorkspaceRecord,
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
type ProjectRegistry,
|
import { getRealpathAwareRelativePath } from "../utils/path.js";
|
||||||
type WorkspaceRegistry,
|
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
createPersistedProjectRecord,
|
import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||||
createPersistedWorkspaceRecord,
|
|
||||||
} from "./workspace-registry.js";
|
|
||||||
import {
|
|
||||||
classifyDirectoryForProjectMembership,
|
|
||||||
deriveProjectGroupingName,
|
|
||||||
generateWorkspaceId,
|
|
||||||
} from "./workspace-registry-model.js";
|
|
||||||
import {
|
import {
|
||||||
createWorktreeCore,
|
createWorktreeCore,
|
||||||
type CreateWorktreeCoreDeps,
|
type CreateWorktreeCoreDeps,
|
||||||
type CreateWorktreeCoreInput,
|
type CreateWorktreeCoreInput,
|
||||||
} from "./worktree-core.js";
|
} 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 { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||||
@@ -56,36 +55,89 @@ export interface AttemptFirstAgentBranchAutoNameResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
|
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPaseoWorktree(
|
export async function createPaseoWorktree(
|
||||||
input: CreatePaseoWorktreeInput,
|
input: CreatePaseoWorktreeInput,
|
||||||
deps: CreatePaseoWorktreeDeps,
|
deps: CreatePaseoWorktreeDeps,
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
|
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||||
const createdWorktree = await createWorktreeCore(input, deps);
|
const createdWorktree = await createWorktreeCore(input, deps);
|
||||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
try {
|
||||||
const workspace = await upsertWorkspaceForWorktree({
|
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||||
inputCwd: input.cwd,
|
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||||
projectId: input.projectId,
|
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||||
worktree: createdWorktree.worktree,
|
});
|
||||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
if (!(await isDirectory(workspaceCwd))) {
|
||||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
|
||||||
deps,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||||
worktree: createdWorktree.worktree,
|
|
||||||
intent: createdWorktree.intent,
|
return {
|
||||||
workspace,
|
worktree: createdWorktree.worktree,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
intent: createdWorktree.intent,
|
||||||
created: createdWorktree.created,
|
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: {
|
export async function attemptFirstAgentBranchAutoName(options: {
|
||||||
@@ -213,257 +265,3 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null
|
|||||||
return 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 { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.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 { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
|
||||||
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||||
import {
|
import {
|
||||||
@@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false;
|
|||||||
|
|
||||||
type TestScheduleServiceOptions = Omit<
|
type TestScheduleServiceOptions = Omit<
|
||||||
ScheduleServiceOptions,
|
ScheduleServiceOptions,
|
||||||
| "createAgent"
|
"createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace"
|
||||||
| "createLocalCheckoutWorkspace"
|
|
||||||
| "createPaseoWorktreeWorkspace"
|
|
||||||
| "archiveWorkspace"
|
|
||||||
> & {
|
> & {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||||
createAgent?: ScheduleServiceOptions["createAgent"];
|
createAgent?: ScheduleServiceOptions["createAgent"];
|
||||||
createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||||
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
|
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
|
||||||
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
|
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
|
||||||
};
|
};
|
||||||
@@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
|||||||
let workspaceCounter = 0;
|
let workspaceCounter = 0;
|
||||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||||
const workspaceGitService = createNoopWorkspaceGitService();
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async (
|
const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async (
|
||||||
input,
|
input,
|
||||||
) => {
|
) => {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -141,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: null,
|
|
||||||
requestId: "schedule-service-test",
|
requestId: "schedule-service-test",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -163,7 +159,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
|||||||
},
|
},
|
||||||
input,
|
input,
|
||||||
)),
|
)),
|
||||||
createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace,
|
createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace,
|
||||||
createPaseoWorktreeWorkspace:
|
createPaseoWorktreeWorkspace:
|
||||||
options.createPaseoWorktreeWorkspace ??
|
options.createPaseoWorktreeWorkspace ??
|
||||||
(async (input) => {
|
(async (input) => {
|
||||||
@@ -182,7 +178,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
|||||||
|
|
||||||
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
|
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
|
||||||
workspaceRegistry: FileBackedWorkspaceRegistry;
|
workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||||
createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||||
createArchiveWorkspace: (input: {
|
createArchiveWorkspace: (input: {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
@@ -200,12 +196,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
|||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
await projectRegistry.initialize();
|
await projectRegistry.initialize();
|
||||||
const workspaceGitService = createNoopWorkspaceGitService();
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
createLocalCheckoutWorkspace: async (input) => {
|
createDirectoryWorkspace: async (input) => {
|
||||||
return createLocalCheckoutWorkspace(
|
return workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
{ cwd: input.cwd, title: input.firstAgentContext.prompt },
|
input.cwd,
|
||||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
input.firstAgentContext.prompt,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
createArchiveWorkspace:
|
createArchiveWorkspace:
|
||||||
@@ -240,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: null,
|
|
||||||
requestId: "schedule-service-test",
|
requestId: "schedule-service-test",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -493,7 +493,7 @@ describe("ScheduleService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("new-agent schedule records create no workspace until run time", async () => {
|
test("new-agent schedule records create no workspace until run time", async () => {
|
||||||
const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } =
|
const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } =
|
||||||
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||||
const service = createScheduleService({
|
const service = createScheduleService({
|
||||||
paseoHome: tempDir,
|
paseoHome: tempDir,
|
||||||
@@ -501,7 +501,7 @@ describe("ScheduleService", () => {
|
|||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -531,7 +531,7 @@ describe("ScheduleService", () => {
|
|||||||
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
||||||
const {
|
const {
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
createArchiveWorkspace,
|
createArchiveWorkspace,
|
||||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
@@ -545,7 +545,7 @@ describe("ScheduleService", () => {
|
|||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
archiveWorkspace: createArchiveWorkspace({
|
archiveWorkspace: createArchiveWorkspace({
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
@@ -599,7 +599,7 @@ describe("ScheduleService", () => {
|
|||||||
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
|
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
|
||||||
const {
|
const {
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
createArchiveWorkspace,
|
createArchiveWorkspace,
|
||||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
@@ -620,7 +620,7 @@ describe("ScheduleService", () => {
|
|||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
archiveWorkspace: createArchiveWorkspace({
|
archiveWorkspace: createArchiveWorkspace({
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
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 () => {
|
test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => {
|
||||||
const {
|
const {
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
createArchiveWorkspace,
|
createArchiveWorkspace,
|
||||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
@@ -678,7 +678,7 @@ describe("ScheduleService", () => {
|
|||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||||
archiveWorkspace: createArchiveWorkspace({
|
archiveWorkspace: createArchiveWorkspace({
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
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");
|
now = new Date("2026-01-01T00:10:00.000Z");
|
||||||
const service2 = createScheduleService({
|
const service2 = createScheduleService({
|
||||||
paseoHome: tempDir,
|
paseoHome: tempDir,
|
||||||
@@ -1914,13 +1914,13 @@ describe("ScheduleService", () => {
|
|||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
archiveCalls.push(archivedWorkspaceId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await service2.start();
|
await service2.start();
|
||||||
|
|
||||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
expect(archiveCalls).toEqual([workspaceId]);
|
||||||
const inspected = await service2.inspect(created.id);
|
const inspected = await service2.inspect(created.id);
|
||||||
expect(inspected.runs[0]).toMatchObject({
|
expect(inspected.runs[0]).toMatchObject({
|
||||||
status: "failed",
|
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");
|
now = new Date("2026-01-01T00:10:00.000Z");
|
||||||
const service2 = createScheduleService({
|
const service2 = createScheduleService({
|
||||||
paseoHome: tempDir,
|
paseoHome: tempDir,
|
||||||
@@ -1982,13 +1982,13 @@ describe("ScheduleService", () => {
|
|||||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
archiveCalls.push(archivedWorkspaceId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await service2.start();
|
await service2.start();
|
||||||
|
|
||||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
expect(archiveCalls).toEqual([workspaceId]);
|
||||||
const inspected = await service2.inspect(created.id);
|
const inspected = await service2.inspect(created.id);
|
||||||
expect(inspected.runs[0]).toMatchObject({
|
expect(inspected.runs[0]).toMatchObject({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
|||||||
@@ -218,13 +218,13 @@ export interface ScheduleServiceOptions {
|
|||||||
agentManager: ScheduleAgentManager;
|
agentManager: ScheduleAgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
createAgent: BoundCreateAgentCommand;
|
createAgent: BoundCreateAgentCommand;
|
||||||
createLocalCheckoutWorkspace: (
|
createDirectoryWorkspace: (
|
||||||
input: ScheduleWorkspaceCreateInput,
|
input: ScheduleWorkspaceCreateInput,
|
||||||
) => Promise<PersistedWorkspaceRecord>;
|
) => Promise<PersistedWorkspaceRecord>;
|
||||||
createPaseoWorktreeWorkspace: (
|
createPaseoWorktreeWorkspace: (
|
||||||
input: ScheduleWorkspaceCreateInput,
|
input: ScheduleWorkspaceCreateInput,
|
||||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||||
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||||
}
|
}
|
||||||
@@ -235,13 +235,13 @@ export class ScheduleService {
|
|||||||
private readonly agentManager: ScheduleAgentManager;
|
private readonly agentManager: ScheduleAgentManager;
|
||||||
private readonly agentStorage: AgentStorage;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly createAgent: BoundCreateAgentCommand;
|
private readonly createAgent: BoundCreateAgentCommand;
|
||||||
private readonly createLocalCheckoutWorkspace: (
|
private readonly createDirectoryWorkspace: (
|
||||||
input: ScheduleWorkspaceCreateInput,
|
input: ScheduleWorkspaceCreateInput,
|
||||||
) => Promise<PersistedWorkspaceRecord>;
|
) => Promise<PersistedWorkspaceRecord>;
|
||||||
private readonly createPaseoWorktreeWorkspace: (
|
private readonly createPaseoWorktreeWorkspace: (
|
||||||
input: ScheduleWorkspaceCreateInput,
|
input: ScheduleWorkspaceCreateInput,
|
||||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||||
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
private readonly archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private readonly runner: (
|
private readonly runner: (
|
||||||
schedule: StoredSchedule,
|
schedule: StoredSchedule,
|
||||||
@@ -256,7 +256,7 @@ export class ScheduleService {
|
|||||||
this.agentManager = options.agentManager;
|
this.agentManager = options.agentManager;
|
||||||
this.agentStorage = options.agentStorage;
|
this.agentStorage = options.agentStorage;
|
||||||
this.createAgent = options.createAgent;
|
this.createAgent = options.createAgent;
|
||||||
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
|
this.createDirectoryWorkspace = options.createDirectoryWorkspace;
|
||||||
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
||||||
this.archiveWorkspace = options.archiveWorkspace;
|
this.archiveWorkspace = options.archiveWorkspace;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
@@ -579,7 +579,6 @@ export class ScheduleService {
|
|||||||
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
|
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
|
||||||
const interruptedWorkspaces: Array<{
|
const interruptedWorkspaces: Array<{
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoRoot: string;
|
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
runId: string;
|
runId: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
@@ -601,7 +600,6 @@ export class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
interruptedWorkspaces.push({
|
interruptedWorkspaces.push({
|
||||||
workspaceId: runningRun.workspaceId,
|
workspaceId: runningRun.workspaceId,
|
||||||
repoRoot: updated.target.config.cwd,
|
|
||||||
agentId: runningRun.agentId,
|
agentId: runningRun.agentId,
|
||||||
runId: runningRun.id,
|
runId: runningRun.id,
|
||||||
});
|
});
|
||||||
@@ -639,7 +637,7 @@ export class ScheduleService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
|
await this.archiveWorkspace(interruptedWorkspace.workspaceId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{
|
{
|
||||||
@@ -930,7 +928,7 @@ export class ScheduleService {
|
|||||||
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
|
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
|
await this.archiveWorkspace(workspace.workspaceId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{
|
{
|
||||||
@@ -954,7 +952,7 @@ export class ScheduleService {
|
|||||||
const firstAgentContext = { prompt };
|
const firstAgentContext = { prompt };
|
||||||
switch (config.isolation ?? "local") {
|
switch (config.isolation ?? "local") {
|
||||||
case "local":
|
case "local":
|
||||||
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
|
return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||||
case "worktree":
|
case "worktree":
|
||||||
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
||||||
.workspace;
|
.workspace;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
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 { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||||
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
|
|
||||||
let ctx: DaemonTestContext;
|
let ctx: DaemonTestContext;
|
||||||
const tempRoots: string[] = [];
|
const tempRoots: string[] = [];
|
||||||
@@ -42,6 +43,18 @@ function createGitRepo(): string {
|
|||||||
return repoDir;
|
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> {
|
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
|
|||||||
async function createAgentInBranchOffWorktree(options?: {
|
async function createAgentInBranchOffWorktree(options?: {
|
||||||
autoArchive?: boolean;
|
autoArchive?: boolean;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
repoDir?: string;
|
||||||
}): Promise<{ repoDir: string; agentId: string; worktreePath: 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 branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
|
||||||
const created = await ctx.client.createAgent({
|
const created = await ctx.client.createAgent({
|
||||||
config: {
|
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);
|
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||||
}, 30000);
|
}, 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 () => {
|
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
|
||||||
const repoDir = createGitRepo();
|
const repoDir = createGitRepo();
|
||||||
const created = await ctx.client.createAgent({
|
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 { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
||||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-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 { SessionOptions } from "./session.js";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -294,6 +295,7 @@ interface SessionForTestOptions {
|
|||||||
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
||||||
resolveForge?: ReturnType<typeof vi.fn>;
|
resolveForge?: ReturnType<typeof vi.fn>;
|
||||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||||
|
getProjectSlug?: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||||
@@ -342,6 +344,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
// Mirror production: invalidateForge resolves the forge and busts the
|
// Mirror production: invalidateForge resolves the forge and busts the
|
||||||
// adapter's cache. The resolved forge here is github, so delegate to it.
|
// adapter's cache. The resolved forge here is github, so delegate to it.
|
||||||
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
|
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
|
||||||
|
getProjectSlug: vi.fn(),
|
||||||
...options.workspaceGitService,
|
...options.workspaceGitService,
|
||||||
};
|
};
|
||||||
const messages = options.messages ?? [];
|
const messages = options.messages ?? [];
|
||||||
@@ -369,14 +372,16 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
...options.agentStorage,
|
...options.agentStorage,
|
||||||
}),
|
}),
|
||||||
projectRegistry: options.projectRegistry ?? {
|
projectRegistry: {
|
||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
getOrCreateActiveByRoot: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
archive: vi.fn(),
|
archive: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
initialize: vi.fn(),
|
initialize: vi.fn(),
|
||||||
existsOnDisk: vi.fn(),
|
existsOnDisk: vi.fn(),
|
||||||
|
...options.projectRegistry,
|
||||||
},
|
},
|
||||||
workspaceRegistry: options.workspaceRegistry ?? {
|
workspaceRegistry: options.workspaceRegistry ?? {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
@@ -525,12 +530,20 @@ describe("project command-center RPCs", () => {
|
|||||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||||
const directoryPath = join(parentDirectory, "new-project");
|
const directoryPath = join(parentDirectory, "new-project");
|
||||||
const messages: SessionOutboundMessage[] = [];
|
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({
|
const session = createSessionForTest({
|
||||||
messages,
|
messages,
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
list: vi.fn().mockResolvedValue([]),
|
getOrCreateActiveByRoot: projectAllocation,
|
||||||
upsert: projectUpsert,
|
|
||||||
},
|
},
|
||||||
workspaceGitService: {
|
workspaceGitService: {
|
||||||
getCheckout: vi.fn(async (cwd: string) => ({
|
getCheckout: vi.fn(async (cwd: string) => ({
|
||||||
@@ -554,7 +567,12 @@ describe("project command-center RPCs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(existsSync(directoryPath)).toBe(true);
|
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([
|
expect(messages).toEqual([
|
||||||
{
|
{
|
||||||
type: "project.create_directory.response",
|
type: "project.create_directory.response",
|
||||||
@@ -562,7 +580,7 @@ describe("project command-center RPCs", () => {
|
|||||||
requestId: "req-create-directory",
|
requestId: "req-create-directory",
|
||||||
directoryPath,
|
directoryPath,
|
||||||
project: {
|
project: {
|
||||||
projectId: directoryPath,
|
projectId: "prj_created_directory",
|
||||||
projectDisplayName: "new-project",
|
projectDisplayName: "new-project",
|
||||||
projectCustomName: null,
|
projectCustomName: null,
|
||||||
projectRootPath: directoryPath,
|
projectRootPath: directoryPath,
|
||||||
@@ -585,8 +603,7 @@ describe("project command-center RPCs", () => {
|
|||||||
const session = createSessionForTest({
|
const session = createSessionForTest({
|
||||||
messages,
|
messages,
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
list: vi.fn().mockResolvedValue([]),
|
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||||
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
|
||||||
},
|
},
|
||||||
workspaceGitService: {
|
workspaceGitService: {
|
||||||
getCheckout: vi.fn(async (cwd: string) => ({
|
getCheckout: vi.fn(async (cwd: string) => ({
|
||||||
@@ -4041,17 +4058,17 @@ describe("session paseo worktree creation handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("session workspace script 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 messages: unknown[] = [];
|
||||||
const workspaceGitService = {
|
const snapshot = createWorkspaceGitSnapshot("/tmp/repo", {
|
||||||
peekSnapshot: vi.fn(() => null),
|
git: {
|
||||||
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
|
|
||||||
projectKind: "git",
|
|
||||||
projectDisplayName: "getpaseo/paseo",
|
|
||||||
workspaceDisplayName: "feature/service-scripts",
|
|
||||||
projectSlug: "paseo",
|
|
||||||
currentBranch: "feature/service-scripts",
|
currentBranch: "feature/service-scripts",
|
||||||
}),
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const workspaceGitService = {
|
||||||
|
peekSnapshot: vi.fn(() => snapshot),
|
||||||
|
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
|
||||||
};
|
};
|
||||||
const workspaceRegistry = {
|
const workspaceRegistry = {
|
||||||
get: vi.fn().mockResolvedValue({
|
get: vi.fn().mockResolvedValue({
|
||||||
@@ -4084,8 +4101,6 @@ describe("session workspace script handling", () => {
|
|||||||
requestId: "request-script",
|
requestId: "request-script",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
|
|
||||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
|
|
||||||
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
repoRoot: "/tmp/repo",
|
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", () => {
|
describe("agent config setters", () => {
|
||||||
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
|
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
|
||||||
const messages: SessionOutboundMessage[] = [];
|
const messages: SessionOutboundMessage[] = [];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
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 { homedir } from "node:os";
|
||||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +61,7 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti
|
|||||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
||||||
import {
|
import {
|
||||||
CLIENT_SHUTDOWN_RPC_REASON,
|
CLIENT_SHUTDOWN_RPC_REASON,
|
||||||
normalizeClientRestartRpcReason,
|
normalizeClientRestartRpcReason,
|
||||||
@@ -119,6 +120,7 @@ import {
|
|||||||
} from "./agent/import-sessions.js";
|
} from "./agent/import-sessions.js";
|
||||||
import {
|
import {
|
||||||
checkoutLiteFromGitSnapshot,
|
checkoutLiteFromGitSnapshot,
|
||||||
|
checkoutFromPersistedWorkspacePlacement,
|
||||||
deriveWorkspaceDisplayName,
|
deriveWorkspaceDisplayName,
|
||||||
} from "./workspace-registry-model.js";
|
} from "./workspace-registry-model.js";
|
||||||
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
||||||
@@ -165,6 +167,7 @@ import {
|
|||||||
} from "./session/git-mutation/git-mutation-service.js";
|
} from "./session/git-mutation/git-mutation-service.js";
|
||||||
import {
|
import {
|
||||||
createWorkspaceProvisioningService,
|
createWorkspaceProvisioningService,
|
||||||
|
WorkspaceProvisioningError,
|
||||||
type WorkspaceProvisioningService,
|
type WorkspaceProvisioningService,
|
||||||
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||||
import {
|
import {
|
||||||
@@ -199,13 +202,13 @@ import type { ForgeService } from "../services/forge-service.js";
|
|||||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||||
import {
|
import {
|
||||||
summarizeFetchWorkspacesEntries,
|
summarizeFetchWorkspacesEntries,
|
||||||
|
workspaceIdsForProjects,
|
||||||
workspaceIdsOnCheckout,
|
workspaceIdsOnCheckout,
|
||||||
WorkspaceDirectory,
|
WorkspaceDirectory,
|
||||||
type WorkspaceUpdatesFilter,
|
type WorkspaceUpdatesFilter,
|
||||||
} from "./workspace-directory.js";
|
} from "./workspace-directory.js";
|
||||||
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
||||||
import {
|
import {
|
||||||
createLocalCheckoutWorkspace,
|
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
type CreatePaseoWorktreeInput,
|
type CreatePaseoWorktreeInput,
|
||||||
type CreatePaseoWorktreeResult,
|
type CreatePaseoWorktreeResult,
|
||||||
@@ -222,17 +225,12 @@ import {
|
|||||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||||
} from "./worktree-session.js";
|
} from "./worktree-session.js";
|
||||||
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
||||||
import {
|
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
|
||||||
WorktreeRequestError,
|
|
||||||
toWorktreeRequestError,
|
|
||||||
toWorktreeWireError,
|
|
||||||
} from "./worktree-errors.js";
|
|
||||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||||
import {
|
import {
|
||||||
createProjectDirectory,
|
createProjectDirectory,
|
||||||
ProjectDirectoryRequestError,
|
ProjectDirectoryRequestError,
|
||||||
} from "./project-directory-service.js";
|
} from "./project-directory-service.js";
|
||||||
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||||
|
|
||||||
@@ -259,49 +257,6 @@ function resolveSubscriptionId(
|
|||||||
return uuidv4();
|
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 {
|
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
|
||||||
if (!appVersion) return false;
|
if (!appVersion) return false;
|
||||||
// Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
|
// Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
|
||||||
@@ -589,7 +544,7 @@ export class Session {
|
|||||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||||
private viewedTimelineAgentIds = new Set<string>();
|
private viewedTimelineAgentIds = new Set<string>();
|
||||||
private readonly viewedTimelineAgentIdsBySource = new Map<object, 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 readonly defaultTimelineSubscriptionSource = {};
|
||||||
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
|
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
|
||||||
private readonly agentUpdates: AgentUpdatesService;
|
private readonly agentUpdates: AgentUpdatesService;
|
||||||
@@ -727,10 +682,11 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
worktreesRoot: this.worktreesRoot,
|
||||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||||
getProject: (projectId) => this.projectRegistry.get(projectId),
|
getProject: (projectId) => this.projectRegistry.get(projectId),
|
||||||
isDirectory: (path) => this.filesystem.isDirectory(path),
|
isDirectory: (path) => this.filesystem.isDirectory(path),
|
||||||
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
|
|
||||||
unarchiveWorkspace: async (workspace) => {
|
unarchiveWorkspace: async (workspace) => {
|
||||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||||
},
|
},
|
||||||
@@ -907,6 +863,7 @@ export class Session {
|
|||||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
workspaceRegistry: this.workspaceRegistry,
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
|
projectRegistry: this.projectRegistry,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||||
@@ -976,10 +933,7 @@ export class Session {
|
|||||||
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void {
|
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void {
|
||||||
this.clientCapabilities = parseClientCapabilities(capabilities);
|
this.clientCapabilities = parseClientCapabilities(capabilities);
|
||||||
if (source) {
|
if (source) {
|
||||||
this.selectiveTimelineCapabilityBySource.set(
|
this.clientCapabilitiesBySource.set(source, this.clientCapabilities);
|
||||||
source,
|
|
||||||
this.supports(CLIENT_CAPS.selectiveAgentTimeline),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
|
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
|
||||||
this.viewedTimelineAgentIdsBySource.clear();
|
this.viewedTimelineAgentIdsBySource.clear();
|
||||||
@@ -988,7 +942,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearAgentTimelineSubscription(source: object): void {
|
clearAgentTimelineSubscription(source: object): void {
|
||||||
this.selectiveTimelineCapabilityBySource.delete(source);
|
this.clientCapabilitiesBySource.delete(source);
|
||||||
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
|
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
|
||||||
this.rebuildViewedTimelineAgentIds();
|
this.rebuildViewedTimelineAgentIds();
|
||||||
}
|
}
|
||||||
@@ -1010,11 +964,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private usesSelectiveTimelineDelivery(): boolean {
|
private usesSelectiveTimelineDelivery(): boolean {
|
||||||
if (this.selectiveTimelineCapabilityBySource.size === 0) {
|
if (this.clientCapabilitiesBySource.size === 0) {
|
||||||
return this.supports(CLIENT_CAPS.selectiveAgentTimeline);
|
return this.supports(CLIENT_CAPS.selectiveAgentTimeline);
|
||||||
}
|
}
|
||||||
for (const capable of this.selectiveTimelineCapabilityBySource.values()) {
|
for (const capabilities of this.clientCapabilitiesBySource.values()) {
|
||||||
if (!capable) return false;
|
if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1023,7 +977,7 @@ export class Session {
|
|||||||
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
|
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
|
||||||
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
||||||
): void {
|
): void {
|
||||||
if (this.selectiveTimelineCapabilityBySource.size === 0 || !this.onMessageToSource) {
|
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
|
||||||
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
|
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "agent_attention_required",
|
type: "agent_attention_required",
|
||||||
@@ -1047,7 +1001,8 @@ export class Session {
|
|||||||
return;
|
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") {
|
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
|
||||||
this.onMessageToSource(source, {
|
this.onMessageToSource(source, {
|
||||||
type: "agent_attention_required",
|
type: "agent_attention_required",
|
||||||
@@ -1079,10 +1034,28 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
supportsForSource(capability: ClientCapability, source: object): boolean {
|
supportsForSource(capability: ClientCapability, source: object): boolean {
|
||||||
if (capability === CLIENT_CAPS.selectiveAgentTimeline) {
|
return (
|
||||||
return this.selectiveTimelineCapabilityBySource.get(source) ?? this.supports(capability);
|
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> {
|
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
@@ -1118,8 +1091,24 @@ export class Session {
|
|||||||
this.clearWorkspaceArchiving(workspaceIds);
|
this.clearWorkspaceArchiving(workspaceIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
|
async emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
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> {
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
@@ -1473,9 +1462,15 @@ export class Session {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
||||||
}
|
}
|
||||||
const liveBranch =
|
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||||
this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
|
const checkout = checkoutFromPersistedWorkspacePlacement({
|
||||||
const checkout = buildWorkspaceCheckout(workspace, project, liveBranch);
|
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 {
|
return {
|
||||||
projectKey: project.projectId,
|
projectKey: project.projectId,
|
||||||
projectName: resolveProjectDisplayName(project),
|
projectName: resolveProjectDisplayName(project),
|
||||||
@@ -1652,8 +1647,7 @@ export class Session {
|
|||||||
const agentIds = [...new Set(msg.agentIds)].sort();
|
const agentIds = [...new Set(msg.agentIds)].sort();
|
||||||
if (
|
if (
|
||||||
source
|
source
|
||||||
? (this.selectiveTimelineCapabilityBySource.get(source) ??
|
? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)
|
||||||
this.supports(CLIENT_CAPS.selectiveAgentTimeline))
|
|
||||||
: this.supports(CLIENT_CAPS.selectiveAgentTimeline)
|
: this.supports(CLIENT_CAPS.selectiveAgentTimeline)
|
||||||
) {
|
) {
|
||||||
this.replaceAgentTimelineSubscription(source, agentIds);
|
this.replaceAgentTimelineSubscription(source, agentIds);
|
||||||
@@ -2756,7 +2750,7 @@ export class Session {
|
|||||||
});
|
});
|
||||||
createdWorktreeForCleanup = createdWorktree;
|
createdWorktreeForCleanup = createdWorktree;
|
||||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
const createAgentConfig: AgentSessionConfig = createdWorktree
|
||||||
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
? { ...config, cwd: createdWorktree.workspace.cwd }
|
||||||
: config;
|
: config;
|
||||||
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
|
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
|
||||||
{
|
{
|
||||||
@@ -3891,7 +3885,7 @@ export class Session {
|
|||||||
statusEnteredAt: null,
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat,
|
diffStat,
|
||||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd),
|
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
|
||||||
...(resolvedProjectRecord
|
...(resolvedProjectRecord
|
||||||
? {
|
? {
|
||||||
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
||||||
@@ -3967,7 +3961,7 @@ export class Session {
|
|||||||
projectCustomName: projectRecord?.customName ?? null,
|
projectCustomName: projectRecord?.customName ?? null,
|
||||||
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
||||||
workspaceDirectory: result.workspace.cwd,
|
workspaceDirectory: result.workspace.cwd,
|
||||||
projectKind: "git",
|
projectKind: projectRecord?.kind ?? "git",
|
||||||
workspaceKind: result.workspace.kind,
|
workspaceKind: result.workspace.kind,
|
||||||
name: resolveWorkspaceName({
|
name: resolveWorkspaceName({
|
||||||
title: result.workspace.title,
|
title: result.workspace.title,
|
||||||
@@ -3999,7 +3993,7 @@ export class Session {
|
|||||||
projectRecord?: PersistedProjectRecord | null;
|
projectRecord?: PersistedProjectRecord | null;
|
||||||
includeGitData: boolean;
|
includeGitData: boolean;
|
||||||
}): Promise<WorkspaceDescriptorPayload> {
|
}): 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.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
|
||||||
}
|
}
|
||||||
return this.describeWorkspaceRecord(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> {
|
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||||
await this.workspaceRecovery.restore(workspaceId);
|
await this.workspaceRecovery.restore(workspaceId);
|
||||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||||
@@ -4237,9 +4172,8 @@ export class Session {
|
|||||||
...(options?.resolveDefaultBranch
|
...(options?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry: this.projectRegistry,
|
|
||||||
workspaceRegistry: this.workspaceRegistry,
|
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
workspaceProvisioning: this.workspaceProvisioning,
|
||||||
});
|
});
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"),
|
this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"),
|
||||||
@@ -4261,6 +4195,9 @@ export class Session {
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
cwd: workspace.cwd,
|
cwd: workspace.cwd,
|
||||||
kind: workspace.kind,
|
kind: workspace.kind,
|
||||||
|
worktreeRoot: workspace.worktreeRoot,
|
||||||
|
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||||
|
mainRepoRoot: workspace.mainRepoRoot,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4292,21 +4229,12 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.teardownArchivedWorkspace({
|
await this.teardownArchivedWorkspace(existingWorkspace.workspaceId);
|
||||||
workspaceId: existingWorkspace.workspaceId,
|
|
||||||
cwd: existingWorkspace.cwd,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git watch and subscription state is keyed by directory; the script runtime
|
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
||||||
// store is keyed by the opaque workspace id. Each cleanup uses its own key so an
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
||||||
// opaque id is never resolved as a filesystem path.
|
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
|
||||||
private async teardownArchivedWorkspace(input: {
|
|
||||||
workspaceId: string;
|
|
||||||
cwd: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
this.workspaceGitObserver.removeForCwd(input.cwd);
|
|
||||||
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||||
@@ -4341,16 +4269,12 @@ export class Session {
|
|||||||
result.changesApplied.map(async (change) => {
|
result.changesApplied.map(async (change) => {
|
||||||
switch (change.kind) {
|
switch (change.kind) {
|
||||||
case "workspace_archived":
|
case "workspace_archived":
|
||||||
await this.teardownArchivedWorkspace({
|
await this.teardownArchivedWorkspace(change.workspaceId);
|
||||||
workspaceId: change.workspaceId,
|
|
||||||
cwd: change.directory,
|
|
||||||
});
|
|
||||||
changedWorkspaceIds.add(change.workspaceId);
|
changedWorkspaceIds.add(change.workspaceId);
|
||||||
break;
|
break;
|
||||||
case "workspace_updated":
|
case "workspace_updated":
|
||||||
changedWorkspaceIds.add(change.workspaceId);
|
changedWorkspaceIds.add(change.workspaceId);
|
||||||
break;
|
break;
|
||||||
case "project_archived":
|
|
||||||
case "project_updated":
|
case "project_updated":
|
||||||
changedProjectIds.add(change.projectId);
|
changedProjectIds.add(change.projectId);
|
||||||
break;
|
break;
|
||||||
@@ -4359,10 +4283,11 @@ export class Session {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (changedProjectIds.size > 0) {
|
if (changedProjectIds.size > 0) {
|
||||||
for (const workspace of await this.workspaceRegistry.list()) {
|
for (const workspaceId of workspaceIdsForProjects(
|
||||||
if (changedProjectIds.has(workspace.projectId)) {
|
await this.workspaceRegistry.list(),
|
||||||
changedWorkspaceIds.add(workspace.workspaceId);
|
changedProjectIds,
|
||||||
}
|
)) {
|
||||||
|
changedWorkspaceIds.add(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4770,6 +4695,7 @@ export class Session {
|
|||||||
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
|
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
|
||||||
"Failed to create workspace",
|
"Failed to create workspace",
|
||||||
);
|
);
|
||||||
|
const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined;
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "workspace.create.response",
|
type: "workspace.create.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -4777,6 +4703,7 @@ export class Session {
|
|||||||
workspace: null,
|
workspace: null,
|
||||||
setupTerminalId: null,
|
setupTerminalId: null,
|
||||||
error: message,
|
error: message,
|
||||||
|
errorCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4807,13 +4734,10 @@ export class Session {
|
|||||||
|
|
||||||
const explicitTitle = request.title?.trim() || null;
|
const explicitTitle = request.title?.trim() || null;
|
||||||
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
||||||
const workspace = await createLocalCheckoutWorkspace(
|
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
{ cwd, title: explicitTitle ?? promptTitle },
|
cwd,
|
||||||
{
|
explicitTitle ?? promptTitle,
|
||||||
projectRegistry: this.projectRegistry,
|
request.source.projectId,
|
||||||
workspaceRegistry: this.workspaceRegistry,
|
|
||||||
workspaceGitService: this.workspaceGitService,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||||
const descriptor = await this.describeWorkspaceRecord(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
|
// 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.
|
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
|
||||||
private buildWorkspaceScriptPayloadSnapshot(
|
private buildWorkspaceScriptPayloadSnapshot(
|
||||||
workspaceId: string,
|
workspace: PersistedWorkspaceRecord,
|
||||||
workspaceDirectory: string,
|
project: PersistedProjectRecord | null,
|
||||||
): WorkspaceDescriptorPayload["scripts"] {
|
): WorkspaceDescriptorPayload["scripts"] {
|
||||||
return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory);
|
return this.workspaceScripts.buildSnapshot(workspace, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||||
@@ -5401,11 +5325,6 @@ export class Session {
|
|||||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
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(
|
await archiveByScope(
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
@@ -5428,8 +5347,6 @@ export class Session {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||||
repoRoot,
|
|
||||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -472,7 +472,6 @@ describe("workspace git watch targets", () => {
|
|||||||
onBranchChanged: handleBranchChange,
|
onBranchChanged: handleBranchChange,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const sessionAny = session as unknown as SessionInternals;
|
|
||||||
seedGitWorkspace({
|
seedGitWorkspace({
|
||||||
projects,
|
projects,
|
||||||
workspaces,
|
workspaces,
|
||||||
@@ -482,7 +481,7 @@ describe("workspace git watch targets", () => {
|
|||||||
name: "old-branch",
|
name: "old-branch",
|
||||||
});
|
});
|
||||||
|
|
||||||
syncGitObserver(session, "/tmp/repo", "ws-10");
|
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]);
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||||
@@ -498,16 +497,6 @@ describe("workspace git watch targets", () => {
|
|||||||
scriptName: "app",
|
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();
|
await session.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ function createHarness(input: {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => Array.from(projects.values()),
|
list: async () => Array.from(projects.values()),
|
||||||
get: async (id: string) => projects.get(id) ?? null,
|
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) => {
|
upsert: async (record: PersistedProjectRecord) => {
|
||||||
projects.set(record.projectId, record);
|
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
|
// S4. Every selected path is an exact lexical root, even inside a Git checkout.
|
||||||
// root, returns the existing workspace. (Per "always go to the nearest git".)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
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({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace(FOO)],
|
workspaces: [gitWorkspace(FOO)],
|
||||||
projects: [gitProject(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);
|
await openProject(h.session, FOO_SUB);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||||
expect(h.workspaces.size).toBe(1);
|
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
|
// S6. Explicit project opening allocates a fresh identity when only archived
|
||||||
// intent to re-open what they archived. Unarchive is correct here.
|
// 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 archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||||
@@ -354,8 +370,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
|||||||
gitRoots: [TOOLBOX],
|
gitRoots: [TOOLBOX],
|
||||||
});
|
});
|
||||||
await openProject(h.session, TOOLBOX);
|
await openProject(h.session, TOOLBOX);
|
||||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
const fresh = Array.from(h.workspaces.values()).find(
|
||||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
(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
|
// S11. Archive then re-add produces a fresh opaque identity; it never reuses
|
||||||
// of an archived project unarchives both the project and its workspace,
|
// archived compatibility IDs.
|
||||||
// reusing the same path-derived 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 archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
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);
|
await openProject(h.session, TOOLBOX);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
|
expect(resp?.workspace?.id).not.toBe("ws-toolbox");
|
||||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
expect(h.workspaces.size).toBe(1);
|
expect(h.workspaces.size).toBe(2);
|
||||||
expect(h.projects.size).toBe(1);
|
expect(h.projects.size).toBe(2);
|
||||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -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
|
// being explicit. To get git features back the user unarchives the parent
|
||||||
// (S6/S11).
|
// (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 archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
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);
|
await openProject(h.session, TOOLBOX_FLOMO);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { createWorktree } from "../utils/worktree.js";
|
import { createWorktree } from "../utils/worktree.js";
|
||||||
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
import {
|
import {
|
||||||
readPaseoWorktreeMetadata,
|
readPaseoWorktreeMetadata,
|
||||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||||
writePaseoWorktreeMetadata,
|
writePaseoWorktreeMetadata,
|
||||||
} from "../utils/worktree-metadata.js";
|
} from "../utils/worktree-metadata.js";
|
||||||
import { WorktreeRequestError } from "./worktree-errors.js";
|
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||||
@@ -128,7 +128,6 @@ interface SessionTestAccess {
|
|||||||
agentUpdates: AgentUpdatesService;
|
agentUpdates: AgentUpdatesService;
|
||||||
workspaceUpdatesSubscription: unknown;
|
workspaceUpdatesSubscription: unknown;
|
||||||
interruptAgentIfRunning(agentId: string): unknown;
|
interruptAgentIfRunning(agentId: string): unknown;
|
||||||
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
|
|
||||||
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
||||||
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
@@ -154,6 +153,10 @@ interface SessionTestAccess {
|
|||||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
||||||
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
||||||
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
||||||
|
emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||||
|
workspaceIds: Iterable<string>,
|
||||||
|
options?: { skipReconcile?: boolean },
|
||||||
|
): Promise<void>;
|
||||||
emit(message: unknown): void;
|
emit(message: unknown): void;
|
||||||
onMessage(message: unknown): void;
|
onMessage(message: unknown): void;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
@@ -652,6 +655,15 @@ function createSessionForWorkspaceTests(
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
get: async () => null,
|
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 () => {},
|
upsert: async () => {},
|
||||||
archive: async () => {},
|
archive: async () => {},
|
||||||
remove: async () => {},
|
remove: async () => {},
|
||||||
@@ -844,7 +856,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
|||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "create_agent_request",
|
type: "create_agent_request",
|
||||||
requestId: "req-create-child",
|
requestId: "req-create-child",
|
||||||
@@ -857,7 +868,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
|||||||
await expect(
|
await expect(
|
||||||
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
projectKey: parent,
|
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||||
checkout: { cwd: child },
|
checkout: { cwd: child },
|
||||||
});
|
});
|
||||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
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 () => {
|
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
|
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");
|
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 () => {
|
test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => {
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const archivedAt = "2026-03-02T12:00:00.000Z";
|
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 emitted: SessionOutboundMessage[] = [];
|
||||||
const createdAt = "2026-05-12T12:00:00.000Z";
|
const createdAt = "2026-05-12T12:00:00.000Z";
|
||||||
vi.setSystemTime(new Date(createdAt));
|
vi.setSystemTime(new Date(createdAt));
|
||||||
@@ -3408,7 +3663,15 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaces = new Map();
|
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.paseoHome = paseoHome;
|
||||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||||
workspaces.get(lookupWorkspaceId) ?? null;
|
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.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
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 (
|
session.projectRegistry.upsert = async (
|
||||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||||
) => {
|
) => {
|
||||||
@@ -3432,6 +3711,7 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
|||||||
await session.handleCreatePaseoWorktreeRequest({
|
await session.handleCreatePaseoWorktreeRequest({
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
projectId: explicitProject.projectId,
|
||||||
worktreeSlug: "worktree-123",
|
worktreeSlug: "worktree-123",
|
||||||
requestId: "req-worktree",
|
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.error).toBeNull();
|
||||||
expect(response?.payload.workspace).toMatchObject({
|
expect(response?.payload.workspace).toMatchObject({
|
||||||
projectDisplayName: "repo",
|
projectId: explicitProject.projectId,
|
||||||
projectKind: "git",
|
projectDisplayName: explicitProject.displayName,
|
||||||
|
projectRootPath: explicitProject.rootPath,
|
||||||
|
projectKind: "non_git",
|
||||||
workspaceKind: "worktree",
|
workspaceKind: "worktree",
|
||||||
name: "worktree-123",
|
name: "worktree-123",
|
||||||
status: "done",
|
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?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||||
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
|
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
|
||||||
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
|
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 () => {
|
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);
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
};
|
};
|
||||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
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 (
|
session.projectRegistry.upsert = async (
|
||||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
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);
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
};
|
};
|
||||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
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 (
|
session.projectRegistry.upsert = async (
|
||||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||||
) => {
|
) => {
|
||||||
@@ -4085,7 +4391,6 @@ test("open_project_request reclassifies an archived directory workspace when git
|
|||||||
"orchestrate",
|
"orchestrate",
|
||||||
"desktop-daemon-settings",
|
"desktop-daemon-settings",
|
||||||
);
|
);
|
||||||
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
|
||||||
const archivedAt = "2026-04-24T09:48:36.168Z";
|
const archivedAt = "2026-04-24T09:48:36.168Z";
|
||||||
const workspaceId = "ws-desktop-daemon-settings";
|
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");
|
const response = findByType(emitted, "open_project_response");
|
||||||
|
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
expect(projects.get(cwd)?.archivedAt).toBe(archivedAt);
|
||||||
expect(projects.get(remoteProjectId)?.kind).toBe("git");
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt);
|
||||||
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
|
|
||||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
|
||||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
|
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");
|
const response = findByType(emitted, "open_project_response");
|
||||||
|
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
expect(response?.payload.workspace?.projectId).toBe(cwd);
|
||||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
expect(workspaces.get(workspaceId)?.projectId).toBe(cwd);
|
||||||
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
|
|
||||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
|
||||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
|
test("open_project_request gives a plain git worktree its own exact-root project", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
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");
|
const response = findByType(emitted, "open_project_response");
|
||||||
|
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
|
||||||
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
||||||
(workspace) => workspace.cwd === cwd,
|
(workspace) => workspace.cwd === cwd,
|
||||||
);
|
);
|
||||||
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
|
expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
expect(worktreeWorkspace?.kind).toBe("worktree");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
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",
|
requestId: "req-open-unarchive",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
expect(projects.get(cwd)?.archivedAt).not.toBeNull();
|
||||||
const response = findByType(emitted, "open_project_response");
|
const response = findByType(emitted, "open_project_response");
|
||||||
expect(response?.payload.error).toBeNull();
|
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 emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
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",
|
requestId: "req-open-removed-project",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(projects.get(cwd)).toEqual(
|
expect(projects.get(cwd)).toBeUndefined();
|
||||||
expect.objectContaining({
|
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||||
projectId: cwd,
|
|
||||||
displayName: "repo",
|
|
||||||
archivedAt: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
|
||||||
const response = findByType(emitted, "open_project_response");
|
const response = findByType(emitted, "open_project_response");
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
||||||
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace recovery stays accepted when git observer warming fails", async () => {
|
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 });
|
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 () => {
|
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
@@ -5747,6 +5977,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
|||||||
expect(descriptors).toEqual([baselineDescriptor]);
|
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 () => {
|
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
|
||||||
const setupSession = () => {
|
const setupSession = () => {
|
||||||
const session = createSessionForWorkspaceTests();
|
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 () => {
|
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
@@ -7497,6 +7845,18 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => Array.from(projects.values()),
|
list: async () => Array.from(projects.values()),
|
||||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
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) => {
|
upsert: async (record) => {
|
||||||
projects.set(record.projectId, 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();
|
vi.useFakeTimers();
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
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, {
|
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||||
placeholderBranchName: "placeholder-branch",
|
placeholderBranchName: "placeholder-branch",
|
||||||
});
|
});
|
||||||
|
const workspaceCwd = path.join(repoDir, "packages", "app");
|
||||||
|
mkdirSync(workspaceCwd, { recursive: true });
|
||||||
|
|
||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-rejected-branch-title",
|
workspaceId: "ws-rejected-branch-title",
|
||||||
projectId: "proj-rejected-branch-title",
|
projectId: "proj-rejected-branch-title",
|
||||||
cwd: repoDir,
|
cwd: workspaceCwd,
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
displayName: "Fix checkout title",
|
displayName: "Fix checkout title",
|
||||||
title: "Fix checkout title",
|
title: "Fix checkout title",
|
||||||
branch: "placeholder-branch",
|
branch: "placeholder-branch",
|
||||||
|
worktreeRoot: repoDir,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
updatedAt: "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(gitMutations).toEqual([]);
|
||||||
expect(emittedCwds).toEqual([repoDir]);
|
expect(emittedCwds).toEqual([workspaceCwd]);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
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([]);
|
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;
|
id: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
projectKind?: string;
|
projectKind?: string;
|
||||||
|
workspaceKind?: "directory" | "local_checkout" | "worktree";
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
diffStat?: { additions: number; deletions: number } | null;
|
diffStat?: { additions: number; deletions: number } | null;
|
||||||
}): WorkspaceDescriptorPayload {
|
}): WorkspaceDescriptorPayload {
|
||||||
@@ -31,6 +32,7 @@ function makeDescriptor(overrides: {
|
|||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
workspaceDirectory: overrides.workspaceDirectory,
|
workspaceDirectory: overrides.workspaceDirectory,
|
||||||
projectKind: overrides.projectKind ?? "git",
|
projectKind: overrides.projectKind ?? "git",
|
||||||
|
workspaceKind: overrides.workspaceKind ?? "local_checkout",
|
||||||
name: overrides.name ?? null,
|
name: overrides.name ?? null,
|
||||||
diffStat: overrides.diffStat ?? null,
|
diffStat: overrides.diffStat ?? null,
|
||||||
} as unknown as WorkspaceDescriptorPayload;
|
} as unknown as WorkspaceDescriptorPayload;
|
||||||
@@ -135,11 +137,24 @@ describe("syncObservers", () => {
|
|||||||
test("does not register a non-git workspace", () => {
|
test("does not register a non-git workspace", () => {
|
||||||
const h = buildHarness();
|
const h = buildHarness();
|
||||||
h.service.syncObservers([
|
h.service.syncObservers([
|
||||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
makeDescriptor({
|
||||||
|
id: "ws1",
|
||||||
|
workspaceDirectory: WS1,
|
||||||
|
projectKind: "directory",
|
||||||
|
workspaceKind: "directory",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(h.registerCalls).toEqual([]);
|
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", () => {
|
test("is idempotent — re-syncing the same git workspace does not re-register", () => {
|
||||||
const h = buildHarness();
|
const h = buildHarness();
|
||||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
|
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
|
||||||
@@ -148,14 +163,46 @@ describe("syncObservers", () => {
|
|||||||
expect(h.registerCalls).toEqual([WS1]);
|
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", () => {
|
test("tears down the subscription when a git workspace becomes non-git", () => {
|
||||||
const h = buildHarness();
|
const h = buildHarness();
|
||||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||||
h.service.syncObservers([
|
h.service.syncObservers([
|
||||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
makeDescriptor({
|
||||||
|
id: "ws1",
|
||||||
|
workspaceDirectory: WS1,
|
||||||
|
projectKind: "directory",
|
||||||
|
workspaceKind: "directory",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
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", () => {
|
describe("git snapshot listener", () => {
|
||||||
@@ -202,6 +249,13 @@ describe("shouldSkipUpdate", () => {
|
|||||||
expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true);
|
expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true);
|
||||||
expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false);
|
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", () => {
|
describe("recordDescriptorState", () => {
|
||||||
@@ -239,10 +293,25 @@ describe("teardown", () => {
|
|||||||
expect(h.unsubscribeCalls).toEqual([]);
|
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();
|
const h = buildHarness();
|
||||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
h.service.syncObservers([
|
||||||
h.service.removeForCwd(WS1);
|
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.unsubscribeCalls).toEqual([WS1]);
|
||||||
expect(() => h.emitSnapshot(WS1, "x")).toThrow();
|
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__";
|
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||||
|
|
||||||
interface WorkspaceGitWatchTarget {
|
interface WorkspaceGitWatchTarget {
|
||||||
|
workspaceIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceGitWatchState {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
workspaceId: string;
|
|
||||||
latestDescriptorStateKey: string | null;
|
latestDescriptorStateKey: string | null;
|
||||||
lastBranchName: string | null;
|
lastBranchName: string | null;
|
||||||
}
|
}
|
||||||
@@ -20,8 +23,9 @@ interface WorkspaceGitWatchTarget {
|
|||||||
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
|
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
|
||||||
* live update fan-out: branch-change notifications, workspace-card refreshes, and
|
* live update fan-out: branch-change notifications, workspace-card refreshes, and
|
||||||
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
|
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
|
||||||
* subscription handles, so the registration / dedupe / teardown lifecycle lives in one
|
* subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and
|
||||||
* module instead of being smeared across the client session.
|
* 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
|
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
|
||||||
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop
|
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop
|
||||||
@@ -37,7 +41,6 @@ export interface WorkspaceGitObserverService {
|
|||||||
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
|
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
|
||||||
handleBranchSnapshot(cwd: string, branchName: string | null): void;
|
handleBranchSnapshot(cwd: string, branchName: string | null): void;
|
||||||
removeForWorkspaceId(workspaceId: string): void;
|
removeForWorkspaceId(workspaceId: string): void;
|
||||||
removeForCwd(cwd: string): void;
|
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ export function createWorkspaceGitObserverService(deps: {
|
|||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||||
|
const workspaceStates = new Map<string, WorkspaceGitWatchState>();
|
||||||
const subscriptions = new Map<string, () => void>();
|
const subscriptions = new Map<string, () => void>();
|
||||||
|
|
||||||
function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
|
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(
|
function rememberDescriptorState(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
workspace: WorkspaceDescriptorPayload | null,
|
workspace: WorkspaceDescriptorPayload | null,
|
||||||
): void {
|
): void {
|
||||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
const state = workspaceStates.get(workspaceId);
|
||||||
if (!target) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestDescriptorStateKey = descriptorStateKey(workspace);
|
state.latestDescriptorStateKey = descriptorStateKey(workspace);
|
||||||
target.lastBranchName = workspace?.name ?? null;
|
state.lastBranchName = workspace?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeForCwd(cwd: string): void {
|
function removeForCwd(cwd: string): void {
|
||||||
const normalizedCwd = resolve(cwd);
|
const normalizedCwd = resolve(cwd);
|
||||||
|
const target = watchTargets.get(normalizedCwd);
|
||||||
|
for (const workspaceId of target?.workspaceIds ?? []) {
|
||||||
|
workspaceStates.delete(workspaceId);
|
||||||
|
}
|
||||||
watchTargets.delete(normalizedCwd);
|
watchTargets.delete(normalizedCwd);
|
||||||
subscriptions.get(normalizedCwd)?.();
|
subscriptions.get(normalizedCwd)?.();
|
||||||
subscriptions.delete(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 {
|
function handleBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||||
const target = watchTargets.get(resolve(cwd));
|
const target = watchTargets.get(resolve(cwd));
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousBranchName = target.lastBranchName;
|
for (const workspaceId of target.workspaceIds) {
|
||||||
if (branchName === previousBranchName) {
|
const state = workspaceStates.get(workspaceId);
|
||||||
return;
|
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 {
|
function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void {
|
||||||
const normalizedCwd = resolve(cwd);
|
const normalizedCwd = resolve(cwd);
|
||||||
|
const currentState = workspaceStates.get(options.workspaceId);
|
||||||
|
if (currentState && currentState.cwd !== normalizedCwd) {
|
||||||
|
removeForWorkspaceId(options.workspaceId);
|
||||||
|
}
|
||||||
if (!options.isGit) {
|
if (!options.isGit) {
|
||||||
removeForCwd(normalizedCwd);
|
removeForWorkspaceId(options.workspaceId);
|
||||||
return;
|
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)) {
|
if (subscriptions.has(normalizedCwd)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target: WorkspaceGitWatchTarget = {
|
let subscription: ReturnType<WorkspaceGitService["registerWorkspace"]>;
|
||||||
cwd: normalizedCwd,
|
try {
|
||||||
workspaceId: options.workspaceId,
|
subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
|
||||||
latestDescriptorStateKey: null,
|
|
||||||
lastBranchName: null,
|
|
||||||
};
|
|
||||||
watchTargets.set(normalizedCwd, target);
|
|
||||||
|
|
||||||
const subscription = workspaceGitService.registerWorkspace(
|
|
||||||
{ cwd: normalizedCwd },
|
|
||||||
(snapshot) => {
|
|
||||||
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
||||||
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
|
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -152,18 +178,21 @@ export function createWorkspaceGitObserverService(deps: {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
emitStatusUpdate(normalizedCwd, snapshot);
|
emitStatusUpdate(normalizedCwd, snapshot);
|
||||||
},
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
removeForWorkspaceId(options.workspaceId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
subscriptions.set(normalizedCwd, subscription.unsubscribe);
|
subscriptions.set(normalizedCwd, subscription.unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
syncObserver(workspace.workspaceDirectory, {
|
syncObserver(workspace.workspaceDirectory, {
|
||||||
isGit: workspace.projectKind === "git",
|
isGit: workspace.workspaceKind !== "directory",
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
rememberDescriptorState(workspace.workspaceDirectory, workspace);
|
rememberDescriptorState(workspace.id, workspace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,24 +211,24 @@ export function createWorkspaceGitObserverService(deps: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
shouldSkipUpdate(workspaceId, workspace) {
|
shouldSkipUpdate(workspaceId, workspace) {
|
||||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
const state = workspaceStates.get(workspaceId);
|
||||||
if (!target) {
|
if (!state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nextStateKey = descriptorStateKey(workspace);
|
const nextStateKey = descriptorStateKey(workspace);
|
||||||
if (target.latestDescriptorStateKey === nextStateKey) {
|
if (state.latestDescriptorStateKey === nextStateKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
target.latestDescriptorStateKey = nextStateKey;
|
state.latestDescriptorStateKey = nextStateKey;
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
recordDescriptorState(workspaceId, nextWorkspace) {
|
recordDescriptorState(workspaceId, nextWorkspace) {
|
||||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
const state = workspaceStates.get(workspaceId);
|
||||||
if (target && onBranchChanged) {
|
if (state && onBranchChanged) {
|
||||||
const newBranchName = nextWorkspace?.name ?? null;
|
const newBranchName = nextWorkspace?.name ?? null;
|
||||||
if (newBranchName !== target.lastBranchName) {
|
if (newBranchName !== state.lastBranchName) {
|
||||||
onBranchChanged(workspaceId, target.lastBranchName, newBranchName);
|
onBranchChanged(workspaceId, state.lastBranchName, newBranchName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rememberDescriptorState(workspaceId, nextWorkspace);
|
rememberDescriptorState(workspaceId, nextWorkspace);
|
||||||
@@ -207,14 +236,7 @@ export function createWorkspaceGitObserverService(deps: {
|
|||||||
|
|
||||||
handleBranchSnapshot,
|
handleBranchSnapshot,
|
||||||
|
|
||||||
removeForWorkspaceId(workspaceId) {
|
removeForWorkspaceId,
|
||||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
|
||||||
if (target) {
|
|
||||||
removeForCwd(target.cwd);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeForCwd,
|
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
for (const unsubscribe of subscriptions.values()) {
|
for (const unsubscribe of subscriptions.values()) {
|
||||||
@@ -222,6 +244,7 @@ export function createWorkspaceGitObserverService(deps: {
|
|||||||
}
|
}
|
||||||
subscriptions.clear();
|
subscriptions.clear();
|
||||||
watchTargets.clear();
|
watchTargets.clear();
|
||||||
|
workspaceStates.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
FileBackedProjectRegistry,
|
FileBackedProjectRegistry,
|
||||||
FileBackedWorkspaceRegistry,
|
FileBackedWorkspaceRegistry,
|
||||||
type PersistedProjectRecord,
|
type PersistedProjectRecord,
|
||||||
|
createPersistedWorkspaceRecord,
|
||||||
|
type WorkspaceRegistry,
|
||||||
} from "../../workspace-registry.js";
|
} from "../../workspace-registry.js";
|
||||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||||
import {
|
import {
|
||||||
createWorkspaceProvisioningService,
|
createWorkspaceProvisioningService,
|
||||||
|
WorkspaceProvisioningError,
|
||||||
type WorkspaceProvisioningService,
|
type WorkspaceProvisioningService,
|
||||||
} from "./workspace-provisioning-service.js";
|
} from "./workspace-provisioning-service.js";
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
|||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let gitRoots: Set<string>;
|
let gitRoots: Set<string>;
|
||||||
|
let gitBranches: Map<string, string | null>;
|
||||||
|
let checkoutFailure: Error | null;
|
||||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||||
let projectRegistry: FileBackedProjectRegistry;
|
let projectRegistry: FileBackedProjectRegistry;
|
||||||
let provisioning: WorkspaceProvisioningService;
|
let provisioning: WorkspaceProvisioningService;
|
||||||
@@ -35,6 +40,7 @@ function gitService() {
|
|||||||
return createNoopWorkspaceGitService({
|
return createNoopWorkspaceGitService({
|
||||||
peekSnapshot: () => null,
|
peekSnapshot: () => null,
|
||||||
getCheckout: async (cwd: string) => {
|
getCheckout: async (cwd: string) => {
|
||||||
|
if (checkoutFailure) throw checkoutFailure;
|
||||||
let worktreeRoot: string | null = null;
|
let worktreeRoot: string | null = null;
|
||||||
for (const root of gitRoots) {
|
for (const root of gitRoots) {
|
||||||
if (
|
if (
|
||||||
@@ -47,7 +53,7 @@ function gitService() {
|
|||||||
return {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
isGit: worktreeRoot !== null,
|
isGit: worktreeRoot !== null,
|
||||||
currentBranch: worktreeRoot ? "main" : null,
|
currentBranch: worktreeRoot ? (gitBranches.get(worktreeRoot) ?? "main") : null,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
@@ -60,6 +66,8 @@ function gitService() {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-"));
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-"));
|
||||||
gitRoots = new Set();
|
gitRoots = new Set();
|
||||||
|
gitBranches = new Map();
|
||||||
|
checkoutFailure = null;
|
||||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||||
path.join(tmpDir, "projects", "workspaces.json"),
|
path.join(tmpDir, "projects", "workspaces.json"),
|
||||||
logger,
|
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);
|
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 () => {
|
test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => {
|
||||||
const repo = path.join(tmpDir, "repo");
|
const repo = path.join(tmpDir, "repo");
|
||||||
gitRoots.add(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();
|
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 () => {
|
test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => {
|
||||||
const repo = path.join(tmpDir, "repo");
|
const repo = path.join(tmpDir, "repo");
|
||||||
gitRoots.add(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);
|
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");
|
const repo = path.join(tmpDir, "repo");
|
||||||
gitRoots.add(repo);
|
gitRoots.add(repo);
|
||||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(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();
|
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 () => {
|
test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => {
|
||||||
// The branch only reads workspace.workspaceId off the worktree result.
|
// The branch only reads workspace.workspaceId off the worktree result.
|
||||||
const createdWorktree = {
|
const createdWorktree = {
|
||||||
@@ -207,15 +403,90 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
|
|||||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
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");
|
const repo = path.join(tmpDir, "repo");
|
||||||
gitRoots.add(repo);
|
gitRoots.add(repo);
|
||||||
|
|
||||||
const first = await provisioning.findOrCreateProjectForDirectory(repo);
|
const first = await provisioning.findOrCreateProjectForDirectory(repo);
|
||||||
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
|
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
|
||||||
|
|
||||||
expect(second.projectId).toBe(first.projectId);
|
expect(second.projectId).not.toBe(first.projectId);
|
||||||
expect(await projectRegistry.list()).toHaveLength(1);
|
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 () => {
|
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 type { Logger } from "pino";
|
||||||
import {
|
import {
|
||||||
checkoutLiteFromGitSnapshot,
|
|
||||||
classifyDirectoryForProjectMembership,
|
|
||||||
generateWorkspaceId,
|
generateWorkspaceId,
|
||||||
|
initialWorkspacePlacement,
|
||||||
|
reconcileWorkspacePlacement,
|
||||||
} from "../../workspace-registry-model.js";
|
} from "../../workspace-registry-model.js";
|
||||||
import {
|
import {
|
||||||
createPersistedProjectRecord,
|
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
type PersistedProjectRecord,
|
type PersistedProjectRecord,
|
||||||
type PersistedWorkspaceRecord,
|
type PersistedWorkspaceRecord,
|
||||||
@@ -15,20 +14,8 @@ import {
|
|||||||
} from "../../workspace-registry.js";
|
} from "../../workspace-registry.js";
|
||||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.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 {
|
export interface ResolveOrCreateWorkspaceIdInput {
|
||||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||||
requestedWorkspaceId?: string;
|
requestedWorkspaceId?: string;
|
||||||
@@ -46,6 +33,17 @@ export interface ImportWorkspaceResult<T> {
|
|||||||
createdWorkspace: PersistedWorkspaceRecord | null;
|
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 {
|
export interface WorkspaceProvisioningService {
|
||||||
runInImportWorkspace<T>(
|
runInImportWorkspace<T>(
|
||||||
input: ImportWorkspaceInput,
|
input: ImportWorkspaceInput,
|
||||||
@@ -56,6 +54,10 @@ export interface WorkspaceProvisioningService {
|
|||||||
createWorkspaceForDirectory(
|
createWorkspaceForDirectory(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
title?: string | null,
|
title?: string | null,
|
||||||
|
projectId?: string,
|
||||||
|
): Promise<PersistedWorkspaceRecord>;
|
||||||
|
createWorkspaceForWorktree(
|
||||||
|
input: CreateWorktreeWorkspaceInput,
|
||||||
): Promise<PersistedWorkspaceRecord>;
|
): Promise<PersistedWorkspaceRecord>;
|
||||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||||
ensureWorkspaceRecordUnarchived(
|
ensureWorkspaceRecordUnarchived(
|
||||||
@@ -63,6 +65,22 @@ export interface WorkspaceProvisioningService {
|
|||||||
): Promise<PersistedWorkspaceRecord>;
|
): 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: {
|
export function createWorkspaceProvisioningService(deps: {
|
||||||
workspaceRegistry: WorkspaceRegistry;
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
projectRegistry: ProjectRegistry;
|
projectRegistry: ProjectRegistry;
|
||||||
@@ -134,224 +152,220 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveWorkspaceDirectory(
|
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||||
cwd: string,
|
const rootPath = resolve(cwd);
|
||||||
options?: { refreshGit?: boolean },
|
const checkout = await workspaceGitService.getCheckout(rootPath);
|
||||||
): 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 });
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const projectRecord = await resolveProjectRecordForPlacement({
|
return projectRegistry.getOrCreateActiveByRoot({
|
||||||
membership,
|
rootPath,
|
||||||
|
kind: checkout.isGit ? "git" : "non_git",
|
||||||
|
displayName: basename(rootPath) || rootPath,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
const projectId = projectRecord.projectId;
|
}
|
||||||
const kind = membership.workspaceKind;
|
|
||||||
const displayName = membership.workspaceDisplayName;
|
|
||||||
|
|
||||||
if (
|
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
|
||||||
input.workspace.projectId === projectId &&
|
const project = await projectRegistry.get(projectId);
|
||||||
input.workspace.kind === kind &&
|
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
|
||||||
input.workspace.displayName === displayName
|
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
|
||||||
) {
|
return project;
|
||||||
if (!input.project) {
|
}
|
||||||
await projectRegistry.upsert(projectRecord);
|
|
||||||
}
|
async function createWorkspaceForDirectory(
|
||||||
return ensureWorkspaceRecordUnarchived(input.workspace);
|
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 = {
|
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||||
...input.workspace,
|
rootPath: input.repoRoot,
|
||||||
projectId,
|
kind: "git",
|
||||||
cwd: input.cwd,
|
displayName: basename(input.repoRoot) || input.repoRoot,
|
||||||
kind,
|
timestamp: new Date().toISOString(),
|
||||||
displayName,
|
});
|
||||||
archivedAt: null,
|
return refreshProjectKind(project);
|
||||||
updatedAt: timestamp,
|
|
||||||
};
|
|
||||||
await workspaceRegistry.upsert(nextWorkspace);
|
|
||||||
return nextWorkspace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||||
const inputCwd = resolve(cwd);
|
const normalizedCwd = resolve(cwd);
|
||||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd);
|
const workspaces = await workspaceRegistry.list();
|
||||||
const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, {
|
const active = workspaces
|
||||||
refreshGit: false,
|
.filter(
|
||||||
});
|
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||||
if (existingWorkspace) {
|
)
|
||||||
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
|
.sort(
|
||||||
const timestamp = new Date().toISOString();
|
(left, right) =>
|
||||||
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
|
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||||
isGit: false,
|
left.workspaceId.localeCompare(right.workspaceId),
|
||||||
currentBranch: null,
|
)[0];
|
||||||
remoteUrl: null,
|
if (active) return refreshWorkspaceRecord(active);
|
||||||
repoRoot: null,
|
const archived = workspaces
|
||||||
isPaseoOwnedWorktree: false,
|
.filter(
|
||||||
mainRepoRoot: null,
|
(workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||||
});
|
)
|
||||||
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
|
.sort(
|
||||||
const projectRecord = await resolveProjectRecordForPlacement({
|
(left, right) =>
|
||||||
membership,
|
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||||
timestamp,
|
left.workspaceId.localeCompare(right.workspaceId),
|
||||||
});
|
)[0];
|
||||||
await projectRegistry.upsert(projectRecord);
|
if (archived) {
|
||||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
const project = await projectRegistry.get(archived.projectId);
|
||||||
workspaceId: generateWorkspaceId(),
|
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return createWorkspaceForDirectory(normalizedCwd);
|
return createWorkspaceForDirectory(normalizedCwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOrCreateWorkspaceIdForCreateAgent(
|
async function resolveOrCreateWorkspaceIdForCreateAgent(
|
||||||
input: ResolveOrCreateWorkspaceIdInput,
|
input: ResolveOrCreateWorkspaceIdInput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (input.createdWorktree) {
|
if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId;
|
||||||
return input.createdWorktree.workspace.workspaceId;
|
if (input.requestedWorkspaceId) return input.requestedWorkspaceId;
|
||||||
}
|
|
||||||
|
|
||||||
if (input.requestedWorkspaceId) {
|
|
||||||
return input.requestedWorkspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
|
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(
|
async function ensureWorkspaceRecordUnarchived(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
const project = await projectRegistry.get(workspace.projectId);
|
const project = await projectRegistry.get(workspace.projectId);
|
||||||
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
|
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
|
||||||
return workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
let unarchivedWorkspace = workspace;
|
const checkout =
|
||||||
if (workspace.archivedAt) {
|
workspace.archivedAt || project.archivedAt
|
||||||
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
|
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||||
await workspaceRegistry.upsert(unarchivedWorkspace);
|
: null;
|
||||||
}
|
let next: PersistedWorkspaceRecord | null = null;
|
||||||
if (project?.archivedAt) {
|
if (workspace.archivedAt && checkout) {
|
||||||
await projectRegistry.upsert({
|
const placementUpdate = reconcileWorkspacePlacement({
|
||||||
...project,
|
workspace,
|
||||||
archivedAt: null,
|
checkout,
|
||||||
updatedAt: timestamp,
|
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 {
|
return {
|
||||||
@@ -359,6 +373,7 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
findOrCreateWorkspaceForDirectory,
|
findOrCreateWorkspaceForDirectory,
|
||||||
resolveOrCreateWorkspaceIdForCreateAgent,
|
resolveOrCreateWorkspaceIdForCreateAgent,
|
||||||
createWorkspaceForDirectory,
|
createWorkspaceForDirectory,
|
||||||
|
createWorkspaceForWorktree,
|
||||||
findOrCreateProjectForDirectory,
|
findOrCreateProjectForDirectory,
|
||||||
ensureWorkspaceRecordUnarchived,
|
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 {
|
import {
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
@@ -8,15 +23,23 @@ import {
|
|||||||
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
|
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
|
||||||
|
|
||||||
const NOW = "2026-07-11T10:12:30.752Z";
|
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({
|
return createPersistedProjectRecord({
|
||||||
projectId: "/repo",
|
projectId: "/project",
|
||||||
rootPath: "/repo",
|
rootPath: "/project",
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "project",
|
||||||
createdAt: NOW,
|
createdAt: NOW,
|
||||||
updatedAt: NOW,
|
updatedAt: NOW,
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,12 +48,15 @@ function createWorkspace(
|
|||||||
): PersistedWorkspaceRecord {
|
): PersistedWorkspaceRecord {
|
||||||
return createPersistedWorkspaceRecord({
|
return createPersistedWorkspaceRecord({
|
||||||
workspaceId: "wks_15a1b5630ebaab33",
|
workspaceId: "wks_15a1b5630ebaab33",
|
||||||
projectId: "/repo",
|
projectId: "/project",
|
||||||
cwd: "/worktrees/trigger-1525443412986298439",
|
cwd: "/worktrees/trigger-1525443412986298439",
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
displayName: "diagnose-repro-tdd",
|
displayName: "diagnose-repro-tdd",
|
||||||
title: "Codex TDD reproduction",
|
title: "TDD reproduction",
|
||||||
branch: "diagnose-repro-tdd",
|
branch: "diagnose-repro-tdd",
|
||||||
|
worktreeRoot: "/worktrees/trigger-1525443412986298439",
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
mainRepoRoot: "/repo",
|
||||||
createdAt: NOW,
|
createdAt: NOW,
|
||||||
updatedAt: NOW,
|
updatedAt: NOW,
|
||||||
archivedAt: NOW,
|
archivedAt: NOW,
|
||||||
@@ -42,55 +68,53 @@ function createHarness(input?: {
|
|||||||
workspace?: PersistedWorkspaceRecord | null;
|
workspace?: PersistedWorkspaceRecord | null;
|
||||||
project?: PersistedProjectRecord | null;
|
project?: PersistedProjectRecord | null;
|
||||||
directories?: string[];
|
directories?: string[];
|
||||||
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
paseoHome?: string;
|
||||||
|
worktreesRoot?: string;
|
||||||
}) {
|
}) {
|
||||||
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
|
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
|
||||||
const project = input?.project === undefined ? createProject() : input.project;
|
const project = input?.project === undefined ? createProject() : input.project;
|
||||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||||
const unarchived: string[] = [];
|
const unarchived: string[] = [];
|
||||||
const recreated: string[] = [];
|
|
||||||
const service = createWorkspaceRecoveryService({
|
const service = createWorkspaceRecoveryService({
|
||||||
|
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||||
|
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||||
getWorkspace: async (workspaceId) =>
|
getWorkspace: async (workspaceId) =>
|
||||||
workspace?.workspaceId === workspaceId ? workspace : null,
|
workspace?.workspaceId === workspaceId ? workspace : null,
|
||||||
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
|
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
|
||||||
isDirectory: async (path) => directories.has(path),
|
isDirectory: async (path) => directories.has(path),
|
||||||
recreateWorktree: async (record) => {
|
|
||||||
recreated.push(record.workspaceId);
|
|
||||||
await input?.recreate?.(record);
|
|
||||||
},
|
|
||||||
unarchiveWorkspace: async (record) => {
|
unarchiveWorkspace: async (record) => {
|
||||||
unarchived.push(record.workspaceId);
|
unarchived.push(record.workspaceId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { service, recreated, unarchived };
|
return { service, unarchived };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("workspace recovery", () => {
|
describe("workspace recovery", () => {
|
||||||
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
|
test("describes a missing archived worktree from persisted placement", async () => {
|
||||||
const { service, recreated, unarchived } = createHarness();
|
const { service, unarchived } = createHarness();
|
||||||
|
|
||||||
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
|
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||||
kind: "recoverable",
|
kind: "recoverable",
|
||||||
workspaceId: "wks_15a1b5630ebaab33",
|
workspaceId: "wks_15a1b5630ebaab33",
|
||||||
workspaceName: "Codex TDD reproduction",
|
workspaceName: "TDD reproduction",
|
||||||
action: "restore",
|
action: "restore",
|
||||||
branch: "diagnose-repro-tdd",
|
branch: "diagnose-repro-tdd",
|
||||||
});
|
});
|
||||||
expect(recreated).toEqual([]);
|
|
||||||
expect(unarchived).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 workspace = createWorkspace({ kind: "directory", branch: null });
|
||||||
const { service } = createHarness({
|
const { service, unarchived } = createHarness({
|
||||||
workspace,
|
workspace,
|
||||||
directories: ["/repo", workspace.cwd],
|
directories: [workspace.cwd],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
|
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||||
kind: "recoverable",
|
workspaceId: workspace.workspaceId,
|
||||||
action: "unarchive",
|
action: "unarchive",
|
||||||
});
|
});
|
||||||
|
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
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 () => {
|
test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => {
|
||||||
let attempts = 0;
|
const { tempDir, repoDir } = createGitRepository();
|
||||||
const { service, recreated, unarchived } = createHarness({
|
const branch = "feature/mixed-project";
|
||||||
recreate: async () => {
|
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||||
attempts += 1;
|
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||||
if (attempts === 1) {
|
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||||
throw new Error("git branch diagnose-repro-tdd is unavailable");
|
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(
|
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||||
"git branch diagnose-repro-tdd is unavailable",
|
workspaceId: workspace.workspaceId,
|
||||||
);
|
|
||||||
expect(unarchived).toEqual([]);
|
|
||||||
|
|
||||||
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
|
|
||||||
workspaceId: "wks_15a1b5630ebaab33",
|
|
||||||
action: "restore",
|
action: "restore",
|
||||||
});
|
});
|
||||||
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
|
expect(existsSync(worktreeRoot)).toBe(true);
|
||||||
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
|
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 {
|
import {
|
||||||
resolveWorkspaceDisplayName,
|
resolveWorkspaceDisplayName,
|
||||||
type PersistedProjectRecord,
|
type PersistedProjectRecord,
|
||||||
@@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService {
|
|||||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
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: {
|
export function createWorkspaceRecoveryService(deps: {
|
||||||
|
paseoHome: string;
|
||||||
|
worktreesRoot?: string;
|
||||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||||
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
|
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
|
||||||
isDirectory: (path: string) => Promise<boolean>;
|
isDirectory: (path: string) => Promise<boolean>;
|
||||||
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
|
||||||
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||||
}): WorkspaceRecoveryService {
|
}): WorkspaceRecoveryService {
|
||||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
async function resolveRecovery(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<UnavailableRecoveryState | RecoveryPlan> {
|
||||||
const workspace = await deps.getWorkspace(workspaceId);
|
const workspace = await deps.getWorkspace(workspaceId);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return {
|
return {
|
||||||
@@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (await deps.isDirectory(workspace.cwd)) {
|
if (await deps.isDirectory(workspace.cwd)) {
|
||||||
return {
|
return createRecoveryPlan({ action: "unarchive", workspace });
|
||||||
kind: "recoverable",
|
|
||||||
workspaceId,
|
|
||||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
|
||||||
action: "unarchive",
|
|
||||||
branch: workspace.branch,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.kind !== "worktree") {
|
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.",
|
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 {
|
return {
|
||||||
kind: "unavailable",
|
kind: "unavailable",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
reason: "project_directory_missing",
|
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 {
|
return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot });
|
||||||
kind: "recoverable",
|
}
|
||||||
workspaceId,
|
|
||||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||||
action: "restore",
|
const resolved = await resolveRecovery(workspaceId);
|
||||||
branch: workspace.branch,
|
return resolved.kind === "unavailable" ? resolved : resolved.state;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(
|
async function restore(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||||
const state = await inspect(workspaceId);
|
const resolved = await resolveRecovery(workspaceId);
|
||||||
if (state.kind === "unavailable") {
|
if (resolved.kind === "unavailable") {
|
||||||
throw new Error(state.message);
|
throw new Error(resolved.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await deps.getWorkspace(workspaceId);
|
if (resolved.kind === "restore") {
|
||||||
if (!workspace?.archivedAt) {
|
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
|
||||||
throw new Error("The archived workspace changed before it could be recovered.");
|
|
||||||
}
|
}
|
||||||
if (state.action === "restore") {
|
await deps.unarchiveWorkspace(resolved.workspace);
|
||||||
await deps.recreateWorktree(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 };
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { pino } from "pino";
|
import { pino } from "pino";
|
||||||
@@ -6,34 +6,29 @@ import { afterEach, describe, expect, test } from "vitest";
|
|||||||
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
|
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
|
||||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
||||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
|
import type {
|
||||||
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
|
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 { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||||
import type {
|
import type {
|
||||||
SpawnWorkspaceScriptOptions,
|
SpawnWorkspaceScriptOptions,
|
||||||
WorktreeScriptResult,
|
WorktreeScriptResult,
|
||||||
} from "../../worktree-bootstrap.js";
|
} from "../../worktree-bootstrap.js";
|
||||||
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
import { createWorkspaceScriptsService } from "./workspace-scripts-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
|
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||||
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
// 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.
|
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||||
|
|
||||||
const logger = pino({ level: "silent" });
|
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(
|
function fakeWorkspaceRegistry(
|
||||||
record: PersistedWorkspaceRecord | null,
|
record: PersistedWorkspaceRecord | null,
|
||||||
): Pick<WorkspaceRegistry, "get"> {
|
): 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 {
|
return {
|
||||||
peekSnapshot() {
|
peekSnapshot() {
|
||||||
return null;
|
return snapshot;
|
||||||
},
|
|
||||||
async getWorkspaceGitMetadata() {
|
|
||||||
return metadata;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,7 +74,9 @@ interface BuildOptions {
|
|||||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
|
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
workspace?: PersistedWorkspaceRecord | null;
|
workspace?: PersistedWorkspaceRecord | null;
|
||||||
|
project?: PersistedProjectRecord | null;
|
||||||
spawnThrows?: string;
|
spawnThrows?: string;
|
||||||
|
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildService(options: BuildOptions = {}) {
|
function buildService(options: BuildOptions = {}) {
|
||||||
@@ -87,7 +99,8 @@ function buildService(options: BuildOptions = {}) {
|
|||||||
terminalManager:
|
terminalManager:
|
||||||
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||||
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||||
workspaceGitService: fakeGitService(),
|
projectRegistry: fakeProjectRegistry(options.project ?? null),
|
||||||
|
workspaceGitService: options.gitService ?? fakeGitService(),
|
||||||
getDaemonTcpPort: () => 6767,
|
getDaemonTcpPort: () => 6767,
|
||||||
getDaemonTcpHost: () => "127.0.0.1",
|
getDaemonTcpHost: () => "127.0.0.1",
|
||||||
serviceProxyPublicBaseUrl: null,
|
serviceProxyPublicBaseUrl: null,
|
||||||
@@ -130,28 +143,77 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSnapshot", () => {
|
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 });
|
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 });
|
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-"));
|
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const { service } = buildService();
|
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", () => {
|
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();
|
const { service, emitted } = buildService();
|
||||||
service.emitStatusUpdate("ws-1", "/tmp/repo");
|
await service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
|
{ 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 () => {
|
test("reports the launcher error when spawning fails", async () => {
|
||||||
const { service, emitted } = buildService({ spawnThrows: "boom" });
|
const { service, emitted } = buildService({ spawnThrows: "boom" });
|
||||||
await service.start(request);
|
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 { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||||
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
||||||
import type { WorkspaceGitService } from "../../workspace-git-service.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 {
|
import type {
|
||||||
SpawnWorkspaceScriptOptions,
|
SpawnWorkspaceScriptOptions,
|
||||||
WorktreeScriptResult,
|
WorktreeScriptResult,
|
||||||
@@ -18,15 +23,10 @@ import {
|
|||||||
buildWorkspaceScriptPayloads,
|
buildWorkspaceScriptPayloads,
|
||||||
readPaseoConfigForProjection,
|
readPaseoConfigForProjection,
|
||||||
} from "../../script-status-projection.js";
|
} from "../../script-status-projection.js";
|
||||||
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
|
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||||
|
|
||||||
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||||
|
|
||||||
interface WorkspaceScriptGitMetadata {
|
|
||||||
projectSlug: string;
|
|
||||||
currentBranch: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
||||||
* snapshot, emit a script_status_update to clients, and start a script.
|
* 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.
|
* that assembly and guard across the session.
|
||||||
*/
|
*/
|
||||||
export interface WorkspaceScriptsService {
|
export interface WorkspaceScriptsService {
|
||||||
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
|
buildSnapshot(
|
||||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
|
workspace: PersistedWorkspaceRecord,
|
||||||
|
project?: PersistedProjectRecord | null,
|
||||||
|
): WorkspaceScriptsPayload;
|
||||||
|
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
||||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceScriptsGitSource = Pick<
|
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
|
||||||
WorkspaceGitService,
|
|
||||||
"peekSnapshot" | "getWorkspaceGitMetadata"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function createWorkspaceScriptsService(deps: {
|
export function createWorkspaceScriptsService(deps: {
|
||||||
serviceProxy: ServiceProxySubsystem | null;
|
serviceProxy: ServiceProxySubsystem | null;
|
||||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
||||||
|
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||||
workspaceGitService: WorkspaceScriptsGitSource;
|
workspaceGitService: WorkspaceScriptsGitSource;
|
||||||
getDaemonTcpPort: (() => number | null) | null;
|
getDaemonTcpPort: (() => number | null) | null;
|
||||||
getDaemonTcpHost: (() => string | null) | null;
|
getDaemonTcpHost: (() => string | null) | null;
|
||||||
@@ -66,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
scriptRuntimeStore,
|
scriptRuntimeStore,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
|
projectRegistry,
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
getDaemonTcpPort,
|
getDaemonTcpPort,
|
||||||
getDaemonTcpHost,
|
getDaemonTcpHost,
|
||||||
@@ -76,45 +78,60 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
spawnWorkspaceScript,
|
spawnWorkspaceScript,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
|
function resolveGitMetadata(
|
||||||
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
workspace: PersistedWorkspaceRecord,
|
||||||
if (!snapshot) {
|
project: { projectId: string; rootPath: string } | null,
|
||||||
return undefined;
|
) {
|
||||||
|
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 {
|
return {
|
||||||
projectSlug: deriveProjectSlug(
|
projectSlug: deriveProjectSlug(
|
||||||
workspaceDirectory,
|
workspace.cwd,
|
||||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
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) {
|
if (!serviceProxy || !scriptRuntimeStore) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildWorkspaceScriptPayloads({
|
return buildWorkspaceScriptPayloads({
|
||||||
workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
workspaceDirectory,
|
workspaceDirectory: workspace.cwd,
|
||||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
|
||||||
serviceProxy,
|
serviceProxy,
|
||||||
runtimeStore: scriptRuntimeStore,
|
runtimeStore: scriptRuntimeStore,
|
||||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||||
serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl,
|
||||||
gitMetadata: resolveGitMetadata(workspaceDirectory),
|
gitMetadata: resolveGitMetadata(workspace, project),
|
||||||
resolveHealth: resolveScriptHealth ?? undefined,
|
resolveHealth: resolveScriptHealth ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> {
|
||||||
emit({
|
try {
|
||||||
type: "script_status_update",
|
const workspace = await workspaceRegistry.get(workspaceId);
|
||||||
payload: {
|
if (!workspace) return;
|
||||||
workspaceId,
|
const project = await projectRegistry.get(workspace.projectId);
|
||||||
scripts: buildSnapshot(workspaceId, workspaceDirectory),
|
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> {
|
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||||
@@ -127,13 +144,23 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
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({
|
const serviceResult = await spawnWorkspaceScript({
|
||||||
repoRoot: workspace.cwd,
|
repoRoot: workspace.cwd,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
projectSlug: gitMetadata.projectSlug,
|
projectSlug,
|
||||||
branchName: gitMetadata.currentBranch,
|
branchName,
|
||||||
scriptName: request.scriptName,
|
scriptName: request.scriptName,
|
||||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||||
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
||||||
@@ -143,11 +170,11 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
terminalManager,
|
terminalManager,
|
||||||
logger,
|
logger,
|
||||||
onLifecycleChanged: () => {
|
onLifecycleChanged: () => {
|
||||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
emit({
|
emit({
|
||||||
type: "start_workspace_script_response",
|
type: "start_workspace_script_response",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { basename } from "node:path";
|
|
||||||
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
||||||
import {
|
import { deriveProjectSlug } from "../workspace-git-metadata.js";
|
||||||
buildWorkspaceGitMetadataFromSnapshot,
|
|
||||||
type WorkspaceGitMetadata,
|
|
||||||
} from "../workspace-git-metadata.js";
|
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
|
|
||||||
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
@@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService(
|
|||||||
registerWorkspace: () => ({
|
registerWorkspace: () => ({
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
|
onSnapshotUpdated: () => ({
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
peekSnapshot: () => null,
|
peekSnapshot: () => null,
|
||||||
getCheckout: async (cwd: string) => ({
|
getCheckout: async (cwd: string) => ({
|
||||||
cwd,
|
cwd,
|
||||||
@@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService(
|
|||||||
suggestBranchesForCwd: async () => [],
|
suggestBranchesForCwd: async () => [],
|
||||||
listStashes: async () => [],
|
listStashes: async () => [],
|
||||||
listWorktrees: async () => [],
|
listWorktrees: async () => [],
|
||||||
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
|
getProjectSlug: async (cwd: string) => {
|
||||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||||
return buildWorkspaceGitMetadataFromSnapshot({
|
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
resolveForge: async () => null,
|
resolveForge: async () => null,
|
||||||
resolveRepoRoot: async (cwd: string) => cwd,
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
|
|||||||
@@ -921,6 +921,20 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
|
||||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||||
const server = createServer({ speechReadiness });
|
const server = createServer({ speechReadiness });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
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 { hostname as getHostname } from "node:os";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { monitorEventLoopDelay } from "node:perf_hooks";
|
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 { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
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 { FileBackedChatService } from "./chat/chat-service.js";
|
||||||
import type { LoopService } from "./loop-service.js";
|
import type { LoopService } from "./loop-service.js";
|
||||||
import type { ScheduleService } from "./schedule/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 { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import type { WorkspaceAutoName } from "./workspace-auto-name.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 { PushTokenStore } from "./push/token-store.js";
|
||||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||||
@@ -190,17 +191,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
|||||||
suggestBranchesForCwd: async () => [],
|
suggestBranchesForCwd: async () => [],
|
||||||
listStashes: async () => [],
|
listStashes: async () => [],
|
||||||
listWorktrees: async () => [],
|
listWorktrees: async () => [],
|
||||||
getWorkspaceGitMetadata: async (cwd: string, options) => {
|
getProjectSlug: async (cwd: string) => {
|
||||||
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
||||||
return buildWorkspaceGitMetadataFromSnapshot({
|
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
resolveRepoRoot: async (cwd: string) => cwd,
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
resolveDefaultBranch: async () => "main",
|
resolveDefaultBranch: async () => "main",
|
||||||
@@ -223,6 +216,16 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
get: async () => null,
|
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 () => {},
|
upsert: async () => {},
|
||||||
archive: async () => {},
|
archive: async () => {},
|
||||||
remove: 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 {
|
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||||
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
||||||
}
|
}
|
||||||
@@ -1293,6 +1300,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
forgeProviders: true,
|
forgeProviders: true,
|
||||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||||
selectiveAgentTimeline: true,
|
selectiveAgentTimeline: true,
|
||||||
|
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||||
|
stableProjectIdentity: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import {
|
|||||||
AgentSnapshotPayloadSchema,
|
AgentSnapshotPayloadSchema,
|
||||||
AgentTimelineItemPayloadSchema,
|
AgentTimelineItemPayloadSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
|
ServerInfoStatusPayloadSchema,
|
||||||
SessionInboundMessageSchema,
|
SessionInboundMessageSchema,
|
||||||
|
SessionOutboundMessageSchema,
|
||||||
type SessionOutboundMessage,
|
type SessionOutboundMessage,
|
||||||
|
WSHelloMessageSchema,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { Session, type SessionOptions } from "./session.js";
|
import { Session, type SessionOptions } from "./session.js";
|
||||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||||
import type { AgentTimelineRow } from "./agent/agent-manager.js";
|
import type { AgentTimelineRow } from "./agent/agent-manager.js";
|
||||||
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
|
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
|
||||||
|
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
const LegacyTimelineEntryPayloadSchema = z.object({
|
const LegacyTimelineEntryPayloadSchema = z.object({
|
||||||
provider: z.enum(["claude", "codex", "opencode"]),
|
provider: z.enum(["claude", "codex", "opencode"]),
|
||||||
@@ -288,8 +292,8 @@ function createSessionForWireCompatTest(options?: {
|
|||||||
async resolveRepoRemoteUrl() {
|
async resolveRepoRemoteUrl() {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
async getWorkspaceGitMetadata() {
|
async getProjectSlug() {
|
||||||
return null;
|
return "project";
|
||||||
},
|
},
|
||||||
} as unknown as SessionOptions["workspaceGitService"],
|
} as unknown as SessionOptions["workspaceGitService"],
|
||||||
daemonConfigStore:
|
daemonConfigStore:
|
||||||
@@ -326,6 +330,99 @@ async function emitTimelineResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("wire compatibility", () => {
|
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", () => {
|
test("assistant timeline message ids are optional on the wire", () => {
|
||||||
expect(
|
expect(
|
||||||
AgentTimelineItemPayloadSchema.parse({
|
AgentTimelineItemPayloadSchema.parse({
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino, { type Logger } from "pino";
|
import pino, { type Logger } from "pino";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { ForgeService } from "../services/forge-service.js";
|
import type { ForgeService } from "../services/forge-service.js";
|
||||||
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
|
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
|
||||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
@@ -205,7 +206,6 @@ describe("archiveByScope", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-last-ref-workspace",
|
requestId: "req-last-ref-workspace",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -217,8 +217,23 @@ describe("archiveByScope", () => {
|
|||||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
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();
|
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 paseoHome = path.join(tempDir, ".paseo");
|
||||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace");
|
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace");
|
||||||
const workspaceA = "ws-sibling-a";
|
const workspaceA = "ws-sibling-a";
|
||||||
@@ -234,7 +249,6 @@ describe("archiveByScope", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId: workspaceA },
|
scope: { kind: "workspace", workspaceId: workspaceA },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-sibling-workspace",
|
requestId: "req-sibling-workspace",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -244,34 +258,228 @@ describe("archiveByScope", () => {
|
|||||||
removedDirectory: false,
|
removedDirectory: false,
|
||||||
});
|
});
|
||||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
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 { tempDir, repoDir } = createGitRepo();
|
||||||
const paseoHome = path.join(tempDir, ".paseo");
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
|
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-sibling");
|
||||||
const workspaceA = "ws-worktree-a";
|
const sourceWorkspaceId = "ws-subdirectory-source";
|
||||||
const workspaceB = "ws-worktree-b";
|
const siblingWorkspaceId = "ws-subdirectory-sibling";
|
||||||
|
const siblingDirectory = path.join(worktree.worktreePath, "packages", "app");
|
||||||
|
mkdirSync(siblingDirectory, { recursive: true });
|
||||||
|
|
||||||
const result = await archiveByScope(
|
const result = await archiveByScope(
|
||||||
createArchiveDeps({
|
createArchiveDeps({
|
||||||
paseoHome,
|
paseoHome,
|
||||||
activeWorkspaces: [
|
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 },
|
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-worktree-scope",
|
requestId: "req-worktree-scope",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB]));
|
expect(result.archivedWorkspaceIds).toEqual(
|
||||||
expect(result.archivedWorkspaceIds).toHaveLength(2);
|
expect.arrayContaining([workspaceA, workspaceB, workspaceC]),
|
||||||
|
);
|
||||||
|
expect(result.archivedWorkspaceIds).toHaveLength(3);
|
||||||
expect(result.removedDirectory).toBe(true);
|
expect(result.removedDirectory).toBe(true);
|
||||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
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 () => {
|
test("workspace scope never removes a non-Paseo-owned directory", async () => {
|
||||||
@@ -286,7 +494,6 @@ describe("archiveByScope", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: null,
|
|
||||||
requestId: "req-local-checkout",
|
requestId: "req-local-checkout",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -322,7 +529,6 @@ describe("archiveByScope", () => {
|
|||||||
|
|
||||||
const result = await archiveByScope(deps, {
|
const result = await archiveByScope(deps, {
|
||||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-partial-failure",
|
requestId: "req-partial-failure",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,7 +553,6 @@ describe("archiveByScope", () => {
|
|||||||
|
|
||||||
const result = await archiveByScope(deps, {
|
const result = await archiveByScope(deps, {
|
||||||
scope: { kind: "workspace", workspaceId: "ws-does-not-exist" },
|
scope: { kind: "workspace", workspaceId: "ws-does-not-exist" },
|
||||||
repoRoot: null,
|
|
||||||
requestId: "req-unknown-workspace",
|
requestId: "req-unknown-workspace",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,7 +577,6 @@ describe("archiveByScope", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-zero-records",
|
requestId: "req-zero-records",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -452,7 +656,6 @@ describe("archiveByScope", () => {
|
|||||||
|
|
||||||
await archiveByScope(deps, {
|
await archiveByScope(deps, {
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-lifecycle",
|
requestId: "req-lifecycle",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -506,7 +709,6 @@ describe("archiveByScope", () => {
|
|||||||
|
|
||||||
const result = await archiveByScope(deps, {
|
const result = await archiveByScope(deps, {
|
||||||
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
|
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-snapshot-scope",
|
requestId: "req-snapshot-scope",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -540,7 +742,6 @@ describe("archiveByScope", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||||
repoRoot: repoDir,
|
|
||||||
requestId: "req-worktree-scope-n3",
|
requestId: "req-worktree-scope-n3",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ import type { ForgeService } from "../services/forge-service.js";
|
|||||||
import {
|
import {
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
resolvePaseoWorktreeRootForCwd,
|
runWorktreeTeardownCommands,
|
||||||
WorktreeTeardownError,
|
WorktreeTeardownError,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
|
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
|
||||||
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
|
|
||||||
export interface ActiveWorkspaceRef {
|
export type ActiveWorkspaceRef = Pick<
|
||||||
workspaceId: string;
|
PersistedWorkspaceRecord,
|
||||||
cwd: string;
|
"workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot"
|
||||||
kind?: "local_checkout" | "worktree" | "directory";
|
>;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveDependencies {
|
export interface ArchiveDependencies {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
// Base directory that may hold worktrees across repositories. Used as a fallback
|
// Base directory that may hold worktrees across repositories.
|
||||||
// when the request does not supply a per-repo root.
|
|
||||||
paseoWorktreesBaseRoot?: string;
|
paseoWorktreesBaseRoot?: string;
|
||||||
github: ForgeService;
|
github: ForgeService;
|
||||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||||
@@ -65,22 +64,29 @@ export interface ArchiveResult {
|
|||||||
|
|
||||||
export interface ArchiveByScopeRequest {
|
export interface ArchiveByScopeRequest {
|
||||||
scope: ArchiveScope;
|
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;
|
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(
|
export async function resolveWorkspaceIdAtPath(
|
||||||
dependencies: Pick<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
|
dependencies: Pick<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const targetDir = resolve(targetPath);
|
const matchesTarget = createRealpathAwarePathMatcher(targetPath);
|
||||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
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");
|
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
|
||||||
if (worktreeMatch) {
|
if (worktreeMatch) {
|
||||||
return worktreeMatch.workspaceId;
|
return worktreeMatch.workspaceId;
|
||||||
@@ -88,18 +94,15 @@ export async function resolveWorkspaceIdAtPath(
|
|||||||
return dependencies.findWorkspaceIdForCwd(targetPath);
|
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
|
// (agents + terminals + record), then removes the backing directory iff it is
|
||||||
// Paseo-owned AND no active workspace still references it.
|
// Paseo-owned AND no active workspace still references it.
|
||||||
export async function archiveByScope(
|
export async function archiveByScope(
|
||||||
dependencies: ArchiveDependencies,
|
dependencies: ArchiveDependencies,
|
||||||
request: ArchiveByScopeRequest,
|
request: ArchiveByScopeRequest,
|
||||||
): Promise<ArchiveResult> {
|
): Promise<ArchiveResult> {
|
||||||
const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets(
|
const target = await resolveArchiveTarget(dependencies, request.scope);
|
||||||
dependencies,
|
const targetWorkspaceIds = target.workspaceIds;
|
||||||
request.scope,
|
|
||||||
request.paseoWorktreesBaseRoot,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetWorkspaceIds.length > 0) {
|
if (targetWorkspaceIds.length > 0) {
|
||||||
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
|
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
|
||||||
@@ -118,25 +121,25 @@ export async function archiveByScope(
|
|||||||
request.requestId,
|
request.requestId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (request.repoRoot) {
|
if (target.backing?.mainRepoRoot) {
|
||||||
try {
|
try {
|
||||||
await dependencies.workspaceGitService.getSnapshot(request.repoRoot, {
|
await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "archive-worktree",
|
reason: "archive-worktree",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dependencies.sessionLogger?.warn(
|
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",
|
"Failed to force-refresh workspace git snapshot after archiving",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetDir !== null) {
|
if (target.backing !== null) {
|
||||||
removedDirectory = await maybeRemoveDirectory(
|
removedDirectory = await maybeRemoveDirectory(
|
||||||
dependencies,
|
dependencies,
|
||||||
request,
|
request,
|
||||||
targetDir,
|
target,
|
||||||
archivedWorkspaceIds,
|
archivedWorkspaceIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,11 +157,10 @@ export async function archiveByScope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveArchiveTargets(
|
async function resolveArchiveTarget(
|
||||||
dependencies: ArchiveDependencies,
|
dependencies: ArchiveDependencies,
|
||||||
scope: ArchiveScope,
|
scope: ArchiveScope,
|
||||||
paseoWorktreesBaseRoot?: string,
|
): Promise<ArchiveTarget> {
|
||||||
): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> {
|
|
||||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
||||||
|
|
||||||
if (scope.kind === "workspace") {
|
if (scope.kind === "workspace") {
|
||||||
@@ -169,24 +171,89 @@ async function resolveArchiveTargets(
|
|||||||
{ workspaceId },
|
{ workspaceId },
|
||||||
"Workspace not found for archive-by-scope; skipping",
|
"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 backing = await resolveBackingDirectory(scope.targetPath, dependencies);
|
||||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
const matchesBackingDirectory = createRealpathAwarePathMatcher(backing.path);
|
||||||
paseoHome: dependencies.paseoHome,
|
const targetWorkspaces = (
|
||||||
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
await Promise.all(
|
||||||
});
|
activeWorkspaces.map(async (workspace) => {
|
||||||
if (resolvedWorktree) {
|
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
|
||||||
targetPath = resolvedWorktree.worktreePath;
|
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);
|
if (workspace.kind !== "worktree") {
|
||||||
const targetWorkspaceIds = activeWorkspaces
|
return {
|
||||||
.filter((workspace) => resolve(workspace.cwd) === targetDir)
|
path: resolve(workspace.cwd),
|
||||||
.map((workspace) => workspace.workspaceId);
|
isPaseoOwnedWorktree: false,
|
||||||
return { targetDir, targetWorkspaceIds };
|
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(
|
async function archiveTargetRecords(
|
||||||
@@ -224,37 +291,72 @@ async function archiveTargetRecords(
|
|||||||
|
|
||||||
async function maybeRemoveDirectory(
|
async function maybeRemoveDirectory(
|
||||||
dependencies: ArchiveDependencies,
|
dependencies: ArchiveDependencies,
|
||||||
request: Omit<ArchiveByScopeRequest, "scope">,
|
request: Pick<ArchiveByScopeRequest, "requestId">,
|
||||||
targetDir: string,
|
target: ArchiveTarget,
|
||||||
archivedWorkspaceIds: string[],
|
archivedWorkspaceIds: string[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const ownership = await isPaseoOwnedWorktreeCwd(targetDir, {
|
const backing = target.backing;
|
||||||
paseoHome: dependencies.paseoHome,
|
if (!backing?.isPaseoOwnedWorktree) {
|
||||||
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
|
||||||
});
|
|
||||||
if (!ownership.allowed) {
|
|
||||||
return false;
|
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();
|
const remainingActive = await dependencies.listActiveWorkspaces();
|
||||||
if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) {
|
if (
|
||||||
|
!(await isDirectoryUnreferenced(
|
||||||
|
remainingActive,
|
||||||
|
backing.path,
|
||||||
|
new Set(archivedWorkspaceIds),
|
||||||
|
dependencies,
|
||||||
|
))
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deletePaseoWorktree({
|
await deletePaseoWorktree({
|
||||||
cwd: request.repoRoot,
|
cwd: backing.mainRepoRoot,
|
||||||
worktreePath: targetDir,
|
worktreePath: backing.path,
|
||||||
worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot,
|
teardownCwds: [],
|
||||||
|
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
|
||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||||
});
|
});
|
||||||
dependencies.github.invalidate({ cwd: targetDir });
|
dependencies.github.invalidate({ cwd: backing.path });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WorktreeTeardownError) {
|
if (error instanceof WorktreeTeardownError) {
|
||||||
dependencies.sessionLogger?.warn(
|
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",
|
"Worktree disk removal failed during archive; workspace already archived",
|
||||||
);
|
);
|
||||||
return false;
|
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<
|
export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||||
ArchiveDependencies,
|
ArchiveDependencies,
|
||||||
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
|
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
|
||||||
@@ -323,19 +435,23 @@ export async function archiveWorkspaceContents(
|
|||||||
return archivedAgents;
|
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
|
// the in-scope records, no active workspace still points at targetDir. Derived
|
||||||
// from records each call — no stored counter.
|
// from records each call — no stored counter.
|
||||||
function isDirectoryUnreferenced(
|
async function isDirectoryUnreferenced(
|
||||||
activeWorkspaces: ActiveWorkspaceRef[],
|
activeWorkspaces: ActiveWorkspaceRef[],
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
archivedWorkspaceIds: ReadonlySet<string>,
|
archivedWorkspaceIds: ReadonlySet<string>,
|
||||||
): boolean {
|
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||||
|
): Promise<boolean> {
|
||||||
const target = resolve(targetDir);
|
const target = resolve(targetDir);
|
||||||
return !activeWorkspaces.some(
|
const matchesTarget = createRealpathAwarePathMatcher(target);
|
||||||
(workspace) =>
|
for (const workspace of activeWorkspaces) {
|
||||||
!archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target,
|
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
|
||||||
);
|
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
|
||||||
|
if (matchesTarget(backingDirectory.path)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killTerminalsForWorkspace(
|
export async function killTerminalsForWorkspace(
|
||||||
|
|||||||
@@ -108,13 +108,14 @@ export class WorkspaceAutoName {
|
|||||||
firstAgentContext: FirstAgentContext;
|
firstAgentContext: FirstAgentContext;
|
||||||
currentSelection: CurrentSelection;
|
currentSelection: CurrentSelection;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const worktreeRoot = input.workspace.worktreeRoot ?? input.workspace.cwd;
|
||||||
let generated: GeneratedWorkspaceName | null = null;
|
let generated: GeneratedWorkspaceName | null = null;
|
||||||
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
|
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
|
||||||
cwd: input.workspace.cwd,
|
cwd: worktreeRoot,
|
||||||
firstAgentContext: input.firstAgentContext,
|
firstAgentContext: input.firstAgentContext,
|
||||||
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
|
generateBranchNameFromContext: ({ firstAgentContext }) => {
|
||||||
return this.generateFromContext({
|
return this.generateFromContext({
|
||||||
cwd,
|
cwd: input.workspace.cwd,
|
||||||
firstAgentContext,
|
firstAgentContext,
|
||||||
currentSelection: input.currentSelection,
|
currentSelection: input.currentSelection,
|
||||||
}).then((nextGenerated) => {
|
}).then((nextGenerated) => {
|
||||||
@@ -146,7 +147,7 @@ export class WorkspaceAutoName {
|
|||||||
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||||
});
|
});
|
||||||
if (result.renamed) {
|
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);
|
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export function workspaceIdsOnCheckout(
|
|||||||
.map((workspace) => workspace.workspaceId);
|
.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 {
|
export class WorkspaceDirectory {
|
||||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildWorkspaceGitMetadataFromSnapshot,
|
deriveProjectServiceSlug,
|
||||||
deriveProjectSlug,
|
deriveProjectSlug,
|
||||||
parseGitHubRepoNameFromRemote,
|
parseGitHubRepoNameFromRemote,
|
||||||
} from "./workspace-git-metadata.js";
|
} from "./workspace-git-metadata.js";
|
||||||
@@ -171,46 +171,12 @@ describe("deriveProjectSlug", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorkspaceGitMetadataFromSnapshot", () => {
|
describe("deriveProjectServiceSlug", () => {
|
||||||
test("uses owner/repo as the display name for GitHub remotes", () => {
|
test("keeps same-basename exact projects distinct and stable", () => {
|
||||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
const first = { projectId: "prj_aaaaaaaaaaaaaaaa", rootPath: "/repo-a/app" };
|
||||||
cwd: "/repos/some-dir",
|
const second = { projectId: "prj_bbbbbbbbbbbbbbbb", rootPath: "/repo-b/app" };
|
||||||
directoryName: "some-dir",
|
|
||||||
isGit: true,
|
|
||||||
repoRoot: "/repos/some-dir",
|
|
||||||
mainRepoRoot: null,
|
|
||||||
currentBranch: "main",
|
|
||||||
remoteUrl: "git@github.com:acme/widgets.git",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projectDisplayName).toBe("acme/widgets");
|
expect(deriveProjectServiceSlug(first)).toBe(deriveProjectServiceSlug(first));
|
||||||
});
|
expect(deriveProjectServiceSlug(first)).not.toBe(deriveProjectServiceSlug(second));
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { basename } from "path";
|
import { basename } from "path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
|
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
|
||||||
import { slugify } from "../utils/worktree.js";
|
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 {
|
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||||
return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null;
|
return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null;
|
||||||
@@ -34,49 +22,7 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null):
|
|||||||
return slugify(sourceName) || "untitled";
|
return slugify(sourceName) || "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkspaceGitMetadataFromSnapshot(input: {
|
export function deriveProjectServiceSlug(project: { projectId: string; rootPath: string }): string {
|
||||||
cwd: string;
|
const identity = createHash("sha256").update(project.projectId).digest("hex").slice(0, 8);
|
||||||
directoryName: string;
|
return `${deriveProjectSlug(project.rootPath)}-${identity}`;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,6 +390,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
vi.useRealTimers();
|
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 () => {
|
test("getSnapshot returns the current snapshot without shelling out", async () => {
|
||||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
@@ -1735,7 +1747,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
service.dispose();
|
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;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||||
createCheckoutStatus(cwd, {
|
createCheckoutStatus(cwd, {
|
||||||
@@ -1749,22 +1761,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(service.getProjectSlug(REPO_CWD)).resolves.toBe("paseo");
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
|
await service.getProjectSlug(join(REPO_CWD, "."));
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { watch, type FSWatcher } from "node:fs";
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
import { readFile, readdir } from "node:fs/promises";
|
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 { LRUCache } from "lru-cache";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
import type pino from "pino";
|
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 { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
import {
|
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||||
buildWorkspaceGitMetadataFromSnapshot,
|
|
||||||
type WorkspaceGitMetadata,
|
|
||||||
} from "./workspace-git-metadata.js";
|
|
||||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
@@ -162,10 +159,7 @@ export interface WorkspaceGitService {
|
|||||||
cwdOrRepoRoot: string,
|
cwdOrRepoRoot: string,
|
||||||
options?: WorkspaceGitReadOptions,
|
options?: WorkspaceGitReadOptions,
|
||||||
): Promise<WorkspaceGitWorktreeInfo[]>;
|
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||||
getWorkspaceGitMetadata(
|
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||||
cwd: string,
|
|
||||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
|
||||||
): Promise<WorkspaceGitMetadata>;
|
|
||||||
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||||
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||||
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
|
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
|
||||||
@@ -473,31 +467,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||||
const normalizedCwd = resolve(cwd);
|
const normalizedCwd = resolve(cwd);
|
||||||
try {
|
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
||||||
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
paseoHome: this.paseoHome,
|
||||||
paseoHome: this.paseoHome,
|
worktreesRoot: this.worktreesRoot,
|
||||||
worktreesRoot: this.worktreesRoot,
|
logger: this.logger,
|
||||||
logger: this.logger,
|
});
|
||||||
});
|
if (!status.isGit) {
|
||||||
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 {
|
|
||||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||||
isGit: false,
|
isGit: false,
|
||||||
currentBranch: null,
|
currentBranch: null,
|
||||||
@@ -507,6 +482,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
mainRepoRoot: null,
|
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 {
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||||
@@ -654,21 +637,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaceGitMetadata(
|
async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string> {
|
||||||
cwd: string,
|
|
||||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
|
||||||
): Promise<WorkspaceGitMetadata> {
|
|
||||||
const snapshot = await this.getSnapshot(cwd, options);
|
const snapshot = await this.getSnapshot(cwd, options);
|
||||||
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
|
return deriveProjectSlug(resolve(cwd), snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveRepoRemoteUrl(
|
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 { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
@@ -14,7 +15,10 @@ import type {
|
|||||||
ProjectRegistry,
|
ProjectRegistry,
|
||||||
WorkspaceRegistry,
|
WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
import {
|
||||||
|
type ReconciliationChange,
|
||||||
|
WorkspaceReconciliationService,
|
||||||
|
} from "./workspace-reconciliation-service.js";
|
||||||
|
|
||||||
function createTestRegistries() {
|
function createTestRegistries() {
|
||||||
const projects = new Map<string, PersistedProjectRecord>();
|
const projects = new Map<string, PersistedProjectRecord>();
|
||||||
@@ -25,6 +29,22 @@ function createTestRegistries() {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => Array.from(projects.values()),
|
list: async () => Array.from(projects.values()),
|
||||||
get: async (id: string) => projects.get(id) ?? null,
|
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) => {
|
upsert: async (record: PersistedProjectRecord) => {
|
||||||
projects.set(record.projectId, record);
|
projects.set(record.projectId, record);
|
||||||
},
|
},
|
||||||
@@ -64,11 +84,11 @@ function createTestRegistries() {
|
|||||||
function createTestLogger() {
|
function createTestLogger() {
|
||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
trace: vi.fn(),
|
trace: () => undefined,
|
||||||
debug: vi.fn(),
|
debug: () => undefined,
|
||||||
info: vi.fn(),
|
info: () => undefined,
|
||||||
warn: vi.fn(),
|
warn: () => undefined,
|
||||||
error: vi.fn(),
|
error: () => undefined,
|
||||||
};
|
};
|
||||||
return logger as unknown as pino.Logger;
|
return logger as unknown as pino.Logger;
|
||||||
}
|
}
|
||||||
@@ -106,35 +126,62 @@ function createWorkspaceGitServiceStub(
|
|||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => {
|
getCheckout: async (cwd: string) => {
|
||||||
const metadata = metadataByCwd[cwd];
|
const metadata = metadataByCwd[cwd];
|
||||||
const directoryName = options?.directoryName ?? path.basename(cwd);
|
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
return {
|
return {
|
||||||
projectKind: "directory" as const,
|
cwd,
|
||||||
projectDisplayName: directoryName,
|
isGit: false as const,
|
||||||
workspaceDisplayName: directoryName,
|
|
||||||
gitRemote: null,
|
|
||||||
isWorktree: false,
|
|
||||||
projectSlug: "untitled",
|
|
||||||
repoRoot: null,
|
|
||||||
currentBranch: null,
|
currentBranch: null,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
gitRemote: metadata.gitRemote ?? null,
|
cwd,
|
||||||
isWorktree: false,
|
isGit: metadata.projectKind === "git",
|
||||||
projectSlug: "repo",
|
currentBranch: metadata.currentBranch ?? metadata.workspaceDisplayName,
|
||||||
repoRoot: cwd,
|
|
||||||
currentBranch: metadata.workspaceDisplayName,
|
|
||||||
remoteUrl: metadata.gitRemote ?? null,
|
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 {
|
function initGitRepoInDir(dir: string): void {
|
||||||
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
||||||
execFileSync("git", ["config", "user.email", "test@test.com"], { 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;
|
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 () => {
|
test("archives workspaces whose directories no longer exist", async () => {
|
||||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||||
|
|
||||||
@@ -213,17 +516,15 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
test("keeps a project active after all its workspaces are archived", async () => {
|
test("keeps a project active after all its workspaces are archived", async () => {
|
||||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||||
|
|
||||||
projects.set(
|
const project = createPersistedProjectRecord({
|
||||||
"p1",
|
projectId: "p1",
|
||||||
createPersistedProjectRecord({
|
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||||
projectId: "p1",
|
kind: "non_git",
|
||||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
displayName: "orphan",
|
||||||
kind: "non_git",
|
createdAt: timestamp,
|
||||||
displayName: "orphan",
|
updatedAt: timestamp,
|
||||||
createdAt: timestamp,
|
});
|
||||||
updatedAt: timestamp,
|
projects.set(project.projectId, project);
|
||||||
}),
|
|
||||||
);
|
|
||||||
workspaces.set(
|
workspaces.set(
|
||||||
"w1",
|
"w1",
|
||||||
createPersistedWorkspaceRecord({
|
createPersistedWorkspaceRecord({
|
||||||
@@ -245,9 +546,32 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
|
|
||||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
expect(result.changesApplied).toEqual([
|
||||||
expect(projChange).toBeUndefined();
|
{
|
||||||
expect(projects.get("p1")!.archivedAt).toBeFalsy();
|
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 () => {
|
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");
|
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-");
|
const repoDir = createTempGitRepo("reconcile-duplicate-project-");
|
||||||
tempDirs.push(repoDir);
|
tempDirs.push(repoDir);
|
||||||
const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat");
|
const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat");
|
||||||
@@ -442,33 +766,35 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
|
|
||||||
expect(result.changesApplied).toEqual(
|
expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([
|
||||||
expect.arrayContaining([
|
"workspace_updated",
|
||||||
expect.objectContaining({
|
"workspace_updated",
|
||||||
kind: "workspace_updated",
|
]);
|
||||||
workspaceId: "gigantic-blowfish",
|
expect(projects.get("remote:github.com/blank-dot-page/editor")).toMatchObject({
|
||||||
fields: { projectId: "remote:github.com/blank-dot-page/editor" },
|
projectId: "remote:github.com/blank-dot-page/editor",
|
||||||
}),
|
rootPath: repoDir,
|
||||||
expect.objectContaining({
|
displayName: "blank-dot-page/editor",
|
||||||
kind: "project_updated",
|
customName: null,
|
||||||
projectId: "remote:github.com/blank-dot-page/editor",
|
archivedAt: null,
|
||||||
fields: { customName: "Editor" },
|
});
|
||||||
}),
|
expect(projects.get(repoDir)).toMatchObject({
|
||||||
expect.objectContaining({
|
projectId: repoDir,
|
||||||
kind: "project_archived",
|
rootPath: repoDir,
|
||||||
projectId: repoDir,
|
displayName: "editor",
|
||||||
reason: "merged_duplicate",
|
customName: "Editor",
|
||||||
}),
|
archivedAt: null,
|
||||||
]),
|
});
|
||||||
);
|
expect(workspaces.get("focused-bat")).toMatchObject({
|
||||||
expect(workspaces.get("gigantic-blowfish")!.projectId).toBe(
|
projectId: "remote:github.com/blank-dot-page/editor",
|
||||||
"remote:github.com/blank-dot-page/editor",
|
archivedAt: null,
|
||||||
);
|
});
|
||||||
expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor");
|
expect(workspaces.get("gigantic-blowfish")).toMatchObject({
|
||||||
expect(projects.get(repoDir)!.archivedAt).toBeTruthy();
|
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-");
|
const dir = createTempGitRepo("reconcile-remote-");
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
@@ -520,12 +846,11 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
|
|
||||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined();
|
||||||
expect(projUpdate).toBeDefined();
|
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-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-");
|
const dir = createTempGitRepo("reconcile-customname-");
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
@@ -577,10 +902,151 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
|
|
||||||
await service.runOnce();
|
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");
|
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 () => {
|
test("updates workspace branch metadata without clobbering the workspace name", async () => {
|
||||||
const dir = createTempGitRepo("reconcile-branch-");
|
const dir = createTempGitRepo("reconcile-branch-");
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
@@ -707,18 +1173,24 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onChanges = vi.fn();
|
const reportedChanges: ReconciliationChange[] = [];
|
||||||
const service = new WorkspaceReconciliationService({
|
const service = new WorkspaceReconciliationService({
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
onChanges,
|
onChanges: (changes) => reportedChanges.push(...changes),
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.runOnce();
|
await service.runOnce();
|
||||||
|
|
||||||
expect(onChanges).toHaveBeenCalledTimes(1);
|
expect(reportedChanges).toEqual([
|
||||||
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
|
{
|
||||||
|
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 () => {
|
test("logs reconciliation changes with affected paths and reasons", async () => {
|
||||||
@@ -791,4 +1263,69 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
|
|
||||||
expect(infoRecords).toEqual([]);
|
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 { statSync, watch as watchPath } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type {
|
import type {
|
||||||
ProjectRegistry,
|
ProjectRegistry,
|
||||||
@@ -8,49 +8,71 @@ import type {
|
|||||||
PersistedWorkspaceRecord,
|
PersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.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: {
|
export type ProjectUpdate =
|
||||||
projectKind: "git" | "directory";
|
| { kind: "upsert"; project: PersistedProjectRecord }
|
||||||
isWorktree: boolean;
|
| { kind: "remove"; projectId: string };
|
||||||
}): PersistedWorkspaceRecord["kind"] {
|
|
||||||
if (metadata.projectKind !== "git") return "directory";
|
interface ProjectRootWatcher {
|
||||||
if (metadata.isWorktree) return "worktree";
|
close(): void;
|
||||||
return "local_checkout";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord {
|
export interface ProjectRootWatch {
|
||||||
return [...projects].sort((left, right) => {
|
(
|
||||||
const leftRemote = left.projectId.startsWith("remote:");
|
rootPath: string,
|
||||||
const rightRemote = right.projectId.startsWith("remote:");
|
options: { recursive: false },
|
||||||
if (leftRemote !== rightRemote) {
|
onChange: (event: string, filename: string | Buffer | null) => void,
|
||||||
return leftRemote ? -1 : 1;
|
onError: (error: Error) => void,
|
||||||
}
|
): ProjectRootWatcher;
|
||||||
const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
|
||||||
if (createdAt !== 0) {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
return left.projectId.localeCompare(right.projectId);
|
|
||||||
})[0]!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
export type ReconciliationChange =
|
||||||
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||||
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
|
||||||
| {
|
| {
|
||||||
kind: "project_updated";
|
kind: "project_updated";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<
|
fields: Partial<Pick<PersistedProjectRecord, "kind">>;
|
||||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "workspace_updated";
|
kind: "workspace_updated";
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
|
fields: Partial<MutableWorkspacePlacement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReconciliationResult {
|
export interface ReconciliationResult {
|
||||||
@@ -62,62 +84,130 @@ export interface WorkspaceReconciliationServiceOptions {
|
|||||||
projectRegistry: ProjectRegistry;
|
projectRegistry: ProjectRegistry;
|
||||||
workspaceRegistry: WorkspaceRegistry;
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
intervalMs?: number;
|
|
||||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
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 {
|
export class WorkspaceReconciliationService {
|
||||||
private readonly projectRegistry: ProjectRegistry;
|
private readonly projectRegistry: ProjectRegistry;
|
||||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly intervalMs: number;
|
|
||||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getWorkspaceGitMetadata"> | null;
|
private readonly workspaceGitService: Pick<WorkspaceGitService, "getCheckout"> | null;
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private readonly onProjectUpdate: ((update: ProjectUpdate) => void) | null;
|
||||||
private running = false;
|
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) {
|
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||||
this.projectRegistry = options.projectRegistry;
|
this.projectRegistry = options.projectRegistry;
|
||||||
this.workspaceRegistry = options.workspaceRegistry;
|
this.workspaceRegistry = options.workspaceRegistry;
|
||||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
|
||||||
this.onChanges = options.onChanges ?? null;
|
this.onChanges = options.onChanges ?? null;
|
||||||
this.workspaceGitService = options.workspaceGitService ?? 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 {
|
async start(): Promise<void> {
|
||||||
if (this.timer) return;
|
if (this.started) return;
|
||||||
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
|
this.started = true;
|
||||||
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
|
this.unsubscribeRegistry =
|
||||||
// Run once immediately on start
|
this.projectRegistry.subscribeToMutations?.(async (mutation) => {
|
||||||
void this.runSafe();
|
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 {
|
dispose(): void {
|
||||||
if (this.timer) {
|
this.disposed = true;
|
||||||
clearInterval(this.timer);
|
this.unsubscribeRegistry?.();
|
||||||
this.timer = null;
|
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> {
|
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 start = Date.now();
|
||||||
const changes: ReconciliationChange[] = [];
|
const changes: ReconciliationChange[] = [];
|
||||||
|
|
||||||
@@ -126,16 +216,23 @@ export class WorkspaceReconciliationService {
|
|||||||
|
|
||||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.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[]>();
|
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) ?? [];
|
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||||
list.push(workspace);
|
list.push(workspace);
|
||||||
workspacesByProject.set(workspace.projectId, list);
|
workspacesByProject.set(workspace.projectId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Archive workspaces whose directories no longer exist
|
// 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(
|
await Promise.all(
|
||||||
missingWorkspaces.map(async (workspace) => {
|
missingWorkspaces.map(async (workspace) => {
|
||||||
const timestamp = new Date().toISOString();
|
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.
|
// 2. Reconcile mutable git metadata without changing identity or membership.
|
||||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
|
||||||
|
|
||||||
// 3. Reconcile git metadata for active projects whose directories still exist.
|
|
||||||
// Projects persist until explicitly removed, even when they currently have
|
// Projects persist until explicitly removed, even when they currently have
|
||||||
// zero active workspaces, so they still reconcile their own metadata.
|
// zero active workspaces, so they still reconcile their own metadata.
|
||||||
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
await this.reconcileGitMetadataForProjects(
|
||||||
// don't resurrect them by upserting a stale, non-archived copy.
|
activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"),
|
||||||
const archivedProjectIds = new Set(
|
workspacesByProject,
|
||||||
changes
|
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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (changes.length > 0 && this.onChanges) {
|
if (changes.length > 0 && this.onChanges) {
|
||||||
@@ -188,136 +269,72 @@ export class WorkspaceReconciliationService {
|
|||||||
const result = { changesApplied: changes, durationMs: Date.now() - start };
|
const result = { changesApplied: changes, durationMs: Date.now() - start };
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{ changeCount: changes.length, durationMs: result.durationMs, changes },
|
||||||
changeCount: changes.length,
|
|
||||||
durationMs: result.durationMs,
|
|
||||||
changes,
|
|
||||||
},
|
|
||||||
"Workspace reconciliation applied changes",
|
"Workspace reconciliation applied changes",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async mergeDuplicateProjectsByRoot(
|
private async reconcileGitMetadataForProjects(
|
||||||
activeProjects: PersistedProjectRecord[],
|
projectsToReconcile: PersistedProjectRecord[],
|
||||||
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
||||||
changes: ReconciliationChange[],
|
changes: ReconciliationChange[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const projectsByRoot = new Map<string, PersistedProjectRecord[]>();
|
const checkoutReads: CachedCheckoutRead[] = [];
|
||||||
for (const project of activeProjects) {
|
const readCheckout = (cwd: string): Promise<ProjectCheckoutLitePayload> => {
|
||||||
if (project.kind !== "git") {
|
const existing = checkoutReads.find((read) => areEquivalentPaths(read.cwd, cwd));
|
||||||
continue;
|
if (existing) return existing.checkout;
|
||||||
}
|
const checkout = this.readCheckout(cwd);
|
||||||
const rootKey = resolve(project.rootPath);
|
checkoutReads.push({ cwd, checkout });
|
||||||
const group = projectsByRoot.get(rootKey) ?? [];
|
return checkout;
|
||||||
group.push(project);
|
};
|
||||||
projectsByRoot.set(rootKey, group);
|
const roots: Array<{ rootPath: string; projects: PersistedProjectRecord[] }> = [];
|
||||||
}
|
for (const project of projectsToReconcile) {
|
||||||
|
const root = roots.find((candidate) =>
|
||||||
for (const duplicates of projectsByRoot.values()) {
|
areEquivalentPaths(candidate.rootPath, project.rootPath),
|
||||||
if (duplicates.length < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const canonical = chooseCanonicalProject(duplicates);
|
|
||||||
const duplicateProjects = duplicates.filter(
|
|
||||||
(project) => project.projectId !== canonical.projectId,
|
|
||||||
);
|
);
|
||||||
await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes);
|
if (root) root.projects.push(project);
|
||||||
await Promise.all(
|
else roots.push({ rootPath: project.rootPath, projects: [project] });
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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(
|
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
|
||||||
canonical: PersistedProjectRecord,
|
const { project, siblings, currentGit, readCheckout, changes } = input;
|
||||||
duplicateProjects: PersistedProjectRecord[],
|
const workspaceCheckouts = await Promise.all(
|
||||||
changes: ReconciliationChange[],
|
siblings.map(async (workspace) => ({
|
||||||
): Promise<void> {
|
workspace,
|
||||||
if (canonical.customName) {
|
checkout: await readCheckout(workspace.cwd),
|
||||||
return;
|
})),
|
||||||
}
|
);
|
||||||
const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null;
|
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
|
||||||
if (!customName) {
|
const mappedKind = deriveProjectKind(currentGit);
|
||||||
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";
|
|
||||||
|
|
||||||
if (project.kind !== mappedKind) {
|
if (project.kind !== mappedKind) {
|
||||||
projectUpdates.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) {
|
if (Object.keys(projectUpdates).length > 0) {
|
||||||
@@ -335,58 +352,163 @@ export class WorkspaceReconciliationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd));
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
existingSiblings.map(async (workspace) => {
|
workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
await this.workspaceRegistry.upsert({
|
const update = reconcileWorkspacePlacement({
|
||||||
...workspace,
|
workspace,
|
||||||
...workspaceUpdates,
|
checkout: wsGit,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
});
|
});
|
||||||
|
if (!update) return;
|
||||||
|
|
||||||
|
await this.workspaceRegistry.upsert(update.workspace);
|
||||||
changes.push({
|
changes.push({
|
||||||
kind: "workspace_updated",
|
kind: "workspace_updated",
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
directory: workspace.cwd,
|
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) {
|
if (!this.workspaceGitService) {
|
||||||
return {
|
return {
|
||||||
projectKind: "directory" as const,
|
cwd,
|
||||||
projectDisplayName: directoryName,
|
isGit: false as const,
|
||||||
workspaceDisplayName: directoryName,
|
|
||||||
gitRemote: null,
|
|
||||||
isWorktree: false,
|
|
||||||
projectSlug: "untitled",
|
|
||||||
repoRoot: null,
|
|
||||||
currentBranch: null,
|
currentBranch: null,
|
||||||
remoteUrl: 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 os from "node:os";
|
||||||
import path from "node:path";
|
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";
|
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 { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
|
|
||||||
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
|
let NON_GIT_PROJECT: string;
|
||||||
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
|
let ARCHIVED_PROJECT: string;
|
||||||
|
let GIT_PROJECT: string;
|
||||||
|
let GIT_WORKTREE: string;
|
||||||
|
|
||||||
describe("bootstrapWorkspaceRegistries", () => {
|
describe("bootstrapWorkspaceRegistries", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
@@ -25,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
|
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");
|
paseoHome = path.join(tmpDir, ".paseo");
|
||||||
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||||
projectRegistry = new FileBackedProjectRegistry(
|
projectRegistry = new FileBackedProjectRegistry(
|
||||||
@@ -36,12 +42,133 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
workspaceGitService = createNoopWorkspaceGitService();
|
workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) {
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
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 () => {
|
test("materializes workspace registries from non-archived agent records", async () => {
|
||||||
await agentStorage.initialize();
|
await agentStorage.initialize();
|
||||||
await agentStorage.upsert({
|
await agentStorage.upsert({
|
||||||
@@ -175,6 +302,81 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
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 () => {
|
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
|
||||||
await projectRegistry.initialize();
|
await projectRegistry.initialize();
|
||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import {
|
import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js";
|
||||||
classifyDirectoryForProjectMembership,
|
import { generateWorkspaceId } from "./workspace-registry-model.js";
|
||||||
generateWorkspaceId,
|
|
||||||
} from "./workspace-registry-model.js";
|
|
||||||
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
|
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +71,17 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const records = await options.agentStorage.list();
|
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<
|
const recordsByDirectoryKey = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,226 +1,30 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { basename, isAbsolute, resolve } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
classifyDirectoryForProjectMembership,
|
checkoutFromPersistedWorkspacePlacement,
|
||||||
deriveProjectGroupingName,
|
|
||||||
deriveProjectRootPath,
|
|
||||||
deriveWorkspaceDirectoryKey,
|
|
||||||
deriveWorkspaceKind,
|
deriveWorkspaceKind,
|
||||||
detectStaleWorkspaces,
|
|
||||||
generateWorkspaceId,
|
generateWorkspaceId,
|
||||||
|
generateProjectId,
|
||||||
|
initialWorkspacePlacement,
|
||||||
|
reconcileWorkspacePlacement,
|
||||||
} from "./workspace-registry-model.js";
|
} from "./workspace-registry-model.js";
|
||||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
function createWorkspaceRecord(
|
describe("opaque registry ids", () => {
|
||||||
cwd: string,
|
test("generates opaque project ids", () => {
|
||||||
workspaceId: string,
|
expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
test("generates opaque workspace ids that are not filesystem paths", () => {
|
||||||
const workspaceId = generateWorkspaceId();
|
const workspaceId = generateWorkspaceId();
|
||||||
|
|
||||||
expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/);
|
expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/);
|
||||||
expect(isAbsolute(workspaceId)).toBe(false);
|
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", () => {
|
describe("workspace kind", () => {
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies plain git worktrees as workspaces of kind worktree", () => {
|
test("classifies plain git worktrees as workspaces of kind worktree", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveWorkspaceKind({
|
deriveWorkspaceKind({
|
||||||
@@ -235,3 +39,119 @@ describe("git worktree grouping", () => {
|
|||||||
).toBe("worktree");
|
).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 { randomBytes } from "node:crypto";
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ProjectCheckoutLitePayload,
|
ProjectCheckoutLitePayload,
|
||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
|
||||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
export type PersistedProjectKind = "git" | "non_git";
|
export type PersistedProjectKind = "git" | "non_git";
|
||||||
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
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 {
|
export function generateWorkspaceId(): string {
|
||||||
return `wks_${randomBytes(8).toString("hex")}`;
|
return `wks_${randomBytes(8).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path-derived grouping key for a workspace directory. This is NOT the opaque
|
export function generateProjectId(): string {
|
||||||
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
|
return `prj_${randomBytes(8).toString("hex")}`;
|
||||||
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 deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
|
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
|
||||||
@@ -162,6 +28,161 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
|||||||
return checkout.mainRepoRoot ? "worktree" : "local_checkout";
|
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(
|
export function checkoutLiteFromGitSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
git: {
|
git: {
|
||||||
@@ -205,70 +226,3 @@ export function checkoutLiteFromGitSnapshot(
|
|||||||
mainRepoRoot: git.mainRepoRoot,
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
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";
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
@@ -99,43 +99,228 @@ describe("workspace registries", () => {
|
|||||||
expect(await projectRegistry.list()).toEqual([]);
|
expect(await projectRegistry.list()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("PIN: two checkouts of the same git remote collapse into a single project record", async () => {
|
test("publishes only project mutations that change the persisted lifecycle", 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.
|
|
||||||
await projectRegistry.initialize();
|
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(
|
await projectRegistry.upsert(
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
projectId: remoteKey,
|
projectId: "remote:github.com/acme/repo",
|
||||||
rootPath: "/home/me/work/repo",
|
rootPath: "/tmp/legacy",
|
||||||
kind: "git",
|
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",
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await projectRegistry.upsert(
|
const created = await projectRegistry.getOrCreateActiveByRoot({
|
||||||
createPersistedProjectRecord({
|
rootPath: path.join(tmpDir, "new"),
|
||||||
projectId: remoteKey,
|
kind: "non_git",
|
||||||
rootPath: "/home/me/scratch/repo",
|
displayName: "new",
|
||||||
kind: "git",
|
timestamp: "2026-03-02T00:00:00.000Z",
|
||||||
displayName: "acme/repo",
|
});
|
||||||
createdAt: "2026-03-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const all = await projectRegistry.list();
|
expect(created.projectId).toBe("prj_fresh");
|
||||||
expect(all).toHaveLength(1);
|
expect(await projectRegistry.list()).toHaveLength(2);
|
||||||
expect(all[0]?.displayName).toBe("acme/repo");
|
|
||||||
// Second upsert wins — the first rootPath is lost.
|
|
||||||
expect(all[0]?.rootPath).toBe("/home/me/scratch/repo");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("project record schema accepts records without customName (legacy on-disk records)", async () => {
|
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([]);
|
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 () => {
|
test("composes concurrent workspace field updates without losing either change", async () => {
|
||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
await workspaceRegistry.upsert(
|
await workspaceRegistry.upsert(
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import type { Logger } from "pino";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { writeJsonFileAtomic } from "./atomic-file.js";
|
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({
|
const PersistedProjectRecordSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
@@ -45,6 +50,11 @@ const PersistedWorkspaceRecordSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value ?? null),
|
.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
|
// The base branch the worktree was created from (normalized like worktree.json's
|
||||||
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
|
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
|
||||||
// worktrees and directory/local_checkout workspaces leave it null.
|
// worktrees and directory/local_checkout workspaces leave it null.
|
||||||
@@ -53,6 +63,8 @@ const PersistedWorkspaceRecordSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value ?? null),
|
.transform((value) => value ?? null),
|
||||||
|
isPaseoOwnedWorktree: z.boolean().default(false),
|
||||||
|
mainRepoRoot: z.string().nullable().default(null),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
archivedAt: z.string().nullable(),
|
archivedAt: z.string().nullable(),
|
||||||
@@ -71,9 +83,23 @@ export interface ProjectRegistry {
|
|||||||
existsOnDisk(): Promise<boolean>;
|
existsOnDisk(): Promise<boolean>;
|
||||||
list(): Promise<PersistedProjectRecord[]>;
|
list(): Promise<PersistedProjectRecord[]>;
|
||||||
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
||||||
|
getOrCreateActiveByRoot(input: {
|
||||||
|
rootPath: string;
|
||||||
|
kind: PersistedProjectKind;
|
||||||
|
displayName: string;
|
||||||
|
timestamp: string;
|
||||||
|
}): Promise<PersistedProjectRecord>;
|
||||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||||
remove(projectId: 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 {
|
export interface WorkspaceRegistry {
|
||||||
@@ -162,24 +188,43 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
|||||||
async archive(id: string, archivedAt: string): Promise<void> {
|
async archive(id: string, archivedAt: string): Promise<void> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const existing = this.cache.get(id);
|
const existing = this.cache.get(id);
|
||||||
if (!existing) {
|
if (!existing) return;
|
||||||
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({
|
const next = this.schema.parse({
|
||||||
...existing,
|
...existing,
|
||||||
updatedAt: archivedAt,
|
updatedAt: archivedAt,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
});
|
});
|
||||||
this.cache.set(id, next);
|
this.cache.set(this.getId(next), next);
|
||||||
await this.enqueuePersist();
|
await this.enqueuePersist();
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.removeIfPresent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async removeIfPresent(id: string): Promise<TRecord | null> {
|
||||||
await this.load();
|
await this.load();
|
||||||
if (!this.cache.delete(id)) {
|
const existing = this.cache.get(id);
|
||||||
return;
|
if (!existing) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
this.cache.delete(id);
|
||||||
await this.enqueuePersist();
|
await this.enqueuePersist();
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(): Promise<void> {
|
private async load(): Promise<void> {
|
||||||
@@ -219,7 +264,17 @@ export class FileBackedProjectRegistry
|
|||||||
extends FileBackedRegistry<PersistedProjectRecord>
|
extends FileBackedRegistry<PersistedProjectRecord>
|
||||||
implements ProjectRegistry
|
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({
|
super({
|
||||||
filePath,
|
filePath,
|
||||||
logger,
|
logger,
|
||||||
@@ -227,6 +282,89 @@ export class FileBackedProjectRegistry
|
|||||||
getId: (record) => record.projectId,
|
getId: (record) => record.projectId,
|
||||||
component: "projects",
|
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;
|
displayName: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
|
worktreeRoot?: string | null;
|
||||||
baseBranch?: string | null;
|
baseBranch?: string | null;
|
||||||
|
isPaseoOwnedWorktree?: boolean;
|
||||||
|
mainRepoRoot?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
@@ -284,7 +425,10 @@ export function createPersistedWorkspaceRecord(input: {
|
|||||||
...input,
|
...input,
|
||||||
title: input.title ?? null,
|
title: input.title ?? null,
|
||||||
branch: input.branch ?? null,
|
branch: input.branch ?? null,
|
||||||
|
worktreeRoot: input.worktreeRoot ?? null,
|
||||||
baseBranch: input.baseBranch ?? null,
|
baseBranch: input.baseBranch ?? null,
|
||||||
|
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
||||||
|
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||||
archivedAt: input.archivedAt ?? null,
|
archivedAt: input.archivedAt ?? null,
|
||||||
pinnedAt: input.pinnedAt ?? null,
|
pinnedAt: input.pinnedAt ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
|
|||||||
// workspaceId-scoped archive tear these terminals down.
|
// workspaceId-scoped archive tear these terminals down.
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
|
workspaceCwd?: string;
|
||||||
shouldBootstrap?: boolean;
|
shouldBootstrap?: boolean;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||||
@@ -503,7 +504,8 @@ async function runWorktreeTerminalBootstrap(
|
|||||||
options: RunAsyncWorktreeBootstrapOptions,
|
options: RunAsyncWorktreeBootstrapOptions,
|
||||||
runtimeEnv: WorktreeRuntimeEnv,
|
runtimeEnv: WorktreeRuntimeEnv,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||||
|
const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd);
|
||||||
if (terminalSpecs.length === 0) {
|
if (terminalSpecs.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -540,7 +542,7 @@ async function runWorktreeTerminalBootstrap(
|
|||||||
terminalSpecs.map(async (spec): Promise<WorktreeBootstrapTerminalResult> => {
|
terminalSpecs.map(async (spec): Promise<WorktreeBootstrapTerminalResult> => {
|
||||||
try {
|
try {
|
||||||
const terminal = await terminalManager.createTerminal({
|
const terminal = await terminalManager.createTerminal({
|
||||||
cwd: options.worktree.worktreePath,
|
cwd: workspaceCwd,
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
@@ -597,6 +599,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||||
|
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||||
let liveEmitQueue = Promise.resolve();
|
let liveEmitQueue = Promise.resolve();
|
||||||
|
|
||||||
const queueLiveRunningEmit = () => {
|
const queueLiveRunningEmit = () => {
|
||||||
@@ -632,12 +635,12 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
branchName: options.worktree.branchName,
|
branchName: options.worktree.branchName,
|
||||||
});
|
});
|
||||||
options.terminalManager?.registerCwdEnv({
|
options.terminalManager?.registerCwdEnv({
|
||||||
cwd: options.worktree.worktreePath,
|
cwd: workspaceCwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
setupResults = await runWorktreeSetupCommands({
|
setupResults = await runWorktreeSetupCommands({
|
||||||
worktreePath: options.worktree.worktreePath,
|
worktreePath: workspaceCwd,
|
||||||
branchName: options.worktree.branchName,
|
branchName: options.worktree.branchName,
|
||||||
cleanupOnFailure: false,
|
cleanupOnFailure: false,
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
|
|||||||
@@ -32,8 +32,15 @@ import {
|
|||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type { TerminalSession } from "../terminal/terminal.js";
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.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 type { ForgeService } from "../services/forge-service.js";
|
||||||
|
import { areEquivalentPaths } from "../utils/path.js";
|
||||||
import {
|
import {
|
||||||
createPaseoWorktree as createPaseoWorktreeService,
|
createPaseoWorktree as createPaseoWorktreeService,
|
||||||
type CreatePaseoWorktreeFn,
|
type CreatePaseoWorktreeFn,
|
||||||
@@ -41,6 +48,7 @@ import {
|
|||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||||
|
|
||||||
interface LegacyCreateWorktreeTestOptions {
|
interface LegacyCreateWorktreeTestOptions {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
@@ -294,6 +302,70 @@ function createPaseoWorktreeForTest(options: {
|
|||||||
forgeOverrides: { github: createGitHubServiceStub() },
|
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 (input, serviceOptions) => {
|
||||||
return createPaseoWorktreeService(input, {
|
return createPaseoWorktreeService(input, {
|
||||||
@@ -301,22 +373,8 @@ function createPaseoWorktreeForTest(options: {
|
|||||||
...(serviceOptions?.resolveDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: 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,
|
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 () => {
|
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||||
const { tempDir, repoDir } = createGitRepo({
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -1325,7 +1439,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
workspace: {
|
workspace: {
|
||||||
workspaceId: "ws-fix-attached-pr-context",
|
workspaceId: "ws-fix-attached-pr-context",
|
||||||
projectId: "/tmp/repo",
|
projectId: "/tmp/repo",
|
||||||
cwd: "/tmp/worktrees/fix-attached-pr-context",
|
cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app",
|
||||||
kind: "worktree" as const,
|
kind: "worktree" as const,
|
||||||
displayName: "fix-attached-pr-context",
|
displayName: "fix-attached-pr-context",
|
||||||
createdAt: "2026-04-30T00:00:00.000Z",
|
createdAt: "2026-04-30T00:00:00.000Z",
|
||||||
@@ -1382,7 +1496,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
}),
|
}),
|
||||||
expect.anything(),
|
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 () => {
|
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;
|
setupContinuation = createdWorktree.setupContinuation;
|
||||||
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
@@ -627,6 +627,7 @@ export async function createPaseoWorktreeWorkflow(
|
|||||||
shouldBootstrap: createdWorktree.created,
|
shouldBootstrap: createdWorktree.created,
|
||||||
slug,
|
slug,
|
||||||
worktreePath: createdWorktree.worktree.worktreePath,
|
worktreePath: createdWorktree.worktree.worktreePath,
|
||||||
|
workspaceCwd: workspace.cwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -641,6 +642,7 @@ export async function createPaseoWorktreeWorkflow(
|
|||||||
agentId,
|
agentId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
|
workspaceCwd: workspace.cwd,
|
||||||
shouldBootstrap: createdWorktree.created,
|
shouldBootstrap: createdWorktree.created,
|
||||||
terminalManager: setupContinuation.terminalManager,
|
terminalManager: setupContinuation.terminalManager,
|
||||||
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
|
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
|
||||||
@@ -683,6 +685,7 @@ export async function runWorktreeSetupInBackground(
|
|||||||
shouldBootstrap: boolean;
|
shouldBootstrap: boolean;
|
||||||
slug: string;
|
slug: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
workspaceCwd?: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let worktree: WorktreeConfig = options.worktree;
|
let worktree: WorktreeConfig = options.worktree;
|
||||||
@@ -721,7 +724,8 @@ export async function runWorktreeSetupInBackground(
|
|||||||
if (!options.shouldBootstrap) {
|
if (!options.shouldBootstrap) {
|
||||||
emitSetupProgress("completed", null);
|
emitSetupProgress("completed", null);
|
||||||
} else {
|
} else {
|
||||||
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
|
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
|
||||||
|
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
|
||||||
if (setupCommands.length === 0) {
|
if (setupCommands.length === 0) {
|
||||||
setupStarted = true;
|
setupStarted = true;
|
||||||
emitSetupProgress("completed", null);
|
emitSetupProgress("completed", null);
|
||||||
@@ -732,12 +736,12 @@ export async function runWorktreeSetupInBackground(
|
|||||||
repoRootPath: options.repoRoot,
|
repoRootPath: options.repoRoot,
|
||||||
});
|
});
|
||||||
dependencies.terminalManager?.registerCwdEnv({
|
dependencies.terminalManager?.registerCwdEnv({
|
||||||
cwd: worktree.worktreePath,
|
cwd: workspaceCwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
});
|
});
|
||||||
setupStarted = true;
|
setupStarted = true;
|
||||||
setupResults = await runWorktreeSetupCommands({
|
setupResults = await runWorktreeSetupCommands({
|
||||||
worktreePath: worktree.worktreePath,
|
worktreePath: workspaceCwd,
|
||||||
branchName: worktree.branchName,
|
branchName: worktree.branchName,
|
||||||
cleanupOnFailure: false,
|
cleanupOnFailure: false,
|
||||||
repoRootPath: options.repoRoot,
|
repoRootPath: options.repoRoot,
|
||||||
|
|||||||
@@ -122,15 +122,14 @@ export async function archiveCommand(
|
|||||||
dependencies: ArchiveCommandDependencies,
|
dependencies: ArchiveCommandDependencies,
|
||||||
input: ArchiveCommandInput,
|
input: ArchiveCommandInput,
|
||||||
): Promise<ArchiveCommandResult> {
|
): Promise<ArchiveCommandResult> {
|
||||||
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
|
const targetPath = await resolveArchiveTarget(dependencies, input);
|
||||||
const scope = input.scope ?? "workspace";
|
const scope = input.scope ?? "workspace";
|
||||||
|
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||||||
|
paseoHome: dependencies.paseoHome,
|
||||||
|
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||||
|
});
|
||||||
|
|
||||||
if (scope === "worktree") {
|
if (scope === "worktree") {
|
||||||
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
|
|
||||||
paseoHome: dependencies.paseoHome,
|
|
||||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ownership.allowed) {
|
if (!ownership.allowed) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -141,10 +140,7 @@ export async function archiveCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await archiveByScope(dependencies, {
|
const result = await archiveByScope(dependencies, {
|
||||||
scope: { kind: "worktree", targetPath: resolvedTarget.targetPath },
|
scope: { kind: "worktree", targetPath },
|
||||||
repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null,
|
|
||||||
repoWorktreesRoot: ownership.worktreeRoot,
|
|
||||||
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
|
||||||
requestId: input.requestId,
|
requestId: input.requestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,11 +151,11 @@ export async function archiveCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId =
|
const workspaceId =
|
||||||
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath));
|
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, targetPath));
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
dependencies.sessionLogger?.warn(
|
dependencies.sessionLogger?.warn(
|
||||||
{ targetPath: resolvedTarget.targetPath },
|
{ targetPath },
|
||||||
"Could not resolve workspace for archive; skipping",
|
"Could not resolve workspace for archive; skipping",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -170,8 +166,6 @@ export async function archiveCommand(
|
|||||||
|
|
||||||
const result = await archiveByScope(dependencies, {
|
const result = await archiveByScope(dependencies, {
|
||||||
scope: { kind: "workspace", workspaceId },
|
scope: { kind: "workspace", workspaceId },
|
||||||
repoRoot: resolvedTarget.repoRoot,
|
|
||||||
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
|
||||||
requestId: input.requestId,
|
requestId: input.requestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,28 +175,20 @@ export async function archiveCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedArchiveTarget {
|
|
||||||
targetPath: string;
|
|
||||||
repoRoot: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveArchiveTarget(
|
async function resolveArchiveTarget(
|
||||||
dependencies: ArchiveCommandDependencies,
|
dependencies: ArchiveCommandDependencies,
|
||||||
input: ArchiveCommandInput,
|
input: ArchiveCommandInput,
|
||||||
): Promise<ResolvedArchiveTarget> {
|
): Promise<string> {
|
||||||
const repoRoot = input.repoRoot ?? null;
|
const repoRoot = input.repoRoot ?? null;
|
||||||
if (input.worktreePath) {
|
if (input.worktreePath) {
|
||||||
return { targetPath: input.worktreePath, repoRoot };
|
return input.worktreePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.worktreeSlug) {
|
if (input.worktreeSlug) {
|
||||||
if (!repoRoot) {
|
if (!repoRoot) {
|
||||||
throw new Error("repoRoot is required when worktreeSlug is supplied");
|
throw new Error("repoRoot is required when worktreeSlug is supplied");
|
||||||
}
|
}
|
||||||
return {
|
return resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug);
|
||||||
targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug),
|
|
||||||
repoRoot,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repoRoot && input.branchName) {
|
if (repoRoot && input.branchName) {
|
||||||
@@ -211,7 +197,7 @@ async function resolveArchiveTarget(
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Paseo worktree not found for branch ${input.branchName}`);
|
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");
|
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 () => {
|
it("returns null for getCurrentBranch in a repo with no commits", async () => {
|
||||||
const emptyRepo = join(tempDir, "empty-repo");
|
const emptyRepo = join(tempDir, "empty-repo");
|
||||||
mkdirSync(emptyRepo, { recursive: true });
|
mkdirSync(emptyRepo, { recursive: true });
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktr
|
|||||||
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
const READ_ONLY_GIT_ENV = {
|
const READ_ONLY_GIT_ENV = {
|
||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
|
LC_ALL: "C",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -804,11 +805,11 @@ export type CheckoutSnapshotFacts =
|
|||||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isGitError(error: unknown): boolean {
|
function isNotGitRepositoryError(error: unknown): boolean {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
return false;
|
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> {
|
async function requireGitRepo(cwd: string): Promise<void> {
|
||||||
@@ -865,8 +866,11 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
|
|||||||
logger: context?.logger,
|
logger: context?.logger,
|
||||||
});
|
});
|
||||||
return parseGitRevParsePath(stdout);
|
return parseGitRevParsePath(stdout);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
if (isNotGitRepositoryError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,35 +1537,29 @@ async function inspectCheckoutContext(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<CheckoutInspectionContext | null> {
|
): Promise<CheckoutInspectionContext | null> {
|
||||||
try {
|
const root = await getWorktreeRoot(cwd, context);
|
||||||
const root = await getWorktreeRoot(cwd, context);
|
if (!root) {
|
||||||
if (!root) {
|
return null;
|
||||||
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 [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(
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
|
import {
|
||||||
|
areEquivalentPaths,
|
||||||
|
createPathEquivalenceMatcher,
|
||||||
|
getRealpathAwareRelativePath,
|
||||||
|
isPathInsideRoot,
|
||||||
|
} from "./path.js";
|
||||||
|
|
||||||
describe("path equivalence", () => {
|
describe("path equivalence", () => {
|
||||||
test.each([
|
test.each([
|
||||||
@@ -33,4 +41,23 @@ describe("path equivalence", () => {
|
|||||||
false,
|
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 {
|
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 compareAsWindows = shouldCompareAsWindows(root, candidate);
|
||||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||||
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
||||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||||
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||||
|
|
||||||
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
|
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative))
|
||||||
}
|
? relative
|
||||||
|
: null;
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectPathVariants(value: string): string[] {
|
function collectPathVariants(value: string): string[] {
|
||||||
|
|||||||
@@ -4,14 +4,24 @@ import {
|
|||||||
deriveWorktreeProjectHash,
|
deriveWorktreeProjectHash,
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
|
mapWorkspaceCwdToWorktree,
|
||||||
slugify,
|
slugify,
|
||||||
type CreateWorktreeOptions,
|
type CreateWorktreeOptions,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
import { execFileSync } from "child_process";
|
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 { join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
import { createRealpathAwarePathMatcher } from "./path";
|
||||||
|
|
||||||
interface LegacyCreateWorktreeTestOptions {
|
interface LegacyCreateWorktreeTestOptions {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
@@ -86,6 +96,12 @@ describe("paseo worktree manager", () => {
|
|||||||
|
|
||||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||||
expect(ownership.allowed).toBe(true);
|
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 () => {
|
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);
|
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 () => {
|
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
|
||||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||||
const worktreesRoot = join(paseoHome, "worktrees");
|
const worktreesRoot = join(paseoHome, "worktrees");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
|||||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
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";
|
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||||
|
|
||||||
@@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
|||||||
|
|
||||||
export async function runWorktreeTeardownCommands(options: {
|
export async function runWorktreeTeardownCommands(options: {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
teardownCwd?: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
repoRootPath?: string;
|
repoRootPath?: string;
|
||||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
const teardownCwd = options.teardownCwd ?? options.worktreePath;
|
||||||
const teardownCommands = getWorktreeTeardownCommands(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) {
|
if (teardownCommands.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
|
|||||||
const results: WorktreeTeardownCommandResult[] = [];
|
const results: WorktreeTeardownCommandResult[] = [];
|
||||||
for (const cmd of teardownCommands) {
|
for (const cmd of teardownCommands) {
|
||||||
const result = await execSetupCommand(cmd, {
|
const result = await execSetupCommand(cmd, {
|
||||||
cwd: options.worktreePath,
|
cwd: teardownCwd,
|
||||||
env: teardownEnv,
|
env: teardownEnv,
|
||||||
});
|
});
|
||||||
results.push(result);
|
results.push(result);
|
||||||
@@ -772,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: {
|
|||||||
return results;
|
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.
|
* Get the git common directory (shared across worktrees) for a given cwd.
|
||||||
* This is where refs, objects, etc. are stored.
|
* This is where refs, objects, etc. are stored.
|
||||||
@@ -845,6 +866,36 @@ export async function computeWorktreePath(
|
|||||||
return join(projectWorktreesRoot, slug);
|
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 {
|
function normalizePathForOwnership(input: string): string {
|
||||||
try {
|
try {
|
||||||
return realpathSync(input);
|
return realpathSync(input);
|
||||||
@@ -878,13 +929,13 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
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>[/...].
|
// 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
|
// 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
|
// path shape alone is sufficient proof of ownership, even when git has already
|
||||||
// forgotten about the worktree.
|
// forgotten about the worktree.
|
||||||
if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) {
|
if (relativePath === null) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||||
@@ -892,8 +943,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const relative = resolvedCwd.slice(paseoWorktreesPrefix.length);
|
const parts = relativePath.split(sep).filter((part) => part.length > 0);
|
||||||
const parts = relative.split(sep).filter((part) => part.length > 0);
|
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
@@ -907,7 +957,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
allowed: true,
|
allowed: true,
|
||||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||||
worktreeRoot: worktreesRoot,
|
worktreeRoot: worktreesRoot,
|
||||||
worktreePath: resolvedCwd,
|
worktreePath: join(worktreesRoot, parts[1]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,10 +1027,9 @@ export async function listPaseoWorktrees({
|
|||||||
envOverlay: READ_ONLY_GIT_ENV,
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
|
|
||||||
return parseWorktreeList(stdout)
|
return parseWorktreeList(stdout)
|
||||||
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
||||||
.filter((entry) => entry.path.startsWith(rootPrefix))
|
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
||||||
);
|
);
|
||||||
@@ -1018,76 +1067,25 @@ export async function resolveExistingWorktreeForSlug({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolvePaseoWorktreeRootForCwd(
|
export interface DeletePaseoWorktreeOptions {
|
||||||
cwd: string,
|
cwd: string | null;
|
||||||
options?: WorktreeRootOptions,
|
worktreePath?: string;
|
||||||
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
|
teardownCwds?: string[];
|
||||||
let gitCommonDir: string;
|
worktreeSlug?: string;
|
||||||
try {
|
worktreesRoot?: string;
|
||||||
gitCommonDir = await getGitCommonDir(cwd);
|
paseoHome?: string;
|
||||||
} catch {
|
worktreesBaseRoot?: string;
|
||||||
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 async function deletePaseoWorktree({
|
export async function deletePaseoWorktree({
|
||||||
cwd,
|
cwd,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
teardownCwds,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
worktreesRoot,
|
worktreesRoot,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
worktreesBaseRoot,
|
worktreesBaseRoot,
|
||||||
}: {
|
}: DeletePaseoWorktreeOptions): Promise<void> {
|
||||||
cwd: string | null;
|
|
||||||
worktreePath?: string;
|
|
||||||
worktreeSlug?: string;
|
|
||||||
worktreesRoot?: string;
|
|
||||||
paseoHome?: string;
|
|
||||||
worktreesBaseRoot?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!worktreePath && !worktreeSlug) {
|
if (!worktreePath && !worktreeSlug) {
|
||||||
throw new Error("worktreePath or worktreeSlug is required");
|
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");
|
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 requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||||
|
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||||
|
paseoHome,
|
||||||
|
worktreesRoot: worktreesBaseRoot,
|
||||||
|
});
|
||||||
const resolvedWorktree =
|
const resolvedWorktree =
|
||||||
(
|
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||||
await resolvePaseoWorktreeRootForCwd(requestedPath, {
|
|
||||||
paseoHome,
|
|
||||||
worktreesRoot: worktreesBaseRoot,
|
|
||||||
})
|
|
||||||
)?.worktreePath ?? resolvedRequested;
|
|
||||||
|
|
||||||
if (!resolvedWorktree.startsWith(resolvedRoot)) {
|
const relativeWorktreePath = getRealpathAwareRelativePath(
|
||||||
|
resolvedWorktreesRoot,
|
||||||
|
resolvedWorktree,
|
||||||
|
);
|
||||||
|
if (relativeWorktreePath === null || relativeWorktreePath === "") {
|
||||||
throw new Error("Refusing to delete non-Paseo worktree");
|
throw new Error("Refusing to delete non-Paseo worktree");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await pathExists(resolvedWorktree)) {
|
if (await pathExists(resolvedWorktree)) {
|
||||||
await runWorktreeTeardownCommands({
|
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
|
||||||
worktreePath: resolvedWorktree,
|
await runWorktreeTeardownCommands({
|
||||||
});
|
worktreePath: resolvedWorktree,
|
||||||
|
teardownCwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwd) {
|
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> {
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await stat(path);
|
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
|
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||||
// (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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runSetup) {
|
if (runSetup) {
|
||||||
await runWorktreeSetupCommands({
|
await runWorktreeSetupCommands({
|
||||||
|
|||||||
Reference in New Issue
Block a user