Files
continue/core/config/ConfigHandler.ts
Aditya Mitra fc5606cc3a feat: select org profile on sign in (#8859)
* feat: select org profile on sign in

* reset inside cascade init

* reset only when personl
2025-11-27 01:44:55 -08:00

664 lines
20 KiB
TypeScript

import { ConfigResult, ConfigValidationError } from "@continuedev/config-yaml";
import { ControlPlaneClient } from "../control-plane/client.js";
import {
BrowserSerializedContinueConfig,
ContinueConfig,
IContextProvider,
IDE,
IdeSettings,
ILLMLogger,
} from "../index.js";
import { GlobalContext } from "../util/GlobalContext.js";
import EventEmitter from "node:events";
import {
AuthType,
ControlPlaneSessionInfo,
} from "../control-plane/AuthTypes.js";
import { getControlPlaneEnv } from "../control-plane/env.js";
import { PolicySingleton } from "../control-plane/PolicySingleton.js";
import { Logger } from "../util/Logger.js";
import { Telemetry } from "../util/posthog.js";
import {
getAllDotContinueDefinitionFiles,
LoadAssistantFilesOptions,
} from "./loadLocalAssistants.js";
import LocalProfileLoader from "./profile/LocalProfileLoader.js";
import PlatformProfileLoader from "./profile/PlatformProfileLoader.js";
import {
OrganizationDescription,
OrgWithProfiles,
ProfileDescription,
ProfileLifecycleManager,
SerializedOrgWithProfiles,
} from "./ProfileLifecycleManager.js";
export type { ProfileDescription };
type ConfigUpdateFunction = (payload: ConfigResult<ContinueConfig>) => void;
export class ConfigHandler {
controlPlaneClient: ControlPlaneClient;
private readonly globalContext = new GlobalContext();
private globalLocalProfileManager: ProfileLifecycleManager;
private organizations: OrgWithProfiles[] = [];
currentProfile: ProfileLifecycleManager | null;
currentOrg: OrgWithProfiles | null;
totalConfigReloads: number = 0;
public isInitialized: Promise<void>;
private initter: EventEmitter;
cascadeAbortController: AbortController;
private abortCascade() {
this.cascadeAbortController.abort();
this.cascadeAbortController = new AbortController();
}
constructor(
private readonly ide: IDE,
private llmLogger: ILLMLogger,
initialSessionInfoPromise: Promise<ControlPlaneSessionInfo | undefined>,
) {
this.controlPlaneClient = new ControlPlaneClient(
initialSessionInfoPromise,
this.ide,
);
// This profile manager will always be available
this.globalLocalProfileManager = new ProfileLifecycleManager(
new LocalProfileLoader(ide, this.controlPlaneClient, this.llmLogger),
this.ide,
);
this.currentOrg = null;
this.currentProfile = null;
this.organizations = [];
this.initter = new EventEmitter();
this.isInitialized = new Promise((resolve) => {
this.initter.on("init", resolve);
});
this.cascadeAbortController = new AbortController();
void this.cascadeInit("Config handler initialization");
}
private workspaceDirs: string[] | null = null;
async getWorkspaceId() {
if (!this.workspaceDirs) {
this.workspaceDirs = await this.ide.getWorkspaceDirs();
}
return this.workspaceDirs.join("&");
}
async getProfileKey(orgId: string) {
const workspaceId = await this.getWorkspaceId();
return `${workspaceId}:::${orgId}`;
}
private async cascadeInit(reason: string, isLogin?: boolean) {
const signal = this.cascadeAbortController.signal;
this.workspaceDirs = null; // forces workspace dirs reload
// Always update globalLocalProfileManager before recreating all the loaders
// during every cascadeInit so it holds the most recent controlPlaneClient.
this.globalLocalProfileManager = new ProfileLifecycleManager(
new LocalProfileLoader(this.ide, this.controlPlaneClient, this.llmLogger),
this.ide,
);
try {
const { orgs, errors } = await this.getOrgs();
// Figure out selected org
const workspaceId = await this.getWorkspaceId();
const selectedOrgs =
this.globalContext.get("lastSelectedOrgIdForWorkspace") ?? {};
let currentSelection = selectedOrgs[workspaceId];
// reset personal org to first available non-personal org on login
if (isLogin && currentSelection === "personal") {
currentSelection = null;
}
const firstNonPersonal = orgs.find(
(org) => org.id !== this.PERSONAL_ORG_DESC.id,
);
const fallback: OrgWithProfiles | null =
firstNonPersonal ?? orgs[0] ?? null;
let selectedOrg: OrgWithProfiles | null;
if (currentSelection) {
const match = orgs.find((org) => org.id === currentSelection);
if (match) {
selectedOrg = match;
} else {
selectedOrg = fallback;
}
} else {
selectedOrg = fallback;
}
if (signal.aborted) {
return; // local only case, no`fetch to throw abort error
}
this.globalContext.update("lastSelectedOrgIdForWorkspace", {
...selectedOrgs,
[workspaceId]: selectedOrg?.id,
});
this.organizations = orgs;
this.currentOrg = selectedOrg;
this.currentProfile = selectedOrg?.currentProfile;
await this.reloadConfig(reason, errors);
} catch (e) {
if (signal.aborted) {
return;
} else {
this.initter.emit("init"); // Error case counts as init
throw e;
}
}
}
private async getOrgs(): Promise<{
orgs: OrgWithProfiles[];
errors?: ConfigValidationError[];
}> {
const errors: ConfigValidationError[] = [];
const isSignedIn = await this.controlPlaneClient.isSignedIn();
if (isSignedIn) {
try {
// TODO use policy returned with org, not policy endpoint
const policyResponse = await this.controlPlaneClient.getPolicy();
PolicySingleton.getInstance().policy = policyResponse;
const orgDescriptions =
await this.controlPlaneClient.listOrganizations();
const orgsWithPolicy = orgDescriptions.map((d) => ({
...d,
policy: policyResponse?.policy,
}));
if (policyResponse?.policy?.allowOtherOrgs === false) {
if (orgsWithPolicy.length === 0) {
return { orgs: [] };
} else {
const firstOrg = await this.getNonPersonalHubOrg(orgsWithPolicy[0]);
return { orgs: [firstOrg] };
}
}
const orgs = await Promise.all([
this.getPersonalHubOrg(),
...orgsWithPolicy.map((org) => this.getNonPersonalHubOrg(org)),
]);
// TODO make try/catch more granular here, to catch specific org errors
return { orgs };
} catch (e) {
errors.push({
fatal: false,
message: `Error loading Continue Hub assistants${e instanceof Error ? ":\n" + e.message : ""}`,
});
}
} else {
PolicySingleton.getInstance().policy = null;
}
// Load local org if not signed in or hub orgs fail
try {
const orgs = [await this.getLocalOrg()];
return { orgs };
} catch (e) {
errors.push({
fatal: true,
message: `Error loading local assistants${e instanceof Error ? ":\n" + e.message : ""}`,
});
return {
orgs: [],
errors,
};
}
}
getSerializedOrgs(): SerializedOrgWithProfiles[] {
return this.organizations.map((org) => ({
iconUrl: org.iconUrl,
id: org.id,
name: org.name,
slug: org.slug,
profiles: org.profiles.map((profile) => profile.profileDescription),
selectedProfileId: org.currentProfile?.profileDescription.id || null,
}));
}
private async getHubProfiles(orgScopeId: string | null) {
const assistants = await this.controlPlaneClient.listAssistants(orgScopeId);
return await Promise.all(
assistants.map(async (assistant) => {
const profileLoader = await PlatformProfileLoader.create({
configResult: {
...assistant.configResult,
config: assistant.configResult.config,
},
ownerSlug: assistant.ownerSlug,
packageSlug: assistant.packageSlug,
iconUrl: assistant.iconUrl,
versionSlug: assistant.configResult.config?.version ?? "latest",
controlPlaneClient: this.controlPlaneClient,
ide: this.ide,
llmLogger: this.llmLogger,
rawYaml: assistant.rawYaml,
orgScopeId: orgScopeId,
});
return new ProfileLifecycleManager(profileLoader, this.ide);
}),
);
}
private async getNonPersonalHubOrg(
org: OrganizationDescription,
): Promise<OrgWithProfiles> {
const localProfiles = await this.getLocalProfiles({
includeGlobal: false,
includeWorkspace: true,
});
const profiles = [...(await this.getHubProfiles(org.id)), ...localProfiles];
return this.rectifyProfilesForOrg(org, profiles);
}
private PERSONAL_ORG_DESC: OrganizationDescription = {
iconUrl: "",
id: "personal",
name: "Personal",
slug: undefined,
};
private async getPersonalHubOrg() {
const localProfiles = await this.getLocalProfiles({
includeGlobal: true,
includeWorkspace: true,
});
const hubProfiles = await this.getHubProfiles(null);
const profiles = [...hubProfiles, ...localProfiles];
return this.rectifyProfilesForOrg(this.PERSONAL_ORG_DESC, profiles);
}
private async getLocalOrg() {
const localProfiles = await this.getLocalProfiles({
includeGlobal: true,
includeWorkspace: true,
});
return this.rectifyProfilesForOrg(this.PERSONAL_ORG_DESC, localProfiles);
}
private async rectifyProfilesForOrg(
org: OrganizationDescription,
profiles: ProfileLifecycleManager[],
): Promise<OrgWithProfiles> {
const profileKey = await this.getProfileKey(org.id);
const selectedProfiles =
this.globalContext.get("lastSelectedProfileForWorkspace") ?? {};
const currentSelection = selectedProfiles[profileKey];
const firstNonLocal = profiles.find(
(profile) => profile.profileDescription.profileType !== "local",
);
const fallback =
firstNonLocal ?? (profiles.length > 0 ? profiles[0] : null);
let currentProfile: ProfileLifecycleManager | null;
if (currentSelection) {
const match = profiles.find(
(profile) => profile.profileDescription.id === currentSelection,
);
if (match) {
currentProfile = match;
} else {
currentProfile = fallback;
}
} else {
currentProfile = fallback;
}
if (currentProfile) {
this.globalContext.update("lastSelectedProfileForWorkspace", {
...selectedProfiles,
[profileKey]: currentProfile.profileDescription.id,
});
}
return {
...org,
profiles,
currentProfile,
};
}
async getLocalProfiles(options: LoadAssistantFilesOptions) {
/**
* Users can define as many local agents as they want in a `.continue/agents` (or previous .continue/assistants) folder
*/
// Local customization disabled for on-premise deployments
const env = await getControlPlaneEnv(this.ide.getIdeSettings());
if (env.AUTH_TYPE === AuthType.OnPrem) {
return [];
}
const localProfiles: ProfileLifecycleManager[] = [];
if (options.includeGlobal) {
localProfiles.push(this.globalLocalProfileManager);
}
if (options.includeWorkspace) {
const assistantFiles = await getAllDotContinueDefinitionFiles(
this.ide,
options,
"assistants",
);
const agentFiles = await getAllDotContinueDefinitionFiles(
this.ide,
options,
"agents",
);
const profiles = [...assistantFiles, ...agentFiles].map((assistant) => {
return new LocalProfileLoader(
this.ide,
this.controlPlaneClient,
this.llmLogger,
assistant,
);
});
const localAssistantProfiles = profiles.map(
(profile) => new ProfileLifecycleManager(profile, this.ide),
);
localProfiles.push(...localAssistantProfiles);
}
return localProfiles;
}
//////////////////
// External actions that can cause a cascading config refresh
// Should not be used internally
//////////////////
async refreshAll(reason?: string) {
await this.cascadeInit(reason ?? "External refresh all");
}
// Ide settings change: refresh session and cascade refresh from the top
async updateIdeSettings(ideSettings: IdeSettings) {
this.abortCascade();
await this.cascadeInit("IDE settings update");
}
// Session change: refresh session and cascade refresh from the top
async updateControlPlaneSessionInfo(
sessionInfo: ControlPlaneSessionInfo | undefined,
) {
const currentSession = await this.controlPlaneClient.sessionInfoPromise;
const newSession = sessionInfo;
let reload = false;
let isLogin = false;
if (newSession) {
if (currentSession) {
if (
newSession.AUTH_TYPE !== AuthType.OnPrem &&
currentSession.AUTH_TYPE !== AuthType.OnPrem
) {
if (newSession.account.id !== currentSession.account.id) {
// session id change (non-on-prem)
reload = true;
}
}
} else {
// log in
reload = true;
isLogin = true;
}
} else {
if (currentSession) {
// log out
reload = true;
}
}
if (reload) {
this.controlPlaneClient = new ControlPlaneClient(
Promise.resolve(sessionInfo),
this.ide,
);
this.abortCascade();
await this.cascadeInit("Control plane session info update", isLogin);
}
return reload;
}
// Org id: check id validity, save selection, switch and reload
async setSelectedOrgId(orgId: string, profileId?: string) {
if (orgId === this.currentOrg?.id) {
return;
}
const org = this.organizations.find((org) => org.id === orgId);
if (!org) {
throw new Error(`Org ${orgId} not found`);
}
const workspaceId = await this.getWorkspaceId();
const selectedOrgs =
this.globalContext.get("lastSelectedOrgIdForWorkspace") ?? {};
this.globalContext.update("lastSelectedOrgIdForWorkspace", {
...selectedOrgs,
[workspaceId]: org.id,
});
this.currentOrg = org;
if (profileId) {
await this.setSelectedProfileId(profileId);
} else {
this.currentProfile = org.currentProfile;
await this.reloadConfig("Selected org changed");
}
}
// Profile id: check id validity, save selection, switch and reload
async setSelectedProfileId(profileId: string) {
if (!this.currentOrg) {
throw new Error(`No org selected`);
}
if (
this.currentProfile &&
profileId === this.currentProfile.profileDescription.id
) {
return;
}
const profile = this.currentOrg.profiles.find(
(profile) => profile.profileDescription.id === profileId,
);
if (!profile) {
throw new Error(`Profile ${profileId} not found in current org`);
}
const profileKey = await this.getProfileKey(this.currentOrg.id);
const selectedProfiles =
this.globalContext.get("lastSelectedProfileForWorkspace") ?? {};
this.globalContext.update("lastSelectedProfileForWorkspace", {
...selectedProfiles,
[profileKey]: profileId,
});
this.currentProfile = profile;
await this.reloadConfig("Selected profile changed");
}
// Bottom level of cascade: refresh the current profile
// IMPORTANT - must always refresh when switching profiles
// Because of e.g. MCP singleton and docs service using things from config
// Could improve this
async reloadConfig(reason: string, injectErrors?: ConfigValidationError[]) {
const startTime = performance.now();
this.totalConfigReloads += 1;
// console.log(`Reloading config (#${this.totalConfigLoads}): ${reason}`); // Uncomment to see config loading logs
if (!this.currentProfile) {
const out = {
config: undefined,
errors: injectErrors,
configLoadInterrupted: true,
};
this.notifyConfigListeners(out);
return out;
}
for (const org of this.organizations) {
for (const profile of org.profiles) {
if (
profile.profileDescription.id !==
this.currentProfile.profileDescription.id
) {
profile.clearConfig();
}
}
}
const {
config,
errors = [],
configLoadInterrupted,
} = await this.currentProfile.reloadConfig(this.additionalContextProviders);
if (injectErrors) {
errors.unshift(...injectErrors);
}
this.notifyConfigListeners({ config, errors, configLoadInterrupted });
this.initter.emit("init");
// Track config loading telemetry
const endTime = performance.now();
const duration = endTime - startTime;
const isSignedIn = await this.controlPlaneClient.isSignedIn();
const profileDescription = this.currentProfile.profileDescription;
const telemetryData: Record<string, any> = {
duration,
reason,
totalConfigLoads: this.totalConfigReloads,
configLoadInterrupted,
profileType: profileDescription.profileType,
isPersonalOrg: this.currentOrg?.id === this.PERSONAL_ORG_DESC.id,
errorCount: errors.length,
isSignedIn,
};
void Telemetry.capture("config_reload", telemetryData);
if (errors.length) {
Logger.error("Errors loading config: ", errors);
}
return {
config,
errors: errors.length ? errors : undefined,
configLoadInterrupted,
};
}
// Listeners setup - can listen to current profile updates
private notifyConfigListeners(result: ConfigResult<ContinueConfig>) {
for (const listener of this.updateListeners) {
listener(result);
}
}
private updateListeners: ConfigUpdateFunction[] = [];
onConfigUpdate(listener: ConfigUpdateFunction) {
this.updateListeners.push(listener);
}
// Methods for loading (without reloading) config
// Serialized for passing to GUI
// Load for just awaiting current config load promise for the profile
async getSerializedConfig(): Promise<
ConfigResult<BrowserSerializedContinueConfig>
> {
if (!this.currentProfile) {
return {
config: undefined,
errors: undefined,
configLoadInterrupted: true,
};
}
return await this.currentProfile.getSerializedConfig(
this.additionalContextProviders,
);
}
async loadConfig(): Promise<ConfigResult<ContinueConfig>> {
if (!this.currentProfile) {
return {
config: undefined,
errors: undefined,
configLoadInterrupted: true,
};
}
await this.isInitialized;
const config = await this.currentProfile.loadConfig(
this.additionalContextProviders,
);
return config;
}
async openConfigProfile(
profileId?: string,
element?: { sourceFile?: string },
) {
let openProfileId = profileId || this.currentProfile?.profileDescription.id;
if (!openProfileId) {
return;
}
const profile = this.currentOrg?.profiles.find(
(p) => p.profileDescription.id === openProfileId,
);
if (!profile) {
console.error(`Profile ${profileId} not found`);
return;
}
if (profile.profileDescription.profileType === "local") {
const configFile = element?.sourceFile ?? profile.profileDescription.uri;
await this.ide.openFile(configFile);
} else {
const env = await getControlPlaneEnv(this.ide.getIdeSettings());
await this.ide.openUrl(`${env.APP_URL}${openProfileId}`);
}
}
// Ancient method of adding custom providers through vs code
private additionalContextProviders: IContextProvider[] = [];
registerCustomContextProvider(contextProvider: IContextProvider) {
this.additionalContextProviders.push(contextProvider);
void this.reloadConfig("Custom context provider registered");
}
/**
* Retrieves the titles of additional context providers that are of type "submenu".
*
* @returns {string[]} An array of titles of the additional context providers that have a description type of "submenu".
*/
getAdditionalSubmenuContextProviders(): string[] {
return this.additionalContextProviders
.filter((provider) => provider.description.type === "submenu")
.map((provider) => provider.description.title);
}
}