Make workspace git registration nonblocking (#542)
This commit is contained in:
@@ -85,6 +85,17 @@ function DisabledRenderCountProbe({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HookResultProbe({
|
||||||
|
onResult,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
onResult: (result: ReturnType<typeof useSidebarWorkspacesList>) => void;
|
||||||
|
serverId: string;
|
||||||
|
}): null {
|
||||||
|
onResult(useSidebarWorkspacesList({ serverId }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("applyStoredOrdering", () => {
|
describe("applyStoredOrdering", () => {
|
||||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||||
const result = applyStoredOrdering({
|
const result = applyStoredOrdering({
|
||||||
@@ -206,6 +217,7 @@ describe("useSidebarWorkspacesList", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
getHostRuntimeStore().syncHosts([]);
|
getHostRuntimeStore().syncHosts([]);
|
||||||
useSessionStore.getState().clearSession("srv-disabled");
|
useSessionStore.getState().clearSession("srv-disabled");
|
||||||
|
useSessionStore.getState().clearSession("srv-loading");
|
||||||
useSidebarOrderStore.setState({
|
useSidebarOrderStore.setState({
|
||||||
projectOrderByServerId: {},
|
projectOrderByServerId: {},
|
||||||
workspaceOrderByServerAndProject: {},
|
workspaceOrderByServerAndProject: {},
|
||||||
@@ -234,6 +246,30 @@ describe("useSidebarWorkspacesList", () => {
|
|||||||
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
|
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the sidebar in initial load until workspace hydration succeeds", async () => {
|
||||||
|
const onResult = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onResult).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projects: [],
|
||||||
|
isLoading: true,
|
||||||
|
isInitialLoad: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not subscribe to order updates while disabled", async () => {
|
it("does not subscribe to order updates while disabled", async () => {
|
||||||
const onRender = vi.fn();
|
const onRender = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -303,8 +303,7 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
})();
|
})();
|
||||||
}, [connectionStatus, isActive, runtime, serverId]);
|
}, [connectionStatus, isActive, runtime, serverId]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces;
|
||||||
isActive && Boolean(serverId) && connectionStatus === "online" && !hasHydratedWorkspaces;
|
|
||||||
const isInitialLoad = isLoading && projects.length === 0;
|
const isInitialLoad = isLoading && projects.length === 0;
|
||||||
const isRevalidating = false;
|
const isRevalidating = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1274,11 +1274,9 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
const tool = (server as any)._registeredTools["list_agents"];
|
const tool = (server as any)._registeredTools["list_agents"];
|
||||||
const response = await tool.callback({});
|
const response = await tool.callback({});
|
||||||
|
|
||||||
expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
const agentIds = response.structuredContent.agents.map((agent: { id: string }) => agent.id);
|
||||||
"in-cwd",
|
expect(agentIds).toHaveLength(3);
|
||||||
"in-child-cwd",
|
expect(new Set(agentIds)).toEqual(new Set(["in-cwd", "in-child-cwd", "stored-in-cwd"]));
|
||||||
"stored-in-cwd",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => {
|
it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => {
|
||||||
|
|||||||
@@ -236,8 +236,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
|
|
||||||
function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||||
return {
|
return {
|
||||||
subscribe: async (params) => ({
|
registerWorkspace: () => ({
|
||||||
initial: createWorkspaceGitSnapshot(params.cwd),
|
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./pase
|
|||||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||||
|
|
||||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||||
type GitMutationRefreshReason =
|
type GitMutationRefreshReason =
|
||||||
| "commit-changes"
|
| "commit-changes"
|
||||||
| "pull"
|
| "pull"
|
||||||
@@ -326,7 +326,7 @@ type WorkspaceGitWatchTarget = {
|
|||||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||||
refreshPromise: Promise<void> | null;
|
refreshPromise: Promise<void> | null;
|
||||||
refreshQueued: boolean;
|
refreshQueued: boolean;
|
||||||
latestFingerprint: string | null;
|
latestDescriptorStateKey: string | null;
|
||||||
lastBranchName: string | null;
|
lastBranchName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -897,11 +897,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async primeWorkspaceGitWatchFingerprintForWorkspace(
|
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
workspace: PersistedWorkspaceRecord,
|
|
||||||
): Promise<void> {
|
|
||||||
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||||
await this.primeWorkspaceGitWatchFingerprints([descriptor]);
|
this.syncWorkspaceGitObservers([descriptor]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
|
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
|
||||||
@@ -917,7 +915,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4360,9 +4358,9 @@ export class Session {
|
|||||||
this.workspaceGitSubscriptions.delete(normalizedCwd);
|
this.workspaceGitSubscriptions.delete(normalizedCwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string {
|
private workspaceGitDescriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT;
|
return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY;
|
||||||
}
|
}
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
workspace.name,
|
workspace.name,
|
||||||
@@ -4378,15 +4376,15 @@ export class Session {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
|
const nextStateKey = this.workspaceGitDescriptorStateKey(workspace);
|
||||||
if (target.latestFingerprint === nextFingerprint) {
|
if (target.latestDescriptorStateKey === nextStateKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
target.latestFingerprint = nextFingerprint;
|
target.latestDescriptorStateKey = nextStateKey;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberWorkspaceGitWatchFingerprint(
|
private rememberWorkspaceGitDescriptorState(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
workspace: WorkspaceDescriptorPayload | null,
|
workspace: WorkspaceDescriptorPayload | null,
|
||||||
): void {
|
): void {
|
||||||
@@ -4394,29 +4392,20 @@ export class Session {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
|
target.latestDescriptorStateKey = this.workspaceGitDescriptorStateKey(workspace);
|
||||||
target.lastBranchName = workspace?.name ?? null;
|
target.lastBranchName = workspace?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async primeWorkspaceGitWatchFingerprints(
|
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||||
workspaces: Iterable<WorkspaceDescriptorPayload>,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
const persistedWorkspace = await this.workspaceRegistry.get(workspace.id);
|
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
|
||||||
if (!persistedWorkspace) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
|
|
||||||
isGit: workspace.projectKind === "git",
|
isGit: workspace.projectKind === "git",
|
||||||
});
|
});
|
||||||
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace);
|
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncWorkspaceGitWatchTarget(
|
private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
|
||||||
cwd: string,
|
|
||||||
options: { isGit: boolean },
|
|
||||||
): Promise<void> {
|
|
||||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||||
if (!options.isGit) {
|
if (!options.isGit) {
|
||||||
this.removeWorkspaceGitSubscription(normalizedCwd);
|
this.removeWorkspaceGitSubscription(normalizedCwd);
|
||||||
@@ -4427,7 +4416,7 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.workspaceGitService.subscribe(
|
const subscription = this.workspaceGitService.registerWorkspace(
|
||||||
{ cwd: normalizedCwd },
|
{ cwd: normalizedCwd },
|
||||||
(snapshot) => {
|
(snapshot) => {
|
||||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||||
@@ -6523,7 +6512,7 @@ export class Session {
|
|||||||
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
|
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
|
this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
|
||||||
|
|
||||||
if (!nextWorkspace) {
|
if (!nextWorkspace) {
|
||||||
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
||||||
@@ -6694,7 +6683,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await this.listFetchWorkspacesEntries(request);
|
const payload = await this.listFetchWorkspacesEntries(request);
|
||||||
await this.primeWorkspaceGitWatchFingerprints(payload.entries);
|
this.syncWorkspaceGitObservers(payload.entries);
|
||||||
this.sessionLogger.debug(
|
this.sessionLogger.debug(
|
||||||
{
|
{
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||||
|
|
||||||
function createWorkspaceRuntimeSnapshot(
|
function createWorkspaceRuntimeSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -68,7 +69,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
|
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
|
||||||
workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
||||||
workspaceGitService: WorkspaceGitService & {
|
workspaceGitService: WorkspaceGitService & {
|
||||||
subscribe: ReturnType<typeof vi.fn>;
|
registerWorkspace: ReturnType<typeof vi.fn>;
|
||||||
peekSnapshot: ReturnType<typeof vi.fn>;
|
peekSnapshot: ReturnType<typeof vi.fn>;
|
||||||
getSnapshot: ReturnType<typeof vi.fn>;
|
getSnapshot: ReturnType<typeof vi.fn>;
|
||||||
refresh: ReturnType<typeof vi.fn>;
|
refresh: ReturnType<typeof vi.fn>;
|
||||||
@@ -99,7 +100,8 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => {
|
...createNoopWorkspaceGitService(),
|
||||||
|
registerWorkspace: vi.fn((params: { cwd: string }, listener: WorkspaceGitListener) => {
|
||||||
const unsubscribe = vi.fn();
|
const unsubscribe = vi.fn();
|
||||||
subscriptions.push({
|
subscriptions.push({
|
||||||
params,
|
params,
|
||||||
@@ -107,7 +109,6 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
unsubscribe,
|
unsubscribe,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
initial: createWorkspaceRuntimeSnapshot(params.cwd),
|
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -186,7 +187,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: workspaceGitService as any,
|
workspaceGitService,
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -200,7 +201,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
emitted,
|
emitted,
|
||||||
projects,
|
projects,
|
||||||
workspaces,
|
workspaces,
|
||||||
workspaceGitService: workspaceGitService as any,
|
workspaceGitService,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -275,9 +276,9 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||||
|
|
||||||
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||||
|
|
||||||
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
|
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||||
{ cwd: "/tmp/repo" },
|
{ cwd: "/tmp/repo" },
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
@@ -334,7 +335,7 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
@@ -369,7 +370,7 @@ describe("workspace git watch targets", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
requestId: "subscription:/tmp/repo",
|
requestId: "subscription:/tmp/repo",
|
||||||
});
|
});
|
||||||
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
|
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||||
{ cwd: "/tmp/repo" },
|
{ cwd: "/tmp/repo" },
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
@@ -397,7 +398,7 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "../shared/messages.js";
|
} from "../shared/messages.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||||
|
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||||
import {
|
import {
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
@@ -112,67 +113,6 @@ function agentIdsFromEntries(entries: Array<{ agent: Pick<AgentSnapshotPayload,
|
|||||||
return entries.map((entry) => entry.agent.id);
|
return entries.map((entry) => entry.agent.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNoopWorkspaceGitService() {
|
|
||||||
return {
|
|
||||||
subscribe: async (params: { cwd: string }) => ({
|
|
||||||
initial: {
|
|
||||||
cwd: params.cwd,
|
|
||||||
git: {
|
|
||||||
isGit: false,
|
|
||||||
repoRoot: null,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
isDirty: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
diffStat: null,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
featuresEnabled: false,
|
|
||||||
pullRequest: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unsubscribe: () => {},
|
|
||||||
}),
|
|
||||||
peekSnapshot: (_cwd: string) => null,
|
|
||||||
getSnapshot: async (cwd: string) => ({
|
|
||||||
cwd,
|
|
||||||
git: {
|
|
||||||
isGit: false,
|
|
||||||
repoRoot: null,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
isDirty: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
diffStat: null,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
featuresEnabled: false,
|
|
||||||
pullRequest: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
resolveRepoRemoteUrl: async () => null,
|
|
||||||
resolveRepoRoot: async (cwd: string) => cwd,
|
|
||||||
resolveDefaultBranch: async () => "main",
|
|
||||||
refresh: async () => {},
|
|
||||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
|
||||||
repoRoot: cwd,
|
|
||||||
unsubscribe: () => {},
|
|
||||||
}),
|
|
||||||
scheduleRefreshForCwd: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWorkspaceRuntimeSnapshot(
|
function createWorkspaceRuntimeSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
overrides?: {
|
overrides?: {
|
||||||
@@ -301,7 +241,7 @@ function createSessionForWorkspaceTests(
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any,
|
workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -593,7 +533,7 @@ describe("workspace aggregation", () => {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: createNoopWorkspaceGitService() as any,
|
workspaceGitService: createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -745,7 +685,7 @@ describe("workspace aggregation", () => {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: createNoopWorkspaceGitService() as any,
|
workspaceGitService: createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -924,7 +864,7 @@ describe("workspace aggregation", () => {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: createNoopWorkspaceGitService() as any,
|
workspaceGitService: createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -1066,7 +1006,7 @@ describe("workspace aggregation", () => {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: createNoopWorkspaceGitService() as any,
|
workspaceGitService: createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -1683,7 +1623,7 @@ describe("workspace aggregation", () => {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
workspaceGitService: createNoopWorkspaceGitService() as any,
|
workspaceGitService: createNoopWorkspaceGitService(),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
@@ -2646,8 +2586,7 @@ describe("workspace aggregation", () => {
|
|||||||
});
|
});
|
||||||
const workspaceGitService = createNoopWorkspaceGitService();
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
||||||
workspaceGitService.subscribe = vi.fn(async () => ({
|
workspaceGitService.registerWorkspace = vi.fn(() => ({
|
||||||
initial: runtimeSnapshot,
|
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -2728,6 +2667,64 @@ describe("workspace aggregation", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const emitted: Array<{ type: string; payload: any }> = [];
|
||||||
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
const getSnapshot = vi.fn(async (cwd: string) => {
|
||||||
|
events.push(`git:${cwd}`);
|
||||||
|
return createWorkspaceRuntimeSnapshot(cwd);
|
||||||
|
});
|
||||||
|
workspaceGitService.getSnapshot = getSnapshot;
|
||||||
|
workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void getSnapshot(params.cwd);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const session = createSessionForWorkspaceTests({
|
||||||
|
workspaceGitService,
|
||||||
|
}) as any;
|
||||||
|
const project = createPersistedProjectRecord({
|
||||||
|
projectId: "proj-fetch-boundary",
|
||||||
|
rootPath: "/tmp/repo",
|
||||||
|
kind: "git",
|
||||||
|
displayName: "repo",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: "ws-fetch-boundary",
|
||||||
|
projectId: project.projectId,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
session.emit = (message: any) => {
|
||||||
|
if (message.type === "fetch_workspaces_response") {
|
||||||
|
events.push("response");
|
||||||
|
}
|
||||||
|
emitted.push(message);
|
||||||
|
};
|
||||||
|
session.listAgentPayloads = async () => [];
|
||||||
|
session.projectRegistry.list = async () => [project];
|
||||||
|
session.workspaceRegistry.list = async () => [workspace];
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "fetch_workspaces_request",
|
||||||
|
requestId: "req-fetch-workspaces-boundary",
|
||||||
|
subscribe: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined();
|
||||||
|
expect(events[0]).toBe("response");
|
||||||
|
});
|
||||||
|
|
||||||
test("workspace_update includes updated runtime fields", async () => {
|
test("workspace_update includes updated runtime fields", async () => {
|
||||||
const emitted: Array<{ type: string; payload: any }> = [];
|
const emitted: Array<{ type: string; payload: any }> = [];
|
||||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||||
@@ -2865,6 +2862,7 @@ describe("workspace aggregation", () => {
|
|||||||
projectId: gitProject.projectId,
|
projectId: gitProject.projectId,
|
||||||
projectDisplayName: gitProject.displayName,
|
projectDisplayName: gitProject.displayName,
|
||||||
projectRootPath: gitProject.rootPath,
|
projectRootPath: gitProject.rootPath,
|
||||||
|
workspaceDirectory: gitWorkspace.cwd,
|
||||||
projectKind: gitProject.kind,
|
projectKind: gitProject.kind,
|
||||||
workspaceKind: gitWorkspace.kind,
|
workspaceKind: gitWorkspace.kind,
|
||||||
name: "main",
|
name: "main",
|
||||||
@@ -2881,6 +2879,7 @@ describe("workspace aggregation", () => {
|
|||||||
projectId: directoryProject.projectId,
|
projectId: directoryProject.projectId,
|
||||||
projectDisplayName: directoryProject.displayName,
|
projectDisplayName: directoryProject.displayName,
|
||||||
projectRootPath: directoryProject.rootPath,
|
projectRootPath: directoryProject.rootPath,
|
||||||
|
workspaceDirectory: directoryWorkspace.cwd,
|
||||||
projectKind: directoryProject.kind,
|
projectKind: directoryProject.kind,
|
||||||
workspaceKind: directoryWorkspace.kind,
|
workspaceKind: directoryWorkspace.kind,
|
||||||
name: "docs",
|
name: "docs",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { basename } from "node:path";
|
||||||
|
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
||||||
|
import {
|
||||||
|
buildWorkspaceGitMetadataFromSnapshot,
|
||||||
|
type WorkspaceGitMetadata,
|
||||||
|
} from "../workspace-git-metadata.js";
|
||||||
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
|
|
||||||
|
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
git: {
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
isDirty: null,
|
||||||
|
baseRef: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
hasRemote: false,
|
||||||
|
diffStat: null,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNoopWorkspaceGitService(
|
||||||
|
overrides: Partial<WorkspaceGitService> = {},
|
||||||
|
): WorkspaceGitService {
|
||||||
|
const service: WorkspaceGitService = {
|
||||||
|
registerWorkspace: () => ({
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
peekSnapshot: () => null,
|
||||||
|
getSnapshot: async (cwd: string) => createNoGitWorkspaceRuntimeSnapshot(cwd),
|
||||||
|
getCheckoutDiff: async (): Promise<CheckoutDiffResult> => ({ diff: "" }),
|
||||||
|
validateBranchRef: async () => ({ kind: "not-found" }),
|
||||||
|
hasLocalBranch: async () => false,
|
||||||
|
suggestBranchesForCwd: async () => [],
|
||||||
|
listStashes: async () => [],
|
||||||
|
listWorktrees: async () => [],
|
||||||
|
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
|
||||||
|
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||||
|
return buildWorkspaceGitMetadataFromSnapshot({
|
||||||
|
cwd,
|
||||||
|
directoryName: options?.directoryName ?? basename(cwd),
|
||||||
|
isGit: snapshot.git.isGit,
|
||||||
|
repoRoot: snapshot.git.repoRoot,
|
||||||
|
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||||
|
currentBranch: snapshot.git.currentBranch,
|
||||||
|
remoteUrl: snapshot.git.remoteUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
|
resolveDefaultBranch: async () => "main",
|
||||||
|
resolveRepoRemoteUrl: async () => null,
|
||||||
|
refresh: async () => {},
|
||||||
|
requestWorkingTreeWatch: async () => ({
|
||||||
|
repoRoot: null,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
scheduleRefreshForCwd: () => {},
|
||||||
|
dispose: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import type { Server as HTTPServer } from "http";
|
import type { Server as HTTPServer } from "http";
|
||||||
import { join } from "path";
|
import { basename, join } from "path";
|
||||||
import { hostname as getHostname } from "node:os";
|
import { hostname as getHostname } from "node:os";
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
@@ -35,7 +35,8 @@ import type {
|
|||||||
} from "./agent/provider-launch-config.js";
|
} from "./agent/provider-launch-config.js";
|
||||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import { PushService } from "./push/push-service.js";
|
import { PushService } from "./push/push-service.js";
|
||||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||||
@@ -67,62 +68,68 @@ type WebSocketServerConfig = {
|
|||||||
|
|
||||||
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
||||||
|
|
||||||
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
|
function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
return {
|
return {
|
||||||
subscribe: async ({ cwd }: { cwd: string }) => ({
|
cwd,
|
||||||
initial: {
|
git: {
|
||||||
cwd,
|
isGit: false,
|
||||||
git: {
|
repoRoot: null,
|
||||||
isGit: false,
|
mainRepoRoot: null,
|
||||||
repoRoot: null,
|
currentBranch: null,
|
||||||
mainRepoRoot: null,
|
remoteUrl: null,
|
||||||
currentBranch: null,
|
isPaseoOwnedWorktree: false,
|
||||||
remoteUrl: null,
|
isDirty: null,
|
||||||
isPaseoOwnedWorktree: false,
|
baseRef: null,
|
||||||
isDirty: null,
|
aheadBehind: null,
|
||||||
aheadBehind: null,
|
aheadOfOrigin: null,
|
||||||
aheadOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
hasRemote: false,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
featuresEnabled: false,
|
featuresEnabled: false,
|
||||||
pullRequest: null,
|
pullRequest: null,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||||
|
return {
|
||||||
|
registerWorkspace: () => ({
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
peekSnapshot: () => null,
|
peekSnapshot: () => null,
|
||||||
getSnapshot: async (cwd: string) => ({
|
getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd),
|
||||||
cwd,
|
getCheckoutDiff: async () => ({ diff: "" }),
|
||||||
git: {
|
validateBranchRef: async () => ({ kind: "not-found" }),
|
||||||
isGit: false,
|
hasLocalBranch: async () => false,
|
||||||
repoRoot: null,
|
suggestBranchesForCwd: async () => [],
|
||||||
mainRepoRoot: null,
|
listStashes: async () => [],
|
||||||
currentBranch: null,
|
listWorktrees: async () => [],
|
||||||
remoteUrl: null,
|
getWorkspaceGitMetadata: async (cwd: string, options) => {
|
||||||
isPaseoOwnedWorktree: false,
|
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
||||||
isDirty: null,
|
return buildWorkspaceGitMetadataFromSnapshot({
|
||||||
aheadBehind: null,
|
cwd,
|
||||||
aheadOfOrigin: null,
|
directoryName: options?.directoryName ?? basename(cwd),
|
||||||
behindOfOrigin: null,
|
isGit: snapshot.git.isGit,
|
||||||
diffStat: null,
|
repoRoot: snapshot.git.repoRoot,
|
||||||
},
|
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||||
github: {
|
currentBranch: snapshot.git.currentBranch,
|
||||||
featuresEnabled: false,
|
remoteUrl: snapshot.git.remoteUrl,
|
||||||
pullRequest: null,
|
});
|
||||||
error: null,
|
},
|
||||||
},
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
}),
|
resolveDefaultBranch: async () => "main",
|
||||||
|
resolveRepoRemoteUrl: async () => null,
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
requestWorkingTreeWatch: async () => ({
|
||||||
repoRoot: cwd,
|
repoRoot: null,
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as unknown as WorkspaceGitServiceImpl;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNoopProjectRegistry(): ProjectRegistry {
|
function createNoopProjectRegistry(): ProjectRegistry {
|
||||||
@@ -297,7 +304,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly scheduleService: ScheduleService;
|
private readonly scheduleService: ScheduleService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||||
private readonly github: GitHubService;
|
private readonly github: GitHubService;
|
||||||
private readonly workspaceGitService: WorkspaceGitServiceImpl;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
private readonly daemonConfigStore: DaemonConfigStore;
|
private readonly daemonConfigStore: DaemonConfigStore;
|
||||||
@@ -394,7 +401,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
getDaemonTcpPort?: () => number | null,
|
getDaemonTcpPort?: () => number | null,
|
||||||
getDaemonTcpHost?: () => string | null,
|
getDaemonTcpHost?: () => string | null,
|
||||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
||||||
workspaceGitService?: WorkspaceGitServiceImpl,
|
workspaceGitService?: WorkspaceGitService,
|
||||||
github?: GitHubService,
|
github?: GitHubService,
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
|
|||||||
@@ -295,6 +295,33 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("registerWorkspace returns a subscription without waiting for a cold snapshot", async () => {
|
||||||
|
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||||
|
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||||
|
const service = createService({ getCheckoutStatus });
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
|
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||||
|
expect(getCheckoutStatus).not.toHaveBeenCalled();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
||||||
|
|
||||||
|
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||||
|
|
||||||
|
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => {
|
test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
@@ -315,8 +342,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
test("forced getSnapshot emits even when the fingerprint matches", async () => {
|
test("forced getSnapshot emits even when the fingerprint matches", async () => {
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
|
await service.getSnapshot("/tmp/repo");
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
||||||
|
|
||||||
@@ -336,8 +365,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
await service.getSnapshot("/tmp/repo");
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
@@ -566,7 +597,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
@@ -582,6 +614,103 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("self-heal retries workspace observation setup while a listener remains active", async () => {
|
||||||
|
let nowMs = 0;
|
||||||
|
const resolveAbsoluteGitDir = vi
|
||||||
|
.fn<() => Promise<string | null>>()
|
||||||
|
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
|
||||||
|
.mockResolvedValue("/tmp/repo/.git");
|
||||||
|
const watch = vi.fn(() => createWatcher() as never);
|
||||||
|
const service = createService({
|
||||||
|
resolveAbsoluteGitDir,
|
||||||
|
watch,
|
||||||
|
now: () => new Date(nowMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
expect(watch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
nowMs = 60_000;
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo");
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale workspace watcher callbacks do not refresh after unsubscribe", async () => {
|
||||||
|
const watchCallbacks: Array<() => void> = [];
|
||||||
|
const watch = vi.fn(
|
||||||
|
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
|
||||||
|
watchCallbacks.push(callback);
|
||||||
|
return createWatcher() as never;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutStatus,
|
||||||
|
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
||||||
|
watch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(watchCallbacks.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
watchCallbacks[0]?.();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale GitHub poll callbacks do not refresh after unsubscribe", async () => {
|
||||||
|
let pollStatus: (() => void) | null = null;
|
||||||
|
const pollUnsubscribe = vi.fn();
|
||||||
|
const github = {
|
||||||
|
...createGitHubServiceStub(),
|
||||||
|
retainCurrentPullRequestStatusPoll: vi.fn((options: { onStatus: () => void }) => {
|
||||||
|
pollStatus = options.onStatus;
|
||||||
|
return { unsubscribe: pollUnsubscribe };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutStatus,
|
||||||
|
github,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(github.retainCurrentPullRequestStatusPoll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
pollStatus?.();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(pollUnsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("subscription starts GitHub self-heal reads within the fast poll window", async () => {
|
test("subscription starts GitHub self-heal reads within the fast poll window", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
|
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
|
||||||
@@ -611,7 +740,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
github,
|
github,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 20_000;
|
nowMs = 20_000;
|
||||||
await vi.advanceTimersByTimeAsync(20_000);
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
@@ -643,7 +773,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
github,
|
github,
|
||||||
});
|
});
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
|
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -658,8 +789,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
const second = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
|
const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
@@ -679,14 +811,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
@@ -698,14 +830,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
|
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
|
||||||
@@ -719,7 +851,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
|||||||
@@ -223,14 +223,16 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("subscribe returns an initial workspace runtime snapshot", async () => {
|
test("registerWorkspace returns a subscription without an initial snapshot contract", async () => {
|
||||||
const service = createService();
|
const service = createService();
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
|
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||||
|
expect("initial" in subscription).toBe(false);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -282,7 +284,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
await service.getSnapshot("/tmp/repo");
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
@@ -294,7 +297,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
|
test("cold getSnapshot calls share one workspace target and cache the snapshot", async () => {
|
||||||
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
@@ -313,8 +316,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
expect((service as any).workspaceTargets.size).toBe(1);
|
|
||||||
expect((service as any).workspaceTargetSetups.size).toBe(1);
|
|
||||||
|
|
||||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||||
|
|
||||||
@@ -325,8 +326,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
expect((service as any).workspaceTargets.size).toBe(1);
|
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||||
@@ -347,14 +347,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [first, second] = await Promise.all([
|
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()),
|
const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()),
|
await flushPromises();
|
||||||
]);
|
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
expect((service as any).workspaceTargets.size).toBe(1);
|
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
second.unsubscribe();
|
second.unsubscribe();
|
||||||
@@ -372,9 +370,9 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
|
||||||
|
|
||||||
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
@@ -383,7 +381,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
expect((service as any).workspaceTargets.size).toBe(1);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -392,20 +389,23 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
||||||
const runGitFetch = vi.fn(async () => {});
|
const runGitFetch = vi.fn(async () => {});
|
||||||
const hasOriginRemote = vi.fn(async () => true);
|
const hasOriginRemote = vi.fn(async () => true);
|
||||||
|
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||||
|
|
||||||
const service = createService({
|
const service = createService({
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
resolveAbsoluteGitDir,
|
||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
runGitFetch,
|
runGitFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
|
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||||
const second = await service.subscribe({ cwd: "/tmp/repo/packages/server" }, vi.fn());
|
const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn());
|
||||||
await flushPromises();
|
await vi.waitFor(() => {
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
expect(hasOriginRemote).toHaveBeenCalledTimes(1);
|
expect(hasOriginRemote).toHaveBeenCalledTimes(1);
|
||||||
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||||
expect((service as any).repoTargets.size).toBe(1);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(180_000);
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -452,9 +452,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh");
|
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
||||||
|
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -521,9 +522,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
expect(subscription.initial.git.currentBranch).toBe("main");
|
expect(initialSnapshot.git.currentBranch).toBe("main");
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
@@ -564,7 +566,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
await service.getSnapshot("/tmp/repo");
|
||||||
|
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
await service.getSnapshot("/tmp/repo", {
|
await service.getSnapshot("/tmp/repo", {
|
||||||
force: true,
|
force: true,
|
||||||
@@ -765,10 +768,14 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const service = createService({ getCheckoutShortstat, watch });
|
const service = createService({ getCheckoutShortstat, watch });
|
||||||
const workspaceListener = vi.fn();
|
const workspaceListener = vi.fn();
|
||||||
|
|
||||||
const workspaceSubscription = await service.subscribe({ cwd: "/tmp/repo" }, workspaceListener);
|
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||||
|
const workspaceSubscription = service.registerWorkspace(
|
||||||
|
{ cwd: "/tmp/repo" },
|
||||||
|
workspaceListener,
|
||||||
|
);
|
||||||
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
||||||
|
|
||||||
expect(workspaceSubscription.initial.git.diffStat).toEqual({ additions: 1, deletions: 0 });
|
expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
|
||||||
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo");
|
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo");
|
||||||
expect(repoRootWatch).toBeDefined();
|
expect(repoRootWatch).toBeDefined();
|
||||||
|
|
||||||
|
|||||||
@@ -83,13 +83,10 @@ export type WorkspaceGitRuntimeSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface WorkspaceGitService {
|
export interface WorkspaceGitService {
|
||||||
subscribe(
|
registerWorkspace(
|
||||||
params: { cwd: string },
|
params: { cwd: string },
|
||||||
listener: WorkspaceGitListener,
|
listener: WorkspaceGitListener,
|
||||||
): Promise<{
|
): WorkspaceGitSubscription;
|
||||||
initial: WorkspaceGitRuntimeSnapshot;
|
|
||||||
unsubscribe: () => void;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||||
getSnapshot(
|
getSnapshot(
|
||||||
@@ -139,6 +136,10 @@ export interface WorkspaceGitService {
|
|||||||
|
|
||||||
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
|
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
|
||||||
|
|
||||||
|
export type WorkspaceGitSubscription = {
|
||||||
|
unsubscribe: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceGitReadOptions =
|
export type WorkspaceGitReadOptions =
|
||||||
| {
|
| {
|
||||||
force?: false;
|
force?: false;
|
||||||
@@ -246,6 +247,9 @@ interface WorkspaceGitTarget {
|
|||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
lastShellOutAtMs: number | null;
|
lastShellOutAtMs: number | null;
|
||||||
repoGitRoot: string | null;
|
repoGitRoot: string | null;
|
||||||
|
observationSetupPromise: Promise<void> | null;
|
||||||
|
observationSetupComplete: boolean;
|
||||||
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepoGitTarget {
|
interface RepoGitTarget {
|
||||||
@@ -281,7 +285,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private readonly deps: WorkspaceGitServiceDependencies;
|
private readonly deps: WorkspaceGitServiceDependencies;
|
||||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||||
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
|
||||||
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||||
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
||||||
private readonly branchValidationCache = new Map<
|
private readonly branchValidationCache = new Map<
|
||||||
@@ -337,22 +340,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribe(
|
registerWorkspace(
|
||||||
params: { cwd: string },
|
params: { cwd: string },
|
||||||
listener: WorkspaceGitListener,
|
listener: WorkspaceGitListener,
|
||||||
): Promise<{
|
): WorkspaceGitSubscription {
|
||||||
initial: WorkspaceGitRuntimeSnapshot;
|
|
||||||
unsubscribe: () => void;
|
|
||||||
}> {
|
|
||||||
const cwd = normalizeWorkspaceId(params.cwd);
|
const cwd = normalizeWorkspaceId(params.cwd);
|
||||||
const target = await this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
target.listeners.add(listener);
|
target.listeners.add(listener);
|
||||||
if (target.listeners.size === 1) {
|
if (target.listeners.size === 1) {
|
||||||
this.startWorkspaceSubscriptionTimers(target);
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
}
|
}
|
||||||
|
if (!target.latestSnapshot) {
|
||||||
|
this.scheduleInitialWorkspaceRefresh(target);
|
||||||
|
}
|
||||||
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)),
|
|
||||||
unsubscribe: () => {
|
unsubscribe: () => {
|
||||||
this.removeWorkspaceListener(cwd, listener);
|
this.removeWorkspaceListener(cwd, listener);
|
||||||
},
|
},
|
||||||
@@ -365,7 +368,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||||
const target = await this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
if (!request.force && this.isSnapshotWarm(target)) {
|
if (!request.force && this.isSnapshotWarm(target)) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
@@ -544,18 +547,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
|
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
|
||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
if (target) {
|
await this.refreshWorkspaceTarget(target, {
|
||||||
await this.refreshWorkspaceTarget(target, {
|
force: false,
|
||||||
force: false,
|
includeGitHub: false,
|
||||||
includeGitHub: false,
|
reason: "refresh",
|
||||||
reason: "refresh",
|
notify: true,
|
||||||
notify: true,
|
});
|
||||||
});
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ensureWorkspaceTarget(cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestWorkingTreeWatch(
|
async requestWorkingTreeWatch(
|
||||||
@@ -587,7 +586,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.closeWorkspaceTarget(target);
|
this.closeWorkspaceTarget(target);
|
||||||
}
|
}
|
||||||
this.workspaceTargets.clear();
|
this.workspaceTargets.clear();
|
||||||
this.workspaceTargetSetups.clear();
|
|
||||||
|
|
||||||
for (const target of this.repoTargets.values()) {
|
for (const target of this.repoTargets.values()) {
|
||||||
this.closeRepoTarget(target);
|
this.closeRepoTarget(target);
|
||||||
@@ -601,22 +599,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.workingTreeWatchSetups.clear();
|
this.workingTreeWatchSetups.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
|
||||||
const existingTarget = this.workspaceTargets.get(cwd);
|
const existingTarget = this.workspaceTargets.get(cwd);
|
||||||
if (existingTarget) {
|
if (existingTarget) {
|
||||||
return existingTarget;
|
return existingTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSetup = this.workspaceTargetSetups.get(cwd);
|
return this.createWorkspaceTarget(cwd);
|
||||||
if (existingSetup) {
|
|
||||||
return existingSetup;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setup = this.createWorkspaceTarget(cwd).finally(() => {
|
|
||||||
this.workspaceTargetSetups.delete(cwd);
|
|
||||||
});
|
|
||||||
this.workspaceTargetSetups.set(cwd, setup);
|
|
||||||
return setup;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readAuxiliaryCache<T>(
|
private readAuxiliaryCache<T>(
|
||||||
@@ -698,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return setup;
|
return setup;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
|
||||||
const target: WorkspaceGitTarget = {
|
const target: WorkspaceGitTarget = {
|
||||||
cwd,
|
cwd,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
@@ -713,35 +702,85 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
lastShellOutAtMs: null,
|
lastShellOutAtMs: null,
|
||||||
repoGitRoot: null,
|
repoGitRoot: null,
|
||||||
|
observationSetupPromise: null,
|
||||||
|
observationSetupComplete: false,
|
||||||
|
closed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.workspaceTargets.set(cwd, target);
|
this.workspaceTargets.set(cwd, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
private scheduleInitialWorkspaceRefresh(target: WorkspaceGitTarget): void {
|
||||||
await this.requestWorkspaceSnapshot(target, {
|
queueMicrotask(() => {
|
||||||
|
if (!this.isActiveObservedWorkspaceTarget(target) || target.latestSnapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.refreshWorkspaceTarget(target, {
|
||||||
force: false,
|
force: false,
|
||||||
includeGitHub: true,
|
includeGitHub: true,
|
||||||
reason: "initial",
|
reason: "initial",
|
||||||
notify: false,
|
notify: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
|
private scheduleWorkspaceObservationSetup(target: WorkspaceGitTarget): void {
|
||||||
|
if (
|
||||||
|
target.observationSetupComplete ||
|
||||||
|
target.observationSetupPromise ||
|
||||||
|
!this.isActiveObservedWorkspaceTarget(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.observationSetupPromise = Promise.resolve()
|
||||||
|
.then(() => this.setupWorkspaceObservation(target))
|
||||||
|
.catch((error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, cwd: target.cwd },
|
||||||
|
"Failed to set up workspace git observation",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
target.observationSetupPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise<void> {
|
||||||
|
try {
|
||||||
|
const gitDir = await this.deps.resolveAbsoluteGitDir(target.cwd);
|
||||||
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!gitDir) {
|
if (!gitDir) {
|
||||||
return target;
|
target.observationSetupComplete = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir);
|
const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir);
|
||||||
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
target.repoGitRoot = repoGitRoot;
|
target.repoGitRoot = repoGitRoot;
|
||||||
this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
|
this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
|
||||||
await this.ensureRepoTarget(target);
|
await this.ensureRepoTarget(target);
|
||||||
return target;
|
if (this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
|
target.observationSetupComplete = true;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.closeWorkspaceTarget(target);
|
|
||||||
this.workspaceTargets.delete(cwd);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||||
|
return (
|
||||||
|
!target.closed &&
|
||||||
|
target.listeners.size > 0 &&
|
||||||
|
this.workspaceTargets.get(target.cwd) === target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||||
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||||
const target: WorkingTreeWatchTarget = {
|
const target: WorkingTreeWatchTarget = {
|
||||||
@@ -862,7 +901,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> {
|
private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> {
|
||||||
const repoGitRoot = workspaceTarget.repoGitRoot;
|
const repoGitRoot = workspaceTarget.repoGitRoot;
|
||||||
if (!repoGitRoot) {
|
if (!repoGitRoot || !this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,6 +912,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd);
|
const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd);
|
||||||
|
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasOrigin) {
|
if (!hasOrigin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -904,7 +946,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
typeof targetOrCwd === "string"
|
typeof targetOrCwd === "string"
|
||||||
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
|
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
|
||||||
: targetOrCwd;
|
: targetOrCwd;
|
||||||
if (!target) {
|
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,6 +955,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target.debounceTimer = setTimeout(() => {
|
target.debounceTimer = setTimeout(() => {
|
||||||
|
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
void this.refreshWorkspaceTarget(target, {
|
void this.refreshWorkspaceTarget(target, {
|
||||||
force: options?.force === true,
|
force: options?.force === true,
|
||||||
@@ -926,6 +971,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
|
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
|
||||||
if (!target.selfHealTimer) {
|
if (!target.selfHealTimer) {
|
||||||
target.selfHealTimer = setInterval(() => {
|
target.selfHealTimer = setInterval(() => {
|
||||||
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
|
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||||
@@ -965,6 +1011,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
cwd: target.cwd,
|
cwd: target.cwd,
|
||||||
headRef,
|
headRef,
|
||||||
onStatus: () => {
|
onStatus: () => {
|
||||||
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void this.refreshWorkspaceTarget(target, {
|
void this.refreshWorkspaceTarget(target, {
|
||||||
force: false,
|
force: false,
|
||||||
includeGitHub: true,
|
includeGitHub: true,
|
||||||
@@ -1137,6 +1186,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.requestWorkspaceSnapshot(target, request);
|
await this.requestWorkspaceSnapshot(target, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1397,6 +1449,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
|
target.closed = true;
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
|
|||||||
@@ -6,69 +6,11 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
|
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
|
|
||||||
function createNoopWorkspaceGitService(): WorkspaceGitService {
|
|
||||||
return {
|
|
||||||
subscribe: async (params) => ({
|
|
||||||
initial: {
|
|
||||||
cwd: params.cwd,
|
|
||||||
git: {
|
|
||||||
isGit: false,
|
|
||||||
repoRoot: null,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
isDirty: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
diffStat: null,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
featuresEnabled: false,
|
|
||||||
pullRequest: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unsubscribe: () => {},
|
|
||||||
}),
|
|
||||||
peekSnapshot: () => null,
|
|
||||||
getSnapshot: async (cwd) => ({
|
|
||||||
cwd,
|
|
||||||
git: {
|
|
||||||
isGit: false,
|
|
||||||
repoRoot: null,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
isDirty: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
diffStat: null,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
featuresEnabled: false,
|
|
||||||
pullRequest: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
resolveRepoRemoteUrl: async () => null,
|
|
||||||
refresh: async () => {},
|
|
||||||
requestWorkingTreeWatch: async () => ({
|
|
||||||
repoRoot: null,
|
|
||||||
unsubscribe: () => {},
|
|
||||||
}),
|
|
||||||
scheduleRefreshForCwd: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("bootstrapWorkspaceRegistries", () => {
|
describe("bootstrapWorkspaceRegistries", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user