mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(server): deduplicate workspaces by git worktree root (#190)
* fix(server): deduplicate workspaces by resolving to git worktree root Agents running in subdirectories of the same git repo were creating separate workspace entries in the sidebar. Now workspace IDs resolve to the git worktree root (via the already-computed `worktreeRoot` from `inspectCheckoutContext`), so all agents in the same checkout share a single workspace. Stale subdirectory workspace records are archived during reconciliation. * fix(server): fix 3 broken test files in server unit suite - relay-reconnect: move speech mock to correct constructor position and rename getSpeechReadiness→getReadiness after SpeechService refactor - commands-poc: add credential/CLI availability guards matching claude-agent.integration.test.ts pattern - claude-sdk-behavior: pass pathToClaudeCodeExecutable via findExecutable and add credential guards matching real provider configuration * fix(server): fix race condition in loop-service test mock Replace setTimeout(..., 0) with queueMicrotask() in ScriptedAgentSession so turn_completed events fire before the verify check runs. Fixes flaky CI failure where the file write hadn't completed before verification. * fix(server): use /bin/sh for loop verify checks instead of /bin/zsh More portable across CI environments. Also use queueMicrotask in test mock to fix race condition between event emission and waiter setup. * fix(server): correct import paths for isCommandAvailable and findExecutable Import from utils/executable.js (where they're exported) instead of provider-launch-config.js (which only imports them internally).
This commit is contained in:
@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
it("returns the main repo root for paseo-owned worktrees", () => {
|
||||
const checkout = {
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: "/repo/.paseo/worktrees/feature",
|
||||
mainRepoRoot: "/repo/main",
|
||||
} as CheckoutStatusPayload;
|
||||
} as unknown as CheckoutStatusPayload;
|
||||
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
|
||||
"/repo/main",
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("project-placement", () => {
|
||||
isGit: true as const,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/Users/test/repo",
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
* This pattern is used in claude-agent.ts listModels().
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
query,
|
||||
type Query,
|
||||
type SlashCommand,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { isCommandAvailable } from "../utils/executable.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
|
||||
// Pattern from claude-agent.ts listModels():
|
||||
// Use an empty async generator when you just need control methods
|
||||
@@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
|
||||
describe("Claude Agent SDK Commands POC", () => {
|
||||
describe("supportedCommands() API", () => {
|
||||
it("should return an array of SlashCommand objects", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
|
||||
// Use the pattern from claude-agent.ts:
|
||||
// Create a query with empty prompt generator for control methods
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
@@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("should have valid SlashCommand structure for all commands", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
|
||||
const claudeQuery = query({
|
||||
@@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => {
|
||||
});
|
||||
|
||||
describe("Command Execution", () => {
|
||||
it("should explain that commands are prompts with / prefix", () => {
|
||||
test("should explain that commands are prompts with / prefix", () => {
|
||||
// This is a documentation test - commands ARE just prompts with / prefix
|
||||
// To execute a command:
|
||||
// 1. Create a user message with content: "/{commandName}"
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
@@ -52,10 +53,22 @@ function tmpCwd(): string {
|
||||
}
|
||||
}
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
describe("Claude SDK direct behavior", () => {
|
||||
it("shows what happens after interrupt()", async () => {
|
||||
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
|
||||
beforeAll(() => {
|
||||
if (canRunClaudeIntegration) {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const claudeBinary = findExecutable("claude");
|
||||
|
||||
// Use same options as claude-agent.ts
|
||||
const q = query({
|
||||
@@ -64,6 +77,7 @@ describe("Claude SDK direct behavior", () => {
|
||||
cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: "bypassPermissions",
|
||||
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
||||
systemPrompt: {
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
|
||||
@@ -106,9 +106,9 @@ class ScriptedAgentSession implements AgentSession {
|
||||
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||
const turnId = `turn-${++this.turnCount}`;
|
||||
this.interrupted = false;
|
||||
setTimeout(() => {
|
||||
queueMicrotask(() => {
|
||||
void this.runScript(promptText, turnId);
|
||||
}, 0);
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ async function runVerifyCheck(options: {
|
||||
}): Promise<LoopVerifyCheckResult> {
|
||||
const startedAt = nowIso();
|
||||
try {
|
||||
const result = await execFileAsync("/bin/zsh", ["-lc", options.command], {
|
||||
const result = await execFileAsync("/bin/sh", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
detectStaleWorkspaces,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceId,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||
@@ -1298,11 +1299,15 @@ export class Session {
|
||||
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
changed: boolean;
|
||||
removedWorkspaceId: string | null;
|
||||
}> {
|
||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
|
||||
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId);
|
||||
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
|
||||
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(workspaceId);
|
||||
const placement = await this.buildProjectPlacement(normalizedCwd);
|
||||
const resolvedWorkspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||
const staleWorkspace =
|
||||
resolvedWorkspaceId === normalizedCwd ? null : await this.workspaceRegistry.get(normalizedCwd);
|
||||
const existing = (await this.workspaceRegistry.get(resolvedWorkspaceId)) ?? staleWorkspace;
|
||||
await this.syncWorkspaceGitWatchTarget(resolvedWorkspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
@@ -1310,13 +1315,13 @@ export class Session {
|
||||
const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
|
||||
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
placement,
|
||||
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
placement,
|
||||
createdAt: nextWorkspaceCreatedAt,
|
||||
updatedAt: now,
|
||||
@@ -1335,16 +1340,33 @@ export class Session {
|
||||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
|
||||
currentProjectRecord.kind !== nextProjectRecord.kind ||
|
||||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
|
||||
const needsStaleWorkspaceCleanup =
|
||||
!!staleWorkspace &&
|
||||
!staleWorkspace.archivedAt &&
|
||||
staleWorkspace.workspaceId !== resolvedWorkspaceId;
|
||||
|
||||
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
if (needsStaleWorkspaceCleanup) {
|
||||
await this.workspaceRegistry.archive(staleWorkspace.workspaceId, now);
|
||||
this.removeWorkspaceGitWatchTarget(staleWorkspace.workspaceId);
|
||||
removedWorkspaceId = staleWorkspace.workspaceId;
|
||||
}
|
||||
|
||||
if (!needsWorkspaceUpdate && !needsProjectUpdate && !needsStaleWorkspaceCleanup) {
|
||||
return {
|
||||
workspace: existing!,
|
||||
changed: false,
|
||||
removedWorkspaceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord);
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
if (existing && existing.workspaceId !== resolvedWorkspaceId) {
|
||||
await this.workspaceRegistry.archive(existing.workspaceId, now);
|
||||
this.removeWorkspaceGitWatchTarget(existing.workspaceId);
|
||||
removedWorkspaceId ??= existing.workspaceId;
|
||||
}
|
||||
|
||||
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
|
||||
await this.archiveProjectRecordIfEmpty(existing.projectId, now);
|
||||
@@ -1353,6 +1375,7 @@ export class Session {
|
||||
return {
|
||||
workspace: nextWorkspaceRecord,
|
||||
changed: true,
|
||||
removedWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1386,6 +1409,9 @@ export class Session {
|
||||
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
|
||||
if (result.changed) {
|
||||
changedWorkspaceIds.add(result.workspace.workspaceId);
|
||||
if (result.removedWorkspaceId) {
|
||||
changedWorkspaceIds.add(result.removedWorkspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5131,7 +5157,7 @@ export class Session {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = normalizePersistedWorkspaceId(agent.cwd);
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(agent.cwd, activeRecords);
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
if (!existing) {
|
||||
continue;
|
||||
@@ -5147,6 +5173,32 @@ export class Session {
|
||||
return Array.from(descriptorsByWorkspaceId.values());
|
||||
}
|
||||
|
||||
private resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
const exact = workspaces.find((workspace) => workspace.workspaceId === normalizedCwd);
|
||||
if (exact) {
|
||||
return exact.workspaceId;
|
||||
}
|
||||
|
||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||
for (const workspace of workspaces) {
|
||||
const prefix = workspace.workspaceId.endsWith(sep)
|
||||
? workspace.workspaceId
|
||||
: `${workspace.workspaceId}${sep}`;
|
||||
if (!normalizedCwd.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatch || workspace.workspaceId.length > bestMatch.workspaceId.length) {
|
||||
bestMatch = workspace;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.workspaceId ?? normalizedCwd;
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
await this.reconcileActiveWorkspaceRecords();
|
||||
return this.listWorkspaceDescriptorsSnapshot();
|
||||
@@ -5435,8 +5487,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd);
|
||||
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace;
|
||||
return (await this.reconcileWorkspaceRecord(cwd)).workspace;
|
||||
}
|
||||
|
||||
private async registerPendingWorktreeWorkspace(options: {
|
||||
@@ -5488,11 +5539,17 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd);
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||||
const workspaceIdsToEmit = new Set<string>([workspaceId, ...changedWorkspaceIds]);
|
||||
const workspaceIdsToEmit = new Set<string>([
|
||||
workspaceId,
|
||||
...changedWorkspaceIds,
|
||||
]);
|
||||
|
||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
|
||||
@@ -5529,13 +5586,12 @@ export class Session {
|
||||
}
|
||||
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const uniqueWorkspaceCwds = new Set<string>(changedWorkspaceIds);
|
||||
for (const cwd of cwds) {
|
||||
const normalized = normalizePersistedWorkspaceId(cwd);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
uniqueWorkspaceCwds.add(normalized);
|
||||
uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
|
||||
}
|
||||
|
||||
const subscription = this.workspaceUpdatesSubscription;
|
||||
|
||||
@@ -181,6 +181,7 @@ describe("workspace git watch targets", () => {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -267,6 +268,7 @@ describe("workspace git watch targets", () => {
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -251,6 +251,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -400,6 +401,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -564,6 +566,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -699,6 +702,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -792,6 +796,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: true,
|
||||
currentBranch: "feature/name-from-server",
|
||||
remoteUrl: "https://github.com/acme/repo-branch.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -849,6 +854,41 @@ describe("workspace aggregation", () => {
|
||||
expect(result.entries[0]?.status).toBe("needs_input");
|
||||
});
|
||||
|
||||
test("subdirectory agents map to an existing parent workspace descriptor", async () => {
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
session.workspaceRegistry.list = async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "a1",
|
||||
cwd: "/tmp/repo/packages/app",
|
||||
status: "running",
|
||||
updatedAt: "2026-03-01T12:03:00.000Z",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = await session.listFetchWorkspacesEntries({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "req-subdir-agent",
|
||||
});
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0]).toMatchObject({
|
||||
id: "/tmp/repo",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:03:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace update stream keeps persisted workspace visible after agents stop", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const logger = {
|
||||
@@ -1114,6 +1154,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
@@ -1131,6 +1172,57 @@ describe("workspace aggregation", () => {
|
||||
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const repoRoot = "/tmp/repo";
|
||||
const subdir = "/tmp/repo/packages/app";
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
};
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: repoRoot,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: repoRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "open_project_request",
|
||||
cwd: subdir,
|
||||
requestId: "req-open-subdir",
|
||||
});
|
||||
|
||||
expect(workspaces.get(repoRoot)).toBeTruthy();
|
||||
expect(workspaces.has(subdir)).toBe(false);
|
||||
const response = emitted.find((message) => message.type === "open_project_response") as any;
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBe(repoRoot);
|
||||
});
|
||||
|
||||
test("archive_workspace_request hides non-destructive workspace records", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
@@ -1239,6 +1331,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: true,
|
||||
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
|
||||
remoteUrl: "https://github.com/zimakki/inkwell.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
||||
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
||||
},
|
||||
@@ -1345,6 +1438,7 @@ describe("workspace aggregation", () => {
|
||||
isGit: true,
|
||||
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
|
||||
remoteUrl: "https://github.com/new-owner/inkwell.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
||||
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
||||
},
|
||||
@@ -1367,4 +1461,99 @@ describe("workspace aggregation", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => {
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-")));
|
||||
const repoRoot = path.join(tempDir, "repo");
|
||||
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
|
||||
const projectId = "remote:github.com/acme/repo";
|
||||
|
||||
execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`);
|
||||
|
||||
projects.set(
|
||||
projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId,
|
||||
rootPath: repoRoot,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
repoRoot,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: repoRoot,
|
||||
projectId,
|
||||
cwd: repoRoot,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
subdirWorkspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: subdirWorkspaceId,
|
||||
projectId,
|
||||
cwd: subdirWorkspaceId,
|
||||
kind: "directory",
|
||||
displayName: "app",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
session.projectRegistry.get = async (nextProjectId: string) => projects.get(nextProjectId) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
};
|
||||
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
||||
const existing = workspaces.get(workspaceId);
|
||||
if (!existing) return;
|
||||
workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
};
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: projectId,
|
||||
projectName: "acme/repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: repoRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.workspace.workspaceId).toBe(repoRoot);
|
||||
expect(result.removedWorkspaceId).toBe(subdirWorkspaceId);
|
||||
expect(workspaces.get(repoRoot)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,15 +167,16 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
"/tmp/paseo-test",
|
||||
async () => ({}) as any,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
speechReadiness
|
||||
? {
|
||||
getSpeechReadiness: () => speechReadiness,
|
||||
getReadiness: () => speechReadiness,
|
||||
onReadinessChange: vi.fn(() => () => {}),
|
||||
}
|
||||
: undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
TEST_DAEMON_VERSION,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
deriveWorkspaceId,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
@@ -67,22 +68,26 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
|
||||
const records = await options.agentStorage.list();
|
||||
const activeRecords = records.filter((record) => !record.archivedAt);
|
||||
const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>();
|
||||
const recordsByWorkspaceId = new Map<
|
||||
string,
|
||||
{ placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>; records: StoredAgentRecord[] }
|
||||
>();
|
||||
for (const record of activeRecords) {
|
||||
const workspaceId = normalizeWorkspaceId(record.cwd);
|
||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? [];
|
||||
existing.push(record);
|
||||
const normalizedCwd = normalizeWorkspaceId(record.cwd);
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: normalizedCwd,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
|
||||
existing.records.push(record);
|
||||
recordsByWorkspaceId.set(workspaceId, existing);
|
||||
}
|
||||
|
||||
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
|
||||
|
||||
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) {
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: workspaceId,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
|
||||
for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) {
|
||||
const { placement, records: workspaceRecords } = entry;
|
||||
let workspaceCreatedAt: string | null = null;
|
||||
let workspaceUpdatedAt: string | null = null;
|
||||
for (const record of workspaceRecords) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { detectStaleWorkspaces } from "./workspace-registry-model.js";
|
||||
import { deriveWorkspaceId, detectStaleWorkspaces } from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
function createWorkspaceRecord(workspaceId: string) {
|
||||
@@ -52,3 +52,33 @@ describe("detectStaleWorkspaces", () => {
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkspaceId", () => {
|
||||
test("uses git worktree root when available", () => {
|
||||
expect(
|
||||
deriveWorkspaceId("/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");
|
||||
});
|
||||
|
||||
test("falls back to normalized cwd for non-git directories", () => {
|
||||
expect(
|
||||
deriveWorkspaceId("/tmp/repo/../repo/scratch", {
|
||||
cwd: "/tmp/repo/../repo/scratch",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe("/tmp/repo/scratch");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,10 @@ export function normalizeWorkspaceId(cwd: string): string {
|
||||
return resolve(trimmed);
|
||||
}
|
||||
|
||||
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
|
||||
return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -161,6 +165,7 @@ export async function buildProjectPlacementForCwd(input: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
@@ -172,6 +177,7 @@ export async function buildProjectPlacementForCwd(input: {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
worktreeRoot: status.worktreeRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
};
|
||||
@@ -182,6 +188,7 @@ export async function buildProjectPlacementForCwd(input: {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
worktreeRoot: status.worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
@@ -192,13 +199,14 @@ export async function buildProjectPlacementForCwd(input: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: normalizedCwd,
|
||||
cwd: checkout.worktreeRoot ?? normalizedCwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
|
||||
@@ -518,6 +518,7 @@ export async function registerPendingWorktreeWorkspace(
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
worktreeRoot: options.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
|
||||
@@ -1598,6 +1598,7 @@ export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
|
||||
isGit: z.literal(false),
|
||||
currentBranch: z.null(),
|
||||
remoteUrl: z.null(),
|
||||
worktreeRoot: z.null(),
|
||||
isPaseoOwnedWorktree: z.literal(false),
|
||||
mainRepoRoot: z.null(),
|
||||
});
|
||||
@@ -1607,6 +1608,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
|
||||
isGit: z.literal(true),
|
||||
currentBranch: z.string().nullable(),
|
||||
remoteUrl: z.string().nullable(),
|
||||
worktreeRoot: z.string(),
|
||||
isPaseoOwnedWorktree: z.literal(false),
|
||||
mainRepoRoot: z.null(),
|
||||
});
|
||||
@@ -1616,6 +1618,7 @@ export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
|
||||
isGit: z.literal(true),
|
||||
currentBranch: z.string().nullable(),
|
||||
remoteUrl: z.string().nullable(),
|
||||
worktreeRoot: z.string(),
|
||||
isPaseoOwnedWorktree: z.literal(true),
|
||||
mainRepoRoot: z.string(),
|
||||
});
|
||||
|
||||
@@ -151,6 +151,7 @@ const x = 1;
|
||||
const status = await getCheckoutStatusLite(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
expect(status.worktreeRoot).toBe(repoDir);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||
expect(status.mainRepoRoot).toBeNull();
|
||||
});
|
||||
@@ -373,6 +374,7 @@ const x = 1;
|
||||
|
||||
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.worktreeRoot).toBe(result.worktreePath);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
});
|
||||
|
||||
@@ -542,6 +542,7 @@ export type CheckoutStatusLiteNotGit = {
|
||||
isGit: false;
|
||||
currentBranch: null;
|
||||
remoteUrl: null;
|
||||
worktreeRoot: null;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
@@ -550,6 +551,7 @@ export type CheckoutStatusLiteGitNonPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
@@ -558,6 +560,7 @@ export type CheckoutStatusLiteGitPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string;
|
||||
isPaseoOwnedWorktree: true;
|
||||
mainRepoRoot: string;
|
||||
};
|
||||
@@ -1159,6 +1162,7 @@ export async function getCheckoutStatusLite(
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
@@ -1169,6 +1173,7 @@ export async function getCheckoutStatusLite(
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
worktreeRoot: inspected.worktreeRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: await getMainRepoRoot(cwd),
|
||||
};
|
||||
@@ -1178,6 +1183,7 @@ export async function getCheckoutStatusLite(
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
worktreeRoot: inspected.worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user