mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Phase 1b: extract workspace reconciliation service
This commit is contained in:
@@ -99,11 +99,7 @@ import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
detectStaleWorkspaces,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import type {
|
||||
@@ -112,10 +108,7 @@ import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
@@ -400,6 +393,7 @@ export type SessionOptions = {
|
||||
agentStorage: AgentStorage;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceReconciliationService?: WorkspaceReconciliationService;
|
||||
createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
@@ -583,6 +577,7 @@ export class Session {
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceReconciliationService: WorkspaceReconciliationService;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -643,6 +638,7 @@ export class Session {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceReconciliationService,
|
||||
createAgentMcpTransport,
|
||||
stt,
|
||||
tts,
|
||||
@@ -665,6 +661,17 @@ export class Session {
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry;
|
||||
this.workspaceRegistry = workspaceRegistry;
|
||||
this.workspaceReconciliationService =
|
||||
workspaceReconciliationService ??
|
||||
new WorkspaceReconciliationService({
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
agentStorage: this.agentStorage,
|
||||
buildProjectPlacement: async (cwd) => this.buildProjectPlacement(cwd),
|
||||
syncWorkspaceGitWatchTarget: async (cwd, options) =>
|
||||
this.syncWorkspaceGitWatchTarget(cwd, options),
|
||||
removeWorkspaceGitWatchTarget: async (cwd) => this.removeWorkspaceGitWatchTarget(cwd),
|
||||
});
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.terminalManager = terminalManager;
|
||||
if (this.terminalManager) {
|
||||
@@ -1260,155 +1267,15 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
private buildPersistedProjectRecord(input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(input.placement.checkout),
|
||||
displayName: input.placement.projectName,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
private buildPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: input.workspaceId,
|
||||
projectId: input.placement.projectKey,
|
||||
cwd: input.workspaceId,
|
||||
kind: deriveWorkspaceKind(input.placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(projectId, archivedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
changed: boolean;
|
||||
}> {
|
||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
|
||||
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId);
|
||||
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
|
||||
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const nextProjectCreatedAt = existing?.createdAt ?? now;
|
||||
const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
|
||||
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: nextWorkspaceCreatedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const needsWorkspaceUpdate =
|
||||
!existing ||
|
||||
existing.archivedAt ||
|
||||
existing.projectId !== nextWorkspaceRecord.projectId ||
|
||||
existing.kind !== nextWorkspaceRecord.kind ||
|
||||
existing.displayName !== nextWorkspaceRecord.displayName;
|
||||
|
||||
const needsProjectUpdate =
|
||||
!currentProjectRecord ||
|
||||
currentProjectRecord.archivedAt ||
|
||||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
|
||||
currentProjectRecord.kind !== nextProjectRecord.kind ||
|
||||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
|
||||
|
||||
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
|
||||
return {
|
||||
workspace: existing!,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord);
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
|
||||
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
|
||||
await this.archiveProjectRecordIfEmpty(existing.projectId, now);
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: nextWorkspaceRecord,
|
||||
changed: true,
|
||||
};
|
||||
return this.workspaceReconciliationService.reconcileWorkspaceRecord(workspaceId);
|
||||
}
|
||||
|
||||
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces,
|
||||
agentRecords: (await this.agentStorage.list()).map((agent) => ({
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
})),
|
||||
checkDirectoryExists: async (cwd) => {
|
||||
try {
|
||||
await stat(cwd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for (const workspaceId of staleWorkspaceIds) {
|
||||
await this.archiveWorkspaceRecord(workspaceId);
|
||||
changedWorkspaceIds.add(workspaceId);
|
||||
}
|
||||
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (staleWorkspaceIds.has(workspace.workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
|
||||
if (result.changed) {
|
||||
changedWorkspaceIds.add(result.workspace.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return changedWorkspaceIds;
|
||||
return this.workspaceReconciliationService.reconcileActiveWorkspaceRecords();
|
||||
}
|
||||
|
||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||
@@ -5837,69 +5704,11 @@ export class Session {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||
const existingProject = await this.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord);
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await this.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord;
|
||||
return this.workspaceReconciliationService.registerPendingWorktreeWorkspace(options);
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!existing || existing.archivedAt) {
|
||||
this.removeWorkspaceGitWatchTarget(workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextArchivedAt = archivedAt ?? new Date().toISOString();
|
||||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
|
||||
this.removeWorkspaceGitWatchTarget(workspaceId);
|
||||
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(existing.projectId, nextArchivedAt);
|
||||
}
|
||||
await this.workspaceReconciliationService.archiveWorkspaceRecord(workspaceId, archivedAt);
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
|
||||
@@ -170,7 +170,8 @@ describe("workspace git watch targets", () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
sessionAny.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
|
||||
new Set();
|
||||
|
||||
let descriptor = {
|
||||
id: "/tmp/repo",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { Session } from "./session.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
function createSessionForOwnershipTests(options?: { workspaceReconciliationService?: any }) {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async (projectId: string) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
upsert: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
archive: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
remove: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
archive: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
remove: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
workspaceReconciliationService: options?.workspaceReconciliationService,
|
||||
}) as any;
|
||||
|
||||
return { session, emitted };
|
||||
}
|
||||
|
||||
describe("workspace reconciliation ownership", () => {
|
||||
test("open_project_request delegates registration to WorkspaceReconciliationService", async () => {
|
||||
const reconcileWorkspaceRecord = vi.fn(async () => ({
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
changed: true,
|
||||
}));
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
workspaceReconciliationService: {
|
||||
reconcileWorkspaceRecord,
|
||||
reconcileActiveWorkspaceRecords: async () => new Set<string>(),
|
||||
registerPendingWorktreeWorkspace: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
},
|
||||
});
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
session.emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "open_project_request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "req-open",
|
||||
});
|
||||
|
||||
expect(reconcileWorkspaceRecord).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(emitted).toContainEqual({
|
||||
type: "open_project_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "req-open",
|
||||
error: null,
|
||||
workspace: expect.objectContaining({
|
||||
id: "/tmp/repo",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace update fanout delegates sweep to WorkspaceReconciliationService", async () => {
|
||||
const reconcileActiveWorkspaceRecords = vi.fn(
|
||||
async () => new Set(["/tmp/repo", "/tmp/repo/worktree"]),
|
||||
);
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
workspaceReconciliationService: {
|
||||
reconcileWorkspaceRecord: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
reconcileActiveWorkspaceRecords,
|
||||
registerPendingWorktreeWorkspace: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-ownership",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
{
|
||||
id: "/tmp/repo/worktree",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature-a",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree");
|
||||
|
||||
expect(reconcileActiveWorkspaceRecords).toHaveBeenCalledTimes(1);
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update") as
|
||||
| Array<{ type: "workspace_update"; payload: any }>;
|
||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/worktree",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -292,7 +292,7 @@ describe("workspace aggregation", () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
@@ -411,7 +411,7 @@ describe("workspace aggregation", () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
session.reconcileActiveWorkspaceRecords = async () =>
|
||||
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
|
||||
new Set(["/tmp/repo", "/tmp/repo/worktree"]);
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ProjectPlacementPayload } from "./messages.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
function createProjectPlacement(input: {
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
isGit: boolean;
|
||||
branchName?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
isPaseoOwnedWorktree?: boolean;
|
||||
mainRepoRoot?: string | null;
|
||||
}): ProjectPlacementPayload {
|
||||
return {
|
||||
projectKey: input.projectId,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: input.isGit,
|
||||
currentBranch: input.branchName ?? null,
|
||||
remoteUrl: input.remoteUrl ?? null,
|
||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryRegistries() {
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
return {
|
||||
projects,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
||||
upsert: async (record: ReturnType<typeof createPersistedProjectRecord>) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
archive: async (projectId: string, archivedAt: string) => {
|
||||
const existing = projects.get(projectId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
projects.set(projectId, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
},
|
||||
remove: async (projectId: string) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
},
|
||||
workspaces,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
upsert: async (record: ReturnType<typeof createPersistedWorkspaceRecord>) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId: string, archivedAt: string) => {
|
||||
const existing = workspaces.get(workspaceId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
workspaces.set(workspaceId, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
},
|
||||
remove: async (workspaceId: string) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceReconciliationService", () => {
|
||||
test("reconcileWorkspaceRecord registers a new workspace and project", async () => {
|
||||
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
|
||||
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry: projectRegistry as any,
|
||||
workspaceRegistry: workspaceRegistry as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} as any,
|
||||
buildProjectPlacement: async (cwd: string) =>
|
||||
createProjectPlacement({
|
||||
cwd,
|
||||
projectId: cwd,
|
||||
projectName: "repo",
|
||||
isGit: false,
|
||||
}),
|
||||
syncWorkspaceGitWatchTarget,
|
||||
removeWorkspaceGitWatchTarget: vi.fn(),
|
||||
now: () => "2026-03-25T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await service.reconcileWorkspaceRecord("/tmp/repo");
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: true,
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-25T00:00:00.000Z",
|
||||
updatedAt: "2026-03-25T00:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
expect(projects.get("/tmp/repo")).toEqual(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "/tmp/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-25T00:00:00.000Z",
|
||||
updatedAt: "2026-03-25T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(workspaces.get("/tmp/repo")).toEqual(result.workspace);
|
||||
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith("/tmp/repo", {
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("registerPendingWorktreeWorkspace preregisters a git worktree under the repo project", async () => {
|
||||
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
|
||||
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry: projectRegistry as any,
|
||||
workspaceRegistry: workspaceRegistry as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} as any,
|
||||
buildProjectPlacement: async (cwd: string) =>
|
||||
createProjectPlacement({
|
||||
cwd,
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectName: "acme/repo",
|
||||
isGit: true,
|
||||
branchName: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
}),
|
||||
syncWorkspaceGitWatchTarget,
|
||||
removeWorkspaceGitWatchTarget: vi.fn(),
|
||||
now: () => "2026-03-25T01:00:00.000Z",
|
||||
});
|
||||
|
||||
const workspace = await service.registerPendingWorktreeWorkspace({
|
||||
repoRoot: "/tmp/repo",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
});
|
||||
|
||||
expect(workspace).toEqual(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo/.paseo/worktrees/feature-a",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/feature-a",
|
||||
kind: "worktree",
|
||||
displayName: "feature-a",
|
||||
createdAt: "2026-03-25T01:00:00.000Z",
|
||||
updatedAt: "2026-03-25T01:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(projects.get("remote:github.com/acme/repo")).toEqual(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-25T01:00:00.000Z",
|
||||
updatedAt: "2026-03-25T01:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(workspaces.get("/tmp/repo/.paseo/worktrees/feature-a")).toEqual(workspace);
|
||||
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith(
|
||||
"/tmp/repo/.paseo/worktrees/feature-a",
|
||||
{
|
||||
isGit: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("reconcileActiveWorkspaceRecords reassigns drifted workspaces and archives the old empty project", async () => {
|
||||
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
|
||||
const localProjectId = "/tmp/repo";
|
||||
const remoteProjectId = "remote:github.com/acme/repo";
|
||||
const mainWorkspaceId = "/tmp/repo";
|
||||
const worktreeWorkspaceId = "/tmp/repo/.paseo/worktrees/feature-a";
|
||||
|
||||
projects.set(
|
||||
localProjectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: localProjectId,
|
||||
rootPath: mainWorkspaceId,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
mainWorkspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: mainWorkspaceId,
|
||||
projectId: localProjectId,
|
||||
cwd: mainWorkspaceId,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
worktreeWorkspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: worktreeWorkspaceId,
|
||||
projectId: localProjectId,
|
||||
cwd: worktreeWorkspaceId,
|
||||
kind: "worktree",
|
||||
displayName: "feature-a",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry: projectRegistry as any,
|
||||
workspaceRegistry: workspaceRegistry as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} as any,
|
||||
buildProjectPlacement: async (cwd: string) =>
|
||||
createProjectPlacement({
|
||||
cwd,
|
||||
projectId: remoteProjectId,
|
||||
projectName: "acme/repo",
|
||||
isGit: true,
|
||||
branchName: cwd === mainWorkspaceId ? "main" : "feature-a",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
||||
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
||||
}),
|
||||
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
|
||||
removeWorkspaceGitWatchTarget: vi.fn(),
|
||||
checkDirectoryExists: async () => true,
|
||||
now: () => "2026-03-25T02:00:00.000Z",
|
||||
});
|
||||
|
||||
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
|
||||
|
||||
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
|
||||
mainWorkspaceId,
|
||||
worktreeWorkspaceId,
|
||||
]);
|
||||
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId);
|
||||
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId);
|
||||
expect(projects.get(localProjectId)?.archivedAt).toBe("2026-03-25T02:00:00.000Z");
|
||||
});
|
||||
|
||||
test("reconcileActiveWorkspaceRecords archives missing-dir and all-archived-agent workspaces but keeps no-agent and active-agent cases", async () => {
|
||||
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
|
||||
const activeWorkspaceId = "/tmp/active";
|
||||
const noAgentWorkspaceId = "/tmp/no-agents";
|
||||
const missingWorkspaceId = "/tmp/missing";
|
||||
const archivedAgentsWorkspaceId = "/tmp/all-archived";
|
||||
|
||||
for (const workspaceId of [
|
||||
activeWorkspaceId,
|
||||
noAgentWorkspaceId,
|
||||
missingWorkspaceId,
|
||||
archivedAgentsWorkspaceId,
|
||||
]) {
|
||||
projects.set(
|
||||
workspaceId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: workspaceId,
|
||||
rootPath: workspaceId,
|
||||
kind: "non_git",
|
||||
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd: workspaceId,
|
||||
kind: "directory",
|
||||
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry: projectRegistry as any,
|
||||
workspaceRegistry: workspaceRegistry as any,
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
{
|
||||
id: "archived-agent",
|
||||
cwd: archivedAgentsWorkspaceId,
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "inactive-agent",
|
||||
cwd: activeWorkspaceId,
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "active-agent",
|
||||
cwd: activeWorkspaceId,
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
buildProjectPlacement: async (cwd: string) =>
|
||||
createProjectPlacement({
|
||||
cwd,
|
||||
projectId: cwd,
|
||||
projectName: cwd.split("/").at(-1) ?? cwd,
|
||||
isGit: false,
|
||||
}),
|
||||
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
|
||||
removeWorkspaceGitWatchTarget: vi.fn(),
|
||||
checkDirectoryExists: async (cwd: string) => cwd !== missingWorkspaceId,
|
||||
now: () => "2026-03-25T03:00:00.000Z",
|
||||
});
|
||||
|
||||
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
|
||||
|
||||
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
|
||||
archivedAgentsWorkspaceId,
|
||||
missingWorkspaceId,
|
||||
]);
|
||||
expect(workspaces.get(missingWorkspaceId)?.archivedAt).toBe("2026-03-25T03:00:00.000Z");
|
||||
expect(workspaces.get(archivedAgentsWorkspaceId)?.archivedAt).toBe(
|
||||
"2026-03-25T03:00:00.000Z",
|
||||
);
|
||||
expect(workspaces.get(noAgentWorkspaceId)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(activeWorkspaceId)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("archiveWorkspaceRecord archives the last active workspace and its project", async () => {
|
||||
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
|
||||
const removeWorkspaceGitWatchTarget = vi.fn();
|
||||
const projectId = "/tmp/repo";
|
||||
const workspaceId = "/tmp/repo";
|
||||
|
||||
projects.set(
|
||||
projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath: workspaceId,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId,
|
||||
cwd: workspaceId,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry: projectRegistry as any,
|
||||
workspaceRegistry: workspaceRegistry as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} as any,
|
||||
buildProjectPlacement: vi.fn(),
|
||||
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
|
||||
removeWorkspaceGitWatchTarget,
|
||||
now: () => "2026-03-25T04:00:00.000Z",
|
||||
});
|
||||
|
||||
await service.archiveWorkspaceRecord(workspaceId);
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
|
||||
expect(projects.get(projectId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
|
||||
expect(removeWorkspaceGitWatchTarget).toHaveBeenCalledWith(workspaceId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
|
||||
import type { ProjectPlacementPayload } from "./messages.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
detectStaleWorkspaces,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
type Awaitable<T> = T | Promise<T>;
|
||||
|
||||
export type WorkspaceReconciliationServiceOptions = {
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
syncWorkspaceGitWatchTarget?: (
|
||||
cwd: string,
|
||||
options: { isGit: boolean },
|
||||
) => Awaitable<void>;
|
||||
removeWorkspaceGitWatchTarget?: (cwd: string) => Awaitable<void>;
|
||||
checkDirectoryExists?: (cwd: string) => Promise<boolean>;
|
||||
now?: () => string;
|
||||
};
|
||||
|
||||
export type ReconcileWorkspaceRecordResult = {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly agentStorage: Pick<AgentStorage, "list">;
|
||||
private readonly buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
private readonly syncWorkspaceGitWatchTarget: (
|
||||
cwd: string,
|
||||
options: { isGit: boolean },
|
||||
) => Promise<void>;
|
||||
private readonly removeWorkspaceGitWatchTarget: (cwd: string) => Promise<void>;
|
||||
private readonly checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
||||
private readonly now: () => string;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.buildProjectPlacement = options.buildProjectPlacement;
|
||||
this.syncWorkspaceGitWatchTarget = async (cwd, syncOptions) => {
|
||||
await options.syncWorkspaceGitWatchTarget?.(cwd, syncOptions);
|
||||
};
|
||||
this.removeWorkspaceGitWatchTarget = async (cwd) => {
|
||||
await options.removeWorkspaceGitWatchTarget?.(cwd);
|
||||
};
|
||||
this.checkDirectoryExists =
|
||||
options.checkDirectoryExists ??
|
||||
(async (cwd) => {
|
||||
try {
|
||||
await stat(cwd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
this.now = options.now ?? (() => new Date().toISOString());
|
||||
}
|
||||
|
||||
async reconcileWorkspaceRecord(workspaceId: string): Promise<ReconcileWorkspaceRecordResult> {
|
||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
|
||||
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
|
||||
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
|
||||
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
const now = this.now();
|
||||
const nextWorkspaceCreatedAt = existingWorkspace?.createdAt ?? now;
|
||||
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: currentProjectRecord?.createdAt ?? nextWorkspaceCreatedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: nextWorkspaceCreatedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const needsWorkspaceUpdate =
|
||||
!existingWorkspace ||
|
||||
existingWorkspace.archivedAt ||
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId ||
|
||||
existingWorkspace.kind !== nextWorkspaceRecord.kind ||
|
||||
existingWorkspace.displayName !== nextWorkspaceRecord.displayName;
|
||||
const needsProjectUpdate =
|
||||
!currentProjectRecord ||
|
||||
currentProjectRecord.archivedAt ||
|
||||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
|
||||
currentProjectRecord.kind !== nextProjectRecord.kind ||
|
||||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
|
||||
|
||||
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
|
||||
return {
|
||||
workspace: existingWorkspace!,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord);
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: nextWorkspaceRecord,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces,
|
||||
agentRecords: (await this.agentStorage.list()).map((agent) => ({
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
})),
|
||||
checkDirectoryExists: this.checkDirectoryExists,
|
||||
});
|
||||
|
||||
for (const workspaceId of staleWorkspaceIds) {
|
||||
await this.archiveWorkspaceRecord(workspaceId);
|
||||
changedWorkspaceIds.add(workspaceId);
|
||||
}
|
||||
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (staleWorkspaceIds.has(workspace.workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
|
||||
if (result.changed) {
|
||||
changedWorkspaceIds.add(result.workspace.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return changedWorkspaceIds;
|
||||
}
|
||||
|
||||
async registerPendingWorktreeWorkspace(options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
};
|
||||
const now = this.now();
|
||||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||
const existingProject = await this.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord);
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await this.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord;
|
||||
}
|
||||
|
||||
async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
|
||||
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
|
||||
if (!existingWorkspace || existingWorkspace.archivedAt) {
|
||||
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextArchivedAt = archivedAt ?? this.now();
|
||||
await this.workspaceRegistry.archive(normalizedWorkspaceId, nextArchivedAt);
|
||||
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
|
||||
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private buildPersistedProjectRecord(input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(input.placement.checkout),
|
||||
displayName: input.placement.projectName,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
private buildPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: input.workspaceId,
|
||||
projectId: input.placement.projectKey,
|
||||
cwd: input.workspaceId,
|
||||
kind: deriveWorkspaceKind(input.placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(projectId, archivedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user