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:
Mohamed Boudra
2026-07-15 19:47:43 +00:00
parent 0d3b717cf3
commit de7d09adcf
46 changed files with 3396 additions and 1441 deletions

View File

@@ -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/`

View File

@@ -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

View File

@@ -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`.

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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,
);
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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;

View File

@@ -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()

View File

@@ -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();

View File

@@ -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",
}),

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
},

View File

@@ -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: {

View File

@@ -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: {

View 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([]);
});
});

View 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");
}
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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);

View File

@@ -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");
});

View File

@@ -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",
});
});

View File

@@ -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);
});

View File

@@ -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 {

View File

@@ -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;
},
};
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 });

View File

@@ -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,
},
};
}

View File

@@ -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({

View File

@@ -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");
});
});

View File

@@ -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,
};
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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),
};
}

View File

@@ -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(

View File

@@ -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)));
}
}