mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
This commit is contained in:
@@ -54,6 +54,14 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
for empty projects and without connected clients, then fans metadata changes through the WebSocket
|
||||
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -89,7 +97,7 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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. A daemon-global root observer 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.
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
@@ -422,19 +431,22 @@ Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
||||
workspace `projectId` is authoritative when it names an active project, regardless of cwd
|
||||
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
|
||||
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
|
||||
workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
@@ -452,13 +464,13 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces (local checkout or worktree). Separate from `displayName`/`title`; a branch rename writes this and never the name. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **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, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
|
||||
@@ -1391,6 +1391,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
});
|
||||
|
||||
const unsubProjectUpdate = client.on("project.update", (message) => {
|
||||
if (message.type !== "project.update") return;
|
||||
const update = message.payload;
|
||||
if (update.kind === "remove") {
|
||||
useSessionStore.getState().applyProjectUpdate(serverId, update);
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().applyProjectUpdate(serverId, {
|
||||
kind: "upsert",
|
||||
project: normalizeEmptyProjectDescriptor(update.project),
|
||||
});
|
||||
});
|
||||
|
||||
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
|
||||
if (message.type !== "script_status_update") return;
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
|
||||
@@ -1770,6 +1783,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubProjectUpdate();
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
|
||||
@@ -17,6 +18,7 @@ function createWorkspace(
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectCustomName: input.projectCustomName ?? null,
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
@@ -30,6 +32,18 @@ function createWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(
|
||||
input: Partial<EmptyProjectDescriptor> & Pick<EmptyProjectDescriptor, "projectId">,
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectCustomName: input.projectCustomName ?? null,
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
});
|
||||
@@ -446,6 +460,162 @@ describe("removeEmptyProject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProjectUpdate", () => {
|
||||
it("inserts an unseen project into the empty-project projection", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const project = createProject({
|
||||
projectId: "project-empty",
|
||||
projectDisplayName: "Empty project",
|
||||
projectRootPath: "/empty",
|
||||
projectKind: "non_git",
|
||||
});
|
||||
|
||||
store.applyProjectUpdate("test-server", {
|
||||
kind: "upsert",
|
||||
project,
|
||||
});
|
||||
|
||||
expect(getTestSessionReferences().emptyProjects).toEqual(
|
||||
new Map([[project.projectId, project]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the metadata of an existing empty project", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const existing = createProject({ projectId: "project-empty" });
|
||||
const updated = createProject({
|
||||
projectId: existing.projectId,
|
||||
projectDisplayName: "Renamed project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/repo",
|
||||
projectKind: "non_git",
|
||||
});
|
||||
store.setEmptyProjects("test-server", [existing]);
|
||||
|
||||
store.applyProjectUpdate("test-server", { kind: "upsert", project: updated });
|
||||
|
||||
expect(getTestSessionReferences().emptyProjects).toEqual(
|
||||
new Map([[updated.projectId, updated]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("patches project metadata onto every workspace attached to the project", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const main = createWorkspace({ id: "workspace-main", projectId: "project-1" });
|
||||
const feature = createWorkspace({ id: "workspace-feature", projectId: "project-1" });
|
||||
const unrelated = createWorkspace({ id: "workspace-other", projectId: "project-2" });
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([
|
||||
[main.id, main],
|
||||
[feature.id, feature],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
);
|
||||
const project = createProject({
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Renamed project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/repo",
|
||||
projectKind: "non_git",
|
||||
});
|
||||
|
||||
store.applyProjectUpdate("test-server", { kind: "upsert", project });
|
||||
|
||||
const workspaces = getTestSessionReferences().workspaces;
|
||||
const projectMetadata = {
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
};
|
||||
expect(workspaces).toEqual(
|
||||
new Map([
|
||||
[main.id, { ...main, ...projectMetadata }],
|
||||
[feature.id, { ...feature, ...projectMetadata }],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
);
|
||||
expect(workspaces.get(unrelated.id)).toBe(unrelated);
|
||||
});
|
||||
|
||||
it("removes a stale empty-project projection when the project has a workspace", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({ id: "workspace-main", projectId: "project-1" });
|
||||
const project = createProject({
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
});
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
|
||||
store.setEmptyProjects("test-server", [project]);
|
||||
|
||||
store.applyProjectUpdate("test-server", { kind: "upsert", project });
|
||||
|
||||
const after = getTestSessionReferences();
|
||||
expect(after.emptyProjects).toEqual(new Map());
|
||||
expect(after.workspaces.get(workspace.id)).toBe(workspace);
|
||||
});
|
||||
|
||||
it("removes every workspace and empty-project projection for a removed project", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const removedMain = createWorkspace({ id: "workspace-main", projectId: "project-1" });
|
||||
const removedFeature = createWorkspace({ id: "workspace-feature", projectId: "project-1" });
|
||||
const remainingWorkspace = createWorkspace({ id: "workspace-other", projectId: "project-2" });
|
||||
const removedProject = createProject({ projectId: "project-1" });
|
||||
const remainingProject = createProject({ projectId: "project-empty" });
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([
|
||||
[removedMain.id, removedMain],
|
||||
[removedFeature.id, removedFeature],
|
||||
[remainingWorkspace.id, remainingWorkspace],
|
||||
]),
|
||||
);
|
||||
store.setEmptyProjects("test-server", [removedProject, remainingProject]);
|
||||
|
||||
store.applyProjectUpdate("test-server", {
|
||||
kind: "remove",
|
||||
projectId: removedProject.projectId,
|
||||
});
|
||||
|
||||
const after = getTestSessionReferences();
|
||||
expect(after.workspaces).toEqual(new Map([[remainingWorkspace.id, remainingWorkspace]]));
|
||||
expect(after.emptyProjects).toEqual(new Map([[remainingProject.projectId, remainingProject]]));
|
||||
expect(after.workspaces.get(remainingWorkspace.id)).toBe(remainingWorkspace);
|
||||
});
|
||||
|
||||
it("preserves session and workspace identity when an upsert changes nothing", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({ id: "workspace-main", projectId: "project-1" });
|
||||
const project = createProject({
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
});
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.applyProjectUpdate("test-server", { kind: "upsert", project });
|
||||
|
||||
const after = getTestSessionReferences();
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.session).toBe(before.session);
|
||||
expect(after.workspaces).toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)).toBe(workspace);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||
const script = {
|
||||
|
||||
@@ -317,6 +317,8 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -363,6 +365,9 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -483,6 +488,19 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
applyProjectUpdate: (
|
||||
serverId: string,
|
||||
update:
|
||||
| { kind: "upsert"; project: EmptyProjectDescriptor }
|
||||
| { kind: "remove"; projectId: string },
|
||||
) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -555,6 +573,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1318,6 +1337,115 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
applyProjectUpdate: (serverId, update) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
if (update.kind === "remove") {
|
||||
const workspaces = new Map(session.workspaces);
|
||||
let changed = session.emptyProjects.has(update.projectId);
|
||||
const emptyProjects = new Map(session.emptyProjects);
|
||||
emptyProjects.delete(update.projectId);
|
||||
for (const [workspaceId, workspace] of workspaces) {
|
||||
if (workspace.projectId === update.projectId) {
|
||||
workspaces.delete(workspaceId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, workspaces, emptyProjects } },
|
||||
};
|
||||
}
|
||||
const project = update.project;
|
||||
let changed = false;
|
||||
const workspaces = new Map(session.workspaces);
|
||||
const hasAttachedWorkspace = [...workspaces.values()].some(
|
||||
(workspace) => workspace.projectId === project.projectId,
|
||||
);
|
||||
for (const [workspaceId, workspace] of workspaces) {
|
||||
if (workspace.projectId !== project.projectId) continue;
|
||||
const next = {
|
||||
...workspace,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
};
|
||||
const preserved = preserveWorkspaceDescriptorIdentity(next, workspace);
|
||||
if (preserved !== workspace) {
|
||||
workspaces.set(workspaceId, preserved);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const existingEmpty = session.emptyProjects.get(project.projectId);
|
||||
let emptyProjects = session.emptyProjects;
|
||||
if (hasAttachedWorkspace && existingEmpty) {
|
||||
emptyProjects = new Map(session.emptyProjects);
|
||||
emptyProjects.delete(project.projectId);
|
||||
changed = true;
|
||||
} else if (!hasAttachedWorkspace && (!existingEmpty || !equal(existingEmpty, project))) {
|
||||
emptyProjects = new Map(session.emptyProjects);
|
||||
emptyProjects.set(project.projectId, project);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, workspaces, emptyProjects } },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1331,10 +1459,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1354,6 +1490,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1572,3 +1709,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -555,6 +555,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
custom_mode_icons: true,
|
||||
project_updates: true,
|
||||
provider_subagents: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
@@ -566,6 +567,32 @@ test("advertises client capabilities in hello", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows callers to disable default client capabilities", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_capability_override_test",
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.projectUpdates]: false,
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ preserveSent: true });
|
||||
await connectPromise;
|
||||
|
||||
const hello = z
|
||||
.object({
|
||||
type: z.literal("hello"),
|
||||
capabilities: z.record(z.unknown()),
|
||||
})
|
||||
.parse(JSON.parse(assertStr(mock.sent[0])));
|
||||
expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false);
|
||||
});
|
||||
|
||||
test("sends new-agent run options when creating schedules", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -243,6 +243,10 @@ export type DaemonEvent =
|
||||
workspaceId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "project.update";
|
||||
payload: Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "workspace_setup_progress";
|
||||
workspaceId: string;
|
||||
@@ -4974,6 +4978,7 @@ export class DaemonClient {
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
...this.config.capabilities,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
@@ -5444,6 +5449,8 @@ export class DaemonClient {
|
||||
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "project.update":
|
||||
return { type: "project.update", payload: msg.payload };
|
||||
case "workspace_setup_progress":
|
||||
return {
|
||||
type: "workspace_setup_progress",
|
||||
|
||||
@@ -14,6 +14,8 @@ export const CLIENT_CAPS = {
|
||||
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
||||
// child descriptors and timelines only to clients that understand the new messages.
|
||||
providerSubagents: "provider_subagents",
|
||||
// COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15.
|
||||
projectUpdates: "project_updates",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2560,6 +2560,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: z.boolean().optional(),
|
||||
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
|
||||
workspaceGithubClone: z.boolean().optional(),
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3004,6 +3008,14 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const ProjectUpdateMessageSchema = z.object({
|
||||
type: z.literal("project.update"),
|
||||
payload: z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }),
|
||||
z.object({ kind: z.literal("remove"), projectId: z.string() }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("script_status_update"),
|
||||
payload: z.object({
|
||||
@@ -4562,6 +4574,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArtifactMessageSchema,
|
||||
AgentUpdateMessageSchema,
|
||||
WorkspaceUpdateMessageSchema,
|
||||
ProjectUpdateMessageSchema,
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
@@ -5095,6 +5108,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.projectUpdates]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -91,10 +91,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createRegisteredPaseoWorktree,
|
||||
createLocalCheckoutWorkspace,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
@@ -113,6 +111,7 @@ import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import { ProjectGitObserverService } from "./project-git-observer-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
@@ -744,6 +743,11 @@ export async function createPaseoDaemon(
|
||||
github,
|
||||
},
|
||||
});
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({
|
||||
logger: providerSnapshotLogger,
|
||||
@@ -789,20 +793,31 @@ export async function createPaseoDaemon(
|
||||
logger,
|
||||
workspaceGitService,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await workspaceReconciliation.runOnce();
|
||||
logger.info(
|
||||
{
|
||||
elapsed: elapsed(),
|
||||
changeCount: result.changesApplied.length,
|
||||
},
|
||||
"Workspace registries reconciled",
|
||||
const projectGitObserver = new ProjectGitObserverService({
|
||||
projectRegistry,
|
||||
reconciliation: workspaceReconciliation,
|
||||
logger,
|
||||
onProjectUpdate: (update) => {
|
||||
if (update.kind === "upsert") {
|
||||
wsServer?.publishProjectUpdate(update.project);
|
||||
} else {
|
||||
wsServer?.publishProjectRemove(update.projectId);
|
||||
}
|
||||
},
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
await Promise.all(
|
||||
(wsServer?.listActiveSessions() ?? []).map((session) =>
|
||||
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds, {
|
||||
skipReconcile: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Background workspace reconciliation failed");
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
await projectGitObserver.start();
|
||||
void workspaceReconciliation.runOnce().catch((error) => {
|
||||
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
||||
});
|
||||
await chatService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
@@ -833,9 +848,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
): Promise<string> => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
resolveFirstAgentPromptTitle(firstAgentContext),
|
||||
);
|
||||
if (firstAgentContext) {
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
@@ -1004,9 +1019,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
);
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1066,7 +1081,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
@@ -1445,6 +1460,7 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
projectGitObserver.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
|
||||
@@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(added.project).not.toBeNull();
|
||||
const project = added.project!;
|
||||
expect(project).toMatchObject({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
});
|
||||
expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
|
||||
const workspaces = await client.fetchWorkspaces({
|
||||
filter: { projectId: project.projectId },
|
||||
@@ -73,7 +73,6 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
expect(workspaces.emptyProjects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
}),
|
||||
|
||||
@@ -29,7 +29,7 @@ afterEach(async () => {
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("openProject reclassifies an existing directory workspace into its parent git project", async () => {
|
||||
test("openProject preserves a worktree's exact-root project without rehoming it", async () => {
|
||||
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||
process.env.PASEO_SUPERVISED = "0";
|
||||
try {
|
||||
@@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g
|
||||
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
|
||||
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(response.workspace?.projectId).toBe(worktreeRoot);
|
||||
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
|
||||
repoRoot,
|
||||
);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
|
||||
).toBe(repoRoot);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind,
|
||||
).toBe("worktree");
|
||||
).toBe(worktreeRoot);
|
||||
} finally {
|
||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { type PersistedProjectRecord } from "../workspace-registry.js";
|
||||
|
||||
const cleanupPaths = new Set<string>();
|
||||
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
||||
const cleanupClients = new Set<DaemonClient>();
|
||||
const cleanupListeners = new Set<() => void>();
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
type ProjectUpdatePayload = Extract<DaemonEvent, { type: "project.update" }>["payload"];
|
||||
|
||||
function waitForProjectUpdate(
|
||||
client: DaemonClient,
|
||||
predicate: (payload: ProjectUpdatePayload) => boolean,
|
||||
): Promise<ProjectUpdatePayload> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = client.on("project.update", (message) => {
|
||||
if (!predicate(message.payload)) return;
|
||||
cleanupListeners.delete(unsubscribe);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
cleanupListeners.add(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const unsubscribe of cleanupListeners) unsubscribe();
|
||||
cleanupListeners.clear();
|
||||
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
||||
cleanupClients.clear();
|
||||
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
|
||||
cleanupDaemons.clear();
|
||||
await Promise.all(
|
||||
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
|
||||
);
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("an empty project becomes Git without changing its identity or creating a workspace", async () => {
|
||||
const projectRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-")),
|
||||
);
|
||||
const paseoHomeRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-home-")),
|
||||
);
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } });
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
projectDisplayName: path.basename(projectRoot),
|
||||
projectCustomName: null,
|
||||
projectRootPath: projectRoot,
|
||||
projectKind: "non_git",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === project.projectId &&
|
||||
payload.project.projectRootPath === projectRoot &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
const update = await withTimeout({
|
||||
promise: gitProjectUpdate,
|
||||
timeoutMs: 10_000,
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
await readFile(path.join(daemon.paseoHome, "projects", "projects.json"), "utf8"),
|
||||
) as PersistedProjectRecord[];
|
||||
expect(persistedProjects).toContainEqual({
|
||||
projectId: project.projectId,
|
||||
rootPath: projectRoot,
|
||||
kind: "git",
|
||||
displayName: project.projectDisplayName,
|
||||
customName: null,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
});
|
||||
}, 30_000);
|
||||
@@ -32,7 +32,7 @@ import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
@@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: firstAgentContext?.prompt ?? null },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
firstAgentContext?.prompt ?? null,
|
||||
);
|
||||
return workspace.workspaceId;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
@@ -69,10 +68,41 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.workspace.displayName).toBe("feature-one");
|
||||
expect(result.workspace.baseBranch).toBe("main");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"project:remote:github.com/acme/repo",
|
||||
`workspace:${result.workspace.workspaceId}`,
|
||||
]);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||
});
|
||||
|
||||
test("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);
|
||||
expect(deps.projects.get(result.workspace.projectId)).toMatchObject({
|
||||
projectId: result.workspace.projectId,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
|
||||
@@ -109,6 +139,35 @@ test("registers a new worktree in the existing root project after the main check
|
||||
expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]);
|
||||
});
|
||||
|
||||
test("an explicit project FK remains unchanged when its worktree comes from another checkout", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const project = {
|
||||
...createPersistedProjectRecordForTest({
|
||||
projectId: "prj_explicitproject",
|
||||
rootPath: path.join(tempDir, "unrelated"),
|
||||
displayName: "unrelated",
|
||||
}),
|
||||
kind: "non_git" as const,
|
||||
};
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "attached-worktree",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(project.projectId);
|
||||
expect(deps.projects.get(project.projectId)).toEqual(project);
|
||||
});
|
||||
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"reuses an existing worktree and still upserts the workspace",
|
||||
@@ -152,19 +211,6 @@ test.skipIf(isPlatform("win32"))(
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a distinct local checkout workspace for the same cwd on every call", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
|
||||
const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
|
||||
expect(first.cwd).toBe(second.cwd);
|
||||
expect(first.workspaceId).not.toBe(second.workspaceId);
|
||||
expect(deps.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -679,7 +725,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
);
|
||||
|
||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "getOrCreateActiveByRoot">;
|
||||
projects: Map<string, PersistedProjectRecord>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
}
|
||||
@@ -699,10 +745,18 @@ function createDeps(options?: {
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
list: async () => Array.from(projects.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
getOrCreateActiveByRoot: async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && 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;
|
||||
},
|
||||
},
|
||||
workspaceRegistry: {
|
||||
|
||||
@@ -4,14 +4,9 @@ import {
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { generateWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
@@ -56,7 +51,7 @@ export interface AttemptFirstAgentBranchAutoNameResult {
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "getOrCreateActiveByRoot">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
}
|
||||
@@ -230,32 +225,18 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
// 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({
|
||||
const sourceProjectId = await resolveSourceProjectIdForWorktree({
|
||||
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,
|
||||
projectId: sourceProjectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
@@ -271,186 +252,40 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
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: {
|
||||
async function resolveSourceProjectIdForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
}): Promise<string> {
|
||||
if (options.projectId) {
|
||||
return resolveExplicitProjectForWorktree({
|
||||
projectId: options.projectId,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
const project = await options.deps.projectRegistry.get(options.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found for worktree: ${options.projectId}`);
|
||||
}
|
||||
return project.projectId;
|
||||
}
|
||||
|
||||
const sourceWorkspace =
|
||||
options.existingWorkspace ??
|
||||
(await findWorkspaceForSource({
|
||||
inputCwd: options.inputCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
workspaceRegistry: options.deps.workspaceRegistry,
|
||||
}));
|
||||
const sourceWorkspace = await findWorkspaceForSource({
|
||||
inputCwd: options.inputCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
workspaceRegistry: options.deps.workspaceRegistry,
|
||||
});
|
||||
|
||||
if (sourceWorkspace) {
|
||||
return resolveWorkspaceProjectForWorktree({
|
||||
sourceWorkspace,
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
const sourceProject = await options.deps.projectRegistry.get(sourceWorkspace.projectId);
|
||||
if (sourceProject) return sourceProject.projectId;
|
||||
// COMPAT(worktreeMissingSourceProject): added in v0.1.107, remove after 2027-01-15.
|
||||
// Orphaned legacy workspace FKs fall through to exact-root allocation.
|
||||
}
|
||||
|
||||
return resolveFallbackProjectForWorktree({
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
const project = await options.deps.projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: options.repoRoot,
|
||||
kind: "git",
|
||||
displayName: options.repoRoot.split(/[\\/]/).findLast(Boolean) ?? options.repoRoot,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return project.projectId;
|
||||
}
|
||||
|
||||
async function findWorkspaceForSource(options: {
|
||||
|
||||
731
packages/server/src/server/project-git-observer-service.test.ts
Normal file
731
packages/server/src/server/project-git-observer-service.test.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
import type pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
ProjectGitObserverService,
|
||||
type ProjectGitObserverUpdate,
|
||||
} from "./project-git-observer-service.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
type PersistedProjectRecord,
|
||||
type ProjectRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type {
|
||||
ReconciliationChange,
|
||||
ReconciliationResult,
|
||||
} from "./workspace-reconciliation-service.js";
|
||||
|
||||
const DEBOUNCE_MS = 10;
|
||||
const RESCAN_INTERVAL_MS = 1_000;
|
||||
const TIMESTAMP = "2026-07-15T00:00:00.000Z";
|
||||
|
||||
interface ProjectRegistryMutation {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function project(
|
||||
projectId: string,
|
||||
rootPath: string,
|
||||
archivedAt: string | null = null,
|
||||
): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: projectId,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
archivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class FakeProjectRegistry implements ProjectRegistry {
|
||||
private readonly projects = new Map<string, PersistedProjectRecord>();
|
||||
private readonly listeners = new Set<
|
||||
(mutation: ProjectRegistryMutation) => void | Promise<void>
|
||||
>();
|
||||
private nextListGate: { started: Deferred<void>; release: Deferred<void> } | null = null;
|
||||
|
||||
constructor(
|
||||
projects: PersistedProjectRecord[],
|
||||
private readonly lifecycle: string[],
|
||||
) {
|
||||
for (const record of projects) this.projects.set(record.projectId, record);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async existsOnDisk(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async list(): Promise<PersistedProjectRecord[]> {
|
||||
const gate = this.nextListGate;
|
||||
this.nextListGate = null;
|
||||
if (gate) {
|
||||
gate.started.resolve();
|
||||
await gate.release.promise;
|
||||
}
|
||||
return [...this.projects.values()];
|
||||
}
|
||||
|
||||
async get(projectId: string): Promise<PersistedProjectRecord | null> {
|
||||
return this.projects.get(projectId) ?? null;
|
||||
}
|
||||
|
||||
async getOrCreateActiveByRoot(): Promise<PersistedProjectRecord> {
|
||||
throw new Error("not used by the observer");
|
||||
}
|
||||
|
||||
async upsert(record: PersistedProjectRecord): Promise<void> {
|
||||
this.projects.set(record.projectId, record);
|
||||
await this.publish({ kind: "upsert", projectId: record.projectId, project: record });
|
||||
this.lifecycle.push(`mutator resolved:upsert:${record.projectId}`);
|
||||
}
|
||||
|
||||
async archive(projectId: string, archivedAt: string): Promise<void> {
|
||||
const existing = this.projects.get(projectId);
|
||||
if (!existing) return;
|
||||
const archived = { ...existing, archivedAt, updatedAt: archivedAt };
|
||||
this.projects.set(projectId, archived);
|
||||
await this.publish({ kind: "archive", projectId, project: archived });
|
||||
this.lifecycle.push(`mutator resolved:archive:${projectId}`);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
if (!this.projects.delete(projectId)) return;
|
||||
await this.publish({ kind: "remove", projectId, project: null });
|
||||
this.lifecycle.push(`mutator resolved:remove:${projectId}`);
|
||||
}
|
||||
|
||||
subscribeToMutations(
|
||||
listener: (mutation: ProjectRegistryMutation) => void | Promise<void>,
|
||||
): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
holdNextList(): { started: Promise<void>; release: () => void } {
|
||||
const gate = { started: deferred<void>(), release: deferred<void>() };
|
||||
this.nextListGate = gate;
|
||||
return { started: gate.started.promise, release: () => gate.release.resolve() };
|
||||
}
|
||||
|
||||
get subscriptionCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
|
||||
private async publish(mutation: ProjectRegistryMutation): Promise<void> {
|
||||
await Promise.all([...this.listeners].map((listener) => listener(mutation)));
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeTimer {
|
||||
callback: () => void | Promise<void>;
|
||||
dueAt: number;
|
||||
intervalMs: number | null;
|
||||
sequence: number;
|
||||
cancelled: boolean;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
class TestClock {
|
||||
private nowMs = 0;
|
||||
private sequence = 0;
|
||||
private readonly timers = new Set<FakeTimer>();
|
||||
|
||||
setTimeout(callback: () => void | Promise<void>, delayMs: number): FakeTimer {
|
||||
return this.schedule(callback, delayMs, null);
|
||||
}
|
||||
|
||||
clearTimeout(timer: FakeTimer): void {
|
||||
timer.cancelled = true;
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
setInterval(callback: () => void | Promise<void>, delayMs: number): FakeTimer {
|
||||
return this.schedule(callback, delayMs, delayMs);
|
||||
}
|
||||
|
||||
clearInterval(timer: FakeTimer): void {
|
||||
timer.cancelled = true;
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
async advanceBy(elapsedMs: number): Promise<void> {
|
||||
const target = this.nowMs + elapsedMs;
|
||||
for (;;) {
|
||||
const next = [...this.timers]
|
||||
.filter((timer) => !timer.cancelled && timer.dueAt <= target)
|
||||
.sort((left, right) => left.dueAt - right.dueAt || left.sequence - right.sequence)[0];
|
||||
if (!next) break;
|
||||
this.nowMs = next.dueAt;
|
||||
if (next.intervalMs === null) this.timers.delete(next);
|
||||
else next.dueAt += next.intervalMs;
|
||||
await next.callback();
|
||||
}
|
||||
this.nowMs = target;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.timers.size;
|
||||
}
|
||||
|
||||
private schedule(
|
||||
callback: () => void | Promise<void>,
|
||||
delayMs: number,
|
||||
intervalMs: number | null,
|
||||
): FakeTimer {
|
||||
const timer: FakeTimer = {
|
||||
callback,
|
||||
dueAt: this.nowMs + delayMs,
|
||||
intervalMs,
|
||||
sequence: this.sequence,
|
||||
cancelled: false,
|
||||
unref: () => undefined,
|
||||
};
|
||||
this.sequence += 1;
|
||||
this.timers.add(timer);
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
|
||||
interface WatchInstallation {
|
||||
rootPath: string;
|
||||
recursive: false;
|
||||
watcher: FakeWatcher;
|
||||
}
|
||||
|
||||
class FakeWatcher {
|
||||
closed = false;
|
||||
|
||||
constructor(
|
||||
readonly onChange: (event: string, filename: string | Buffer | null) => void,
|
||||
readonly onError: (error: Error) => void,
|
||||
) {}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProjectRoots {
|
||||
readonly installations: WatchInstallation[] = [];
|
||||
private failures = new Map<string, Error[]>();
|
||||
|
||||
constructor(private readonly lifecycle: string[]) {}
|
||||
|
||||
readonly watch = (
|
||||
rootPath: string,
|
||||
options: { recursive: false },
|
||||
onChange: (event: string, filename: string | Buffer | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): FakeWatcher => {
|
||||
const failures = this.failures.get(rootPath) ?? [];
|
||||
const failure = failures.shift();
|
||||
this.failures.set(rootPath, failures);
|
||||
if (failure) throw failure;
|
||||
const watcher = new FakeWatcher(onChange, onError);
|
||||
this.installations.push({ rootPath, recursive: options.recursive, watcher });
|
||||
this.lifecycle.push(`watch installed:${rootPath}`);
|
||||
return watcher;
|
||||
};
|
||||
|
||||
change(rootPath: string, filename: string | Buffer | null): void {
|
||||
this.openWatcher(rootPath).onChange("rename", filename);
|
||||
}
|
||||
|
||||
fail(rootPath: string, error: Error): void {
|
||||
this.openWatcher(rootPath).onError(error);
|
||||
}
|
||||
|
||||
failNextInstall(rootPath: string, error: Error): void {
|
||||
const failures = this.failures.get(rootPath) ?? [];
|
||||
failures.push(error);
|
||||
this.failures.set(rootPath, failures);
|
||||
}
|
||||
|
||||
get active(): Array<{ rootPath: string; recursive: false }> {
|
||||
return this.installations
|
||||
.filter((installation) => !installation.watcher.closed)
|
||||
.map(({ rootPath, recursive }) => ({ rootPath, recursive }));
|
||||
}
|
||||
|
||||
get closedRoots(): string[] {
|
||||
return this.installations
|
||||
.filter((installation) => installation.watcher.closed)
|
||||
.map((installation) => installation.rootPath);
|
||||
}
|
||||
|
||||
private openWatcher(rootPath: string): FakeWatcher {
|
||||
const installation = this.installations.find(
|
||||
(candidate) => candidate.rootPath === rootPath && !candidate.watcher.closed,
|
||||
);
|
||||
if (!installation) throw new Error(`No active watcher for ${rootPath}`);
|
||||
return installation.watcher;
|
||||
}
|
||||
}
|
||||
|
||||
type ReconciliationRun = () => Promise<ReconciliationResult>;
|
||||
|
||||
class FakeGitMetadata {
|
||||
private readonly plannedRuns: ReconciliationRun[] = [];
|
||||
runs = 0;
|
||||
|
||||
async reconcileGitMetadata(): Promise<ReconciliationResult> {
|
||||
this.runs += 1;
|
||||
const run = this.plannedRuns.shift();
|
||||
if (run) return run();
|
||||
return { changesApplied: [], durationMs: 0 };
|
||||
}
|
||||
|
||||
runNext(run: ReconciliationRun): void {
|
||||
this.plannedRuns.push(run);
|
||||
}
|
||||
|
||||
failNext(error: Error): void {
|
||||
this.plannedRuns.push(async () => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
holdNext(changesApplied: ReconciliationChange[]): {
|
||||
started: Promise<void>;
|
||||
release: () => void;
|
||||
} {
|
||||
const started = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
this.plannedRuns.push(async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return { changesApplied, durationMs: 0 };
|
||||
});
|
||||
return { started: started.promise, release: () => release.resolve() };
|
||||
}
|
||||
}
|
||||
|
||||
interface LogRecord {
|
||||
level: "debug" | "warn";
|
||||
payload: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function capturingLogger(records: LogRecord[]): pino.Logger {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: () => undefined,
|
||||
debug: (payload: unknown, message: string) =>
|
||||
records.push({ level: "debug", payload, message }),
|
||||
info: () => undefined,
|
||||
warn: (payload: unknown, message: string) => records.push({ level: "warn", payload, message }),
|
||||
error: () => undefined,
|
||||
};
|
||||
return logger as unknown as pino.Logger;
|
||||
}
|
||||
|
||||
class ObservedProjects {
|
||||
private readonly lifecycleEvents: string[] = [];
|
||||
private readonly clock = new TestClock();
|
||||
private readonly roots = new FakeProjectRoots(this.lifecycleEvents);
|
||||
private readonly registry: FakeProjectRegistry;
|
||||
private readonly gitMetadata = new FakeGitMetadata();
|
||||
private readonly projectEvents: ProjectGitObserverUpdate[] = [];
|
||||
private readonly workspaceEvents: string[][] = [];
|
||||
private readonly logRecords: LogRecord[] = [];
|
||||
private readonly service: ProjectGitObserverService;
|
||||
|
||||
constructor(initialProjects: PersistedProjectRecord[]) {
|
||||
this.registry = new FakeProjectRegistry(initialProjects, this.lifecycleEvents);
|
||||
this.service = new ProjectGitObserverService({
|
||||
projectRegistry: this.registry,
|
||||
reconciliation: this.gitMetadata,
|
||||
logger: capturingLogger(this.logRecords),
|
||||
onProjectUpdate: (update) => {
|
||||
this.projectEvents.push(update);
|
||||
const projectId = update.kind === "upsert" ? update.project.projectId : update.projectId;
|
||||
this.lifecycleEvents.push(`project published:${update.kind}:${projectId}`);
|
||||
},
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
this.workspaceEvents.push(workspaceIds);
|
||||
},
|
||||
watch: this.roots.watch,
|
||||
clock: this.clock,
|
||||
debounceMs: DEBOUNCE_MS,
|
||||
rescanIntervalMs: RESCAN_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.service.start();
|
||||
}
|
||||
|
||||
async startAgain(): Promise<void> {
|
||||
await this.service.start();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.service.dispose();
|
||||
}
|
||||
|
||||
async add(record: PersistedProjectRecord): Promise<void> {
|
||||
await this.registry.upsert(record);
|
||||
}
|
||||
|
||||
async archive(projectId: string): Promise<void> {
|
||||
await this.registry.archive(projectId, TIMESTAMP);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
await this.registry.remove(projectId);
|
||||
}
|
||||
|
||||
change(rootPath: string, filename: string | Buffer | null): void {
|
||||
this.roots.change(rootPath, filename);
|
||||
}
|
||||
|
||||
watcherFailed(rootPath: string, error: Error): void {
|
||||
this.roots.fail(rootPath, error);
|
||||
}
|
||||
|
||||
failNextWatch(rootPath: string, error: Error): void {
|
||||
this.roots.failNextInstall(rootPath, error);
|
||||
}
|
||||
|
||||
async advanceBy(elapsedMs: number): Promise<void> {
|
||||
await this.clock.advanceBy(elapsedMs);
|
||||
}
|
||||
|
||||
failNextReconciliation(error: Error): void {
|
||||
this.gitMetadata.failNext(error);
|
||||
}
|
||||
|
||||
holdNextRegistryRead(): { started: Promise<void>; release: () => void } {
|
||||
return this.registry.holdNextList();
|
||||
}
|
||||
|
||||
holdNextReconciliation(changesApplied: ReconciliationChange[]): {
|
||||
started: Promise<void>;
|
||||
release: () => void;
|
||||
} {
|
||||
return this.gitMetadata.holdNext(changesApplied);
|
||||
}
|
||||
|
||||
reconcileNextWithProjectUpdate(
|
||||
updatedProject: PersistedProjectRecord,
|
||||
changesApplied: ReconciliationChange[],
|
||||
): void {
|
||||
this.gitMetadata.runNext(async () => {
|
||||
await this.registry.upsert(updatedProject);
|
||||
return { changesApplied, durationMs: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
clearLifecycle(): void {
|
||||
this.lifecycleEvents.length = 0;
|
||||
}
|
||||
|
||||
get watchedRoots(): Array<{ rootPath: string; recursive: false }> {
|
||||
return this.roots.active;
|
||||
}
|
||||
|
||||
get closedRoots(): string[] {
|
||||
return this.roots.closedRoots;
|
||||
}
|
||||
|
||||
get pendingTimerCount(): number {
|
||||
return this.clock.pendingCount;
|
||||
}
|
||||
|
||||
get subscriptionCount(): number {
|
||||
return this.registry.subscriptionCount;
|
||||
}
|
||||
|
||||
get lifecycle(): string[] {
|
||||
return [...this.lifecycleEvents];
|
||||
}
|
||||
|
||||
get publishedProjects(): ProjectGitObserverUpdate[] {
|
||||
return [...this.projectEvents];
|
||||
}
|
||||
|
||||
get publishedWorkspaceBatches(): string[][] {
|
||||
return this.workspaceEvents.map((workspaceIds) => [...workspaceIds]);
|
||||
}
|
||||
|
||||
get reconciliationRuns(): number {
|
||||
return this.gitMetadata.runs;
|
||||
}
|
||||
|
||||
get warnings(): LogRecord[] {
|
||||
return this.logRecords.filter((record) => record.level === "warn");
|
||||
}
|
||||
|
||||
get debugLogs(): LogRecord[] {
|
||||
return this.logRecords.filter((record) => record.level === "debug");
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProjectGitObserverService", () => {
|
||||
test("starts one non-recursive watch per active lexical root and starts only once", async () => {
|
||||
const projects = new ObservedProjects([
|
||||
project("project-one", "/work/repo"),
|
||||
project("project-duplicate", "/work/repo/./"),
|
||||
project("project-two", "/work/other"),
|
||||
project("project-archived", "/work/archived", TIMESTAMP),
|
||||
]);
|
||||
|
||||
await projects.start();
|
||||
await projects.startAgain();
|
||||
|
||||
expect(projects.watchedRoots).toEqual([
|
||||
{ rootPath: "/work/repo", recursive: false },
|
||||
{ rootPath: "/work/other", recursive: false },
|
||||
]);
|
||||
expect(projects.pendingTimerCount).toBe(1);
|
||||
expect(projects.subscriptionCount).toBe(1);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("installs a newly added project's watch and publishes its upsert before add resolves", async () => {
|
||||
const projects = new ObservedProjects([]);
|
||||
await projects.start();
|
||||
projects.clearLifecycle();
|
||||
const added = project("project-new", "/work/new");
|
||||
|
||||
await projects.add(added);
|
||||
|
||||
expect(projects.lifecycle).toEqual([
|
||||
"watch installed:/work/new",
|
||||
"project published:upsert:project-new",
|
||||
"mutator resolved:upsert:project-new",
|
||||
]);
|
||||
expect(projects.publishedProjects).toEqual([{ kind: "upsert", project: added }]);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("tears down archived and removed project watches and publishes exact removes", async () => {
|
||||
const archivedProject = project("project-archive", "/work/archive");
|
||||
const removedProject = project("project-remove", "/work/remove");
|
||||
const projects = new ObservedProjects([archivedProject, removedProject]);
|
||||
await projects.start();
|
||||
|
||||
await projects.archive(archivedProject.projectId);
|
||||
await projects.remove(removedProject.projectId);
|
||||
|
||||
expect(projects.watchedRoots).toEqual([]);
|
||||
expect(projects.closedRoots).toEqual(["/work/archive", "/work/remove"]);
|
||||
expect(projects.publishedProjects).toEqual([
|
||||
{ kind: "remove", projectId: "project-archive" },
|
||||
{ kind: "remove", projectId: "project-remove" },
|
||||
]);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("ignores unrelated filenames and coalesces .git and unknown-filename bursts", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
await projects.start();
|
||||
|
||||
projects.change("/work/repo", "README.md");
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
expect(projects.reconciliationRuns).toBe(0);
|
||||
|
||||
projects.change("/work/repo", ".git");
|
||||
projects.change("/work/repo", ".git");
|
||||
projects.change("/work/repo", ".git");
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
expect(projects.reconciliationRuns).toBe(1);
|
||||
|
||||
projects.change("/work/repo", null);
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
expect(projects.reconciliationRuns).toBe(2);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("drops an errored watch and recreates it on the periodic rescan", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
await projects.start();
|
||||
const watchError = new Error("watch failed");
|
||||
|
||||
projects.watcherFailed("/work/repo", watchError);
|
||||
|
||||
expect(projects.watchedRoots).toEqual([]);
|
||||
expect(projects.closedRoots).toEqual(["/work/repo"]);
|
||||
expect(projects.warnings).toEqual([
|
||||
{
|
||||
level: "warn",
|
||||
payload: { err: watchError, rootPath: "/work/repo" },
|
||||
message: "Project root watch failed",
|
||||
},
|
||||
]);
|
||||
|
||||
await projects.advanceBy(RESCAN_INTERVAL_MS);
|
||||
|
||||
expect(projects.watchedRoots).toEqual([{ rootPath: "/work/repo", recursive: false }]);
|
||||
expect(projects.reconciliationRuns).toBe(1);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("retries a root that becomes watchable before the periodic rescan", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
const installError = new Error("root unavailable");
|
||||
projects.failNextWatch("/work/repo", installError);
|
||||
|
||||
await projects.start();
|
||||
|
||||
expect(projects.watchedRoots).toEqual([]);
|
||||
expect(projects.debugLogs).toEqual([
|
||||
{
|
||||
level: "debug",
|
||||
payload: { err: installError, rootPath: "/work/repo" },
|
||||
message: "Project root is not watchable yet",
|
||||
},
|
||||
]);
|
||||
|
||||
await projects.advanceBy(RESCAN_INTERVAL_MS);
|
||||
|
||||
expect(projects.watchedRoots).toEqual([{ rootPath: "/work/repo", recursive: false }]);
|
||||
expect(projects.reconciliationRuns).toBe(1);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("contains and logs a reconciliation failure so later changes still converge", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
await projects.start();
|
||||
const reconciliationError = new Error("git metadata unavailable");
|
||||
projects.failNextReconciliation(reconciliationError);
|
||||
|
||||
projects.change("/work/repo", ".git");
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
|
||||
expect(projects.warnings).toEqual([
|
||||
{
|
||||
level: "warn",
|
||||
payload: { err: reconciliationError },
|
||||
message: "Project Git metadata reconciliation failed",
|
||||
},
|
||||
]);
|
||||
expect(projects.watchedRoots).toEqual([{ rootPath: "/work/repo", recursive: false }]);
|
||||
|
||||
projects.change("/work/repo", ".git");
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
expect(projects.reconciliationRuns).toBe(2);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("publishes registry project changes once and fans out one deduplicated workspace batch", async () => {
|
||||
const original = project("project-one", "/work/repo");
|
||||
const updated = { ...original, kind: "git" as const };
|
||||
const projects = new ObservedProjects([original]);
|
||||
await projects.start();
|
||||
projects.reconcileNextWithProjectUpdate(updated, [
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: original.projectId,
|
||||
directory: original.rootPath,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "workspace-one",
|
||||
directory: "/work/repo",
|
||||
fields: { kind: "local_checkout" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "workspace-one",
|
||||
directory: "/work/repo/.",
|
||||
fields: { branch: "main" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "workspace-two",
|
||||
directory: "/work/other",
|
||||
fields: { branch: "topic" },
|
||||
},
|
||||
]);
|
||||
|
||||
projects.change(original.rootPath, ".git");
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
|
||||
expect(projects.publishedProjects).toEqual([{ kind: "upsert", project: updated }]);
|
||||
expect(projects.publishedWorkspaceBatches).toEqual([["workspace-one", "workspace-two"]]);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("does not feed an authoritative registry mutation back into metadata reconciliation", async () => {
|
||||
const projects = new ObservedProjects([]);
|
||||
await projects.start();
|
||||
|
||||
await projects.add(project("project-new", "/work/new"));
|
||||
await projects.advanceBy(DEBOUNCE_MS);
|
||||
|
||||
expect(projects.watchedRoots).toEqual([{ rootPath: "/work/new", recursive: false }]);
|
||||
expect(projects.publishedProjects).toEqual([
|
||||
{
|
||||
kind: "upsert",
|
||||
project: project("project-new", "/work/new"),
|
||||
},
|
||||
]);
|
||||
expect(projects.reconciliationRuns).toBe(0);
|
||||
projects.dispose();
|
||||
});
|
||||
|
||||
test("dispose closes watches, timers, and subscription while suppressing an in-flight mutation", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
await projects.start();
|
||||
projects.change("/work/repo", ".git");
|
||||
const registryRead = projects.holdNextRegistryRead();
|
||||
const adding = projects.add(project("project-late", "/work/late"));
|
||||
await registryRead.started;
|
||||
|
||||
projects.dispose();
|
||||
registryRead.release();
|
||||
await adding;
|
||||
|
||||
expect(projects.watchedRoots).toEqual([]);
|
||||
expect(projects.closedRoots).toEqual(["/work/repo"]);
|
||||
expect(projects.pendingTimerCount).toBe(0);
|
||||
expect(projects.subscriptionCount).toBe(0);
|
||||
expect(projects.publishedProjects).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose suppresses workspace fanout from an in-flight reconciliation", async () => {
|
||||
const projects = new ObservedProjects([project("project-one", "/work/repo")]);
|
||||
await projects.start();
|
||||
const reconciliation = projects.holdNextReconciliation([
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "workspace-late",
|
||||
directory: "/work/repo",
|
||||
fields: { branch: "main" },
|
||||
},
|
||||
]);
|
||||
projects.change("/work/repo", ".git");
|
||||
const advancing = projects.advanceBy(DEBOUNCE_MS);
|
||||
await reconciliation.started;
|
||||
|
||||
projects.dispose();
|
||||
reconciliation.release();
|
||||
await advancing;
|
||||
|
||||
expect(projects.publishedWorkspaceBatches).toEqual([]);
|
||||
expect(projects.pendingTimerCount).toBe(0);
|
||||
expect(projects.subscriptionCount).toBe(0);
|
||||
expect(projects.watchedRoots).toEqual([]);
|
||||
});
|
||||
});
|
||||
214
packages/server/src/server/project-git-observer-service.ts
Normal file
214
packages/server/src/server/project-git-observer-service.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { watch as watchPath } from "node:fs";
|
||||
|
||||
import type pino from "pino";
|
||||
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import type { PersistedProjectRecord, ProjectRegistry } from "./workspace-registry.js";
|
||||
import type { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
|
||||
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 100;
|
||||
|
||||
export type ProjectGitObserverUpdate =
|
||||
| { kind: "upsert"; project: PersistedProjectRecord }
|
||||
| { kind: "remove"; projectId: string };
|
||||
|
||||
interface ProjectRootWatcher {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface ProjectRootWatch {
|
||||
(
|
||||
rootPath: string,
|
||||
options: { recursive: false },
|
||||
onChange: (event: string, filename: string | Buffer | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): ProjectRootWatcher;
|
||||
}
|
||||
|
||||
interface ObserverTimer {
|
||||
unref?(): void;
|
||||
}
|
||||
|
||||
interface ObserverClock {
|
||||
setTimeout(callback: () => void | Promise<void>, delayMs: number): ObserverTimer;
|
||||
clearTimeout(timer: ObserverTimer): void;
|
||||
setInterval(callback: () => void | Promise<void>, delayMs: number): ObserverTimer;
|
||||
clearInterval(timer: ObserverTimer): void;
|
||||
}
|
||||
|
||||
const systemClock: ObserverClock = {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Daemon-owned, root-only Git metadata observation. This deliberately does not
|
||||
* use the working-tree watcher: projects may be empty and only `.git` matters.
|
||||
*/
|
||||
export class ProjectGitObserverService {
|
||||
private readonly watchers: Array<{ rootPath: string; watcher: ProjectRootWatcher }> = [];
|
||||
private unsubscribeRegistry: (() => void) | null = null;
|
||||
private rescanTimer: ObserverTimer | null = null;
|
||||
private debounceTimer: ObserverTimer | null = null;
|
||||
private disposed = false;
|
||||
private started = false;
|
||||
private reconciling = false;
|
||||
private queued = false;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
projectRegistry: ProjectRegistry;
|
||||
reconciliation: Pick<WorkspaceReconciliationService, "reconcileGitMetadata">;
|
||||
logger: pino.Logger;
|
||||
onProjectUpdate: (update: ProjectGitObserverUpdate) => void;
|
||||
onWorkspacesChanged: (workspaceIds: string[]) => Promise<void>;
|
||||
watch?: ProjectRootWatch;
|
||||
clock?: ObserverClock;
|
||||
rescanIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.unsubscribeRegistry =
|
||||
this.deps.projectRegistry.subscribeToMutations?.(async (mutation) => {
|
||||
// The registry calls this before its mutator resolves, installing a root
|
||||
// watch before project.add can return and git init can race it.
|
||||
try {
|
||||
await this.sync();
|
||||
if (this.disposed) return;
|
||||
if (mutation.kind === "upsert" && mutation.project && !mutation.project.archivedAt) {
|
||||
this.deps.onProjectUpdate({ kind: "upsert", project: mutation.project });
|
||||
} else {
|
||||
this.deps.onProjectUpdate({ kind: "remove", projectId: mutation.projectId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logger.warn({ err: error }, "Project Git observer mutation handling failed");
|
||||
}
|
||||
}) ?? null;
|
||||
await this.sync();
|
||||
const clock = this.deps.clock ?? systemClock;
|
||||
this.rescanTimer = clock.setInterval(
|
||||
() => this.reconcileSafe(),
|
||||
this.deps.rescanIntervalMs ?? DEFAULT_RESCAN_INTERVAL_MS,
|
||||
);
|
||||
this.rescanTimer.unref?.();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.unsubscribeRegistry?.();
|
||||
this.unsubscribeRegistry = null;
|
||||
const clock = this.deps.clock ?? systemClock;
|
||||
if (this.rescanTimer) clock.clearInterval(this.rescanTimer);
|
||||
if (this.debounceTimer) clock.clearTimeout(this.debounceTimer);
|
||||
for (const { watcher } of this.watchers) watcher.close();
|
||||
this.watchers.length = 0;
|
||||
}
|
||||
|
||||
private async sync(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
const projects = await this.deps.projectRegistry.list();
|
||||
if (this.disposed) return;
|
||||
const active = projects.filter((project) => !project.archivedAt);
|
||||
for (let index = this.watchers.length - 1; index >= 0; index -= 1) {
|
||||
if (
|
||||
!active.some((project) =>
|
||||
areEquivalentPaths(project.rootPath, this.watchers[index]!.rootPath),
|
||||
)
|
||||
) {
|
||||
this.watchers[index]!.watcher.close();
|
||||
this.watchers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
for (const project of active) {
|
||||
if (this.watchers.some((target) => areEquivalentPaths(target.rootPath, project.rootPath)))
|
||||
continue;
|
||||
try {
|
||||
let watcher: ProjectRootWatcher;
|
||||
watcher = (this.deps.watch ?? watchProjectRoot)(
|
||||
project.rootPath,
|
||||
{ recursive: false },
|
||||
(_event, filename) => {
|
||||
if (filename === null || filename.toString() === ".git") this.scheduleReconcile();
|
||||
},
|
||||
(error) => {
|
||||
watcher.close();
|
||||
const index = this.watchers.findIndex((target) => target.watcher === watcher);
|
||||
if (index >= 0) this.watchers.splice(index, 1);
|
||||
this.deps.logger.warn(
|
||||
{ err: error, rootPath: project.rootPath },
|
||||
"Project root watch failed",
|
||||
);
|
||||
},
|
||||
);
|
||||
this.watchers.push({ rootPath: project.rootPath, watcher });
|
||||
} catch (error) {
|
||||
// The slow rescan is the convergence path for missing/unwatchable roots.
|
||||
this.deps.logger.debug(
|
||||
{ err: error, rootPath: project.rootPath },
|
||||
"Project root is not watchable yet",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconcile(): void {
|
||||
if (this.disposed || this.debounceTimer) return;
|
||||
this.debounceTimer = (this.deps.clock ?? systemClock).setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
return this.reconcileSafe();
|
||||
}, this.deps.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.reconciling) {
|
||||
this.queued = true;
|
||||
return;
|
||||
}
|
||||
this.reconciling = true;
|
||||
try {
|
||||
await this.sync();
|
||||
const result = await this.deps.reconciliation.reconcileGitMetadata();
|
||||
const workspaceIds = [
|
||||
...new Set(
|
||||
result.changesApplied
|
||||
.filter(
|
||||
(change): change is Extract<typeof change, { kind: "workspace_updated" }> =>
|
||||
change.kind === "workspace_updated",
|
||||
)
|
||||
.map((change) => change.workspaceId),
|
||||
),
|
||||
];
|
||||
if (!this.disposed && workspaceIds.length > 0)
|
||||
await this.deps.onWorkspacesChanged(workspaceIds);
|
||||
} finally {
|
||||
this.reconciling = false;
|
||||
if (this.queued) {
|
||||
this.queued = false;
|
||||
void this.reconcileSafe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileSafe(): Promise<void> {
|
||||
try {
|
||||
await this.reconcile();
|
||||
} catch (error) {
|
||||
if (!this.disposed)
|
||||
this.deps.logger.warn({ err: error }, "Project Git metadata reconciliation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
|
||||
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||
import {
|
||||
@@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false;
|
||||
|
||||
type TestScheduleServiceOptions = Omit<
|
||||
ScheduleServiceOptions,
|
||||
| "createAgent"
|
||||
| "createLocalCheckoutWorkspace"
|
||||
| "createPaseoWorktreeWorkspace"
|
||||
| "archiveWorkspace"
|
||||
"createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace"
|
||||
> & {
|
||||
agentManager: AgentManager;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||
createAgent?: ScheduleServiceOptions["createAgent"];
|
||||
createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
|
||||
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
|
||||
};
|
||||
@@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
let workspaceCounter = 0;
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async (
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async (
|
||||
input,
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -163,7 +160,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
},
|
||||
input,
|
||||
)),
|
||||
createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace,
|
||||
createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace,
|
||||
createPaseoWorktreeWorkspace:
|
||||
options.createPaseoWorktreeWorkspace ??
|
||||
(async (input) => {
|
||||
@@ -182,7 +179,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
|
||||
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
|
||||
workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createArchiveWorkspace: (input: {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -200,12 +197,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: async (input) => {
|
||||
return createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: input.firstAgentContext.prompt },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
createDirectoryWorkspace: async (input) => {
|
||||
return workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
input.firstAgentContext.prompt,
|
||||
);
|
||||
},
|
||||
createArchiveWorkspace:
|
||||
@@ -493,7 +495,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
|
||||
test("new-agent schedule records create no workspace until run time", async () => {
|
||||
const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } =
|
||||
const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } =
|
||||
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -501,7 +503,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
@@ -531,7 +533,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -545,7 +547,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -599,7 +601,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -620,7 +622,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -663,7 +665,7 @@ describe("ScheduleService", () => {
|
||||
test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -678,7 +680,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
|
||||
@@ -218,7 +218,7 @@ export interface ScheduleServiceOptions {
|
||||
agentManager: ScheduleAgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
createLocalCheckoutWorkspace: (
|
||||
createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
createPaseoWorktreeWorkspace: (
|
||||
@@ -235,7 +235,7 @@ export class ScheduleService {
|
||||
private readonly agentManager: ScheduleAgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly createAgent: BoundCreateAgentCommand;
|
||||
private readonly createLocalCheckoutWorkspace: (
|
||||
private readonly createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
private readonly createPaseoWorktreeWorkspace: (
|
||||
@@ -256,7 +256,7 @@ export class ScheduleService {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgent = options.createAgent;
|
||||
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
|
||||
this.createDirectoryWorkspace = options.createDirectoryWorkspace;
|
||||
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
||||
this.archiveWorkspace = options.archiveWorkspace;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
@@ -954,7 +954,7 @@ export class ScheduleService {
|
||||
const firstAgentContext = { prompt };
|
||||
switch (config.isolation ?? "local") {
|
||||
case "local":
|
||||
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
case "worktree":
|
||||
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
||||
.workspace;
|
||||
|
||||
@@ -288,7 +288,7 @@ interface SessionForTestOptions {
|
||||
hasLocalBranch?: ReturnType<typeof vi.fn>;
|
||||
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
|
||||
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||
getProjectSlug?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
@@ -331,7 +331,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
hasLocalBranch: vi.fn(),
|
||||
resolveRepoRemoteUrl: vi.fn(),
|
||||
resolveRepoRoot: vi.fn(),
|
||||
getWorkspaceGitMetadata: vi.fn(),
|
||||
getProjectSlug: vi.fn(),
|
||||
};
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
@@ -3991,17 +3991,17 @@ describe("session paseo worktree creation handling", () => {
|
||||
});
|
||||
|
||||
describe("session workspace script handling", () => {
|
||||
test("passes service-owned git metadata into workspace script spawning", async () => {
|
||||
test("passes the project slug and cached branch into workspace script spawning", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
workspaceDisplayName: "feature/service-scripts",
|
||||
projectSlug: "paseo",
|
||||
const snapshot = createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "feature/service-scripts",
|
||||
}),
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
},
|
||||
});
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => snapshot),
|
||||
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
|
||||
};
|
||||
const workspaceRegistry = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
@@ -4034,8 +4034,6 @@ describe("session workspace script handling", () => {
|
||||
requestId: "request-script",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoRoot: "/tmp/repo",
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
} from "./session/git-mutation/git-mutation-service.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
WorkspaceProvisioningError,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
@@ -204,7 +205,6 @@ import {
|
||||
} from "./workspace-directory.js";
|
||||
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
||||
import {
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
@@ -970,6 +970,19 @@ export class Session {
|
||||
return this.clientCapabilities.has(capability);
|
||||
}
|
||||
|
||||
emitProjectUpdate(project: PersistedProjectRecord): void {
|
||||
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
|
||||
this.emit({
|
||||
type: "project.update",
|
||||
payload: { kind: "upsert", project: this.buildProjectDescriptor(project) },
|
||||
});
|
||||
}
|
||||
|
||||
emitProjectRemove(projectId: string): void {
|
||||
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
|
||||
this.emit({ type: "project.update", payload: { kind: "remove", projectId } });
|
||||
}
|
||||
|
||||
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
|
||||
}
|
||||
@@ -993,8 +1006,11 @@ export class Session {
|
||||
this.clearWorkspaceArchiving(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { skipReconcile?: boolean },
|
||||
): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
@@ -4202,7 +4218,6 @@ export class Session {
|
||||
case "workspace_updated":
|
||||
changedWorkspaceIds.add(change.workspaceId);
|
||||
break;
|
||||
case "project_archived":
|
||||
case "project_updated":
|
||||
changedProjectIds.add(change.projectId);
|
||||
break;
|
||||
@@ -4601,6 +4616,7 @@ export class Session {
|
||||
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
|
||||
"Failed to create workspace",
|
||||
);
|
||||
const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined;
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
@@ -4608,6 +4624,7 @@ export class Session {
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: message,
|
||||
errorCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4638,13 +4655,10 @@ export class Session {
|
||||
|
||||
const explicitTitle = request.title?.trim() || null;
|
||||
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: explicitTitle ?? promptTitle },
|
||||
{
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
explicitTitle ?? promptTitle,
|
||||
request.source.projectId,
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
|
||||
@@ -112,6 +112,22 @@ function createHarness(input: {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: string) => projects.get(id) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === allocation.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -309,10 +325,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo
|
||||
// root, returns the existing workspace. (Per "always go to the nearest git".)
|
||||
// S4. Every selected path is an exact lexical root, even inside a Git checkout.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
||||
test("S4: open subdir of active git workspace creates an independent exact-root workspace", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
@@ -320,8 +335,9 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
|
||||
});
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(resp?.workspace?.projectId).not.toBe(workspaceByCwd(h.workspaces, FOO)?.projectId);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -342,10 +358,10 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S6. Open the EXACT path of an archived git workspace: this IS explicit user
|
||||
// intent to re-open what they archived. Unarchive is correct here.
|
||||
// S6. Explicit project opening allocates a fresh identity when only archived
|
||||
// records exist. Agent restore is the separate path that restores ownership.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
||||
test("S6: re-opening an archived git workspace by exact path creates a fresh project and workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -353,8 +369,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
const fresh = Array.from(h.workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === TOOLBOX && !workspace.archivedAt,
|
||||
);
|
||||
expect(fresh?.workspaceId).not.toBe("ws-toolbox");
|
||||
expect(fresh?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -432,11 +452,10 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S11. Archive then re-add round-trip (project-level): opening the exact path
|
||||
// of an archived project unarchives both the project and its workspace,
|
||||
// reusing the same path-derived ids.
|
||||
// S11. Archive then re-add produces a fresh opaque identity; it never reuses
|
||||
// archived compatibility IDs.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
||||
test("S11: re-opening an archived project by exact path keeps archived records and allocates fresh ids", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -446,12 +465,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
|
||||
await openProject(h.session, TOOLBOX);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(resp?.workspace?.id).not.toBe("ws-toolbox");
|
||||
expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
expect(h.projects.size).toBe(2);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -481,7 +499,7 @@ test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefi
|
||||
// being explicit. To get git features back the user unarchives the parent
|
||||
// (S6/S11).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
||||
test("S13: subfolder of an archived git repo opens as its own git-backed workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -491,5 +509,5 @@ test("S13: subfolder of an archived git repo opens as a directory workspace", as
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
@@ -154,6 +154,10 @@ interface SessionTestAccess {
|
||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
||||
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { skipReconcile?: boolean },
|
||||
): Promise<void>;
|
||||
emit(message: unknown): void;
|
||||
onMessage(message: unknown): void;
|
||||
paseoHome: string;
|
||||
@@ -652,6 +656,15 @@ function createSessionForWorkspaceTests(
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
getOrCreateActiveByRoot: async (input) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_0000000000000000",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
}),
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -855,7 +868,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
||||
).resolves.toMatchObject({
|
||||
projectKey: parent,
|
||||
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
checkout: { cwd: child },
|
||||
});
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
@@ -3412,6 +3425,22 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === input.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -3539,6 +3568,18 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_githubruntime",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -3770,6 +3811,18 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_githubruntime",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
};
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
@@ -3995,7 +4048,6 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
"orchestrate",
|
||||
"desktop-daemon-settings",
|
||||
);
|
||||
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
||||
const archivedAt = "2026-04-24T09:48:36.168Z";
|
||||
const workspaceId = "ws-desktop-daemon-settings";
|
||||
|
||||
@@ -4073,12 +4125,9 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(projects.get(remoteProjectId)?.kind).toBe("git");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(projects.get(cwd)?.archivedAt).toBe(archivedAt);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
|
||||
@@ -4192,14 +4241,11 @@ test("open_project_request reclassifies an active directory workspace when git m
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
|
||||
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
|
||||
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
|
||||
expect(response?.payload.workspace?.projectId).toBe(cwd);
|
||||
expect(workspaces.get(workspaceId)?.projectId).toBe(cwd);
|
||||
});
|
||||
|
||||
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
|
||||
test("open_project_request gives a plain git worktree its own exact-root project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4285,16 +4331,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === cwd,
|
||||
);
|
||||
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
|
||||
expect(worktreeWorkspace?.kind).toBe("worktree");
|
||||
expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("open_project_request unarchives an existing archived workspace and project", async () => {
|
||||
test("open_project_request keeps archived records and allocates a fresh workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4353,14 +4397,15 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
requestId: "req-open-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).not.toBeNull();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("open_project_request recreates a missing project record when unarchiving its workspace", async () => {
|
||||
test("open_project_request does not repurpose an orphaned archived workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
@@ -4407,18 +4452,12 @@ test("open_project_request recreates a missing project record when unarchiving i
|
||||
requestId: "req-open-removed-project",
|
||||
});
|
||||
|
||||
expect(projects.get(cwd)).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: cwd,
|
||||
displayName: "repo",
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)).toBeUndefined();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
||||
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
||||
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("workspace recovery stays accepted when git observer warming fails", async () => {
|
||||
@@ -6098,6 +6137,90 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
|
||||
]);
|
||||
});
|
||||
|
||||
test("external workspace updates emit one deduplicated batch without reconciling", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-observer-batch",
|
||||
rootPath: "/tmp/observer-batch",
|
||||
kind: "non_git",
|
||||
displayName: "observer-batch",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const main = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-observer-main",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/observer-batch/main",
|
||||
kind: "directory",
|
||||
displayName: "main",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const feature = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-observer-feature",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/observer-batch/feature",
|
||||
kind: "directory",
|
||||
displayName: "feature",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const snapshotReads = { projects: 0, workspaces: 0 };
|
||||
session.projectRegistry.list = async () => {
|
||||
snapshotReads.projects += 1;
|
||||
return [project];
|
||||
};
|
||||
session.workspaceRegistry.list = async () => {
|
||||
snapshotReads.workspaces += 1;
|
||||
return [main, feature];
|
||||
};
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-observer-batch",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
session.onMessage = (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
[main.workspaceId, feature.workspaceId, main.workspaceId],
|
||||
{ skipReconcile: true },
|
||||
);
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: expect.objectContaining({
|
||||
id: main.workspaceId,
|
||||
projectId: project.projectId,
|
||||
workspaceDirectory: main.cwd,
|
||||
name: main.displayName,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: expect.objectContaining({
|
||||
id: feature.workspaceId,
|
||||
projectId: project.projectId,
|
||||
workspaceDirectory: feature.cwd,
|
||||
name: feature.displayName,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 });
|
||||
});
|
||||
|
||||
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
@@ -7341,6 +7464,18 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_worktreefixture",
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -7730,3 +7865,93 @@ test("workspace.create.response persists the first prompt as the initial title",
|
||||
const persisted = await session.workspaceRegistry.get(workspaceId as string);
|
||||
expect(persisted?.title).toBe("Add retries to the payments flow");
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
WorkspaceProvisioningError,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./workspace-provisioning-service.js";
|
||||
|
||||
@@ -135,7 +136,7 @@ test("opening a subpath of an archived git workspace mints a fresh workspace at
|
||||
expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
|
||||
});
|
||||
|
||||
test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => {
|
||||
test("ensureWorkspaceRecordUnarchived restores the owning archived project with the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -204,13 +205,61 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
|
||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => {
|
||||
test("directory creation persists the live branch and a trimmed title", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(repo, " Focused work ");
|
||||
expect(workspace).toMatchObject({ branch: "main", title: "Focused work" });
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => {
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "elsewhere"),
|
||||
kind: "non_git",
|
||||
displayName: "elsewhere",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
);
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
|
||||
).rejects.toMatchObject({
|
||||
code: "unknown_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "archived"),
|
||||
kind: "non_git",
|
||||
displayName: "archived",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.archive(project.projectId, "2026-03-02T00:00:00.000Z");
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "archived_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory keeps nested selected roots independent", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
|
||||
const first = await provisioning.findOrCreateProjectForDirectory(repo);
|
||||
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
|
||||
|
||||
expect(second.projectId).toBe(first.projectId);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
expect(second.projectId).not.toBe(first.projectId);
|
||||
expect(first.rootPath).toBe(repo);
|
||||
expect(second.rootPath).toBe(path.join(repo, "sub"));
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { resolve } from "node:path";
|
||||
import { basename, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
generateWorkspaceId,
|
||||
} from "../../workspace-registry-model.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -15,18 +15,6 @@ import {
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
|
||||
/**
|
||||
* Resolves which workspace and project records a directory belongs to, creating,
|
||||
* reclassifying, or unarchiving them as needed. Every path that needs a workspace
|
||||
* for a cwd — opening a project, importing an agent, creating an agent, restoring
|
||||
* an archived worktree — funnels through this one module, so the
|
||||
* classify → resolve-project → persist → unarchive sequence (and the
|
||||
* archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases)
|
||||
* lives in a single place instead of being smeared across the session.
|
||||
*
|
||||
* Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts;
|
||||
* this module owns the create-and-persist side.
|
||||
*/
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
requestedWorkspaceId?: string;
|
||||
@@ -40,6 +28,7 @@ export interface WorkspaceProvisioningService {
|
||||
createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||
ensureWorkspaceRecordUnarchived(
|
||||
@@ -47,231 +36,123 @@ export interface WorkspaceProvisioningService {
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
}
|
||||
|
||||
export type WorkspaceProvisioningErrorCode = "unknown_project" | "archived_project";
|
||||
|
||||
export class WorkspaceProvisioningError extends Error {
|
||||
constructor(
|
||||
readonly code: WorkspaceProvisioningErrorCode,
|
||||
projectId: string,
|
||||
) {
|
||||
super(
|
||||
code === "unknown_project"
|
||||
? `Unknown project: ${projectId}`
|
||||
: `Archived project: ${projectId}`,
|
||||
);
|
||||
this.name = "WorkspaceProvisioningError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { workspaceRegistry, projectRegistry, workspaceGitService } = deps;
|
||||
|
||||
async function resolveWorkspaceDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<string> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
if (options?.refreshGit === false) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(normalizedCwd);
|
||||
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
return resolve(checkout.worktreeRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
async function findExactWorkspaceByDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<PersistedWorkspaceRecord | null> {
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd, options);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForPlacement(input: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const rootPath = input.membership.projectRootPath;
|
||||
const kind = input.membership.projectKind;
|
||||
const projects = await projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.membership.projectKey,
|
||||
rootPath,
|
||||
kind,
|
||||
displayName: input.membership.projectName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind,
|
||||
archivedAt: null,
|
||||
updatedAt: input.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function reclassifyOrUnarchiveWorkspaceForDirectory(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
project: PersistedProjectRecord | null;
|
||||
cwd: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(input.cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout });
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const rootPath = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(rootPath);
|
||||
const timestamp = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
return projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: checkout.isGit ? "git" : "non_git",
|
||||
displayName: basename(rootPath) || rootPath,
|
||||
timestamp,
|
||||
});
|
||||
const projectId = projectRecord.projectId;
|
||||
const kind = membership.workspaceKind;
|
||||
const displayName = membership.workspaceDisplayName;
|
||||
}
|
||||
|
||||
if (
|
||||
input.workspace.projectId === projectId &&
|
||||
input.workspace.kind === kind &&
|
||||
input.workspace.displayName === displayName
|
||||
) {
|
||||
if (!input.project) {
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
}
|
||||
return ensureWorkspaceRecordUnarchived(input.workspace);
|
||||
}
|
||||
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
|
||||
const project = await projectRegistry.get(projectId);
|
||||
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
|
||||
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
|
||||
return project;
|
||||
}
|
||||
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
|
||||
const nextWorkspace = {
|
||||
...input.workspace,
|
||||
projectId,
|
||||
cwd: input.cwd,
|
||||
kind,
|
||||
displayName,
|
||||
archivedAt: null,
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const project = projectId
|
||||
? await requireActiveProject(projectId)
|
||||
: // 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,
|
||||
cwd: normalizedCwd,
|
||||
kind: deriveWorkspaceKind(checkout),
|
||||
displayName: deriveWorkspaceDisplayName({ cwd: normalizedCwd, checkout }),
|
||||
branch:
|
||||
checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null,
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await workspaceRegistry.upsert(nextWorkspace);
|
||||
return nextWorkspace;
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const inputCwd = resolve(cwd);
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd);
|
||||
const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, {
|
||||
refreshGit: false,
|
||||
});
|
||||
if (existingWorkspace) {
|
||||
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: inputCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
return reclassifyOrUnarchiveWorkspaceForDirectory({
|
||||
workspace: existingWorkspace,
|
||||
project: await projectRegistry.get(existingWorkspace.projectId),
|
||||
cwd: normalizedCwd,
|
||||
});
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const active = (await workspaceRegistry.list())
|
||||
.filter((workspace) => !workspace.archivedAt && workspace.cwd === normalizedCwd)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (active) return active;
|
||||
const archived = (await workspaceRegistry.list())
|
||||
.filter((workspace) => workspace.archivedAt && workspace.cwd === normalizedCwd)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (archived) {
|
||||
const project = await projectRegistry.get(archived.projectId);
|
||||
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
|
||||
}
|
||||
|
||||
return createWorkspaceForDirectory(normalizedCwd);
|
||||
}
|
||||
|
||||
async function resolveOrCreateWorkspaceIdForCreateAgent(
|
||||
input: ResolveOrCreateWorkspaceIdInput,
|
||||
): Promise<string> {
|
||||
if (input.createdWorktree) {
|
||||
return input.createdWorktree.workspace.workspaceId;
|
||||
}
|
||||
|
||||
if (input.requestedWorkspaceId) {
|
||||
return input.requestedWorkspaceId;
|
||||
}
|
||||
|
||||
if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId;
|
||||
if (input.requestedWorkspaceId) return input.requestedWorkspaceId;
|
||||
return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
title: title ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
return projectRecord;
|
||||
}
|
||||
|
||||
async function ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
|
||||
const timestamp = new Date().toISOString();
|
||||
let unarchivedWorkspace = workspace;
|
||||
if (workspace.archivedAt) {
|
||||
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
|
||||
await workspaceRegistry.upsert(unarchivedWorkspace);
|
||||
if (project.archivedAt) {
|
||||
await projectRegistry.upsert({ ...project, archivedAt: null, updatedAt: timestamp });
|
||||
}
|
||||
if (project?.archivedAt) {
|
||||
await projectRegistry.upsert({
|
||||
...project,
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
return unarchivedWorkspace;
|
||||
if (!workspace.archivedAt) return workspace;
|
||||
const next = { ...workspace, archivedAt: null, updatedAt: timestamp };
|
||||
await workspaceRegistry.upsert(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
|
||||
import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
@@ -15,25 +15,13 @@ import type {
|
||||
} from "../../worktree-bootstrap.js";
|
||||
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata},
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
|
||||
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
||||
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const gitMetadata: WorkspaceGitMetadata = {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "repo",
|
||||
workspaceDisplayName: "repo",
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: null,
|
||||
};
|
||||
|
||||
function fakeWorkspaceRegistry(
|
||||
record: PersistedWorkspaceRecord | null,
|
||||
): Pick<WorkspaceRegistry, "get"> {
|
||||
@@ -44,13 +32,23 @@ function fakeWorkspaceRegistry(
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) {
|
||||
function fakeGitService(projectSlug = "paseo") {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
|
||||
snapshot.git = {
|
||||
...snapshot.git,
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
hasRemote: true,
|
||||
};
|
||||
|
||||
return {
|
||||
peekSnapshot() {
|
||||
return null;
|
||||
return snapshot;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return metadata;
|
||||
async getProjectSlug() {
|
||||
return projectSlug;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,11 +22,6 @@ import { deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||
|
||||
interface WorkspaceScriptGitMetadata {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
||||
* snapshot, emit a script_status_update to clients, and start a script.
|
||||
@@ -42,10 +37,7 @@ export interface WorkspaceScriptsService {
|
||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||
}
|
||||
|
||||
type WorkspaceScriptsGitSource = Pick<
|
||||
WorkspaceGitService,
|
||||
"peekSnapshot" | "getWorkspaceGitMetadata"
|
||||
>;
|
||||
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot" | "getProjectSlug">;
|
||||
|
||||
export function createWorkspaceScriptsService(deps: {
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
@@ -76,7 +68,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
spawnWorkspaceScript,
|
||||
} = deps;
|
||||
|
||||
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
|
||||
function resolveGitMetadata(workspaceDirectory: string) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
@@ -127,13 +119,14 @@ export function createWorkspaceScriptsService(deps: {
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||
const projectSlug = await workspaceGitService.getProjectSlug(workspace.cwd);
|
||||
const branchName = workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
|
||||
|
||||
const serviceResult = await spawnWorkspaceScript({
|
||||
repoRoot: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: gitMetadata.projectSlug,
|
||||
branchName: gitMetadata.currentBranch,
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "../workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "../workspace-git-metadata.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
@@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService(
|
||||
registerWorkspace: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
onSnapshotUpdated: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
@@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService(
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd,
|
||||
directoryName: options?.directoryName ?? basename(cwd),
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
},
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
|
||||
@@ -917,6 +917,20 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("advertises stable project identity in initial server_info", async () => {
|
||||
const server = createServer();
|
||||
const socket = new MockSocket();
|
||||
|
||||
const serverInfo = await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-stable-project-identity",
|
||||
});
|
||||
|
||||
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
const server = createServer({ speechReadiness });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||
import { basename, join } from "path";
|
||||
import { join } from "path";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks";
|
||||
@@ -9,7 +9,11 @@ import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
@@ -35,7 +39,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
@@ -187,17 +191,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
getWorkspaceGitMetadata: async (cwd: string, options) => {
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd,
|
||||
directoryName: options?.directoryName ?? basename(cwd),
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
},
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
@@ -219,6 +215,16 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
getOrCreateActiveByRoot: async (input) => ({
|
||||
projectId: "prj_noop",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
customName: null,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
archivedAt: null,
|
||||
}),
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -763,6 +769,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
public publishProjectUpdate(project: PersistedProjectRecord): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectUpdate(project);
|
||||
}
|
||||
|
||||
public publishProjectRemove(projectId: string): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectRemove(projectId);
|
||||
}
|
||||
|
||||
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
||||
}
|
||||
@@ -1266,6 +1280,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
commitsList: true,
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: true,
|
||||
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
|
||||
workspaceGithubClone: true,
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
type SessionOutboundMessage,
|
||||
WSHelloMessageSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import type { AgentTimelineRow } from "./agent/agent-manager.js";
|
||||
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
|
||||
const LegacyTimelineEntryPayloadSchema = z.object({
|
||||
provider: z.enum(["claude", "codex", "opencode"]),
|
||||
@@ -288,8 +292,8 @@ function createSessionForWireCompatTest(options?: {
|
||||
async resolveRepoRemoteUrl() {
|
||||
return null;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return null;
|
||||
async getProjectSlug() {
|
||||
return "project";
|
||||
},
|
||||
} as unknown as SessionOptions["workspaceGitService"],
|
||||
daemonConfigStore:
|
||||
@@ -326,6 +330,99 @@ async function emitTimelineResponse(
|
||||
}
|
||||
|
||||
describe("wire compatibility", () => {
|
||||
test("sends project updates only to clients that declare support", () => {
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project",
|
||||
kind: "git",
|
||||
displayName: "project",
|
||||
customName: "Favorite project",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
});
|
||||
const legacyMessages: SessionOutboundMessage[] = [];
|
||||
const capableMessages: SessionOutboundMessage[] = [];
|
||||
const legacy = createSessionForWireCompatTest({ messages: legacyMessages });
|
||||
const capable = createSessionForWireCompatTest({
|
||||
clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true },
|
||||
messages: capableMessages,
|
||||
});
|
||||
|
||||
legacy.emitProjectUpdate(project);
|
||||
legacy.emitProjectRemove(project.projectId);
|
||||
capable.emitProjectUpdate(project);
|
||||
capable.emitProjectRemove(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: { workspaceGithubClone: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("assistant timeline message ids are optional on the wire", () => {
|
||||
expect(
|
||||
AgentTimelineItemPayloadSchema.parse({
|
||||
|
||||
@@ -5,11 +5,7 @@ import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
deriveProjectSlug,
|
||||
parseGitHubRepoNameFromRemote,
|
||||
} from "./workspace-git-metadata.js";
|
||||
import { deriveProjectSlug, parseGitHubRepoNameFromRemote } from "./workspace-git-metadata.js";
|
||||
|
||||
function runGit(cwd: string, args: string[]): void {
|
||||
execFileSync("git", args, {
|
||||
@@ -170,47 +166,3 @@ describe("deriveProjectSlug", () => {
|
||||
expect(deriveProjectSlug(cwd)).toBe("untitled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceGitMetadataFromSnapshot", () => {
|
||||
test("uses owner/repo as the display name for GitHub remotes", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/some-dir",
|
||||
directoryName: "some-dir",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/some-dir",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@github.com:acme/widgets.git",
|
||||
});
|
||||
|
||||
expect(result.projectDisplayName).toBe("acme/widgets");
|
||||
});
|
||||
|
||||
test("uses owner/repo as the display name for non-GitHub remotes", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/random-name",
|
||||
directoryName: "random-name",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/random-name",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@gitlab.com:acme/app.git",
|
||||
});
|
||||
|
||||
expect(result.projectDisplayName).toBe("acme/app");
|
||||
});
|
||||
|
||||
test("falls back to the directory name when there is no remote", () => {
|
||||
const result = buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: "/repos/local-only",
|
||||
directoryName: "local-only",
|
||||
isGit: true,
|
||||
repoRoot: "/repos/local-only",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
});
|
||||
|
||||
expect(result.projectDisplayName).toBe("local-only");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { basename } from "path";
|
||||
import { parseGitHubRemoteUrl } from "../utils/github-remote.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 {
|
||||
return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null;
|
||||
@@ -33,50 +20,3 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null):
|
||||
const sourceName = githubRepoName ?? basename(cwd);
|
||||
return slugify(sourceName) || "untitled";
|
||||
}
|
||||
|
||||
export function buildWorkspaceGitMetadataFromSnapshot(input: {
|
||||
cwd: string;
|
||||
directoryName: string;
|
||||
isGit: boolean;
|
||||
repoRoot: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceGitMetadata {
|
||||
if (!input.isGit) {
|
||||
return {
|
||||
projectKind: "directory",
|
||||
projectDisplayName: input.directoryName,
|
||||
workspaceDisplayName: input.directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: deriveProjectSlug(input.cwd),
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const isWorktree =
|
||||
input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot;
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: input.repoRoot ?? input.cwd,
|
||||
remoteUrl: input.remoteUrl,
|
||||
mainRepoRoot: input.mainRepoRoot,
|
||||
});
|
||||
const projectDisplayName = projectKey.startsWith("remote:")
|
||||
? deriveProjectGroupingName(projectKey)
|
||||
: input.directoryName;
|
||||
|
||||
return {
|
||||
projectKind: "git",
|
||||
projectDisplayName,
|
||||
workspaceDisplayName: input.currentBranch ?? input.directoryName,
|
||||
gitRemote: input.remoteUrl,
|
||||
isWorktree,
|
||||
projectSlug: deriveProjectSlug(input.cwd, input.remoteUrl),
|
||||
repoRoot: input.repoRoot,
|
||||
currentBranch: input.currentBranch,
|
||||
remoteUrl: input.remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1497,7 +1497,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("getWorkspaceGitMetadata derives reconciliation metadata from the snapshot cache", async () => {
|
||||
test("getProjectSlug derives the slug from the snapshot cache", async () => {
|
||||
let nowMs = 0;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
@@ -1511,22 +1511,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }),
|
||||
).resolves.toEqual({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
workspaceDisplayName: "feature/service-metadata",
|
||||
gitRemote: "https://github.com/getpaseo/paseo.git",
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/service-metadata",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
});
|
||||
await expect(service.getProjectSlug(REPO_CWD)).resolves.toBe("paseo");
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
|
||||
await service.getProjectSlug(join(REPO_CWD, "."));
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import pLimit from "p-limit";
|
||||
import type pino from "pino";
|
||||
@@ -34,10 +34,7 @@ import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "./workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||
@@ -145,10 +142,7 @@ export interface WorkspaceGitService {
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||
getWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||
): Promise<WorkspaceGitMetadata>;
|
||||
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
|
||||
@@ -625,21 +619,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||
): Promise<WorkspaceGitMetadata> {
|
||||
async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string> {
|
||||
const snapshot = await this.getSnapshot(cwd, options);
|
||||
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd: resolve(cwd),
|
||||
directoryName,
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(resolve(cwd), snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
}
|
||||
|
||||
async resolveRepoRemoteUrl(
|
||||
|
||||
@@ -2,8 +2,9 @@ import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type pino from "pino";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -14,7 +15,10 @@ import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
type ReconciliationChange,
|
||||
WorkspaceReconciliationService,
|
||||
} from "./workspace-reconciliation-service.js";
|
||||
|
||||
function createTestRegistries() {
|
||||
const projects = new Map<string, PersistedProjectRecord>();
|
||||
@@ -25,6 +29,22 @@ function createTestRegistries() {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: string) => projects.get(id) ?? null,
|
||||
getOrCreateActiveByRoot: async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === input.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const record = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size}`,
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
projects.set(record.projectId, record);
|
||||
return record;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -64,11 +84,11 @@ function createTestRegistries() {
|
||||
function createTestLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
trace: () => undefined,
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
return logger as unknown as pino.Logger;
|
||||
}
|
||||
@@ -106,35 +126,62 @@ function createWorkspaceGitServiceStub(
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => {
|
||||
getCheckout: async (cwd: string) => {
|
||||
const metadata = metadataByCwd[cwd];
|
||||
const directoryName = options?.directoryName ?? path.basename(cwd);
|
||||
if (!metadata) {
|
||||
return {
|
||||
projectKind: "directory" as const,
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "untitled",
|
||||
repoRoot: null,
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
gitRemote: metadata.gitRemote ?? null,
|
||||
isWorktree: false,
|
||||
projectSlug: "repo",
|
||||
repoRoot: cwd,
|
||||
currentBranch: metadata.workspaceDisplayName,
|
||||
cwd,
|
||||
isGit: metadata.projectKind === "git",
|
||||
currentBranch: metadata.currentBranch ?? metadata.workspaceDisplayName,
|
||||
remoteUrl: metadata.gitRemote ?? null,
|
||||
...metadata,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCheckout(
|
||||
cwd: string,
|
||||
overrides: Partial<ProjectCheckoutLitePayload> = {},
|
||||
): ProjectCheckoutLitePayload {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class TestCheckouts {
|
||||
readonly reads: string[] = [];
|
||||
private readonly checkouts = new Map<string, ProjectCheckoutLitePayload>();
|
||||
|
||||
set(cwd: string, checkout: ProjectCheckoutLitePayload): void {
|
||||
this.checkouts.set(cwd, checkout);
|
||||
}
|
||||
|
||||
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
this.reads.push(cwd);
|
||||
return this.checkouts.get(cwd) ?? createCheckout(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
function initGitRepoInDir(dir: string): void {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
|
||||
@@ -169,6 +216,258 @@ describe("WorkspaceReconciliationService", () => {
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("metadata reconciliation leaves missing workspaces active while a full pass archives them", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-metadata-only-")));
|
||||
const missingWorkspace = path.join(projectRoot, "missing-workspace");
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "metadata-only",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: missingWorkspace,
|
||||
kind: "directory",
|
||||
displayName: "missing-workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const metadataResult = await service.reconcileGitMetadata();
|
||||
|
||||
expect(metadataResult.changesApplied).toEqual([]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toBeNull();
|
||||
|
||||
const fullResult = await service.runOnce();
|
||||
|
||||
expect(fullResult.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: missingWorkspace,
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("reads fresh checkout facts on every metadata pass", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-fresh-git-")));
|
||||
tempDirs.push(projectRoot);
|
||||
const { projects, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const git = new TestCheckouts();
|
||||
git.set(projectRoot, createCheckout(projectRoot));
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "fresh-git",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const beforeGitInit = await service.reconcileGitMetadata();
|
||||
git.set(
|
||||
projectRoot,
|
||||
createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
}),
|
||||
);
|
||||
const afterGitInit = await service.reconcileGitMetadata();
|
||||
|
||||
expect(beforeGitInit.changesApplied).toEqual([]);
|
||||
expect(afterGitInit.changesApplied).toEqual([
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: "p1",
|
||||
directory: projectRoot,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
]);
|
||||
expect(git.reads).toEqual([projectRoot, projectRoot]);
|
||||
expect(projects.get("p1")?.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("deduplicates equivalent project and workspace paths across legacy duplicate projects", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-global-root-")));
|
||||
const workspaceRoot = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-global-workspace-")),
|
||||
);
|
||||
tempDirs.push(projectRoot, workspaceRoot);
|
||||
const equivalentProjectRoot = `${projectRoot}${path.sep}.`;
|
||||
const equivalentWorkspaceRoot = `${workspaceRoot}${path.sep}.`;
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const git = new TestCheckouts();
|
||||
const projectCheckout = createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
});
|
||||
const workspaceCheckout = createCheckout(workspaceRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "topic",
|
||||
worktreeRoot: workspaceRoot,
|
||||
});
|
||||
git.set(projectRoot, projectCheckout);
|
||||
git.set(equivalentProjectRoot, projectCheckout);
|
||||
git.set(workspaceRoot, workspaceCheckout);
|
||||
git.set(equivalentWorkspaceRoot, workspaceCheckout);
|
||||
|
||||
for (const [projectId, rootPath] of [
|
||||
["p1", projectRoot],
|
||||
["p2", equivalentProjectRoot],
|
||||
] as const) {
|
||||
projects.set(
|
||||
projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: projectId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [workspaceId, projectId, cwd] of [
|
||||
["w1", "p1", workspaceRoot],
|
||||
["w2", "p2", equivalentWorkspaceRoot],
|
||||
] as const) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId,
|
||||
cwd,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
branch: "topic",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(git.reads).toEqual([projectRoot, workspaceRoot]);
|
||||
});
|
||||
|
||||
test("updates mutable Git facts without changing project or workspace identity", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-stable-project-")));
|
||||
const workspaceRoot = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-explicit-workspace-")),
|
||||
);
|
||||
tempDirs.push(projectRoot, workspaceRoot);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const originalProject = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
displayName: "Stable project name",
|
||||
customName: "Pinned project name",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const originalWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: workspaceRoot,
|
||||
kind: "local_checkout",
|
||||
displayName: "Stable workspace name",
|
||||
title: "Pinned workspace name",
|
||||
branch: "stale-branch",
|
||||
baseBranch: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
projects.set(originalProject.projectId, originalProject);
|
||||
workspaces.set(originalWorkspace.workspaceId, originalWorkspace);
|
||||
const git = new TestCheckouts();
|
||||
git.set(
|
||||
projectRoot,
|
||||
createCheckout(projectRoot, {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
worktreeRoot: projectRoot,
|
||||
}),
|
||||
);
|
||||
git.set(workspaceRoot, createCheckout(workspaceRoot));
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: git,
|
||||
});
|
||||
|
||||
const result = await service.reconcileGitMetadata();
|
||||
|
||||
expect(result.changesApplied).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
kind: "project_updated",
|
||||
projectId: "p1",
|
||||
directory: projectRoot,
|
||||
fields: { kind: "git" },
|
||||
},
|
||||
{
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "w1",
|
||||
directory: workspaceRoot,
|
||||
fields: { branch: null, kind: "directory" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.changesApplied).toHaveLength(2);
|
||||
expect(projects.get("p1")).toEqual({
|
||||
...originalProject,
|
||||
kind: "git",
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(workspaces.get("w1")).toEqual({
|
||||
...originalWorkspace,
|
||||
kind: "directory",
|
||||
branch: null,
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("archives workspaces whose directories no longer exist", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
@@ -213,17 +512,15 @@ describe("WorkspaceReconciliationService", () => {
|
||||
test("keeps a project active after all its workspaces are archived", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "non_git",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "non_git",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
@@ -245,9 +542,29 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
||||
expect(projChange).toBeUndefined();
|
||||
expect(projects.get("p1")!.archivedAt).toBeFalsy();
|
||||
expect(result.changesApplied).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
expect(workspaces.get("w1")).toEqual({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
title: null,
|
||||
pinnedAt: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
});
|
||||
expect(projects.get("p1")).toEqual(project);
|
||||
});
|
||||
|
||||
test("updates project kind when a directory becomes a git repo", async () => {
|
||||
@@ -357,7 +674,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(workspaces.get("w1")!.kind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
test("moves workspaces from a path-keyed duplicate project to the existing remote-keyed project", async () => {
|
||||
test("keeps legacy duplicate projects and workspace membership intact", async () => {
|
||||
const repoDir = createTempGitRepo("reconcile-duplicate-project-");
|
||||
tempDirs.push(repoDir);
|
||||
const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat");
|
||||
@@ -442,33 +759,35 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "gigantic-blowfish",
|
||||
fields: { projectId: "remote:github.com/blank-dot-page/editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_updated",
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
fields: { customName: "Editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_archived",
|
||||
projectId: repoDir,
|
||||
reason: "merged_duplicate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(workspaces.get("gigantic-blowfish")!.projectId).toBe(
|
||||
"remote:github.com/blank-dot-page/editor",
|
||||
);
|
||||
expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor");
|
||||
expect(projects.get(repoDir)!.archivedAt).toBeTruthy();
|
||||
expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([
|
||||
"workspace_updated",
|
||||
"workspace_updated",
|
||||
]);
|
||||
expect(projects.get("remote:github.com/blank-dot-page/editor")).toMatchObject({
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
rootPath: repoDir,
|
||||
displayName: "blank-dot-page/editor",
|
||||
customName: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(projects.get(repoDir)).toMatchObject({
|
||||
projectId: repoDir,
|
||||
rootPath: repoDir,
|
||||
displayName: "editor",
|
||||
customName: "Editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(workspaces.get("focused-bat")).toMatchObject({
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(workspaces.get("gigantic-blowfish")).toMatchObject({
|
||||
projectId: repoDir,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("updates project display name when git remote changes", async () => {
|
||||
test("keeps project display name stable when git remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -520,12 +839,11 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined();
|
||||
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||
});
|
||||
|
||||
test("preserves customName even when the derived displayName changes", async () => {
|
||||
test("keeps custom and default names stable when the remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-customname-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -577,7 +895,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||
expect(projects.get("p1")!.customName).toBe("My Fork");
|
||||
});
|
||||
|
||||
@@ -707,18 +1025,24 @@ describe("WorkspaceReconciliationService", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const onChanges = vi.fn();
|
||||
const reportedChanges: ReconciliationChange[] = [];
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
onChanges,
|
||||
onChanges: (changes) => reportedChanges.push(...changes),
|
||||
});
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(onChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
|
||||
expect(reportedChanges).toEqual([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
workspaceId: "w1",
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
reason: "directory_missing",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("logs reconciliation changes with affected paths and reasons", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ProjectRegistry,
|
||||
@@ -8,49 +8,22 @@ import type {
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
function deriveWorkspaceKindFromMetadata(metadata: {
|
||||
projectKind: "git" | "directory";
|
||||
isWorktree: boolean;
|
||||
}): PersistedWorkspaceRecord["kind"] {
|
||||
if (metadata.projectKind !== "git") return "directory";
|
||||
if (metadata.isWorktree) return "worktree";
|
||||
return "local_checkout";
|
||||
}
|
||||
|
||||
function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord {
|
||||
return [...projects].sort((left, right) => {
|
||||
const leftRemote = left.projectId.startsWith("remote:");
|
||||
const rightRemote = right.projectId.startsWith("remote:");
|
||||
if (leftRemote !== rightRemote) {
|
||||
return leftRemote ? -1 : 1;
|
||||
}
|
||||
const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
||||
if (createdAt !== 0) {
|
||||
return createdAt;
|
||||
}
|
||||
return left.projectId.localeCompare(right.projectId);
|
||||
})[0]!;
|
||||
}
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import { deriveProjectKind, deriveWorkspaceKind } from "./workspace-registry-model.js";
|
||||
|
||||
export type ReconciliationChange =
|
||||
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
||||
| {
|
||||
kind: "project_updated";
|
||||
projectId: string;
|
||||
directory: string;
|
||||
fields: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
|
||||
>;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind">>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
workspaceId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">>;
|
||||
};
|
||||
|
||||
export interface ReconciliationResult {
|
||||
@@ -62,62 +35,63 @@ export interface WorkspaceReconciliationServiceOptions {
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: pino.Logger;
|
||||
intervalMs?: number;
|
||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getWorkspaceGitMetadata">;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getCheckout">;
|
||||
}
|
||||
|
||||
interface ProjectReconciliationInput {
|
||||
project: PersistedProjectRecord;
|
||||
siblings: PersistedWorkspaceRecord[];
|
||||
currentGit: ProjectCheckoutLitePayload;
|
||||
readCheckout: (cwd: string) => Promise<ProjectCheckoutLitePayload>;
|
||||
changes: ReconciliationChange[];
|
||||
}
|
||||
|
||||
interface CachedCheckoutRead {
|
||||
cwd: string;
|
||||
checkout: Promise<ProjectCheckoutLitePayload>;
|
||||
}
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getWorkspaceGitMetadata"> | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getCheckout"> | null;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||
this.onChanges = options.onChanges ?? null;
|
||||
this.workspaceGitService = options.workspaceGitService ?? null;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
|
||||
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
|
||||
// Run once immediately on start
|
||||
void this.runSafe();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
/** 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) continue;
|
||||
const siblings = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
siblings.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, siblings);
|
||||
}
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
projects.filter((project) => !project.archivedAt && existsSync(project.rootPath)),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
if (changes.length > 0) this.onChanges?.(changes);
|
||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async runOnce(): Promise<ReconciliationResult> {
|
||||
return this.reconcile();
|
||||
}
|
||||
|
||||
private async runSafe(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.reconcile();
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Reconciliation pass failed");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<ReconciliationResult> {
|
||||
const start = Date.now();
|
||||
const changes: ReconciliationChange[] = [];
|
||||
|
||||
@@ -156,29 +130,13 @@ export class WorkspaceReconciliationService {
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Merge duplicate active project records that point at the same repo root.
|
||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
||||
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist.
|
||||
// 2. Reconcile mutable git metadata without changing identity or membership.
|
||||
// Projects persist until explicitly removed, even when they currently have
|
||||
// zero active workspaces, so they still reconcile their own metadata.
|
||||
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
||||
// don't resurrect them by upserting a stale, non-archived copy.
|
||||
const archivedProjectIds = new Set(
|
||||
changes
|
||||
.filter((change) => change.kind === "project_archived")
|
||||
.map((change) => change.projectId),
|
||||
);
|
||||
const projectsToReconcile = activeProjects.filter((project) => {
|
||||
if (project.archivedAt) return false;
|
||||
if (archivedProjectIds.has(project.projectId)) return false;
|
||||
if (!existsSync(project.rootPath)) return false;
|
||||
return true;
|
||||
});
|
||||
await Promise.all(
|
||||
projectsToReconcile.map((project) =>
|
||||
this.reconcileProject(project, workspacesByProject.get(project.projectId) ?? [], changes),
|
||||
),
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
activeProjects.filter((project) => existsSync(project.rootPath)),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
|
||||
if (changes.length > 0 && this.onChanges) {
|
||||
@@ -188,136 +146,60 @@ export class WorkspaceReconciliationService {
|
||||
const result = { changesApplied: changes, durationMs: Date.now() - start };
|
||||
if (changes.length > 0) {
|
||||
this.logger.info(
|
||||
{
|
||||
changeCount: changes.length,
|
||||
durationMs: result.durationMs,
|
||||
changes,
|
||||
},
|
||||
{ changeCount: changes.length, durationMs: result.durationMs, changes },
|
||||
"Workspace reconciliation applied changes",
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectsByRoot(
|
||||
activeProjects: PersistedProjectRecord[],
|
||||
private async reconcileGitMetadataForProjects(
|
||||
projectsToReconcile: PersistedProjectRecord[],
|
||||
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
const projectsByRoot = new Map<string, PersistedProjectRecord[]>();
|
||||
for (const project of activeProjects) {
|
||||
if (project.kind !== "git") {
|
||||
continue;
|
||||
}
|
||||
const rootKey = resolve(project.rootPath);
|
||||
const group = projectsByRoot.get(rootKey) ?? [];
|
||||
group.push(project);
|
||||
projectsByRoot.set(rootKey, group);
|
||||
}
|
||||
|
||||
for (const duplicates of projectsByRoot.values()) {
|
||||
if (duplicates.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const canonical = chooseCanonicalProject(duplicates);
|
||||
const duplicateProjects = duplicates.filter(
|
||||
(project) => project.projectId !== canonical.projectId,
|
||||
const checkoutReads: CachedCheckoutRead[] = [];
|
||||
const readCheckout = (cwd: string): Promise<ProjectCheckoutLitePayload> => {
|
||||
const existing = checkoutReads.find((read) => areEquivalentPaths(read.cwd, cwd));
|
||||
if (existing) return existing.checkout;
|
||||
const checkout = this.readCheckout(cwd);
|
||||
checkoutReads.push({ cwd, checkout });
|
||||
return checkout;
|
||||
};
|
||||
const roots: Array<{ rootPath: string; projects: PersistedProjectRecord[] }> = [];
|
||||
for (const project of projectsToReconcile) {
|
||||
const root = roots.find((candidate) =>
|
||||
areEquivalentPaths(candidate.rootPath, project.rootPath),
|
||||
);
|
||||
await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes);
|
||||
await Promise.all(
|
||||
duplicateProjects.flatMap((project) =>
|
||||
(workspacesByProject.get(project.projectId) ?? []).map(async (workspace) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedWorkspace = {
|
||||
...workspace,
|
||||
projectId: canonical.projectId,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await this.workspaceRegistry.upsert(updatedWorkspace);
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
fields: {
|
||||
projectId: canonical.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const canonicalSiblings = workspacesByProject.get(canonical.projectId) ?? [];
|
||||
canonicalSiblings.push(updatedWorkspace);
|
||||
workspacesByProject.set(canonical.projectId, canonicalSiblings);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const project of duplicateProjects) {
|
||||
workspacesByProject.set(project.projectId, []);
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.projectId, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
reason: "merged_duplicate",
|
||||
});
|
||||
}
|
||||
if (root) root.projects.push(project);
|
||||
else roots.push({ rootPath: project.rootPath, projects: [project] });
|
||||
}
|
||||
await Promise.all(
|
||||
roots.map(async ({ rootPath, projects }) => {
|
||||
const rootGit = await readCheckout(rootPath);
|
||||
await Promise.all(
|
||||
projects.map((project) =>
|
||||
this.reconcileProject({
|
||||
project,
|
||||
siblings: workspacesByProject.get(project.projectId) ?? [],
|
||||
currentGit: rootGit,
|
||||
readCheckout,
|
||||
changes,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectCustomName(
|
||||
canonical: PersistedProjectRecord,
|
||||
duplicateProjects: PersistedProjectRecord[],
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
if (canonical.customName) {
|
||||
return;
|
||||
}
|
||||
const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null;
|
||||
if (!customName) {
|
||||
return;
|
||||
}
|
||||
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
|
||||
const { project, siblings, currentGit, readCheckout, changes } = input;
|
||||
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
|
||||
|
||||
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";
|
||||
const mappedKind = deriveProjectKind(currentGit);
|
||||
|
||||
if (project.kind !== mappedKind) {
|
||||
projectUpdates.kind = mappedKind;
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
}
|
||||
|
||||
if (
|
||||
project.kind === "git" &&
|
||||
currentGit.projectKind === "git" &&
|
||||
project.displayName !== currentGit.projectDisplayName
|
||||
) {
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
}
|
||||
|
||||
if (Object.keys(projectUpdates).length > 0) {
|
||||
@@ -338,15 +220,13 @@ export class WorkspaceReconciliationService {
|
||||
const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd));
|
||||
await Promise.all(
|
||||
existingSiblings.map(async (workspace) => {
|
||||
const wsDirName = workspace.cwd.split(/[\\/]/).findLast(Boolean) ?? workspace.cwd;
|
||||
const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
|
||||
|
||||
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
|
||||
const wsGit = await readCheckout(workspace.cwd);
|
||||
const expectedKind = deriveWorkspaceKind(wsGit);
|
||||
|
||||
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">> = {};
|
||||
|
||||
if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) {
|
||||
workspaceUpdates.branch = wsGit.currentBranch;
|
||||
if (workspace.branch !== (wsGit.isGit ? wsGit.currentBranch : null)) {
|
||||
workspaceUpdates.branch = wsGit.isGit ? wsGit.currentBranch : null;
|
||||
}
|
||||
|
||||
if (workspace.kind !== expectedKind) {
|
||||
@@ -373,20 +253,18 @@ export class WorkspaceReconciliationService {
|
||||
);
|
||||
}
|
||||
|
||||
private async readWorkspaceGitMetadata(cwd: string, directoryName: string) {
|
||||
private async readCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
|
||||
if (!this.workspaceGitService) {
|
||||
return {
|
||||
projectKind: "directory" as const,
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "untitled",
|
||||
repoRoot: null,
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName });
|
||||
return this.workspaceGitService.getCheckout(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"
|
||||
|
||||
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
|
||||
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
|
||||
const GIT_PROJECT = path.resolve("/tmp/legacy-git-project");
|
||||
const GIT_WORKTREE = path.resolve("/tmp/legacy-git-project-feature");
|
||||
|
||||
describe("bootstrapWorkspaceRegistries", () => {
|
||||
let tmpDir: string;
|
||||
@@ -175,6 +177,81 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
||||
});
|
||||
|
||||
test("materializes legacy remote worktrees into one readable project", async () => {
|
||||
workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: cwd === GIT_PROJECT ? "main" : "feature/plain",
|
||||
remoteUrl: "git@github.com:acme/legacy-project.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: cwd === GIT_PROJECT ? null : GIT_PROJECT,
|
||||
}),
|
||||
});
|
||||
await agentStorage.initialize();
|
||||
for (const [id, cwd] of [
|
||||
["main-agent", GIT_PROJECT],
|
||||
["worktree-agent", GIT_WORKTREE],
|
||||
]) {
|
||||
await agentStorage.upsert({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
const projects = await projectRegistry.list();
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]).toMatchObject({
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
rootPath: GIT_PROJECT,
|
||||
kind: "git",
|
||||
displayName: "acme/legacy-project",
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
expect(
|
||||
workspaces
|
||||
.map(({ projectId, cwd, kind, displayName }) => ({ projectId, cwd, kind, displayName }))
|
||||
.sort((left, right) => left.cwd.localeCompare(right.cwd)),
|
||||
).toEqual([
|
||||
{
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
cwd: GIT_PROJECT,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
},
|
||||
{
|
||||
projectId: "remote:github.com/acme/legacy-project",
|
||||
cwd: GIT_WORKTREE,
|
||||
kind: "worktree",
|
||||
displayName: "feature/plain",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await workspaceRegistry.initialize();
|
||||
|
||||
@@ -4,10 +4,8 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js";
|
||||
import { generateWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { basename, isAbsolute, resolve } from "node:path";
|
||||
import { basename, isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDirectoryKey,
|
||||
deriveWorkspaceKind,
|
||||
detectStaleWorkspaces,
|
||||
generateWorkspaceId,
|
||||
generateProjectId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
@@ -29,36 +26,6 @@ function createWorkspaceRecord(
|
||||
});
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
@@ -104,123 +71,20 @@ describe("detectStaleWorkspaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
describe("opaque registry ids", () => {
|
||||
test("generates opaque project ids", () => {
|
||||
expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("falls back to normalized cwd when git worktree root contains multiple lines", () => {
|
||||
const cwd = String.raw`E:\project\node-ai`;
|
||||
|
||||
expect(
|
||||
deriveWorkspaceDirectoryKey(cwd, {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: `--path-format=absolute\n${cwd}`,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe(resolve(cwd));
|
||||
});
|
||||
|
||||
test("falls back to normalized cwd for non-git directories", () => {
|
||||
const cwd = "/tmp/repo/../repo/scratch";
|
||||
|
||||
expect(
|
||||
deriveWorkspaceDirectoryKey(cwd, {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe(resolve("/tmp/repo/scratch"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("opaque workspace id versus directory key", () => {
|
||||
test("generates opaque workspace ids that are not filesystem paths", () => {
|
||||
const workspaceId = generateWorkspaceId();
|
||||
|
||||
expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/);
|
||||
expect(isAbsolute(workspaceId)).toBe(false);
|
||||
});
|
||||
|
||||
test("derives a path-shaped directory key that is never an opaque workspace id", () => {
|
||||
const directoryKey = deriveWorkspaceDirectoryKey("/tmp/repo/scratch", {
|
||||
cwd: "/tmp/repo/scratch",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(directoryKey).toBe(resolve("/tmp/repo/scratch"));
|
||||
expect(directoryKey.startsWith("wks_")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git worktree grouping", () => {
|
||||
test("classifies plain git worktrees for project membership from git facts", () => {
|
||||
const membership = classifyDirectoryForProjectMembership({
|
||||
cwd: "/tmp/repo-feature",
|
||||
checkout: {
|
||||
cwd: "/tmp/repo-feature",
|
||||
isGit: true,
|
||||
currentBranch: "feature/plain",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/tmp/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).toMatchObject({
|
||||
// Path-derived directory key, distinct from the opaque workspace id (generated separately).
|
||||
cwd: resolve("/tmp/repo-feature"),
|
||||
workspaceDirectoryKey: "/tmp/repo-feature",
|
||||
workspaceKind: "worktree",
|
||||
workspaceDisplayName: "feature/plain",
|
||||
projectKey: "remote:github.com/acme/repo",
|
||||
projectName: "acme/repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses mainRepoRoot as the project root for plain git worktrees", () => {
|
||||
expect(
|
||||
deriveProjectRootPath({
|
||||
cwd: "/tmp/repo-feature",
|
||||
checkout: {
|
||||
cwd: "/tmp/repo-feature",
|
||||
isGit: true,
|
||||
currentBranch: "feature/plain",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/tmp/repo-feature",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
},
|
||||
}),
|
||||
).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
describe("workspace kind", () => {
|
||||
test("classifies plain git worktrees as workspaces of kind worktree", () => {
|
||||
expect(
|
||||
deriveWorkspaceKind({
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
ProjectCheckoutLitePayload,
|
||||
ProjectPlacementPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
export type PersistedProjectKind = "git" | "non_git";
|
||||
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
||||
|
||||
export interface DirectoryProjectMembership {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
workspaceDirectoryKey: string;
|
||||
workspaceKind: PersistedWorkspaceKind;
|
||||
workspaceDisplayName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: PersistedProjectKind;
|
||||
}
|
||||
|
||||
export interface DetectStaleWorkspacesInput {
|
||||
activeWorkspaces: PersistedWorkspaceRecord[];
|
||||
checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
||||
@@ -32,123 +15,8 @@ export function generateWorkspaceId(): string {
|
||||
return `wks_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
// Path-derived grouping key for a workspace directory. This is NOT the opaque
|
||||
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
|
||||
export function deriveWorkspaceDirectoryKey(
|
||||
cwd: string,
|
||||
checkout: ProjectCheckoutLitePayload,
|
||||
): string {
|
||||
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
|
||||
return worktreeRoot ?? resolve(cwd);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: string | null = null;
|
||||
let remotePath: string | null = null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
remotePath = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) {
|
||||
cleanedPath = cleanedPath.slice(0, -4);
|
||||
}
|
||||
if (!cleanedPath.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase();
|
||||
if (cleanedHost === "github.com") {
|
||||
return `remote:github.com/${cleanedPath}`;
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingKey(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||
if (remoteKey) {
|
||||
return remoteKey;
|
||||
}
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim();
|
||||
if (mainRepoRoot) {
|
||||
return mainRepoRoot;
|
||||
}
|
||||
|
||||
return options.cwd;
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingName(projectKey: string): string {
|
||||
if (projectKey.startsWith("remote:")) {
|
||||
const remainder = projectKey.slice("remote:".length);
|
||||
const pathSegments = remainder.split("/").filter(Boolean).slice(1);
|
||||
if (pathSegments.length >= 2) {
|
||||
return pathSegments.slice(-2).join("/");
|
||||
}
|
||||
if (pathSegments.length === 1) {
|
||||
return pathSegments[0];
|
||||
}
|
||||
return projectKey;
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectKey;
|
||||
}
|
||||
|
||||
function deriveWorkspaceDirectoryName(cwd: string): string {
|
||||
const normalized = cwd.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? cwd;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceDisplayName(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
const branch = input.checkout.currentBranch?.trim() ?? null;
|
||||
if (branch && branch.toUpperCase() !== "HEAD") {
|
||||
return branch;
|
||||
}
|
||||
return deriveWorkspaceDirectoryName(input.cwd);
|
||||
}
|
||||
|
||||
export function deriveProjectRootPath(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
if (input.checkout.isGit && input.checkout.mainRepoRoot) {
|
||||
return input.checkout.mainRepoRoot;
|
||||
}
|
||||
return input.cwd;
|
||||
export function generateProjectId(): string {
|
||||
return `prj_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
|
||||
@@ -162,6 +30,17 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
||||
return checkout.mainRepoRoot ? "worktree" : "local_checkout";
|
||||
}
|
||||
|
||||
export function deriveWorkspaceDisplayName(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): string {
|
||||
const branch = input.checkout.currentBranch?.trim() ?? null;
|
||||
if (branch && branch.toUpperCase() !== "HEAD") return branch;
|
||||
|
||||
const segments = input.cwd.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? input.cwd;
|
||||
}
|
||||
|
||||
export function checkoutLiteFromGitSnapshot(
|
||||
cwd: string,
|
||||
git: {
|
||||
@@ -225,50 +104,3 @@ export async function detectStaleWorkspaces(
|
||||
|
||||
return staleWorkspaceIds;
|
||||
}
|
||||
|
||||
export function buildProjectPlacementForCwd(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): ProjectPlacementPayload {
|
||||
const membership = classifyDirectoryForProjectMembership(input);
|
||||
return {
|
||||
projectKey: membership.projectKey,
|
||||
projectName: membership.projectName,
|
||||
checkout: membership.checkout,
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyDirectoryForProjectMembership(input: {
|
||||
cwd: string;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}): DirectoryProjectMembership {
|
||||
const normalizedCwd = resolve(input.cwd);
|
||||
const checkout: ProjectCheckoutLitePayload = {
|
||||
...input.checkout,
|
||||
cwd: normalizedCwd,
|
||||
};
|
||||
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: checkout.worktreeRoot ?? normalizedCwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
workspaceDirectoryKey: deriveWorkspaceDirectoryKey(normalizedCwd, checkout),
|
||||
workspaceKind: deriveWorkspaceKind(checkout),
|
||||
workspaceDisplayName: deriveWorkspaceDisplayName({
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
}),
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
projectRootPath: deriveProjectRootPath({
|
||||
cwd: normalizedCwd,
|
||||
checkout,
|
||||
}),
|
||||
projectKind: deriveProjectKind(checkout),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -99,43 +99,221 @@ describe("workspace registries", () => {
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("PIN: two checkouts of the same git remote collapse into a single project record", async () => {
|
||||
// Reproduces the situation in #987: two directories that share a git remote
|
||||
// both derive the same projectKey/displayName. Because the registry is keyed
|
||||
// by projectId, the second upsert overwrites the first — so the registry can
|
||||
// only ever hold one record per remote, and there is no way to distinguish
|
||||
// the two checkouts in the UI.
|
||||
test("publishes only project mutations that change the persisted lifecycle", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const mutations: Array<{
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: ReturnType<typeof createPersistedProjectRecord> | null;
|
||||
}> = [];
|
||||
const unsubscribe = projectRegistry.subscribeToMutations((mutation) => {
|
||||
mutations.push(mutation);
|
||||
});
|
||||
const active = createPersistedProjectRecord({
|
||||
projectId: "project-one",
|
||||
rootPath: "/tmp/project-one",
|
||||
kind: "non_git",
|
||||
displayName: "project-one",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const archived = {
|
||||
...active,
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const remoteKey = "remote:github.com/acme/repo";
|
||||
await projectRegistry.upsert(active);
|
||||
await projectRegistry.archive(active.projectId, archived.archivedAt);
|
||||
await projectRegistry.archive(active.projectId, "2026-03-03T00:00:00.000Z");
|
||||
await projectRegistry.archive("project-unknown", "2026-03-03T00:00:00.000Z");
|
||||
await projectRegistry.remove(active.projectId);
|
||||
await projectRegistry.remove(active.projectId);
|
||||
await projectRegistry.remove("project-unknown");
|
||||
|
||||
expect(mutations).toEqual([
|
||||
{ kind: "upsert", projectId: active.projectId, project: active },
|
||||
{ kind: "archive", projectId: active.projectId, project: archived },
|
||||
{ kind: "remove", projectId: active.projectId, project: null },
|
||||
]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("atomically allocates one opaque project for concurrent exact-root adds", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const rootPath = path.join(tmpDir, "same-root");
|
||||
const projects = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "same-root",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(new Set(projects.map((project) => project.projectId))).toEqual(
|
||||
new Set([projects[0]!.projectId]),
|
||||
);
|
||||
expect(projects[0]!.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps readable legacy IDs alongside newly allocated opaque IDs", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/work/repo",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/legacy",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const opaque = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "/tmp/new",
|
||||
kind: "non_git",
|
||||
displayName: "new",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect((await projectRegistry.get("remote:github.com/acme/repo"))?.rootPath).toBe(
|
||||
"/tmp/legacy",
|
||||
);
|
||||
expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("allocates a fresh opaque ID when only an archived exact root exists", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const rootPath = path.join(tmpDir, "archived-root");
|
||||
const archived = createPersistedProjectRecord({
|
||||
projectId: "prj_archived",
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "archived-root",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.upsert(archived);
|
||||
|
||||
const created = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "archived-root",
|
||||
timestamp: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({ rootPath, archivedAt: null });
|
||||
expect(created.projectId).not.toBe(archived.projectId);
|
||||
expect(await projectRegistry.get(archived.projectId)).toEqual(archived);
|
||||
});
|
||||
|
||||
test("returns the oldest active legacy duplicate without rewriting either record", 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);
|
||||
expect(await projectRegistry.list()).toEqual([oldest, duplicate]);
|
||||
});
|
||||
|
||||
test("reuses an active project for Windows lexical-equivalent root spellings", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const first = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "C:\\Users\\Paseo\\Repo",
|
||||
kind: "git",
|
||||
displayName: "Repo",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const second = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: "c:/users/paseo/repo/.",
|
||||
kind: "git",
|
||||
displayName: "Repo",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(await projectRegistry.list()).toEqual([first]);
|
||||
});
|
||||
|
||||
test("keeps lexical and symlink root spellings distinct without realpath", async () => {
|
||||
await projectRegistry.initialize();
|
||||
const target = path.join(tmpDir, "target");
|
||||
const link = path.join(tmpDir, "link");
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
||||
|
||||
const targetProject = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: target,
|
||||
kind: "non_git",
|
||||
displayName: "target",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const linkProject = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: link,
|
||||
kind: "non_git",
|
||||
displayName: "link",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(linkProject.projectId).not.toBe(targetProject.projectId);
|
||||
expect(await projectRegistry.list()).toEqual([targetProject, linkProject]);
|
||||
});
|
||||
|
||||
test("retries a generated project ID collision", async () => {
|
||||
const generatedIds = ["prj_collision", "prj_fresh"];
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(tmpDir, "projects", "projects.json"),
|
||||
logger,
|
||||
{ projectIdFactory: () => generatedIds.shift() ?? "prj_unexpected" },
|
||||
);
|
||||
await projectRegistry.initialize();
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_collision",
|
||||
rootPath: path.join(tmpDir, "existing"),
|
||||
kind: "non_git",
|
||||
displayName: "existing",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/scratch/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const created = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "new"),
|
||||
kind: "non_git",
|
||||
displayName: "new",
|
||||
timestamp: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const all = await projectRegistry.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.displayName).toBe("acme/repo");
|
||||
// Second upsert wins — the first rootPath is lost.
|
||||
expect(all[0]?.rootPath).toBe("/home/me/scratch/repo");
|
||||
expect(created.projectId).toBe("prj_fresh");
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("project record schema accepts records without customName (legacy on-disk records)", async () => {
|
||||
@@ -212,6 +390,29 @@ describe("workspace registries", () => {
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("refreshes workspace archive timestamps when an archive is repeated", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z");
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z");
|
||||
|
||||
expect(await workspaceRegistry.get("workspace-one")).toMatchObject({
|
||||
archivedAt: "2026-03-03T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("composes concurrent workspace field updates without losing either change", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { writeJsonFileAtomic } from "./atomic-file.js";
|
||||
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
generateProjectId,
|
||||
type PersistedProjectKind,
|
||||
type PersistedWorkspaceKind,
|
||||
} from "./workspace-registry-model.js";
|
||||
|
||||
const PersistedProjectRecordSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -71,9 +76,23 @@ export interface ProjectRegistry {
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedProjectRecord[]>;
|
||||
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
||||
getOrCreateActiveByRoot(input: {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord>;
|
||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||
remove(projectId: string): Promise<void>;
|
||||
/** Central lifecycle seam for daemon-global project observers. */
|
||||
subscribeToMutations?(
|
||||
listener: (mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}) => void | Promise<void>,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRegistry {
|
||||
@@ -162,24 +181,43 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
if (!existing) return;
|
||||
await this.persistArchive(existing, archivedAt);
|
||||
}
|
||||
|
||||
protected async archiveIfActive(id: string, archivedAt: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing || existing.archivedAt) {
|
||||
return null;
|
||||
}
|
||||
return this.persistArchive(existing, archivedAt);
|
||||
}
|
||||
|
||||
private async persistArchive(existing: TRecord, archivedAt: string): Promise<TRecord> {
|
||||
const next = this.schema.parse({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
});
|
||||
this.cache.set(id, next);
|
||||
this.cache.set(this.getId(next), next);
|
||||
await this.enqueuePersist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.removeIfPresent(id);
|
||||
}
|
||||
|
||||
protected async removeIfPresent(id: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
if (!this.cache.delete(id)) {
|
||||
return;
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
this.cache.delete(id);
|
||||
await this.enqueuePersist();
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
@@ -219,7 +257,17 @@ export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
private allocationQueue: Promise<void> = Promise.resolve();
|
||||
private readonly projectIdFactory: () => string;
|
||||
private readonly mutationListeners = new Set<
|
||||
(mutation: {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
projectId: string;
|
||||
project: PersistedProjectRecord | null;
|
||||
}) => void | Promise<void>
|
||||
>();
|
||||
|
||||
constructor(filePath: string, logger: Logger, options?: { projectIdFactory?: () => string }) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
@@ -227,6 +275,84 @@ export class FileBackedProjectRegistry
|
||||
getId: (record) => record.projectId,
|
||||
component: "projects",
|
||||
});
|
||||
this.projectIdFactory = options?.projectIdFactory ?? generateProjectId;
|
||||
}
|
||||
|
||||
async getOrCreateActiveByRoot(input: {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const previous = this.allocationQueue;
|
||||
let release!: () => void;
|
||||
this.allocationQueue = new Promise<void>((resolve) => (release = resolve));
|
||||
await previous;
|
||||
try {
|
||||
const active = (await this.list())
|
||||
.filter(
|
||||
(project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.projectId.localeCompare(right.projectId),
|
||||
)[0];
|
||||
if (active) return active;
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user