* 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.
396 lines
13 KiB
TypeScript
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 });
|
|
}
|
|
}
|