Files
paseo/packages/server/src/server/workspace-reconciliation-service.ts
T
Mohamed BoudraandGitHub 21deb08673 Make workspace IDs opaque, independent of the filesystem path (#1503)
* Make workspace IDs opaque, independent of the filesystem path

Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.

New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.

* Keep attachment scope key stable and align refetch test

The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.

* Fix live agent updates in directories without a registered workspace

The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.

The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.

* Always run server worktree archive, even without a resolved workspace

Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.

* Expect a directory-scoped placement for unregistered agent dirs

This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
2026-06-14 22:03:27 +08:00

396 lines
13 KiB
TypeScript

import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type pino from "pino";
import type {
ProjectRegistry,
WorkspaceRegistry,
PersistedProjectRecord,
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]!;
}
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">
>;
}
| {
kind: "workspace_updated";
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
};
export interface ReconciliationResult {
changesApplied: ReconciliationChange[];
durationMs: number;
}
export interface WorkspaceReconciliationServiceOptions {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: pino.Logger;
intervalMs?: number;
onChanges?: (changes: ReconciliationChange[]) => void;
workspaceGitService?: Pick<WorkspaceGitService, "getWorkspaceGitMetadata">;
}
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;
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;
}
}
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[] = [];
const allProjects = await this.projectRegistry.list();
const allWorkspaces = await this.workspaceRegistry.list();
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const workspace of activeWorkspaces) {
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
workspacesByProject.set(workspace.projectId, list);
}
// 1. Archive workspaces whose directories no longer exist
const missingWorkspaces = activeWorkspaces.filter((workspace) => !existsSync(workspace.cwd));
await Promise.all(
missingWorkspaces.map(async (workspace) => {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.archive(workspace.workspaceId, timestamp);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
reason: "directory_missing",
});
// Update the in-memory list for the project orphan check below
const siblings = workspacesByProject.get(workspace.projectId);
if (siblings) {
const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId);
workspacesByProject.set(workspace.projectId, updated);
}
}),
);
// 2. Merge duplicate active project records that point at the same repo root.
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
// 3. Archive orphaned projects (all workspaces archived/removed)
const orphanedProjects = activeProjects.filter((project) => {
const siblings = workspacesByProject.get(project.projectId) ?? [];
return siblings.length === 0;
});
await Promise.all(
orphanedProjects.map(async (project) => {
const timestamp = new Date().toISOString();
await this.projectRegistry.archive(project.projectId, timestamp);
changes.push({
kind: "project_archived",
projectId: project.projectId,
directory: project.rootPath,
reason: "no_active_workspaces",
});
}),
);
// 4. Reconcile git metadata for active projects whose directories still exist
const projectsToReconcile = activeProjects.filter((project) => {
if (project.archivedAt) return false;
const siblings = workspacesByProject.get(project.projectId) ?? [];
if (siblings.length === 0) return false;
if (!existsSync(project.rootPath)) return false;
return true;
});
await Promise.all(
projectsToReconcile.map((project) =>
this.reconcileProject(project, workspacesByProject.get(project.projectId) ?? [], changes),
),
);
if (changes.length > 0 && this.onChanges) {
this.onChanges(changes);
}
const result = { changesApplied: changes, durationMs: Date.now() - start };
if (changes.length > 0) {
this.logger.info(
{
changeCount: changes.length,
durationMs: result.durationMs,
changes,
},
"Workspace reconciliation applied changes",
);
}
return result;
}
private async mergeDuplicateProjectsByRoot(
activeProjects: 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,
);
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, []);
}
}
}
private async mergeDuplicateProjectCustomName(
canonical: PersistedProjectRecord,
duplicateProjects: PersistedProjectRecord[],
changes: ReconciliationChange[],
): Promise<void> {
if (canonical.customName) {
return;
}
const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null;
if (!customName) {
return;
}
const timestamp = new Date().toISOString();
await this.projectRegistry.upsert({
...canonical,
customName,
updatedAt: timestamp,
});
canonical.customName = customName;
changes.push({
kind: "project_updated",
projectId: canonical.projectId,
directory: canonical.rootPath,
fields: { customName },
});
}
private async reconcileProject(
project: PersistedProjectRecord,
siblings: PersistedWorkspaceRecord[],
changes: ReconciliationChange[],
): Promise<void> {
const directoryName = project.rootPath.split(/[\\/]/).findLast(Boolean) ?? project.rootPath;
const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName);
const projectUpdates: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
> = {};
const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git";
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) {
const timestamp = new Date().toISOString();
await this.projectRegistry.upsert({
...project,
...projectUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "project_updated",
projectId: project.projectId,
directory: project.rootPath,
fields: projectUpdates,
});
}
const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd));
await Promise.all(
existingSiblings.map(async (workspace) => {
const wsDirName = workspace.cwd.split(/[\\/]/).findLast(Boolean) ?? workspace.cwd;
const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">> =
{};
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
workspaceUpdates.displayName = wsGit.workspaceDisplayName;
}
if (workspace.kind !== expectedKind) {
workspaceUpdates.kind = expectedKind;
}
if (Object.keys(workspaceUpdates).length === 0) {
return;
}
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
...workspaceUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
fields: workspaceUpdates,
});
}),
);
}
private async readWorkspaceGitMetadata(cwd: string, directoryName: string) {
if (!this.workspaceGitService) {
return {
projectKind: "directory" as const,
projectDisplayName: directoryName,
workspaceDisplayName: directoryName,
gitRemote: null,
isWorktree: false,
projectSlug: "untitled",
repoRoot: null,
currentBranch: null,
remoteUrl: null,
};
}
return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName });
}
}